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
Anyerror/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.
