From ca6ae3bf33eac218a2572638de6cd4733654dbd8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 21 Mar 2026 11:28:49 +0000 Subject: [PATCH] Split hosted billing admin runtime owners --- .../v6/internal/subsystems/cloud-paid.md | 41 ++- .../v6/internal/subsystems/registry.json | 6 +- .../BillingAdminOrganizationsTable.tsx | 174 +++++++++ .../components/Settings/BillingAdminPanel.tsx | 344 ++---------------- .../__tests__/BillingAdminPanel.test.tsx | 16 + .../__tests__/settingsArchitecture.test.ts | 16 + .../Settings/useBillingAdminPanelState.ts | 169 +++++++++ .../frontendResourceTypeBoundaries.test.ts | 12 +- .../release_control/subsystem_lookup_test.py | 22 ++ 9 files changed, 465 insertions(+), 335 deletions(-) create mode 100644 frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx create mode 100644 frontend-modern/src/components/Settings/useBillingAdminPanelState.ts diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 5a0691ed1..1af1911c4 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 00077b447..05d29e0e5 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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": [], diff --git a/frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx b/frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx new file mode 100644 index 000000000..c1e9af9f4 --- /dev/null +++ b/frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx @@ -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; + 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; +} + +const stripeCustomerCell = (state?: BillingState) => { + const value = (state?.stripe_customer_id || '').trim(); + if (!value) return 'N/A'; + return value; +}; + +export const BillingAdminOrganizationsTable: Component = ( + props, +) => ( + { + const orgID = (org.org_id || '').trim(); + return ( + + ); + }, + }, + { + key: 'owner', + label: 'Owner', + render: (org) => ( + {org.owner_user_id || 'N/A'} + ), + }, + { + key: 'subscription', + label: 'Subscription', + render: (org) => { + const billing = props.billingByOrgID[(org.org_id || '').trim()]; + return ( + + {getLicenseSubscriptionStatusPresentation(billing?.subscription_state).label} + + ); + }, + }, + { + key: 'trial', + label: 'Trial', + render: (org) => { + const billing = props.billingByOrgID[(org.org_id || '').trim()]; + return {getBillingAdminTrialStatus(billing)}; + }, + }, + { + key: 'stripeCustomer', + label: 'Stripe Customer', + render: (org) => { + const billing = props.billingByOrgID[(org.org_id || '').trim()]; + const cellValue = stripeCustomerCell(billing); + return ( + + {cellValue} + + ); + }, + }, + { + 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 ( +
+ + +
+ ); + }, + }, + ]} + keyExtractor={(org) => org.org_id} + isRowExpanded={(org) => props.expandedOrgID === (org.org_id || '').trim()} + expandedRender={(org) => { + const orgID = (org.org_id || '').trim(); + return ( +
+
+
+
Billing state JSON
+ +
+
+              {JSON.stringify(props.billingByOrgID[orgID] ?? { loading: true }, null, 2)}
+            
+
+
+ ); + }} + /> +); diff --git a/frontend-modern/src/components/Settings/BillingAdminPanel.tsx b/frontend-modern/src/components/Settings/BillingAdminPanel.tsx index 7944565e1..d7d7be0f8 100644 --- a/frontend-modern/src/components/Settings/BillingAdminPanel.tsx +++ b/frontend-modern/src/components/Settings/BillingAdminPanel.tsx @@ -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; - -async function promisePool(items: T[], concurrency: number, fn: (item: T) => Promise) { - 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([]); - const [loadingOrgs, setLoadingOrgs] = createSignal(false); - const [orgsError, setOrgsError] = createSignal(null); - - const [billingByOrgID, setBillingByOrgID] = createSignal({}); - const [billingLoadingByOrgID, setBillingLoadingByOrgID] = createSignal>( - {}, - ); - const [savingByOrgID, setSavingByOrgID] = createSignal>({}); - - const [expandedOrgID, setExpandedOrgID] = createSignal(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 => { - 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 ( {ORGANIZATION_SETTINGS_PANEL_UNAVAILABLE_MESSAGE} @@ -169,11 +28,9 @@ export const BillingAdminPanel: Component = () => { - ); - }, - }, - { - key: 'owner', - label: 'Owner', - render: (org) => ( - - {org.owner_user_id || 'N/A'} - - ), - }, - { - key: 'subscription', - label: 'Subscription', - render: (org) => { - const orgID = () => (org.org_id || '').trim(); - const billing = () => billingByOrgID()[orgID()]; - return ( - - {getLicenseSubscriptionStatusPresentation(billing()?.subscription_state).label} - - ); - }, - }, - { - key: 'trial', - label: 'Trial', - render: (org) => { - const orgID = () => (org.org_id || '').trim(); - const billing = () => billingByOrgID()[orgID()]; - return ( - - {getBillingAdminTrialStatus(billing())} - - ); - }, - }, - { - key: 'stripeCustomer', - label: 'Stripe Customer', - render: (org) => { - const orgID = () => (org.org_id || '').trim(); - const billing = () => billingByOrgID()[orgID()]; - return ( - - {stripeCustomerCell(billing())} - - ); - }, - }, - { - 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 ( -
- - -
- ); - }, - }, - ]} - 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 ( -
-
-
-
Billing state JSON
- -
-
-                      {JSON.stringify(billing() ?? { loading: true }, null, 2)}
-                    
-
-
- ); + { + void state.toggleExpandedOrganization(orgID); + }} + onSuspendOrganization={(orgID) => { + void state.updateSubscriptionState(orgID, 'suspended'); + }} + onActivateOrganization={(orgID) => { + void state.updateSubscriptionState(orgID, 'active'); + }} + onReloadOrganization={(orgID) => { + void state.reloadOrganization(orgID); }} /> diff --git a/frontend-modern/src/components/Settings/__tests__/BillingAdminPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/BillingAdminPanel.test.tsx index 68256dd0b..f016bfa9f 100644 --- a/frontend-modern/src/components/Settings/__tests__/BillingAdminPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/BillingAdminPanel.test.tsx @@ -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'); + }); }); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 1fbf03149..56c5b5e5b 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -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*=/); diff --git a/frontend-modern/src/components/Settings/useBillingAdminPanelState.ts b/frontend-modern/src/components/Settings/useBillingAdminPanelState.ts new file mode 100644 index 000000000..159aaf71a --- /dev/null +++ b/frontend-modern/src/components/Settings/useBillingAdminPanelState.ts @@ -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; + +async function promisePool(items: T[], concurrency: number, fn: (item: T) => Promise) { + 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([]); + const [loadingOrgs, setLoadingOrgs] = createSignal(false); + const [orgsError, setOrgsError] = createSignal(null); + const [billingByOrgID, setBillingByOrgID] = createSignal({}); + const [billingLoadingByOrgID, setBillingLoadingByOrgID] = createSignal>( + {}, + ); + const [savingByOrgID, setSavingByOrgID] = createSignal>({}); + const [expandedOrgID, setExpandedOrgID] = createSignal(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 => { + 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, + }; +} diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 89a55f0ad..084d097ab 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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', ); diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 2f864e334..a77158596 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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(