Skip to main content
This is the map from every billing.*/subscription.* state shape the gateway serves (from NAS) to what the terminal actually renders, and from every typed refusal/error code to its exact user-facing copy and recovery action. The guarantee: no NAS billing state and no typed refusal falls through to a generic toast — every case below is an explicit branch in ui-tui/src/app/slash/commands/topup.ts, ui-tui/src/components/billingOverlay.tsx, or ui-tui/src/components/subscriptionOverlay.tsx. An unknown code still degrades gracefully: it hits the default branch (a generic-but-real message pulled from the server payload, never a blank toast) rather than crashing or silently dropping the refusal.

1. billing.state shapes → render

Source: ui-tui/src/components/billingOverlay.tsx (OverviewScreen, BuyScreen, AutoReloadScreen), ui-tui/src/app/slash/commands/topup.ts (/topup run). Note: full = s.is_admin && s.cli_billing_enabled gates the org-level switch, not the per-terminal billing:manage scope — that’s discovered reactively (a charge 403s insufficient_scope) and routes to the resumable step-up screen instead of a preflight check.

2. Refusal codes (renderBillingError, in code order)

Source: renderBillingError in ui-tui/src/app/slash/commands/topup.ts:37-149. “Portal” row = sys('Portal: {portal_url}') is appended whenever portal_url is present, for every code (including default).

3. Charge settlement outcomes (pollCharge / renderChargeFailed)

Source: pollCharge (ui-tui/src/app/slash/commands/topup.ts:170-258) and renderChargeFailed (:260-290). Poll cadence: 2s interval, 5-minute cap (POLL_INTERVAL_MS=2000, POLL_CAP_MS=5*60*1000), applied on every non-terminal path (pending and throttled), so a sustained 429/503 can’t keep the poll alive forever.

4. Subscription preview / pending-change / upgrade outcomes

Source: previewAndRoute, applyPendingAndRoute, upgradeResult, stepUpDenialResult in ui-tui/src/components/subscriptionOverlay.tsx. Preview effect values (drive the Confirm screen): Pending-change apply outcomes (applyPendingAndRoute): Upgrade status × reason matrix (upgradeResult, checked in this order — reason is checked before status): Eventual-consistency apply-poll (ResultScreen, only after status: 'upgraded'): polls billing/subscription state every 2s (UPGRADE_CONFIRM_INTERVAL_MS) up to 15 attempts (UPGRADE_CONFIRM_ATTEMPTS, i.e. ~30s) until current.tier_id flips to the target. While waiting the screen reads Applying…; if it never flips inside the budget it reads Still applying / Your upgrade succeeded and is still applying — refresh in a moment. — the upgrade is never re-reported as failed just because NAS hasn’t caught up yet. Step-up denial copy (stepUpDenialResult, subscription flow): A repeat scope denial during a post-grant replay never re-enters the step-up screen (it’s already mounted there — re-patching would freeze it); allowStepUp=false instead surfaces a terminal result: Remote Spending still isn’t active for this terminal — the authorization didn’t take. Retry, or make this change on the portal.

Text-mode (CLI) parity

cli.py’s _show_billing / _billing_overview and _show_subscription / _subscription_overview render the same state shapes (balance title, two-bar dollar usage, auto-reload line, card line, monthly cap) and share the “fail-open on logged-out/portal-hiccup, never crash” discipline. The CLI’s /subscription gives a paid admin/owner in an interactive context the full in-terminal change flow (tier picker → preview → confirm → apply, parity with the TUI overlay); members and non-interactive contexts fall back to _billing_portal_hint’s deep-link to subscription_manage_url. /topup’s interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and non-interactive contexts fall back to the same text + portal-link rendering, never prompting.

Forward compatibility

Any error/status/reason code not in the tables above lands on the default branch in renderBillingError (§2) or errorResult/upgradeResult’s fallthrough (§4): it still renders the server’s own message (never blank, never a crash), just without bespoke copy or a typed recovery affordance. NAS W3 introduces card-health codes (card_paused, card_expired, card_mismatch) that are not yet typed here — until a client update adds explicit branches, they will arrive as unknown codes and degrade to this default path.