diff --git a/packages/cli/src/admin/cmd.test.ts b/packages/cli/src/admin/cmd.test.ts index ddc8836..a95c408 100644 --- a/packages/cli/src/admin/cmd.test.ts +++ b/packages/cli/src/admin/cmd.test.ts @@ -97,4 +97,64 @@ describe('remove-exceeding-tenant-instances', () => { expect(summary()).toContain('1 instance failures'); expect(process.exitCode).toBe(1); }); + + it.each(['PERSONAL', 'PROFESSIONAL', 'BUSINESS'])( + 'suspends tenants on the paid plan %s below the threshold', + async (planType) => { + (getTenantPlanMap as jest.Mock).mockResolvedValue({ t1: { planType } }); + mockSuspend.mockResolvedValue(undefined); + await run(); + expect(mockSuspend).toHaveBeenCalledTimes(1); + expect(summary()).toContain('suspended 1 instances'); + } + ); + + it.each([ + ['ENTERPRISE', { planType: 'ENTERPRISE' }], + [ + 'managed sub-workspace', + { planType: 'PROFESSIONAL', managedTenant: true } + ], + [ + 'billing exception', + { planType: 'BUSINESS', subWorkspaceBillingException: true } + ], + ['no plan type', { planType: '' }], + ['an unknown plan type', { planType: 'FUTURE_PLAN' }] + ])('does not suspend %s', async (_name, plan) => { + (getTenantPlanMap as jest.Mock).mockResolvedValue({ t1: plan }); + await run(); + expect(mockSuspend).not.toHaveBeenCalled(); + expect(summary()).toContain('suspended 0 instances'); + }); + + it('does not suspend a paid tenant at or above the threshold', async () => { + (getTenantPlanMap as jest.Mock).mockResolvedValue({ + t1: { planType: 'PERSONAL' } + }); + (getTenantTokenCounts as jest.Mock).mockResolvedValue([ + { tenantId: 't1', remainingTokens: -200 } + ]); + await run(); + expect(mockSuspend).not.toHaveBeenCalled(); + }); + + it('does not suspend a tenant that has no plan record', async () => { + (getTenantPlanMap as jest.Mock).mockResolvedValue({}); + await run(); + expect(mockSuspend).not.toHaveBeenCalled(); + }); + + it.each([ + ['managed sub-workspace', { planType: 'FREE', managedTenant: true }], + [ + 'billing exception', + { planType: 'FREE', subWorkspaceBillingException: true } + ] + ])('still suspends a FREE tenant flagged as %s', async (_name, plan) => { + (getTenantPlanMap as jest.Mock).mockResolvedValue({ t1: plan }); + mockSuspend.mockResolvedValue(undefined); + await run(); + expect(mockSuspend).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/cli/src/admin/cmd.ts b/packages/cli/src/admin/cmd.ts index 3d64a6b..82670a3 100644 --- a/packages/cli/src/admin/cmd.ts +++ b/packages/cli/src/admin/cmd.ts @@ -31,6 +31,10 @@ import { removeSubscriptionForTenant } from './subscription'; import { getTenantPlanMap, getTenantTokenCounts } from './money'; +import { + isSuspendableByTokenBalance, + TOKEN_SUSPEND_THRESHOLD +} from './suspend-policy'; const ALREADY_SUSPENDED_REASON = 'Instance is already suspended'; @@ -97,7 +101,7 @@ export default function cmdAdmin() { admin .command('remove-exceeding-tenant-instances') .description( - 'Suspend all instances for all tenants with a negative usage token balance' + 'Suspend all instances for tenants below the token balance threshold (-200), on the Free plan and on self-service paid plans' ) .option('--apply', 'Actually suspend instances, otherwise just a dry run') .action(async (options, command) => { @@ -107,7 +111,7 @@ export default function cmdAdmin() { const tenantPlanMap = await getTenantPlanMap(environment); const tokenCounts = await getTenantTokenCounts(environment); const tenantsExceeding = tokenCounts.filter( - (tenant) => tenant.remainingTokens < -200 + (tenant) => tenant.remainingTokens < TOKEN_SUSPEND_THRESHOLD ); let instancesSuspended = 0; let instancesFailed = 0; @@ -115,7 +119,8 @@ export default function cmdAdmin() { let tenantsFailed = 0; const tenantsExceedingCount = tenantsExceeding.length; for (const tenant of tenantsExceeding) { - if (tenantPlanMap[tenant.tenantId]?.planType === 'FREE') { + const plan = tenantPlanMap[tenant.tenantId]; + if (isSuspendableByTokenBalance(plan)) { try { const instancesToRemove = await getInstancesToRemove( tenant.tenantId, @@ -123,7 +128,7 @@ export default function cmdAdmin() { ); if (instancesToRemove.length > 0) { console.log( - `Tenant ${tenant.tenantId} has a negative or low token balance of ${tenant.remainingTokens} tokens and is on the 'FREE' plan` + `Tenant ${tenant.tenantId} has a negative or low token balance of ${tenant.remainingTokens} tokens and is on the '${plan.planType}' plan` ); console.log('Suspending all instances for this tenant...'); for (const item of instancesToRemove) { diff --git a/packages/cli/src/admin/money.ts b/packages/cli/src/admin/money.ts index 3096185..6407410 100644 --- a/packages/cli/src/admin/money.ts +++ b/packages/cli/src/admin/money.ts @@ -11,6 +11,8 @@ export type TenantTokenCount = { export type TenantPlan = { tenantId: string; planType: string; + managedTenant?: boolean; + subWorkspaceBillingException?: boolean; }; export async function getTenantTokenCounts(environment: string) { diff --git a/packages/cli/src/admin/suspend-policy.ts b/packages/cli/src/admin/suspend-policy.ts new file mode 100644 index 0000000..ca28d85 --- /dev/null +++ b/packages/cli/src/admin/suspend-policy.ts @@ -0,0 +1,21 @@ +import type { TenantPlan } from './money'; + +export const TOKEN_SUSPEND_THRESHOLD = -200; + +const FREE_PLAN = 'FREE'; +const PAID_SELF_SERVICE_PLANS = ['PERSONAL', 'PROFESSIONAL', 'BUSINESS']; + +/** + * Whether the token-balance sweep may suspend instances for a tenant on this + * plan. Allowlist: FREE (always, regardless of flags) and the self-service + * paid plans PERSONAL, PROFESSIONAL and BUSINESS. A paid tenant that is a + * managed reseller sub-workspace, or has the sub-workspace billing exception + * (billed off-platform), is skipped. ENTERPRISE, unknown plan types and + * tenants without a plan record are never suspended by the sweep. + */ +export function isSuspendableByTokenBalance(plan?: TenantPlan): boolean { + if (!plan) return false; + if (plan.planType === FREE_PLAN) return true; + if (!PAID_SELF_SERVICE_PLANS.includes(plan.planType)) return false; + return !plan.managedTenant && !plan.subWorkspaceBillingException; +}