Skip to content

feat(cli): enforce token-balance suspension on paid plans below -200 - #174

Merged
birme merged 2 commits into
mainfrom
fix/suspend-paid-plans-below-threshold
Nov 11, 2026
Merged

birme merged 2 commits into
mainfrom
fix/suspend-paid-plans-below-threshold

Conversation

@birme

@birme birme commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Summary

admin remove-exceeding-tenant-instances (the nightly admin-daily CronJob) suspended instances only for tenants on the FREE plan with remainingTokens < -200. It now also suspends for self-service paid plans (PERSONAL, PROFESSIONAL, BUSINESS).

The policy is an explicit allowlist (FREE, PERSONAL, PROFESSIONAL, BUSINESS). FREE tenants are always suspended, including ones flagged managedTenant or subWorkspaceBillingException (unchanged from before). Paid tenants flagged managedTenant or subWorkspaceBillingException (billed off-platform) are skipped. Not suspended by the sweep: ENTERPRISE (billed by agreement), unknown plan types, and tenants with no plan record. The policy lives in suspend-policy.ts so it is unit-testable separately from the mocked money.ts.

Rollout (not done by this PR)

Merging does nothing in prod until the osccli image tag in the admin-daily CronJob is bumped (infra-osaas) after a release. Run admin remove-exceeding-tenant-instances without --apply first and read how many paid tenants it lists. The first live run will suspend every paid tenant already below -200, not only newly crossing ones.

Contract / consumer audit

CLI behaviour change only. No API, status code or response shape changes; reads the existing /tenantplan fields managedTenant and subWorkspaceBillingException (both optional). Consumer of the CLI: the admin-daily CronJob only.

Tests

cmd.test.ts: paid plans suspended below threshold; ENTERPRISE, managed, billing exception, empty plan type and no plan record are not; -200 exactly is not suspended. 36 tests pass in packages/cli/src/admin.

Risks not tested

No dry run against real prod data (no admin credentials in this session), so the number of affected paid tenants is unknown. Resume of a suspended paid tenant is manual. Community wiki Pricing.md promises a paid-plan "grace period" (undefined); wording needs aligning once this ships.

Lessons

The mocked ./money module in cmd.test.ts swallows any new export added there, so policy helpers added next to mocked fetchers silently become undefined in tests; keep them in a separate module.

🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com

remove-exceeding-tenant-instances previously suspended only FREE-plan
tenants. It now also covers self-service paid plans (PERSONAL, PROFESSIONAL,
BUSINESS). ENTERPRISE, managed reseller sub-workspaces, tenants with the
sub-workspace billing exception, and tenants without a plan record are
skipped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@birme birme self-assigned this Nov 11, 2026
@birme

birme commented Nov 11, 2026

Copy link
Copy Markdown
Member Author

Research Review

Verified

  • Field names / spellings: GET /tenantplan (osaas-money-manager/src/api_tenant_plan.ts, response schema TenantPlanList) returns planType enum FREE|PERSONAL|PROFESSIONAL|BUSINESS|ENTERPRISE (src/manager/schema/TenantPlan.ts), plus optional managedTenant and subWorkspaceBillingException booleans, exact names match the PR's TenantPlan type. List path uses getAllTenantPlans() -> d.toObject() (a plain object, no spread), so the optional fields survive serialization.
  • Balance semantics: /tokencount -> getAllTokenCounts (src/manager/tenantplan.ts:~225-350): remainingTokens = bag.availableTokens - (lifetime usage tokens + metered tokens). Bag grows by daily refill (PERSONAL 40, PROFESSIONAL 300, BUSINESS 1000), the one-time 300 activation bonus, and token packs. Nothing is billed as overage, so a paid tenant below -200 has genuinely consumed beyond its allowance; sweeping is consistent with the Free rule.
  • Tests: ran npx jest --runInBand --coverage=false src/admin in packages/cli: 3 suites, 36/36 pass, matching the PR body. Boundary (-200 exactly) and each exclusion are covered. suspend-policy.ts is a pure function with no object-shape hazard.
  • Consumers / contract: CLI-only change, no API change. No other client-ts code reads the threshold or FREE check (grep).

Needs Verification (non-blocking)

  • suspend-policy.ts:15: FREE tenants with managedTenant/subWorkspaceBillingException are now also skipped. The old code suspended every FREE tenant below -200; the new policy applies the managed/exception exclusion before checking plan, so a FREE managed sub-workspace stops being suspended. The PR body presents the widening as additive and does not mention this narrowing for FREE. Probably intended, but state it (or add a FREE+managed test).
  • Denylist, not allowlist: any planType other than ENTERPRISE/empty is suspendable, whereas the PR body says "PERSONAL, PROFESSIONAL, BUSINESS". A future plan enum value would be swept automatically. Consider an explicit allowlist FREE|PERSONAL|PROFESSIONAL|BUSINESS.
  • Payment-suspended tenants: refillAllTokenBags (tenantplan.ts:~478) skips refill for purchase.status === 'SUSPENDED' (past_due), so those paid tenants drift negative with no refill and will now be suspended on the next run. Plausibly desirable, but it is a population the dry run should be checked for.
  • Metered usage (#413) is folded into remainingTokens for the same bag, so heavy metered consumers (e.g. products on rate-card meters) are in scope of the sweep. The dry run should be reviewed for these tenants before the image tag bump.
  • No test for FREE in the new block (existing FREE test covers it) and none for an unknown planType.

Adjacent Stubs Detected

None in touched files (grepped cmd.ts, money.ts, suspend-policy.ts).

Orphaned Files Detected

None: suspend-policy.ts is imported by cmd.ts.

Escape Hatch Coverage

No terminal-rejection branch added. The sweep is dry-run by default (--apply opt-in), and the PR body documents running without --apply first. Resume is manual (disclosed). Passing-path concern N/A.

Object Shape Verification

No spread of fetched objects in the diff.

Contract diff

No request handler or timeout changed. Files checked: packages/cli/src/admin/{cmd,money,suspend-policy}.ts. This is a behavior change of a nightly CLI job (more tenants suspended), not an API contract; the PR discloses it and the rollout gate (image tag bump in infra-osaas).

Impact Analysis

  • Blast radius: admin-daily CronJob only, after release + osccli tag bump. First live run suspends every paid tenant already below -200 at once (disclosed).
  • Downstream consumers: none found in client-ts or money-manager/deploy-manager for -200/suspend threshold.
  • Coordinated changes needed: infra-osaas image tag bump after release; community wiki Pricing.md lines 14-15 promise an undefined paid-plan "grace period" and need alignment.
  • Breaking change risk: medium (business-impacting for paying tenants), low technically.

Risks Not Tested

  • No dry run against real prod data; the number and identity of affected paid tenants is unknown (PR also says so).
  • Stripe/webhook timing: tenants who just paid a token pack or renewed between /tenantplan and /tokencount reads (two separate calls, not atomic).
  • Whether getInstancesToRemove/suspend behaves the same for paid-tenant instance types (My Apps, HA) was not traced.
  • Live-service behaviour of /tenantplan in prod not curled (no admin key).

Research Suggestions

  • Run the dry run first and review the list for purchase.status === 'SUSPENDED' and metered-usage tenants.
  • Clarify the FREE+managedTenant behavior change in the PR body or restore it.

Verdict

No blocking issues found.


Reviewed by PR Research Validator.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@birme
birme merged commit 188fffa into main Nov 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant