Split hosted billing admin runtime owners

This commit is contained in:
rcourtman
2026-03-21 11:28:49 +00:00
parent 5c96a36deb
commit ca6ae3bf33
9 changed files with 465 additions and 335 deletions
@@ -48,20 +48,22 @@ agreement, and cloud-specific enforcement rules.
26. `frontend-modern/src/useAppRuntimeState.ts`
27. `frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx`
28. `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`
29. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`
30. `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`
31. `frontend-modern/src/components/Settings/ProLicensePanel.tsx`
32. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`
33. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx`
34. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx`
35. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`
36. `frontend-modern/src/components/Settings/RelayPairingSection.tsx`
37. `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts`
38. `frontend-modern/src/components/Settings/useProLicensePanelState.ts`
39. `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`
40. `frontend-modern/src/pages/CloudPricing.tsx`
41. `frontend-modern/src/utils/apiClient.ts`
42. `frontend-modern/src/utils/commercialBillingModel.ts`
29. `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx`
30. `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`
31. `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`
32. `frontend-modern/src/components/Settings/ProLicensePanel.tsx`
33. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`
34. `frontend-modern/src/components/Settings/CommercialBillingSections.tsx`
35. `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx`
36. `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`
37. `frontend-modern/src/components/Settings/RelayPairingSection.tsx`
38. `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts`
39. `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts`
40. `frontend-modern/src/components/Settings/useProLicensePanelState.ts`
41. `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`
42. `frontend-modern/src/pages/CloudPricing.tsx`
43. `frontend-modern/src/utils/apiClient.ts`
44. `frontend-modern/src/utils/commercialBillingModel.ts`
## Shared Boundaries
@@ -82,7 +84,7 @@ agreement, and cloud-specific enforcement rules.
8. Add or change encrypted activation persistence through `pkg/licensing/persistence.go` and `pkg/licensing/activation_store.go`
9. Add or change hosted trial token semantics through `pkg/licensing/trial_activation.go`
10. Add or change hosted signup provisioning through `internal/hosted/provisioner.go`
11. Add or change hosted billing-admin presentation through `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`
11. Add or change hosted billing-admin presentation through `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`, `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx`, and `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts`
12. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts`
13. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts`
14. Add or change self-hosted Pro activation, trial, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialActivationSection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts`
@@ -197,6 +199,15 @@ cloud-paid ownership model as well. Changes to
`frontend-modern/src/components/Settings/BillingAdminPanel.tsx` must carry this
contract and the dedicated billing-admin proof file instead of remaining an
unowned consumer of hosted billing state.
That hosted billing-admin owner is now intentionally split by responsibility:
`frontend-modern/src/components/Settings/BillingAdminPanel.tsx` is the
settings shell, `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts`
owns the hosted organization list, billing-state cache, preload, and
subscription update runtime, and
`frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx`
owns the tenant grid plus expanded JSON presentation. Future hosted billing
admin changes must extend that split instead of pulling hosted state and
mutation flow back into the panel render shell.
The organization billing settings surface now follows the same rule. Changes
to `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` must
carry this contract and the dedicated organization-billing proof file instead
@@ -1093,6 +1093,7 @@
"frontend-modern/src/App.tsx",
"frontend-modern/src/AppLayout.tsx",
"frontend-modern/src/components/Dashboard/RelayOnboardingCard.tsx",
"frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx",
"frontend-modern/src/components/Settings/BillingAdminPanel.tsx",
"frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx",
"frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx",
@@ -1100,6 +1101,7 @@
"frontend-modern/src/components/Settings/ProLicensePlanSection.tsx",
"frontend-modern/src/components/Settings/RelayPairingSection.tsx",
"frontend-modern/src/components/Settings/RelaySettingsPanel.tsx",
"frontend-modern/src/components/Settings/useBillingAdminPanelState.ts",
"frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts",
"frontend-modern/src/components/Settings/useProLicensePanelState.ts",
"frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts",
@@ -1492,7 +1494,9 @@
"label": "hosted billing admin surface proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/BillingAdminPanel.tsx"
"frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx",
"frontend-modern/src/components/Settings/BillingAdminPanel.tsx",
"frontend-modern/src/components/Settings/useBillingAdminPanelState.ts"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
@@ -0,0 +1,174 @@
import type { Component } from 'solid-js';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import {
BILLING_ADMIN_EMPTY_STATE,
getBillingAdminOrganizationBadges,
getBillingAdminTrialStatus,
getLicenseSubscriptionStatusPresentation,
} from '@/utils/licensePresentation';
import type { BillingState, HostedOrganizationSummary } from '@/api/billingAdmin';
import type { BillingStateCache } from './useBillingAdminPanelState';
interface BillingAdminOrganizationsTableProps {
billingByOrgID: BillingStateCache;
billingLoadingByOrgID: Record<string, boolean>;
expandedOrgID: string | null;
isLoading: boolean;
orgs: HostedOrganizationSummary[];
onActivateOrganization: (orgID: string) => void;
onReloadOrganization: (orgID: string) => void;
onSuspendOrganization: (orgID: string) => void;
onToggleOrganization: (orgID: string) => void;
savingByOrgID: Record<string, boolean>;
}
const stripeCustomerCell = (state?: BillingState) => {
const value = (state?.stripe_customer_id || '').trim();
if (!value) return 'N/A';
return value;
};
export const BillingAdminOrganizationsTable: Component<BillingAdminOrganizationsTableProps> = (
props,
) => (
<PulseDataGrid
data={props.orgs}
isLoading={props.isLoading}
emptyState={BILLING_ADMIN_EMPTY_STATE}
desktopMinWidth="920px"
columns={[
{
key: 'organization',
label: 'Organization',
render: (org) => {
const orgID = (org.org_id || '').trim();
return (
<button
type="button"
class="text-left w-full"
onClick={() => {
props.onToggleOrganization(orgID);
}}
>
<div class="font-medium text-base-content">{org.display_name || org.org_id}</div>
<div class="text-xs text-muted">
<span class="font-mono">{org.org_id}</span>
{getBillingAdminOrganizationBadges(org).map((badge) => (
<span class={`ml-2 rounded px-1.5 py-0.5 ${badge.badgeClass}`}>{badge.label}</span>
))}
</div>
</button>
);
},
},
{
key: 'owner',
label: 'Owner',
render: (org) => (
<span class="font-mono text-xs text-base-content">{org.owner_user_id || 'N/A'}</span>
),
},
{
key: 'subscription',
label: 'Subscription',
render: (org) => {
const billing = props.billingByOrgID[(org.org_id || '').trim()];
return (
<span class="font-mono text-xs text-base-content">
{getLicenseSubscriptionStatusPresentation(billing?.subscription_state).label}
</span>
);
},
},
{
key: 'trial',
label: 'Trial',
render: (org) => {
const billing = props.billingByOrgID[(org.org_id || '').trim()];
return <span class="text-xs text-base-content">{getBillingAdminTrialStatus(billing)}</span>;
},
},
{
key: 'stripeCustomer',
label: 'Stripe Customer',
render: (org) => {
const billing = props.billingByOrgID[(org.org_id || '').trim()];
const cellValue = stripeCustomerCell(billing);
return (
<span class="font-mono text-xs text-base-content" title={cellValue}>
{cellValue}
</span>
);
},
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (org) => {
const orgID = (org.org_id || '').trim();
const billing = props.billingByOrgID[orgID];
const currentSubState = (billing?.subscription_state || '').toLowerCase() || 'unknown';
return (
<div class="inline-flex flex-col sm:flex-row sm:items-center gap-2">
<button
type="button"
onClick={() => {
props.onSuspendOrganization(orgID);
}}
disabled={
props.savingByOrgID[orgID] ||
props.billingLoadingByOrgID[orgID] ||
currentSubState === 'suspended'
}
class="px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
>
Suspend Org
</button>
<button
type="button"
onClick={() => {
props.onActivateOrganization(orgID);
}}
disabled={
props.savingByOrgID[orgID] ||
props.billingLoadingByOrgID[orgID] ||
currentSubState === 'active'
}
class="px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
>
Activate Org
</button>
</div>
);
},
},
]}
keyExtractor={(org) => org.org_id}
isRowExpanded={(org) => props.expandedOrgID === (org.org_id || '').trim()}
expandedRender={(org) => {
const orgID = (org.org_id || '').trim();
return (
<div class="px-3 pb-3 bg-surface-alt">
<div class="rounded-md border border-border bg-surface-alt p-3">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-2">
<div class="text-xs font-semibold text-muted">Billing state JSON</div>
<button
type="button"
onClick={() => {
props.onReloadOrganization(orgID);
}}
class="px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover"
>
Reload
</button>
</div>
<pre class="text-xs overflow-x-auto whitespace-pre-wrap font-mono text-base-content">
{JSON.stringify(props.billingByOrgID[orgID] ?? { loading: true }, null, 2)}
</pre>
</div>
</div>
);
}}
/>
);
@@ -1,160 +1,19 @@
import { Component, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { Component, Show } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import {
BillingAdminAPI,
type BillingState,
type HostedOrganizationSummary,
} from '@/api/billingAdmin';
import { isHostedModeEnabled, isMultiTenantEnabled } from '@/stores/license';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import {
BILLING_ADMIN_EMPTY_STATE,
getBillingAdminOrganizationBadges,
getBillingAdminStateUpdateSuccessMessage,
getBillingAdminTrialStatus,
getLicenseSubscriptionStatusPresentation,
} from '@/utils/licensePresentation';
import {
getOrganizationSettingsLoadErrorMessage as getOrganizationSettingsPanelLoadErrorMessage,
ORGANIZATION_SETTINGS_UNAVAILABLE_CLASS as ORGANIZATION_SETTINGS_PANEL_UNAVAILABLE_CLASS,
ORGANIZATION_SETTINGS_UNAVAILABLE_MESSAGE as ORGANIZATION_SETTINGS_PANEL_UNAVAILABLE_MESSAGE,
} from '@/utils/organizationSettingsPresentation';
import CreditCard from 'lucide-solid/icons/credit-card';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
type BillingStateCache = Record<string, BillingState | undefined>;
async function promisePool<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>) {
if (items.length === 0) return;
const limit = Math.max(1, Math.min(concurrency, items.length));
let idx = 0;
const workers = Array.from({ length: limit }).map(async () => {
for (;;) {
const current = idx;
idx += 1;
if (current >= items.length) return;
await fn(items[current]);
}
});
await Promise.all(workers);
}
import { BillingAdminOrganizationsTable } from './BillingAdminOrganizationsTable';
import { useBillingAdminPanelState } from './useBillingAdminPanelState';
export const BillingAdminPanel: Component = () => {
const [orgs, setOrgs] = createSignal<HostedOrganizationSummary[]>([]);
const [loadingOrgs, setLoadingOrgs] = createSignal(false);
const [orgsError, setOrgsError] = createSignal<string | null>(null);
const [billingByOrgID, setBillingByOrgID] = createSignal<BillingStateCache>({});
const [billingLoadingByOrgID, setBillingLoadingByOrgID] = createSignal<Record<string, boolean>>(
{},
);
const [savingByOrgID, setSavingByOrgID] = createSignal<Record<string, boolean>>({});
const [expandedOrgID, setExpandedOrgID] = createSignal<string | null>(null);
const loadOrganizations = async () => {
setLoadingOrgs(true);
setOrgsError(null);
try {
const next = await BillingAdminAPI.listOrganizations();
setOrgs(next ?? []);
} catch (err) {
logger.error('Failed to list hosted organizations', err);
const msg = err instanceof Error ? err.message : '';
const errorMessage = getOrganizationSettingsPanelLoadErrorMessage(msg, 'billing-admin');
setOrgsError(errorMessage);
notificationStore.error(errorMessage);
} finally {
setLoadingOrgs(false);
}
};
const setBillingLoading = (orgID: string, value: boolean) => {
setBillingLoadingByOrgID((prev) => ({ ...prev, [orgID]: value }));
};
const setSaving = (orgID: string, value: boolean) => {
setSavingByOrgID((prev) => ({ ...prev, [orgID]: value }));
};
const ensureBillingState = async (orgID: string): Promise<BillingState | null> => {
const cached = billingByOrgID()[orgID];
if (cached) return cached;
if (billingLoadingByOrgID()[orgID]) {
return null;
}
setBillingLoading(orgID, true);
try {
const state = await BillingAdminAPI.getBillingState(orgID);
setBillingByOrgID((prev) => ({ ...prev, [orgID]: state }));
return state;
} catch (err) {
logger.error('Failed to fetch billing state', { orgID, err });
return null;
} finally {
setBillingLoading(orgID, false);
}
};
const updateSubscriptionState = async (orgID: string, nextState: 'suspended' | 'active') => {
setSaving(orgID, true);
try {
const current =
(await ensureBillingState(orgID)) ?? (await BillingAdminAPI.getBillingState(orgID));
const planVersion = current.plan_version?.trim() || undefined;
const payload: BillingState = {
...current,
subscription_state: nextState,
capabilities: Array.isArray(current.capabilities) ? current.capabilities : [],
limits: current.limits ?? {},
meters_enabled: Array.isArray(current.meters_enabled) ? current.meters_enabled : [],
...(planVersion ? { plan_version: planVersion } : {}),
};
const saved = await BillingAdminAPI.putBillingState(orgID, payload);
setBillingByOrgID((prev) => ({ ...prev, [orgID]: saved }));
notificationStore.success(getBillingAdminStateUpdateSuccessMessage(nextState), 2500);
} catch (err) {
logger.error('Failed to update billing state', { orgID, err });
const msg = err instanceof Error ? err.message : 'Failed to update billing state';
notificationStore.error(msg);
} finally {
setSaving(orgID, false);
}
};
const hostedEnabled = createMemo(() => isMultiTenantEnabled() && isHostedModeEnabled());
createEffect(() => {
if (!hostedEnabled()) return;
void loadOrganizations();
});
// Preload billing state for the visible table so key columns can render without per-row clicks.
createEffect(() => {
const list = orgs();
if (!hostedEnabled() || loadingOrgs() || list.length === 0) return;
const missing = list.map((o) => o.org_id).filter((id) => id && !billingByOrgID()[id]);
if (missing.length === 0) return;
void promisePool(missing, 6, async (orgID) => {
await ensureBillingState(orgID);
});
});
const stripeCustomerCell = (state?: BillingState) => {
const value = (state?.stripe_customer_id || '').trim();
if (!value) return 'N/A';
return value;
};
const state = useBillingAdminPanelState();
return (
<Show
when={hostedEnabled()}
when={state.hostedEnabled()}
fallback={
<div class={ORGANIZATION_SETTINGS_PANEL_UNAVAILABLE_CLASS}>
{ORGANIZATION_SETTINGS_PANEL_UNAVAILABLE_MESSAGE}
@@ -169,11 +28,9 @@ export const BillingAdminPanel: Component = () => {
<button
type="button"
onClick={() => {
setBillingByOrgID({});
setExpandedOrgID(null);
void loadOrganizations();
void state.refreshOrganizations();
}}
disabled={loadingOrgs()}
disabled={state.loadingOrgs()}
class="w-full sm:w-auto px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
>
Refresh
@@ -181,178 +38,31 @@ export const BillingAdminPanel: Component = () => {
}
bodyClass="space-y-4"
>
<Show when={orgsError()}>
<Show when={state.orgsError()}>
<div class="rounded-md border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-900 p-3 text-sm text-red-800 dark:text-red-200">
{orgsError()}
{state.orgsError()}
</div>
</Show>
<div class="mt-4">
<PulseDataGrid
data={orgs()}
isLoading={loadingOrgs()}
emptyState={BILLING_ADMIN_EMPTY_STATE}
desktopMinWidth="920px"
columns={[
{
key: 'organization',
label: 'Organization',
render: (org) => {
const orgID = () => (org.org_id || '').trim();
return (
<button
type="button"
class="text-left w-full"
onClick={() => {
const id = orgID();
if (!id) return;
setExpandedOrgID((prev) => (prev === id ? null : id));
void ensureBillingState(id);
}}
>
<div class="font-medium text-base-content">
{org.display_name || org.org_id}
</div>
<div class="text-xs text-muted">
<span class="font-mono">{org.org_id}</span>
{getBillingAdminOrganizationBadges(org).map((badge) => (
<span class={`ml-2 rounded px-1.5 py-0.5 ${badge.badgeClass}`}>
{badge.label}
</span>
))}
</div>
</button>
);
},
},
{
key: 'owner',
label: 'Owner',
render: (org) => (
<span class="font-mono text-xs text-base-content">
{org.owner_user_id || 'N/A'}
</span>
),
},
{
key: 'subscription',
label: 'Subscription',
render: (org) => {
const orgID = () => (org.org_id || '').trim();
const billing = () => billingByOrgID()[orgID()];
return (
<span class="font-mono text-xs text-base-content">
{getLicenseSubscriptionStatusPresentation(billing()?.subscription_state).label}
</span>
);
},
},
{
key: 'trial',
label: 'Trial',
render: (org) => {
const orgID = () => (org.org_id || '').trim();
const billing = () => billingByOrgID()[orgID()];
return (
<span class="text-xs text-base-content">
{getBillingAdminTrialStatus(billing())}
</span>
);
},
},
{
key: 'stripeCustomer',
label: 'Stripe Customer',
render: (org) => {
const orgID = () => (org.org_id || '').trim();
const billing = () => billingByOrgID()[orgID()];
return (
<span
class="font-mono text-xs text-base-content"
title={stripeCustomerCell(billing())}
>
{stripeCustomerCell(billing())}
</span>
);
},
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (org) => {
const orgID = () => (org.org_id || '').trim();
const billing = () => billingByOrgID()[orgID()];
const currentSubState = () =>
(billing()?.subscription_state || '').toLowerCase() || 'unknown';
return (
<div class="inline-flex flex-col sm:flex-row sm:items-center gap-2">
<button
type="button"
onClick={() => {
const id = orgID();
if (!id) return;
void updateSubscriptionState(id, 'suspended');
}}
disabled={
savingByOrgID()[orgID()] ||
billingLoadingByOrgID()[orgID()] ||
currentSubState() === 'suspended'
}
class="px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
>
Suspend Org
</button>
<button
type="button"
onClick={() => {
const id = orgID();
if (!id) return;
void updateSubscriptionState(id, 'active');
}}
disabled={
savingByOrgID()[orgID()] ||
billingLoadingByOrgID()[orgID()] ||
currentSubState() === 'active'
}
class="px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
>
Activate Org
</button>
</div>
);
},
},
]}
keyExtractor={(org) => org.org_id}
isRowExpanded={(org) => expandedOrgID() === (org.org_id || '').trim()}
expandedRender={(org) => {
const orgID = () => (org.org_id || '').trim();
const billing = () => billingByOrgID()[orgID()];
return (
<div class="px-3 pb-3 bg-surface-alt">
<div class="rounded-md border border-border bg-surface-alt p-3">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-2">
<div class="text-xs font-semibold text-muted">Billing state JSON</div>
<button
type="button"
onClick={() => {
const id = orgID();
if (!id) return;
setBillingByOrgID((prev) => ({ ...prev, [id]: undefined }));
void ensureBillingState(id);
}}
class="px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover"
>
Reload
</button>
</div>
<pre class="text-xs overflow-x-auto whitespace-pre-wrap font-mono text-base-content">
{JSON.stringify(billing() ?? { loading: true }, null, 2)}
</pre>
</div>
</div>
);
<BillingAdminOrganizationsTable
orgs={state.orgs()}
isLoading={state.loadingOrgs()}
billingByOrgID={state.billingByOrgID()}
billingLoadingByOrgID={state.billingLoadingByOrgID()}
savingByOrgID={state.savingByOrgID()}
expandedOrgID={state.expandedOrgID()}
onToggleOrganization={(orgID) => {
void state.toggleExpandedOrganization(orgID);
}}
onSuspendOrganization={(orgID) => {
void state.updateSubscriptionState(orgID, 'suspended');
}}
onActivateOrganization={(orgID) => {
void state.updateSubscriptionState(orgID, 'active');
}}
onReloadOrganization={(orgID) => {
void state.reloadOrganization(orgID);
}}
/>
</div>
@@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { BillingAdminPanel } from '../BillingAdminPanel';
import billingAdminOrganizationsTableSource from '../BillingAdminOrganizationsTable.tsx?raw';
import billingAdminPanelSource from '../BillingAdminPanel.tsx?raw';
import billingAdminPanelStateSource from '../useBillingAdminPanelState.ts?raw';
const listOrganizationsMock = vi.fn();
const getBillingStateMock = vi.fn();
@@ -114,4 +117,17 @@ describe('BillingAdminPanel', () => {
expect(payload.subscription_state).toBe('suspended');
expect(payload.plan_version).toBe('cloud_power');
});
it('keeps hosted billing admin split into shell, runtime, and table owners', () => {
expect(billingAdminPanelSource).toContain('./useBillingAdminPanelState');
expect(billingAdminPanelSource).toContain('./BillingAdminOrganizationsTable');
expect(billingAdminPanelSource).not.toContain('createSignal(');
expect(billingAdminPanelSource).not.toContain('BillingAdminAPI.listOrganizations');
expect(billingAdminPanelStateSource).toContain('BillingAdminAPI.listOrganizations');
expect(billingAdminPanelStateSource).toContain('BillingAdminAPI.getBillingState');
expect(billingAdminPanelStateSource).toContain('BillingAdminAPI.putBillingState');
expect(billingAdminPanelStateSource).toContain('promisePool');
expect(billingAdminOrganizationsTableSource).toContain('PulseDataGrid');
expect(billingAdminOrganizationsTableSource).toContain('Billing state JSON');
});
});
@@ -25,7 +25,9 @@ import auditLogPanelSource from '../AuditLogPanel.tsx?raw';
import auditLogStateSource from '../useAuditLogPanelState.ts?raw';
import auditWebhookPanelSource from '../AuditWebhookPanel.tsx?raw';
import auditWebhookStateSource from '../useAuditWebhookPanelState.ts?raw';
import billingAdminOrganizationsTableSource from '../BillingAdminOrganizationsTable.tsx?raw';
import billingAdminPanelSource from '../BillingAdminPanel.tsx?raw';
import billingAdminPanelStateSource from '../useBillingAdminPanelState.ts?raw';
import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
import aiSettingsPanelSource from '../AISettings.tsx?raw';
import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw';
@@ -109,6 +111,8 @@ const extractedModules = [
'../useAPITokenManagerState.ts',
'../useAuditLogPanelState.ts',
'../useAuditWebhookPanelState.ts',
'../BillingAdminOrganizationsTable.tsx',
'../useBillingAdminPanelState.ts',
'../NodeModal.tsx',
'../nodeModalModel.ts',
'../useNodeModalState.ts',
@@ -390,6 +394,18 @@ describe('Settings architecture guardrails', () => {
expect(relayPairingSectionSource).toContain('Pair New Device');
});
it('keeps hosted billing admin split into shell, runtime, and table owners', () => {
expect(billingAdminPanelSource).toContain('./useBillingAdminPanelState');
expect(billingAdminPanelSource).toContain('./BillingAdminOrganizationsTable');
expect(billingAdminPanelSource).not.toContain('createSignal(');
expect(billingAdminPanelSource).not.toContain('BillingAdminAPI.listOrganizations');
expect(billingAdminPanelStateSource).toContain('BillingAdminAPI.listOrganizations');
expect(billingAdminPanelStateSource).toContain('BillingAdminAPI.putBillingState');
expect(billingAdminPanelStateSource).toContain('promisePool');
expect(billingAdminOrganizationsTableSource).toContain('PulseDataGrid');
expect(billingAdminOrganizationsTableSource).toContain('Billing state JSON');
});
it('does not re-inline extracted tab and header metadata definitions', () => {
expect(settingsSource).not.toMatch(/\b(?:const|let|var)\s+baseTabGroups\s*=/);
expect(settingsSource).not.toMatch(/\b(?:const|let|var)\s+SETTINGS_HEADER_META\s*=/);
@@ -0,0 +1,169 @@
import { createEffect, createMemo, createSignal } from 'solid-js';
import {
BillingAdminAPI,
type BillingState,
type HostedOrganizationSummary,
} from '@/api/billingAdmin';
import { isHostedModeEnabled, isMultiTenantEnabled } from '@/stores/license';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import {
getBillingAdminStateUpdateSuccessMessage,
} from '@/utils/licensePresentation';
import {
getOrganizationSettingsLoadErrorMessage as getOrganizationSettingsPanelLoadErrorMessage,
} from '@/utils/organizationSettingsPresentation';
export type BillingStateCache = Record<string, BillingState | undefined>;
async function promisePool<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>) {
if (items.length === 0) return;
const limit = Math.max(1, Math.min(concurrency, items.length));
let idx = 0;
const workers = Array.from({ length: limit }).map(async () => {
for (;;) {
const current = idx;
idx += 1;
if (current >= items.length) return;
await fn(items[current]);
}
});
await Promise.all(workers);
}
export function useBillingAdminPanelState() {
const [orgs, setOrgs] = createSignal<HostedOrganizationSummary[]>([]);
const [loadingOrgs, setLoadingOrgs] = createSignal(false);
const [orgsError, setOrgsError] = createSignal<string | null>(null);
const [billingByOrgID, setBillingByOrgID] = createSignal<BillingStateCache>({});
const [billingLoadingByOrgID, setBillingLoadingByOrgID] = createSignal<Record<string, boolean>>(
{},
);
const [savingByOrgID, setSavingByOrgID] = createSignal<Record<string, boolean>>({});
const [expandedOrgID, setExpandedOrgID] = createSignal<string | null>(null);
const hostedEnabled = createMemo(() => isMultiTenantEnabled() && isHostedModeEnabled());
const setBillingLoading = (orgID: string, value: boolean) => {
setBillingLoadingByOrgID((prev) => ({ ...prev, [orgID]: value }));
};
const setSaving = (orgID: string, value: boolean) => {
setSavingByOrgID((prev) => ({ ...prev, [orgID]: value }));
};
const loadOrganizations = async () => {
setLoadingOrgs(true);
setOrgsError(null);
try {
const next = await BillingAdminAPI.listOrganizations();
setOrgs(next ?? []);
} catch (err) {
logger.error('Failed to list hosted organizations', err);
const msg = err instanceof Error ? err.message : '';
const errorMessage = getOrganizationSettingsPanelLoadErrorMessage(msg, 'billing-admin');
setOrgsError(errorMessage);
notificationStore.error(errorMessage);
} finally {
setLoadingOrgs(false);
}
};
const ensureBillingState = async (orgID: string): Promise<BillingState | null> => {
const cached = billingByOrgID()[orgID];
if (cached) return cached;
if (billingLoadingByOrgID()[orgID]) {
return null;
}
setBillingLoading(orgID, true);
try {
const state = await BillingAdminAPI.getBillingState(orgID);
setBillingByOrgID((prev) => ({ ...prev, [orgID]: state }));
return state;
} catch (err) {
logger.error('Failed to fetch billing state', { orgID, err });
return null;
} finally {
setBillingLoading(orgID, false);
}
};
const updateSubscriptionState = async (orgID: string, nextState: 'suspended' | 'active') => {
setSaving(orgID, true);
try {
const current =
(await ensureBillingState(orgID)) ?? (await BillingAdminAPI.getBillingState(orgID));
const planVersion = current.plan_version?.trim() || undefined;
const payload: BillingState = {
...current,
subscription_state: nextState,
capabilities: Array.isArray(current.capabilities) ? current.capabilities : [],
limits: current.limits ?? {},
meters_enabled: Array.isArray(current.meters_enabled) ? current.meters_enabled : [],
...(planVersion ? { plan_version: planVersion } : {}),
};
const saved = await BillingAdminAPI.putBillingState(orgID, payload);
setBillingByOrgID((prev) => ({ ...prev, [orgID]: saved }));
notificationStore.success(getBillingAdminStateUpdateSuccessMessage(nextState), 2500);
} catch (err) {
logger.error('Failed to update billing state', { orgID, err });
const msg = err instanceof Error ? err.message : 'Failed to update billing state';
notificationStore.error(msg);
} finally {
setSaving(orgID, false);
}
};
const refreshOrganizations = async () => {
setBillingByOrgID({});
setExpandedOrgID(null);
await loadOrganizations();
};
const toggleExpandedOrganization = async (orgID: string) => {
if (!orgID) return;
setExpandedOrgID((prev) => (prev === orgID ? null : orgID));
await ensureBillingState(orgID);
};
const reloadOrganization = async (orgID: string) => {
if (!orgID) return;
setBillingByOrgID((prev) => ({ ...prev, [orgID]: undefined }));
await ensureBillingState(orgID);
};
createEffect(() => {
if (!hostedEnabled()) return;
void loadOrganizations();
});
createEffect(() => {
const list = orgs();
if (!hostedEnabled() || loadingOrgs() || list.length === 0) return;
const missing = list.map((o) => o.org_id).filter((id) => id && !billingByOrgID()[id]);
if (missing.length === 0) return;
void promisePool(missing, 6, async (orgID) => {
await ensureBillingState(orgID);
});
});
return {
billingByOrgID,
billingLoadingByOrgID,
expandedOrgID,
hostedEnabled,
loadingOrgs,
orgs,
orgsError,
refreshOrganizations,
reloadOrganization,
savingByOrgID,
toggleExpandedOrganization,
updateSubscriptionState,
};
}
@@ -287,7 +287,9 @@ import organizationAccessPanelSource from '@/components/Settings/OrganizationAcc
import organizationAccessLoadingStateSource from '@/components/Settings/OrganizationAccessLoadingState.tsx?raw';
import organizationAccessManagementSectionSource from '@/components/Settings/OrganizationAccessManagementSection.tsx?raw';
import organizationAccessMembersSectionSource from '@/components/Settings/OrganizationAccessMembersSection.tsx?raw';
import billingAdminOrganizationsTableSource from '@/components/Settings/BillingAdminOrganizationsTable.tsx?raw';
import billingAdminPanelSource from '@/components/Settings/BillingAdminPanel.tsx?raw';
import billingAdminPanelStateSource from '@/components/Settings/useBillingAdminPanelState.ts?raw';
import organizationBillingLoadingStateSource from '@/components/Settings/OrganizationBillingLoadingState.tsx?raw';
import organizationBillingPanelSource from '@/components/Settings/OrganizationBillingPanel.tsx?raw';
import organizationOverviewLoadingStateSource from '@/components/Settings/OrganizationOverviewLoadingState.tsx?raw';
@@ -880,15 +882,18 @@ describe('frontend resource type boundaries', () => {
expect(organizationBillingPanelSource).toContain('./OrganizationBillingLoadingState');
expect(organizationBillingStateSource).toContain('normalizeOrgScope(getOrgID())');
expect(organizationAccessStateSource).toContain('@/utils/organizationSettingsPresentation');
expect(billingAdminPanelSource).toContain('@/utils/organizationSettingsPresentation');
expect(billingAdminPanelSource).toContain('@/utils/licensePresentation');
expect(billingAdminPanelStateSource).toContain('@/utils/organizationSettingsPresentation');
expect(billingAdminOrganizationsTableSource).toContain('@/utils/licensePresentation');
expect(organizationOverviewStateSource).toContain('@/utils/organizationSettingsPresentation');
expect(organizationSharingStateSource).toContain('@/utils/organizationSettingsPresentation');
expect(organizationBillingPanelSource).toContain('@/utils/organizationSettingsPresentation');
expect(organizationBillingStateSource).toContain('@/utils/licensePresentation');
expect(organizationBillingStateSource).toContain('@/utils/organizationSettingsPresentation');
expect(billingAdminPanelSource).toContain('./useBillingAdminPanelState');
expect(billingAdminPanelSource).toContain('./BillingAdminOrganizationsTable');
expect(organizationBillingPanelSource).not.toContain('normalizeOrgScope(getOrgID())');
expect(organizationBillingPanelSource).not.toContain('createSignal(');
expect(billingAdminPanelSource).not.toContain('createSignal(');
expect(organizationBillingPanelSource).not.toContain('Grace Period');
expect(organizationBillingPanelSource).not.toContain('No License');
expect(organizationAccessPanelSource).not.toContain(
@@ -938,6 +943,9 @@ describe('frontend resource type boundaries', () => {
expect(billingAdminPanelSource).not.toContain('No trial');
expect(billingAdminPanelSource).not.toContain('soft-deleted');
expect(billingAdminPanelSource).not.toContain('Organization billing suspended');
expect(billingAdminPanelStateSource).toContain('getBillingAdminStateUpdateSuccessMessage');
expect(billingAdminPanelStateSource).toContain('getOrganizationSettingsPanelLoadErrorMessage');
expect(billingAdminOrganizationsTableSource).toContain('getBillingAdminTrialStatus');
expect(organizationSettingsPresentationSource).toContain(
'export function getOrganizationSettingsLoadErrorMessage',
);
@@ -2550,6 +2550,28 @@ class SubsystemLookupTest(unittest.TestCase):
},
)
def test_lookup_paths_assigns_billing_admin_state_owner_to_cloud_paid(self) -> None:
result = lookup_paths(
["frontend-modern/src/components/Settings/useBillingAdminPanelState.ts"]
)
self.assertEqual(result["unowned_runtime_files"], [])
file_entry = result["files"][0]
self.assertEqual(file_entry["classification"], "runtime")
self.assertEqual(len(file_entry["matches"]), 1)
match = file_entry["matches"][0]
self.assertEqual(match["subsystem"], "cloud-paid")
self.assertEqual(
match["contract"],
"docs/release-control/v6/internal/subsystems/cloud-paid.md",
)
self.assertEqual(match["lane_context"]["lane_id"], "L3")
self.assertEqual(
match["verification_requirement"]["id"],
"hosted-billing-admin-surface",
)
def test_lookup_paths_keeps_cross_cutting_resolved_decisions_for_lane(self) -> None:
result = lookup_paths(["internal/monitoring/monitor.go"])
match = next(