mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
Split hosted billing admin runtime owners
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user