feat(tui+cli): change your Nous plan from the terminal (/subscription, /topup, terminal-billing UX) (#51639)

* feat(tui): rename /billing slash command to /topup

Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.

* refactor(tui): extract overlay primitives to shared module

Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.

* feat(tui): add /subscription + /topup CTAs to /usage output

Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.

* feat(tui): add subscription wire types

Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.

* feat(gateway): add subscription.state + subscription.manage_link RPCs

- agent/subscription_view.py: SubscriptionState dataclass + fail-open
  build_subscription_state() (mirrors billing_view pattern) +
  get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
  post_subscription_manage_link() HTTP helpers for the two NAS endpoints
  (WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
  when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
  subscription.state RPC (fail-open) + subscription.manage_link RPC
  (returns {ok,kind,url} or typed error envelope via
  _serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
  HTTP round-trip, not a device flow.

* feat(tui): add subscription overlay state types + store slot

Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.

* feat(tui): build SubscriptionOverlay — overview + confirm + handoff

Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.

* feat(tui): add /subscription command + overlay wiring

- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
  refreshState, requestRemoteSpending) + run handler that fetches
  subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
  includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
  /upgrade alias, /subscription resolves).

* fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type

Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).

* feat(tui/subscription): render cancellation-scheduled note with headline precedence

Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'

Headline precedence when multiple flags co-occur:
  past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.

* feat(tui/subscription): team-context screen — redirect to /topup for team orgs

Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:

  'This terminal is connected to {org_name}. Teams run on shared
   credits — use /topup to add funds. Personal subscriptions live
   on your personal account.'

The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.

* fix(subscription): drop manage-link gateway RPC, build URL locally

The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.

- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
  it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
  /manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)

* chore(subscription): drop unused format_money import

* feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs

Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
  'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).

* feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan

- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
  bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
  (a card-failing subscriber returns as a normal plan now), and treat no-plan as
  current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
  drive every state (CLI + live TUI) with no portal.

Verified against handoff 2026-06-24_subscription-tui-handoff.md.

* feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)

Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
  reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
  from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
  recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
  immediately (no 15-min zombie button), handles session_revoked, the dual-
  emitted cli_billing_disabled/remote_spending_disabled, role_required,
  idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
  balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.

Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.

* refactor(subscription): remove dead step-up scaffolding from /subscription

/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.

* feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path

Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
  needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
  opens via the existing out-of-band billing.step_up.verification event) →
  replay the held charge (pendingCharge.amount) and settle, with no command
  re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
  the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.

Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).

* feat(billing): shared dollar usage model + two-bar view (drop "credits")

Single source of truth for the /usage and /subscription usage bars across
TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total
remaining, monthly allowance, renewal) and produces a surface-agnostic model:
two full-resolution bars (plan allowance + purchased top-up), a status
classification (free | healthy | low | depleted), and a human renewal date.

- agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account
  (fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware),
  format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold.
- tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a
  usage.bars RPC, and the model embedded into subscription.state so the overlay
  renders the same bars from its single fetch.
- Dollars only, never "credits"; two separate bars (not a crammed
  three-segment one) for legibility at terminal widths.
- tests/agent/test_billing_usage.py: status classification, bar math
  (clamp/over-cap), NaN/Inf rejection, fail-open invariants.

* feat(tui): dollar usage bars on /usage + /subscription, drop tier picker

Render the shared two-bar dollar model in both overlays; strip "credits" and
the in-terminal tier selection per UX feedback.

- overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance,
  green top-up) + usageBarsText for the /usage panel. Plan name labels the
  bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up
  "never expires".
- subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the
  breakdown), human renewal date, state-matched nudges (free upsell / <$5
  low alert) with box-safe ASCII markers (! / >) instead of the width-unstable
  emoji that broke the border. Tier picker removed — overview shows usage +
  plan, then "Manage on portal" / "Close" (free users get "Start a
  subscription"). No "credits" anywhere.
- session.ts: /usage renders the dollar bars + balance summary, falling back
  to the legacy credits lines only when the model is unavailable; CTA reworded.
- gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on
  SessionUsageResponse/SubscriptionStateResponse.
- Tests updated to the new contract (no "credits", "left of", dedup, markers).

* feat(cli): mirror dollar usage bars on /usage + /subscription

CLI parity with the TUI billing rework, from the same shared usage model.

- _print_nous_credits_block (/usage) and _subscription_overview render the
  two-bar dollar view (plan name on the bar, "$X left of $Y · N% used",
  top-up "never expires", total spendable) instead of the credits-worded block.
- Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and
  every user-facing "credits"; team copy says "shared balance".
- Human renewal date via the shared format_renews; status line dedupes the
  "$X left"; free upsell + <$5 low alert with ASCII markers.
- /subscription manage modal no longer dumps the raw manage-subscription URL
  in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it.
  Title is "Manage your subscription" (no in-terminal plan change). The raw URL
  stays only in the non-interactive / not-admin fallbacks, which have no menu.
- /usage token-usage panel (model, tokens, cost, context) left untouched.

* feat(billing): embed dollar usage model into billing.state for /topup

The /topup overview renders the same two-bar dollar usage (plan + top-up) as
/usage and /subscription. Embed the shared usage model into the billing.state
RPC payload (mirrors subscription.state) so the overlay gets the bars from its
single fetch, and add the `usage` field to BillingStateResponse.

* feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume

Reworks the /topup overlay per the Jun 19 review and the no-preflight decision.

Overview:
- Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar
  usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar.
- "Add funds" is the first action (was "Buy credits"); auto-reload / monthly
  limit / manage-on-portal follow. Dollars only — no "credits" anywhere.
- No "Enable terminal billing" menu item and NO scope preflight: whether the
  terminal can charge is discovered reactively at pay time. (We deliberately do
  not read/refresh the OAuth token to gate UI.)

Step-up (reached only on a charge's insufficient_scope 403):
- New 4-phase flow that keeps the modal mounted: prompt (one-time-setup
  heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to
  resume") → replay the held charge → settle. The press-Enter beat is the
  reassuring "you're back, finish your purchase" moment.
- Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never
  leaks the raw billing:manage scope (guarded by the render test).
- topup.ts error copy de-crufted to terminal-billing wording, emoji removed.

Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests
(balance-in-title, Add-funds-first, two-bar usage, no "credits").

* feat(cli/topup): mirror overview reorder + in-flight reauth resume

CLI parity with the TUI /topup rehaul, from the same shared usage model.

- _billing_overview: balance in the title, the two-bar dollar usage (plan name
  on the plan bar, top-up "never expires") in place of the old cap spend bar,
  "Add funds" first, dollars throughout — no "credits", no scope preflight.
- _billing_handle_scope_required: now takes the held amount + idempotency key
  and runs the in-flight flow — "Enable terminal billing" → browser device-flow
  → re-check the org kill-switch → press-Enter to resume → replay the held
  charge (reusing the key so a double-submit collapses to one). Stops leaking
  the raw billing:manage scope.
- Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars.
- Tests updated to the new overview + buy copy.

* fix(billing): guard non-JSON 2xx responses in the billing HTTP client

A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML
page served when a billing route isn't actually mounted on a deployment — hit
json.loads() on the success path of _request() and raised a raw
json.JSONDecodeError. That escaped the typed-BillingError contract, so callers'
`except BillingError` missed it and fell through to a generic fail-open that
rendered as a misleading "not logged in" (observed when /api/billing/subscription
was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]).

Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable")
so surfaces degrade gracefully ("could not load …") instead of crashing or
mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its
.json(); this closes the same hole on the success path.

Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed
error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON
parses.

* feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing

build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE
is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States:
nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the
card-on-file gate, admin role, and kill-switch paths are exercisable offline
without a live portal. Env-var gated; returns None when unset (no prod leak).

Adds 8 behavior tests asserting the card/admin/billing-on contract per state.

* refactor(billing): fold /credits into /topup

/credits is redundant now that /topup shows the dollar balance + portal handoff.
Make 'credits' (and 'billing') aliases of /topup so typing /credits still works,
resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help).

Remove the standalone /credits surface across 6 places:
- CLI _show_credits handler + dispatch
- gateway _handle_credits_command -> renamed _handle_topup_command, copy softened
  to 'Manage billing on the portal' (the messaging billing surface; /topup is now
  gateway-available so messaging keeps billing — credits was the only one before)
- TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry
- tui_gateway credits.view RPC + the CreditsViewResponse type
- Slack _SLACK_VIA_HERMES_ONLY: credits -> topup

Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and
stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests
updated (test_credits_folds_into_topup) or pruned for the removed symbols.

* fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph

In-terminal charge (POST /charge against the org's server-held card, no card ref
leaves the client):
- card present: confirm screen shows 'Your card saved on the portal will be
  charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI)
- no card on file: /topup overview + buy flow detect it and route to the portal
  to add a card, instead of offering a charge that 403s no_payment_method

/usage bar ordering: route the dollar block through _cprint consistently. The
Plan: line (_cprint) and the bar (raw print) flushed to different buffers under
patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA
is stable across all states.

Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal
titles — it measures 1 char but renders 2 columns, shifting the box's right
border (the stray '|'). Includes the f-string 'Pay $X?' title.

Small /credits -> /topup string bits in cli.py ride along with the surrounding
charge edits (the fold lives in the sibling refactor commit).

* refactor(billing): apply safe simplify-pass fixes

Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency):
- dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real
  mismatch vs subscription_view's _DEV_FIXTURE_PORTAL)
- TUI billingOverlay choose(): collapse two byte-identical branches (needsCard +
  the not-full else both = portal-or-close at index 0) into one tail; the only
  divergent path (full && !needsCard → buy/auto/limit) stays explicit
- /topup overview comment: correct the stale 'buy_flow detects no_payment_method'
  note (the overview's no-card gate fires first, so reaching Add funds implies a
  card on file)

Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live
dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge
(cheap correct defense on the money path), and folding the no-card handoff into a
shared helper (touches 4 money-path sites for tidiness — not worth the risk here).

* fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal)

* refactor(billing): drop the /credits alias entirely

The /credits fold made it an alias of /topup; now remove that too. Typing
/credits is an unknown command, not a silent redirect — billing lives only on
/topup (with /billing kept as the old command's back-compat name). Dropped the
alias from the registry CommandDef and the TUI topup.ts; updated the test to
assert /credits resolves to nothing (no command, no alias).

* docs(billing): fix stale comment in _billing_overview — describe reactive no-card path

The comment still described the removed overview-level card gate ('no-card case
handled above'). Corrected to: the buy flow reacts to the server's
no_payment_method 403 and hands off to the portal at charge time (no preflight).

* refactor(billing): simplify-pass — share usage-payload helper, drop dead bar wire fields + redundant admin gate

* refactor(billing): drop the /billing alias too — /topup is the only billing command

Following /credits removal, retire the old /billing name as well. /topup now has
NO aliases — both /credits and /billing are unknown commands. Dropped the alias
from the registry CommandDef and TUI topup.ts; fixed the one live user-facing
straggler (the not-logged-in message said 'then /billing' → /topup) and the
_show_billing docstring/default-arg references. Test asserts /topup carries no
aliases and neither old name resolves.

* fix(billing): code-review fixes — money-path + parity bugs

Money path (TUI):
- auto-reload "Turn off" now echoes current threshold/top_up_amount so the
  PATCH succeeds (was sending {enabled:false} → invalid_request → stayed ON)
- charge poll honors the 5-min cap on the 429/503 throttle branch too (was
  rescheduling forever); cap folded into one timedOut() helper
- step-up resume reacts to the replay outcome instead of unconditionally
  closing on a reassuring line with no charge made
- synchronous submit guard on Confirm so two key events can't double-charge

Gateway:
- billing.step_up routes typed errors through _serialize_billing_error (was a
  raw {error:'error'} dict → generic copy for session_revoked)
- billing.state / subscription.state / usage.bars / session.usage moved to
  _LONG_HANDLERS (blocking portal HTTP no longer stalls the main stdin loop)

CLI:
- _billing_render_charge_error handles insufficient_scope without leaking the
  raw billing:manage scope name on a post-grant replay re-raise

Python model:
- subscription_view tier parse None-coalesces tierOrder/dollarsPerMonth so a
  free tier's 0 survives ($0, not "—"; correct sort order)

TUI parity/robustness:
- /usage shows formatted renews_display, not raw ISO renews_at
- subscription overview guards a null pending_downgrade_at (was "on null.")
- subscription overview surfaces a message instead of silently closing when
  portal_url is missing
- buildManageUrl wraps new URL() so a malformed portal_url can't throw out of
  the Ink key handler

* fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating

- CLI plan bar now fills by REMAINING (fuel-gauge), matching the shared model's
  fill_fraction, the top-up bar, and the TUI — same account renders identically
  on both surfaces (#8)
- subscription serializer emits cancellation_effective_display /
  pending_downgrade_display (format_renews); TUI shows 'Jul 1, 2026' not raw ISO (#14b)
- _SLACK_VIA_HERMES_ONLY now includes the 'billing' alias so it follows its
  canonical /topup via /hermes instead of leaking a native Slack slot (#9)

* fix(billing): thread idempotency key through the TUI step-up replay (#2)

Mint a stable idempotency key when the purchase amount is chosen; it rides
pendingCharge into both the Confirm charge and the post-grant step-up replay,
so a retried charge dedups server-side (the gateway already echoes the key).
A fresh amount selection gets a fresh key. Combined with the sync submit guard,
a double-submit now collapses to one charge.

* refactor(billing): remove dead /subscription tier-picker scaffolding (#18)

The in-terminal plan picker was cut (deep-link only), leaving a whole unreached
state machine. Removed end-to-end:
- TUI: ConfirmScreen, HandoffScreen, the 'confirm'/'handoff' screen types,
  pendingTargetTierId, and the now-dead onPatch threading (collapsed the dispatch
  to a single overview screen + folded the duplicate Box wrapper)
- gateway: the tiers serialization + SubscriptionTierOption wire type
- model: SubscriptionTier, _parse_tier, _coalesce, _dev_tiers and the tiers field
  (never displayed on either surface, so this supersedes the tier-parse fix)
- tests: dropped the confirm/handoff/tier-passthrough tests; slimmed the overview
  render tests

Net: a large dead-code cull (no behavior change — the picker never ran).

* test(billing): parametrize usage-model tests; drop dead is_low/is_free props

Collapse the fail-open + status-classification cases into parametrized tables
(same coverage, ~80 fewer lines) and remove the now-unused UsageModel.is_low /
is_free properties (only a test pinned them).

* fix(billing): revert dead 'billing' Slack-via-hermes entry — the alias was dropped

#9 was based on a stale review diff: /billing is no longer an alias of /topup
(dropped earlier), so routing it via /hermes filtered a name that doesn't exist.

* test(billing): cull redundant TUI billing tests (parametrize, merge dupes)

usageCommand: collapse 3 CTA tests into one + a panel helper.
billingStepUp: merge the two step-up render asserts.
topupCommand: parametrize requestRemoteSpending + the revoked-actor pair, drop
the redundant happy-path-submitted test. Money-path + error-mapping coverage
preserved.

* refactor(billing): extract _usage_bar_lines — one source of truth for the CLI bars

The plan + top-up bar format was copy-pasted across _print_nous_credits_block,
_subscription_overview, and _billing_overview. Extract a helper returning the
ready-to-print lines; each caller keeps its own print fn (the _cprint-ordering
constraint stays) and resolves its plan-name label. Centralizes the format so
the three surfaces can't drift.

* feat(billing): NAS V3 subscription-change HTTP client wrappers

Add the four write-side wrappers for the V3 subscription contract to nous_billing,
each a thin _request() call (reusing auth, JSON, 401-retry, typed errors):
- post_subscription_preview      → POST  /subscription/preview      (chargeless quote)
- put_subscription_pending_change→ PUT   /subscription/pending-change (downgrade/cancel)
- delete_subscription_pending_change → DELETE .../pending-change      (resume/undo)
- post_subscription_upgrade      → POST  /subscription/upgrade        (the money route)

pending-change takes a discriminated body (tier_change | cancellation); upgrade
requires an Idempotency-Key (mandatory, validated client-side before any I/O).
Tests assert the exact method/path/body/header each wrapper puts on the wire.

* feat(billing): subscription tier catalog + change-preview models

Reinstate the catalog the in-terminal picker needs (was culled when /subscription
was deep-link-only): SubscriptionTier + SubscriptionState.tiers + _parse_tier, with
_coalesce so the free tier's 0 tierOrder/price survives a falsy-or. Parse the
catalog from GET /subscription's tiers and seed _dev_tiers into every fixture.

Add SubscriptionChangePreview + subscription_change_preview_from_payload for the
POST /preview quote (effect/amountDueNowCents/effectiveAt/reason + tier delta); a
malformed/missing effect fails safe to 'blocked' so a bad quote never reads as a
charge. Module docstring updated: the overlay is no longer deep-link-only.

* feat(billing): gateway RPCs for the V3 subscription change flow

Add subscription.preview / .change / .resume / .upgrade RPCs, each wrapping its
nous_billing call and reusing _serialize_billing_error for the typed envelope
(so a 403 still drives the device step-up). upgrade mints + echoes the
idempotency key and surfaces status + recovery_url so the TUI can route an
SCA/decline to the portal. Re-add the tier catalog to _serialize_subscription_state
(price pre-formatted) for the picker. All four are pool-routed (_LONG_HANDLERS) —
preview + upgrade hit Stripe and must not stall the main stdin loop.

* feat(billing): in-terminal subscription change flow (TUI)

/subscription is no longer deep-link-only: it drives the change in-terminal
against the V3 contract via the new gateway RPCs. The overlay is a state machine
overview → picker → confirm → result:
- picker lists the tier catalog with upgrade/downgrade hints (current + free
  excluded; free=cancel, on the overview);
- confirm shows the previewed effect — pay $X now (upgrade) / scheduled at date
  (downgrade) / cancel at period end / blocked-with-reason — then applies it;
- an upgrade's SCA/decline routes to the portal via the result screen's recovery
  link; resume/cancel/downgrade are chargeless.

Starting a NEW subscription still deep-links (needs a fresh card). insufficient_scope
points to /topup (the step-up stays there, not duplicated here). Adds the wire
types (tiers + preview/upgrade responses), widens the overlay ctx + screen state,
and threads onPatch. Render tests cover every screen.

* feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI)

Two improvements to the /subscription overlay:

Step-up re-auth in place. When a mutation (preview/change/upgrade/resume) returns
insufficient_scope, route to a new 'stepup' screen that grants terminal billing
via billing.step_up and AUTO-REPLAYS the held action on grant — no bounce to
/topup. Scope routing is centralized in previewAndRoute/applyPendingAndRoute/
resumeAndRoute (shared by the picker, confirm, overview + the step-up replay). The
browser opens via the shared global verification handler; copy never leaks the raw
billing:manage scope.

Make a scheduled change unmissable. A downgrade/cancel was one buried warn line
that read as 'nothing happened'. Now the overview leads with a banner
( Scheduled change · Ultra ──▶ Plus · <date> · you keep Ultra until then), the
status line echoes the transition (Plan: Ultra → Plus), 'Keep <tier> (undo)' is
promoted to the first olive action, the result screen says 'your plan doesn't
change today', and confirm gets a charged-now / scheduled chip.

* feat(billing): full in-terminal subscription change flow in the classic CLI

Bring the CLI to parity with the TUI overlay — /subscription is no longer
deep-link-only. A paid admin/owner gets picker → preview → confirm → apply,
mirroring the /topup buy flow's modal idioms:
- _subscription_change_menu (change / undo-or-cancel / manage-on-portal),
- _subscription_pick_tier (catalog with upgrade/downgrade hints),
- _subscription_preview_and_confirm (POST /preview → effect-aware confirm),
- _subscription_apply (schedule / cancel / resume chargeless; upgrade charges
  the sub's card, SCA/decline → portal),
- _subscription_handle_scope_required (insufficient_scope → step_up_nous_billing_scope
  inline, then replays the held preview/mutation — reusing the upgrade idempotency key).

Also the scheduled-change UX fix: the overview leads with a prominent banner
( Scheduled change · Super ──▶ Plus · <date> · you keep Super until then) and the
status line echoes the transition, matching the TUI. Members / non-interactive /
free still deep-link. Tests drive every branch via a mocked modal + nous_billing.

* fix(billing): close TUI subscription money-path holes (ultracode review)

- Un-consented charge (P1): the step-up now HOLDS at a 'granted' phase requiring
  an explicit Continue, and an abortedRef gates the grant's late .then — a cancel
  during the browser flow can no longer replay the held upgrade + charge.
- Missing idempotency key (P2): mint it when building an upgrade 'pending' so it
  rides into confirm AND the step-up replay (was always undefined → gateway minted
  a fresh key per call, defeating dedup).
- Navigate-away re-charge (P2): confirm 'back' is guarded by submittingRef while an
  apply is in flight.
- Ambiguous charge (P2): a transport-null upgrade is reported as 'may or may not
  have charged — re-check', never a flat failure that invites a blind retry.
- Typed step-up denial (P2): requestRemoteSpending returns {granted,error,message};
  the screen maps session_revoked / remote_spending_revoked / rate_limited to the
  right recovery instead of always 'an admin must allow it'.

* fix(billing): close CLI subscription money-path holes (ultracode review)

- Bounded step-up (P2): bust the 30s token cache after a grant (it held the
  pre-grant unscoped token; _request only busts on 401, not 403) and replay ONCE
  with allow_stepup=False so a still-denied scope can't re-prompt/re-open in a loop.
- Stray-keystroke charge (P3→near-P2): the upgrade confirm defaults to 'Go back',
  not 'Pay ' — a bare Enter can't move money.
- Fail-open on unknown effect (P3→near-P2): an unrecognized preview effect now
  fails SAFE (portal hand-off) instead of scheduling a real PUT.
- 'cancel' word collision (P3): the Close row uses value 'close' so typing 'cancel'
  can't hit it and falsely report 'Cancelled'.
- blocked effect re-offers the portal; undo is promoted to the first row when a
  change is pending (TUI parity).

* fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A)

The P1 fix split the auto-replay into a user-triggered resume() on the granted
screen, where the default row is the charging action — but resume() had no
re-entrancy guard, so a double-Enter fired two replays (the upgrade dedups on the
shared key, but schedule/cancel/resume replays carry none → duplicate PUT/DELETEs).
Mirror billingOverlay.resume(): flip to a 'resuming' phase + a resumingRef so it
fires at most once, and block 'back' once resuming (no re-mount → no second submit).

* fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B)

The TUI hardened upgradeResult(null) but the CLI charging route did not: a
transport/timeout/500 (or unknown 2xx status) on post_subscription_upgrade — after
NAS may have already prorated + charged — printed a flat failure, and a manual
re-run mints a FRESH idempotency key the server can't dedup → a real second charge.
Now the charge route reports 'your card may or may not have been charged — re-run
/subscription to check before trying again' and steers away from a blind retry
(the CLI can't persist the key across a command re-run). Also thread allow_stepup
through the preview→apply replay (BUG C.1) and route the requires_action/
payment_failed portal lines through _cprint for deterministic ordering.

* fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1)

The round-2 resume guard ('resuming' phase + resumingRef) could deadlock: on a
REPEAT insufficient_scope during the post-grant replay, the route helpers did
onPatch({screen:'stepup'}) — a no-op since we're already mounted on stepup (no key
→ no remount) — leaving phase='resuming'/resumingRef=true frozen on 'Applying your
change…'. Thread allowStepUp through previewAndRoute/applyPendingAndRoute/
resumeAndRoute; the resume() replay passes false, so a repeat scope denial surfaces
a 'still isn't enabled' result instead (mirrors the CLI's allow_stepup=False cap).
Also: applyPendingAndRoute(pending=null) now routes to overview, not a stranded
Promise.resolve().

* fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2)

The round-2 fix caught EVERY non-scope BillingError as 'may or may not have been
charged' — but typed pre-charge rejections (BillingRateLimited 429, BillingSessionRevoked
401, BillingRemoteSpendingRevoked 403, role_required/no_payment_method 4xx) never
reached Stripe, so the ambiguity copy was wrong and dropped their real recovery hints.
Now route those to _subscription_render_error, and reserve the ambiguous copy for
genuinely indeterminate outcomes (network_error / endpoint_unavailable / status None /
5xx). Tests: rate-limit stays deterministic; a real transport failure stays ambiguous.

* feat(billing): card visibility + guided add-card path in /topup and /subscription

Consume the NAS card-resolver contract (card.resolvedVia + chargeability) across
both surfaces, degrading cleanly on today's NAS (fields absent → prior behavior):

- WHICH card: the payment lines render provenance — 'Visa ····4242 — the card on
  your subscription' (resolvedVia → label; unknown rung/older NAS → masked card +
  the old generic line). Link payment methods render the brand alone (last4 is
  empty — never 'Link ····').
- Presence at a glance: the /topup overview now shows 'Card: …' or 'No saved
  card on file' for the full-menu case, plus a warning when the resolver marks
  the card needs_repair (failing auto-reloads) on overview/buy/confirm.
- Add-card path: with no card on file, 'Add funds' becomes a guided screen —
  open the portal billing page, then 'I've added it — check again' re-fetches
  billing state and continues straight into the purchase (also recovers a
  transient display miss). Cards are never entered in-terminal.
- /subscription upgrade confirm names the exact card ('Visa ····4242 — the card
  on your subscription — will be charged'), best-effort via billing.state and
  only when the resolution rung matches what a subscription charge actually
  uses (subPin/customerDefault, mirroring Stripe's precedence); otherwise the
  generic line stands. Fail-soft: any lookup error keeps the generic line.
- Gateway serializes display/resolved_via/needs_repair; TUI ctx gains
  refreshState (topup) + fetchCard (subscription); new offline fixtures
  card-sub / card-repair.

Tests: TUI ctx mocks extended; CLI suites cover provenance + repair-warning
render, the Link guard, the add-card path (continue-after-recheck + abandon),
the sub-confirm card line, and keep the confirm-time lookup offline in tests.

* fix(billing): consume server canChangePlan, preserve distinct refusal codes, drop dead chargeability

- Parse canChangePlan verbatim from NAS payloads into BillingState and
  SubscriptionState; fall back to the legacy OWNER/ADMIN check only when the
  server omits the field (FINANCE_ADMIN stops being locked out where NAS
  authorizes it). Role model updated to the 5-role enum.
- Add the autoReload.card union (canonical | distinct | none) end-to-end:
  parse + gateway serialization, distinct carries payment_method_id/brand/last4
  with nullable display fields.
- stripe_unavailable (503, transient) and upgrade_cap_exceeded (429, daily cap)
  now survive to the wire as their own codes instead of collapsing into
  rate_limited; new exception types subclass BillingRateLimited so existing
  backoff call sites keep working.
- Remove card.chargeability / needs_repair parsing, serialization, fixtures and
  the cli warning blocks: NAS #670 removed the field, so the repair path was
  permanently dead. The future card-health signal belongs to the NAS W1/W3 work.
- Tests: five-role fixtures, canChangePlan override/fallback, all three
  auto-reload card variants, 429-vs-503 code preservation end-to-end.

* feat(tui): render the full NAS billing refusal surface

- billingOverlay: divergence notice when auto-refill charges a distinct card
  (portal deep-link to reconcile); needs_repair warnings removed with the field.
- topup: explicit copy for consent_required, org_access_denied,
  upgrade_cap_exceeded, auto_top_up_disabled_failures and stripe_unavailable
  (honors retry_after); processing_error is an explicit charge-failure case;
  transport loss during charge polling now reads as an unconfirmed outcome
  (check balance before retrying), matching the revocation path.
- subscriptionOverlay: branch on upgrade reason, not status, so an SCA-needing
  upgrade routes to portal verification even while NAS pre-#711 labels it
  payment_failed; after an upgrade, poll subscription state until the tier
  flips (bounded), rendering applying/still-applying rather than assuming
  immediacy.
- Capability-neutral refusal copy (owner, admin, or finance admin) replaces
  the stale org admin/owner wording.
- gatewayTypes: BillingAutoReload.card union added, needs_repair removed.

* docs(billing): client-side billing state and refusal lifecycle table

Enumerates, from the code, every billing.state shape and typed refusal the
gateway serves and the exact TUI copy + recovery each renders. Acceptance from
the billing-integration handoff: no NAS billing state or typed refusal falls
through to a generic toast; unknown codes still degrade to the default branch
that surfaces the server message.
This commit is contained in:
Siddharth Balyan 2026-07-18 14:30:24 +05:30 committed by GitHub
parent 5402cb5531
commit b51fbc738b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 8357 additions and 1400 deletions

View file

@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
return None
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /credits)")
details.append("(or run /topup)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/credits`` command.
"""Surface-agnostic data for the ``/topup`` balance view.
One portal fetch, one parse consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
@ -356,11 +356,11 @@ class CreditsView:
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /credits view: balance block + identity line + top-up URL.
"""Build the /topup balance view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
snapshot MINUS its trailing top-up/command-hint lines (the /topup surface
supplies its own affordance). Fail-open ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
# ("Top up: <url>" + "(or run /topup)") that build_nous_credits_snapshot
# appends for the /usage surface. /topup renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)

323
agent/billing_usage.py Normal file
View file

@ -0,0 +1,323 @@
"""Shared dollar-denominated usage model for the billing/subscription surfaces.
The single source of truth behind the ``/usage`` and ``/subscription`` usage
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
**dollars**, never "credits", and every usage bar must make the monthly
subscription allowance and separately-purchased top-up dollars distinctly
visible.
Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
``paid_service_access_info`` carries the three dollar magnitudes we render
(despite the legacy ``*_credits`` field names, these are USD floats):
- ``subscription_credits_remaining`` -> plan dollars left this month
- ``purchased_credits_remaining`` -> top-up dollars left (rolls over)
- ``total_usable_credits`` -> total spendable
plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).
Design: two SEPARATE bars (decided with the user) rather than one crammed
three-segment bar at terminal widths three same-glyph density segments are
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
the top-up bar is "money you bought, doesn't expire". Each gets full
resolution and a single fill glyph, so the bar is never ambiguous and never
relies on color.
Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
magnitudes-only view; a logged-out / unreachable portal yields
``available=False`` and the surface shows nothing.
"""
from __future__ import annotations
import logging
import math
import os
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
# (user feedback): "any amount below $5 should be an alert status."
LOW_BALANCE_THRESHOLD_USD = 5.0
def _finite(value: Any) -> Optional[float]:
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
f = float(value)
return f if math.isfinite(f) else None
def _fmt_usd(value: Optional[float]) -> str:
"""``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
return f"${(value or 0.0):,.2f}"
def format_renews(value: Optional[str]) -> Optional[str]:
"""Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.
Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
string unchanged if it can't be parsed (never raises), and ``None`` for
empty input.
"""
if not value:
return None
from datetime import datetime
text = str(value).strip()
if not text:
return None
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
try:
dt = datetime.fromisoformat(iso)
except ValueError:
# Fall back to a bare date prefix (YYYY-MM-DD) if present.
try:
dt = datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return text
# %-d isn't portable to Windows; build the day without a leading zero.
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
@dataclass(frozen=True)
class UsageBar:
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
(purchased dollars, no denominator ``spent`` is 0 and ``total`` ==
``remaining`` so it renders as a full bar of available balance).
"""
kind: str # "plan" | "topup"
remaining_usd: float
total_usd: float
spent_usd: float = 0.0
@property
def pct_used(self) -> Optional[int]:
if self.kind != "plan" or self.total_usd <= 0:
return None
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
@property
def fill_fraction(self) -> float:
"""Fraction of the bar that should read as 'remaining' (filled)."""
if self.total_usd <= 0:
return 0.0
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
@dataclass(frozen=True)
class UsageModel:
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
``status`` classifies the account for copy selection:
- ``"free"`` : no paid access / no subscription (free models only)
- ``"low"`` : paid, but total spendable < $5 (ALERT)
- ``"healthy"`` : paid, total spendable >= $5
- ``"depleted"`` : paid access lost (balance exhausted)
"""
available: bool
status: str = "free"
plan_name: Optional[str] = None
renews_at: Optional[str] = None
renews_display: Optional[str] = None
subscription_remaining_usd: Optional[float] = None
topup_remaining_usd: Optional[float] = None
total_spendable_usd: Optional[float] = None
plan_bar: Optional[UsageBar] = None
topup_bar: Optional[UsageBar] = None
@property
def has_topup(self) -> bool:
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
def usage_model_from_account(account_info: Any) -> UsageModel:
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.
Returns ``UsageModel(available=False)`` when there's no usable account info
(logged out, no entitlement block). Never raises.
"""
try:
if account_info is None or not getattr(account_info, "logged_in", False):
return UsageModel(available=False)
access = getattr(account_info, "paid_service_access_info", None)
sub = getattr(account_info, "subscription", None)
paid = getattr(account_info, "paid_service_access", None)
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
plan_name = getattr(sub, "plan", None) if sub is not None else None
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
# Total spendable: prefer the server's total; else sum the parts we have.
if total_usable is not None:
total_spendable = total_usable
else:
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
total_spendable = sum(parts) if parts else None
# Status classification.
if paid is False:
status = "depleted"
elif not has_subscription and not (topup_remaining and topup_remaining > 0):
# No plan and no purchased balance -> free-models-only.
status = "free"
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
status = "low"
else:
status = "healthy"
# Plan bar — only with a positive monthly allowance AND a remaining we
# can place on it. spent = cap - remaining, clamped (a debt/over-cap
# balance reads as fully spent rather than a nonsensical negative).
plan_bar: Optional[UsageBar] = None
if monthly is not None and monthly > 0 and sub_remaining is not None:
remaining = max(0.0, min(monthly, sub_remaining))
plan_bar = UsageBar(
kind="plan",
remaining_usd=remaining,
total_usd=monthly,
spent_usd=max(0.0, monthly - sub_remaining),
)
# Top-up bar — only when there are purchased dollars to show. No
# denominator (top-up has no monthly cap), so it renders full = balance.
topup_bar: Optional[UsageBar] = None
if topup_remaining is not None and topup_remaining > 0:
topup_bar = UsageBar(
kind="topup",
remaining_usd=topup_remaining,
total_usd=topup_remaining,
spent_usd=0.0,
)
return UsageModel(
available=True,
status=status,
plan_name=plan_name,
renews_at=renews_at,
renews_display=format_renews(renews_at),
subscription_remaining_usd=sub_remaining,
topup_remaining_usd=topup_remaining,
total_spendable_usd=total_spendable,
plan_bar=plan_bar,
topup_bar=topup_bar,
)
except Exception:
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
return UsageModel(available=False)
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
"""Fetch account-info and build the shared usage model. Fail-open.
Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
every usage state is testable without a live account (mirrors the existing
``/usage`` credits-block fixture path).
"""
fixture = _dev_fixture_usage_model()
if fixture is not None:
return fixture
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return UsageModel(available=False)
except Exception:
return UsageModel(available=False)
try:
import concurrent.futures
from hermes_cli.nous_account import get_nous_portal_account_info
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
return usage_model_from_account(account)
except Exception:
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
return UsageModel(available=False)
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_usage_model() -> Optional[UsageModel]:
"""Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.
Recognized names: ``free | healthy | low | topup | depleted``. Returns
``None`` when the env var is unset (real portal path runs).
"""
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
if not name:
return None
if name == "free":
return UsageModel(available=True, status="free", plan_name=None)
if name in ("healthy", "mid"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
total_spendable_usd=14.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
)
if name in ("topup", "top-up"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
topup_remaining_usd=12.0,
total_spendable_usd=26.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
)
if name == "low":
return UsageModel(
available=True,
status="low",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=3.4,
total_spendable_usd=3.4,
plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
)
if name == "depleted":
return UsageModel(
available=True,
status="depleted",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=0.0,
total_spendable_usd=0.0,
plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
)
return None

View file

@ -15,6 +15,7 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
from __future__ import annotations
import logging
import os
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
@ -64,15 +65,47 @@ def format_money(value: Optional[Decimal]) -> str:
# =============================================================================
# resolvedVia → the human answer to "why THIS card?". Keys are the server's card
# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label
# so the display degrades cleanly on servers that don't send resolvedVia yet.
_CARD_PROVENANCE_LABELS = {
"subPin": "the card on your subscription",
"customerDefault": "your default card saved on the portal",
"autoRefill": "your auto-reload card",
}
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
# NAS card-on-file field (post card-resolver): which ladder rung found the
# card. Defaults off so pre-resolver payloads parse unchanged.
resolved_via: Optional[str] = None
@property
def masked(self) -> str:
# A Link payment method has no card number (last4 = "") — render the
# brand alone, not "Link ····".
if not self.last4:
return self.brand
return f"{self.brand} ····{self.last4}"
@property
def provenance(self) -> Optional[str]:
"""Human label for why this card was picked, or None (unknown rung /
server too old to say)."""
if self.resolved_via is None:
return None
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
@property
def display(self) -> str:
"""The one-line card display: ``Visa ····4242 — the card on your
subscription`` (or just the masked card when provenance is unknown)."""
label = self.provenance
return f"{self.masked}{label}" if label else self.masked
@dataclass(frozen=True)
class MonthlyCap:
@ -81,11 +114,20 @@ class MonthlyCap:
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReloadCard:
kind: str # "canonical" | "distinct" | "none"
payment_method_id: Optional[str] = None
brand: Optional[str] = None
last4: Optional[str] = None
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
card: Optional[AutoReloadCard] = None
@dataclass(frozen=True)
@ -100,7 +142,8 @@ class BillingState:
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
@ -115,17 +158,33 @@ class BillingState:
@property
def is_admin(self) -> bool:
"""True for OWNER/ADMIN — the roles that can manage billing."""
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
@property
def can_charge(self) -> bool:
"""True when the UI should offer charge/auto-reload actions.
Admin role AND the per-org kill-switch on. (The server still enforces;
this is just for graying out actions the user can't take.)
Uses the server-granted plan-change capability (``can_change_plan``,
which itself falls back to the legacy OWNER/ADMIN role check when the
server omits ``canChangePlan``) AND the per-org kill-switch. This lets
the server grant charge capability to non-OWNER/ADMIN roles (e.g.
FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the
deprecated 3-role admin check. (The server still enforces; this is
just for graying out actions the user can't take.)
"""
return self.is_admin and self.cli_billing_enabled
return self.can_change_plan and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
@ -133,9 +192,13 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
return None
brand = raw.get("brand")
last4 = raw.get("last4")
if isinstance(brand, str) and isinstance(last4, str):
return CardInfo(brand=brand, last4=last4)
return None
if not (isinstance(brand, str) and isinstance(last4, str)):
return None
# Post-resolver fields — all optional so both payload generations parse.
resolved_via = raw.get("resolvedVia")
if not isinstance(resolved_via, str):
resolved_via = None
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
@ -155,6 +218,27 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
card=_parse_auto_reload_card(raw.get("card")),
)
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
if not isinstance(raw, dict):
return None
kind = raw.get("kind")
if kind not in ("canonical", "distinct", "none"):
return None
if kind in ("canonical", "none"):
return AutoReloadCard(kind=kind)
payment_method_id = raw.get("paymentMethodId")
brand = raw.get("brand")
last4 = raw.get("last4")
return AutoReloadCard(
kind=kind,
payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None,
brand=brand if isinstance(brand, str) else None,
last4=last4 if isinstance(last4, str) else None,
)
@ -179,6 +263,11 @@ def billing_state_from_payload(
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
@ -202,7 +291,15 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState:
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
a clear message rather than crashing.
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
card-on-file / admin / scope states are testable offline (mirrors
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
"""
fixture = _dev_fixture_billing_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
@ -243,6 +340,72 @@ def _fallback_portal_url(base: str) -> str:
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_billing_state() -> Optional[BillingState]:
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
Recognized names::
nocard logged in · billing on · admin · NO card on file
card card on file · auto-reload off
card-autoreload card on file · auto-reload on
notadmin logged in · MEMBER role (billing actions disabled)
billing-off logged in · admin · per-org kill-switch OFF
logged-out not logged in
Returns ``None`` when the env var is unset (the real portal path runs).
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
"""
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
if not name:
return None
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
portal = "https://portal.nousresearch.com/billing?topup=open"
common: dict[str, Any] = dict(
org_id="org_acme",
org_slug="acme",
org_name="Acme Inc",
role="OWNER",
balance_usd=Decimal("3.40"),
cli_billing_enabled=True,
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
min_usd=Decimal("5"),
max_usd=Decimal("500"),
portal_url=portal,
)
card = CardInfo(brand="Visa", last4="4242")
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
if name in ("logged-out", "logged_out", "loggedout"):
return BillingState(logged_in=False)
if name == "nocard":
return BillingState(logged_in=True, card=None, **common)
if name == "card":
return BillingState(logged_in=True, card=card, **common)
if name in ("card-sub", "card_sub"):
# Post-resolver: the card came from the subscription (provenance label).
_sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin")
return BillingState(logged_in=True, card=_sub_card, **common)
if name in ("card-autoreload", "card_autoreload", "autoreload"):
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
if name in ("notadmin", "not-admin", "member"):
opts = {**common, "role": "MEMBER"}
return BillingState(logged_in=True, card=card, **opts)
if name in ("billing-off", "billing_off", "off"):
opts = {**common, "cli_billing_enabled": False}
return BillingState(logged_in=True, card=None, **opts)
# Unknown name → logged-out so the misconfiguration is visible.
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
# =============================================================================
# Idempotency
# =============================================================================

View file

@ -355,7 +355,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /credits to top up",
text="✕ Credit access paused · run /topup to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",

421
agent/subscription_view.py Normal file
View file

@ -0,0 +1,421 @@
"""Surface-agnostic core for the ``/subscription`` TUI screen.
Companion to :mod:`agent.billing_view` same fail-open philosophy: when not
logged in or the portal is unreachable, return a struct with ``logged_in=False``
and let the surface degrade gracefully (never crash). Money is decimal end-to-end
(server emits decimal strings); we only format for display.
The TUI ``SubscriptionOverlay`` drives the plan change in-terminal (V3): it
previews the effect, then schedules a downgrade / cancellation / resume
(chargeless) or applies an upgrade (charges the card on the subscription). The
portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the
fallback for an upgrade that needs 3DS / was declined.
WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A).
Until it ships, the fail-open contract handles 404s the builder returns
``logged_in=False`` and the surface degrades gracefully.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Optional
from agent.billing_view import parse_money
logger = logging.getLogger(__name__)
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class CurrentSubscription:
"""The user's active subscription. ``None`` (not this object) = no plan.
When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` /
``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a
fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade
fields are optional.
"""
tier_id: Optional[str] = None
tier_name: Optional[str] = None
monthly_credits: Optional[Decimal] = None
credits_remaining: Optional[Decimal] = None
cycle_ends_at: Optional[str] = None # ISO
pending_downgrade_tier_name: Optional[str] = None
pending_downgrade_at: Optional[str] = None # ISO
cancel_at_period_end: bool = False
cancellation_effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionTier:
"""A selectable plan in the catalog — one row of the in-terminal tier picker.
Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan
(shown but not selectable); ``is_enabled=False`` is a grandfathered tier the
user is on but that can no longer be selected. ``tier_order`` sorts the picker
and drives the upgrade-vs-downgrade direction hint.
"""
tier_id: str
name: str
tier_order: int = 0
dollars_per_month: Optional[Decimal] = None
monthly_credits: Optional[Decimal] = None
is_current: bool = False
is_enabled: bool = True
@dataclass(frozen=True)
class SubscriptionChangePreview:
"""Parsed ``POST /api/billing/subscription/preview`` — what a change would do.
``effect`` is the disposition the commit would take:
- ``charge_now`` an upgrade; ``amount_due_now_cents`` is the prorated charge.
- ``scheduled`` a downgrade / same-price change at ``effective_at`` (period end).
- ``no_op`` already on the target tier.
- ``blocked`` the commit would be refused; ``reason`` says why.
"""
effect: str
reason: Optional[str] = None
current_tier_id: Optional[str] = None
current_tier_name: Optional[str] = None
target_tier_id: Optional[str] = None
target_tier_name: Optional[str] = None
monthly_credits_delta: Optional[Decimal] = None
amount_due_now_cents: Optional[int] = None
effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionState:
"""Parsed ``GET /api/billing/subscription`` — the overview screen's data.
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
portal is unreachable.
"""
logged_in: bool
org_name: Optional[str] = None
org_id: Optional[str] = None # org.id from the NAS response
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
context: str = "personal" # "personal" | "team"
current: Optional[CurrentSubscription] = None
tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker)
portal_url: Optional[str] = None
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
error: Optional[str] = None
@property
def is_admin(self) -> bool:
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
# =============================================================================
# Payload parsing
# =============================================================================
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
# "No plan" is wire-represented as current:null (free personal OR team) —
# the old all-null-object shape is gone. A present current is a real plan,
# so guard on a real tier id and return None otherwise.
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return CurrentSubscription(
tier_id=tier_id,
tier_name=raw.get("tierName") or raw.get("name"),
monthly_credits=parse_money(raw.get("monthlyCredits")),
credits_remaining=parse_money(raw.get("creditsRemaining")),
cycle_ends_at=raw.get("cycleEndsAt"),
pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"),
pending_downgrade_at=raw.get("pendingDowngradeAt"),
cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")),
cancellation_effective_at=raw.get("cancellationEffectiveAt") or None,
)
def _coalesce(*vals: Any) -> Any:
"""First non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``).
NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain
``x or default`` would drop those, so coalesce on ``None`` specifically.
"""
for v in vals:
if v is not None:
return v
return None
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
"""Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`."""
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return SubscriptionTier(
tier_id=tier_id,
name=raw.get("name") or "",
tier_order=int(_coalesce(raw.get("tierOrder"), 0)),
dollars_per_month=parse_money(raw.get("dollarsPerMonthDisplay")),
monthly_credits=parse_money(raw.get("monthlyCredits")),
is_current=bool(raw.get("isCurrent")),
is_enabled=bool(_coalesce(raw.get("isEnabled"), True)),
)
def subscription_change_preview_from_payload(
payload: dict[str, Any],
) -> SubscriptionChangePreview:
"""Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`."""
effect = payload.get("effect")
cents = payload.get("amountDueNowCents")
return SubscriptionChangePreview(
# An unrecognized/missing effect is treated as ``blocked`` — fail safe, never
# charge on a malformed quote.
effect=effect if isinstance(effect, str) else "blocked",
reason=payload.get("reason") or None,
current_tier_id=payload.get("currentTierId"),
current_tier_name=payload.get("currentTierName"),
target_tier_id=payload.get("targetTierId"),
target_tier_name=payload.get("targetTierName"),
monthly_credits_delta=parse_money(payload.get("monthlyCreditsDelta")),
amount_due_now_cents=int(cents) if isinstance(cents, (int, float)) else None,
effective_at=payload.get("effectiveAt") or None,
)
def subscription_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> SubscriptionState:
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
raw_context = payload.get("context")
context = raw_context if raw_context in ("personal", "team") else "personal"
raw_tiers = payload.get("tiers")
tiers = (
tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None)
if isinstance(raw_tiers, list)
else ()
)
return SubscriptionState(
logged_in=True,
org_name=org.get("name"),
org_id=org.get("id") or None,
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
context=context,
current=_parse_current(payload.get("current")),
tiers=tiers,
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open.
Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a
portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the
surface can show a clear message rather than crashing.
Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state,
``/subscription`` renders from that fixture instead of the real portal so
every plan/cancel/downgrade/team/not-admin state is testable on both
the CLI and TUI without a live account. Throwaway scaffolding; see
:func:`dev_fixture_subscription_state`.
"""
fixture = dev_fixture_subscription_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_subscription_state,
resolve_portal_base_url,
)
except Exception:
return SubscriptionState(logged_in=False, error="billing client unavailable")
try:
payload = get_subscription_state(timeout=timeout)
except BillingAuthError:
return SubscriptionState(logged_in=False)
except BillingError as exc:
logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error=str(exc))
except Exception:
logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error="could not load subscription state")
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = resolve_portal_base_url()
except Exception:
portal_url = None
return subscription_state_from_payload(payload, portal_url=portal_url)
def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
Portal decided Jun 23), which routes upgradeCheckout / downgradescheduled
internally. ``org_id`` pins the page to the right account in multi-org
situations. Returns ``None`` when no portal URL is resolvable.
"""
from urllib.parse import urlencode, urlsplit, urlunsplit
if not state.portal_url:
return None
try:
parts = urlsplit(state.portal_url)
except Exception:
return None
if not parts.scheme or not parts.netloc:
return None
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
def _dev_current(**over: Any) -> CurrentSubscription:
base: dict[str, Any] = dict(
tier_id="plus",
tier_name="Plus",
monthly_credits=Decimal("1000"),
credits_remaining=Decimal("420"),
cycle_ends_at="2026-07-01",
)
base.update(over)
return CurrentSubscription(**base)
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
"""A sample plan catalog for fixtures (marks ``current_id`` as the active tier)."""
specs = (
("free", "Free", 0, "0", "0"),
("plus", "Plus", 1, "20", "1000"),
("super", "Super", 2, "40", "3000"),
("ultra", "Ultra", 3, "80", "7000"),
)
return tuple(
SubscriptionTier(
tier_id=tid,
name=name,
tier_order=order,
dollars_per_month=parse_money(dpm),
monthly_credits=parse_money(mc),
is_current=(tid == current_id),
is_enabled=True,
)
for tid, name, order, dpm, mc in specs
)
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
"""Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``.
Lets every CLI/TUI subscription state be exercised without a live portal:
free | mid | top | not-admin | downgrade | cancel | team |
logged-out
Returns ``None`` when the env var is unset/empty (the real portal path runs).
Throwaway scaffolding mirrors ``HERMES_DEV_CREDITS_FIXTURE``.
"""
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
if not name:
return None
common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
if name in ("logged-out", "logged_out", "loggedout"):
return SubscriptionState(logged_in=False)
if name == "free":
return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common)
if name in ("mid", "mid-tier"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common)
if name in ("top", "top-tier"):
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
tiers=_dev_tiers("ultra"),
**common,
)
if name in ("not-admin", "member"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **{**common, "role": "MEMBER"})
if name == "downgrade":
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
tiers=_dev_tiers("super"),
**common,
)
if name == "cancel":
return SubscriptionState(
logged_in=True,
current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"),
tiers=_dev_tiers("plus"),
**common,
)
if name == "team":
return SubscriptionState(logged_in=True, context="team", current=None, org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
# Unknown name → behave as logged-out so the misconfiguration is visible.
return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}")

744
cli.py
View file

@ -54,6 +54,7 @@ import yaml
from hermes_cli.fallback_config import get_fallback_chain
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
from hermes_cli.cli_commands_mixin import CLICommandsMixin
from hermes_cli.cli_billing_mixin import CLIBillingMixin
# prompt_toolkit for fixed input area TUI
from prompt_toolkit.history import FileHistory
@ -3698,7 +3699,7 @@ def save_config_value(key_path: str, value: any) -> bool:
# HermesCLI Class
# ============================================================================
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"""
Interactive CLI for the Hermes Agent.
@ -8760,9 +8761,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._manual_compress(cmd_original)
elif canonical == "usage":
self._handle_usage_command(cmd_original)
elif canonical == "credits":
self._show_credits()
elif canonical == "billing":
elif canonical == "subscription":
self._show_subscription()
elif canonical == "topup":
self._show_billing(cmd_original)
elif canonical == "insights":
self._show_insights(cmd_original)
@ -9771,7 +9772,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
which would otherwise early-return before any credits showed.
"""
if not self.agent:
if not self._print_nous_credits_block():
if self._print_nous_credits_block():
self._print_usage_cta()
else:
print("(._.) No active agent -- send a message first.")
return
@ -9779,7 +9782,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
calls = agent.session_api_calls
if calls == 0:
if not self._print_nous_credits_block():
if self._print_nous_credits_block():
self._print_usage_cta()
else:
print("(._.) No API calls made yet in this session.")
return
@ -9850,7 +9855,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Nous credits magnitudes + monthly-grant gauge (agent-independent — also
# runs at the no-agent / no-calls early-returns above). See the helper.
self._print_nous_credits_block()
if self._print_nous_credits_block():
self._print_usage_cta()
if self.verbose:
logging.getLogger().setLevel(logging.DEBUG)
@ -9866,730 +9872,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Console quietness is enforced by hermes_logging not
# installing a console StreamHandler in non-verbose mode.
def _print_nous_credits_block(self) -> bool:
"""Print the Nous credits magnitudes + monthly-grant gauge when a Nous account
is logged in. Returns True if it printed anything.
Delegates to the shared ``agent.account_usage.nous_credits_lines`` helper
the single source for the /usage credits block across CLI, gateway, and TUI.
It's agent-independent (a portal fetch gated on "a Nous account is logged in",
NOT the inference-provider string), so /usage shows the block even in the TUI
slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and
wall-clock-bounded inside the helper; also honors HERMES_DEV_CREDITS_FIXTURE
for offline testing same behavior as every other surface.
"""
from agent.account_usage import nous_credits_lines
lines = nous_credits_lines()
if not lines:
return False
print()
for line in lines:
print(f" {line}")
return True
def _show_credits(self):
"""`/credits` — focused Nous credit balance + top-up handoff.
Interactive CLI: balance block + identity line + a 3-button panel
(Open top-up / Copy link / Cancel). Non-interactive contexts the TUI
slash-worker subprocess and any place without a live prompt_toolkit app
(``self._app is None``) render a text variant (balance + tappable
top-up URL), because the modal would try to read the RPC stdin and crash
the worker. The terminal never confirms or polls payment (billing phase
2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
message, never a crash.
"""
from agent.account_usage import build_credits_view
view = build_credits_view()
if not view.logged_in:
print()
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
print(" Run `hermes portal` to log in, then /credits.")
return
print()
print(" 💳 Nous credits")
print(f" {'' * 41}")
for line in view.balance_lines:
# Drop the helper's own "📈 Nous credits" header — we print our own.
if line.lstrip().startswith("📈"):
continue
print(f" {line}")
print(f" {'' * 41}")
if view.identity_line:
print(f" {view.identity_line}")
if not view.topup_url:
return
# Non-interactive (TUI slash-worker, piped, no live app): the
# prompt_toolkit modal can't run here — it would read the worker's
# JSON-RPC stdin and crash the command. Render the text variant: the
# tappable URL IS the affordance, same as the messaging surfaces.
if not getattr(self, "_app", None):
print()
print(f" Top up: {view.topup_url}")
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
return
choices = [
("open", "Open top-up in browser", "launch the portal billing page"),
("copy", "Copy link", "copy the top-up URL to your clipboard"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Add credits?",
detail=f"Top-up page:\n{view.topup_url}",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "open":
opened = False
try:
import webbrowser
opened = webbrowser.open(view.topup_url)
except Exception:
opened = False
if not opened:
print(f" Open this URL to top up: {view.topup_url}")
print()
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
elif choice == "copy":
try:
self._write_osc52_clipboard(view.topup_url)
print(f" 📋 Copied: {view.topup_url}")
except Exception:
print(f" Top-up URL: {view.topup_url}")
else:
print(" 🟡 Cancelled. No credits added.")
# ------------------------------------------------------------------
# /billing — Phase 2b terminal billing (CLI surface, all 5 screens)
# ------------------------------------------------------------------
def _show_billing(self, command: str = "/billing"):
"""`/billing` — terminal billing for Nous (one interactive modal).
ZERO sub-commands: any argument is ignored. Bare ``/billing`` always
opens the Overview (Screen 1), whose numbered menu is the *only* way to
reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified
UX spec §0.4 ``/billing buy`` etc. are gone; we don't error on a stray
arg, we just open the menu.)
Interactive CLI uses the prompt_toolkit modal; non-interactive contexts
(TUI slash-worker / no live app) render text + the portal deep-link, never
prompting (the URL is the affordance), same discipline as ``_show_credits``.
All money is Decimal end-to-end; the terminal never collects card details.
"""
from agent.billing_view import build_billing_state
state = build_billing_state()
if not state.logged_in:
print()
if state.error:
_msg = f"Couldn't load billing: {state.error}"
_cprint(f" 💳 {_d(_msg)}")
else:
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
print(" Run `hermes portal` to log in, then /billing.")
return
# Any sub-arg is intentionally ignored — always open the menu.
self._billing_overview(state)
def _billing_portal_hint(self, state, *, reason: str = "") -> None:
"""Print a portal deep-link line (the funnel for portal-only actions)."""
url = getattr(state, "portal_url", None)
if not url:
return
if reason:
print(f" {reason}")
print(f" Manage on portal: {url}")
def _billing_overview(self, state):
"""Screen 1 — overview: balance, spend bar, role-gated action menu."""
from agent.billing_view import format_money
print()
_cprint(f" 💳 {_b('Usage credits')}")
print(f" {'' * 41}")
cap = state.monthly_cap
if cap is not None and cap.limit_usd is not None:
spent = format_money(cap.spent_this_month_usd)
limit = format_money(cap.limit_usd)
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
bar, pct = self._billing_spend_bar(
cap.spent_this_month_usd, cap.limit_usd
)
print(f" {spent} of {limit} used{ceiling} {bar} {pct}%")
print(f" Balance: {format_money(state.balance_usd)}")
ar = state.auto_reload
if ar is not None:
if ar.enabled:
print(
f" Auto-reload: on — below {format_money(ar.threshold_usd)} "
f"→ reload to {format_money(ar.reload_to_usd)}"
)
else:
print(" Auto-reload: off")
if state.org_name:
role = (state.role or "").title()
_org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}"
_cprint(f" {_d(_org_line)}")
print(f" {'' * 41}")
# Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal.
if not state.is_admin:
_cprint(f" {_d('Billing actions require an org admin/owner.')}")
self._billing_portal_hint(state)
return
if not state.cli_billing_enabled:
_cprint(f" {_d('Terminal billing is turned off for this org.')}")
self._billing_portal_hint(state, reason="Enable it on the portal to buy credits here.")
return
# Optimistic funnel: no card on file → a charge will 403 no_payment_method.
# Surface that up front (with the portal link) but DON'T hide Buy — /state.card
# can't fully prove CLI-chargeability, so we advise rather than gate.
if state.card is None:
_cprint(
f" {_d('No saved card for terminal charges yet — set one up on the portal first.')}"
)
self._billing_portal_hint(state)
# Non-interactive (slash-worker / no live app): no modal, no sub-command
# advertising — just the portal funnel (the URL is the affordance).
if not getattr(self, "_app", None):
self._billing_portal_hint(state)
return
choices = [
("buy", "Buy credits", "purchase a one-time credit top-up"),
("auto", "Adjust auto-reload", "configure automatic top-ups"),
("limit", "Adjust monthly limit", "show the monthly spend cap (read-only)"),
("portal", "Manage on portal", "open the billing page in your browser"),
("cancel", "Cancel", "do nothing"),
]
# The overview summary is already printed above; the modal only needs to
# present the action menu — repeating the title/balance reads as a dupe.
raw = self._prompt_text_input_modal(
title="💳 Choose an action", detail="",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "buy":
self._billing_buy_flow(state)
elif choice == "auto":
self._billing_auto_reload_flow(state)
elif choice == "limit":
self._billing_limit_screen(state)
elif choice == "portal":
self._billing_open_portal(state)
else:
print(" 🟡 Cancelled.")
def _billing_spend_bar(self, spent, limit, *, cells: int = 10):
"""Render a 10-cell `█`/`░` spend bar + integer percent from spent/limit.
Returns ``(bar, pct)`` where ``bar`` is like ``[]`` and ``pct``
is the spent/limit percentage clamped to 0..100. Box-drawing glyphs are
not SGR codes, so this is leak-safe even without ``_b()``/``_d()``.
"""
from decimal import Decimal
try:
s = Decimal(str(spent)) if spent is not None else Decimal("0")
l = Decimal(str(limit)) if limit is not None else Decimal("0")
except Exception:
s, l = Decimal("0"), Decimal("0")
if l <= 0:
pct = 0
else:
pct = int((s / l) * 100)
pct = max(0, min(100, pct))
filled = int(round(pct / 100 * cells))
filled = max(0, min(cells, filled))
bar = ("" * filled) + ("" * (cells - filled))
return bar, pct
def _billing_open_portal(self, state):
url = getattr(state, "portal_url", None)
if not url:
print(" No portal URL available.")
return
opened = False
try:
import webbrowser
opened = webbrowser.open(url)
except Exception:
opened = False
if not opened:
print(f" Open this URL: {url}")
print(" Complete billing changes in the browser.")
def _billing_require_admin(self, state) -> bool:
"""Guard charge/auto-reload entry points; print + return False if blocked."""
if not state.is_admin:
print()
_cprint(f" 💳 {_d('Billing actions require an org admin/owner.')}")
self._billing_portal_hint(state)
return False
if not state.cli_billing_enabled:
print()
_cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}")
self._billing_portal_hint(state, reason="Enable it on the portal first.")
return False
return True
def _billing_buy_flow(self, state):
"""Screen 2 (preset select) → Screen 3 (confirm + charge + poll)."""
from agent.billing_view import format_money, validate_charge_amount
if not self._billing_require_admin(state):
return
# Screen 3 — preset selection.
if not getattr(self, "_app", None):
presets = ", ".join(format_money(p) for p in state.charge_presets)
print()
_cprint(f" 💳 {_b('Buy usage credits')}")
print(f" Presets: {presets}")
print(" Run this in the interactive CLI to complete a purchase.")
self._billing_portal_hint(state)
return
preset_choices = []
for p in state.charge_presets:
preset_choices.append((str(p), format_money(p), "one-time credit purchase"))
preset_choices.append(("custom", "Custom amount…", "enter your own amount"))
preset_choices.append(("cancel", "Cancel", "do nothing"))
card = state.card
detail = f"Payment: {card.masked}" if card else "No saved card on file"
raw = self._prompt_text_input_modal(
title="💳 Buy usage credits", detail=detail, choices=preset_choices,
)
choice = self._normalize_slash_confirm_choice(raw, preset_choices)
if not choice or choice == "cancel":
print(" 🟡 Cancelled. No credits added.")
return
from decimal import Decimal
if choice == "custom":
entered = self._prompt_text_input(" Amount (USD): ")
if entered is None:
# None = cancelled (e.g. slash-worker can't prompt off-thread).
print(" 🟡 Cancelled. No credits added.")
return
v = validate_charge_amount(
entered or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not v.ok:
print(f" 🔴 {v.error}")
return
amount = v.amount
else:
try:
amount = Decimal(choice)
except Exception:
print(" 🔴 Invalid selection.")
return
self._billing_confirm_and_charge(state, amount)
def _billing_confirm_and_charge(self, state, amount):
"""Screen 3 — confirm total + consent, charge, then poll to settlement."""
from agent.billing_view import format_money, new_idempotency_key
card = state.card
print()
_cprint(f" 💳 {_b('Confirm purchase')}")
print(f" {'' * 41}")
print(f" Total: {format_money(amount)}")
if card:
print(f" Payment: {card.masked}")
print(f" {'' * 41}")
_consent = (
"By confirming, you allow Nous Research to charge your card."
)
_cprint(f" {_d(_consent)}")
confirm_choices = [
("pay", f"Pay {format_money(amount)} now", "submit the charge"),
("cancel", "Go back", "do not charge"),
]
if not getattr(self, "_app", None):
print(" Run in the interactive CLI to confirm a purchase.")
return
raw = self._prompt_text_input_modal(
title=f"💳 Pay {format_money(amount)}?",
detail=(card.masked if card else "no saved card"),
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "pay":
print(" 🟡 Cancelled. No credits added.")
return
# Submit the charge with a fresh idempotency key (reused on retry).
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
post_charge,
)
key = new_idempotency_key()
try:
result = post_charge(amount_usd=amount, idempotency_key=key)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
charge_id = result.get("chargeId")
if not charge_id:
print(" 🔴 No charge id returned; please check the portal.")
return
_cprint(f" {_d('Charge submitted — confirming settlement…')}")
self._billing_poll_charge(state, charge_id, amount)
def _billing_poll_charge(self, state, charge_id, amount):
"""Poll loop: 2s interval, 5-min cap, cancellable. settled = ledger truth."""
import time as _time
from agent.billing_view import format_money
from hermes_cli.nous_billing import (
BillingError,
BillingRateLimited,
get_charge_status,
)
deadline = _time.time() + 300 # 5-minute cap
interval = 2.0
while _time.time() < deadline:
try:
status = get_charge_status(charge_id)
except BillingRateLimited as exc:
# Retry-after, NOT a failure — back off and keep polling.
wait = exc.retry_after or 5
_time.sleep(min(wait, 30))
continue
except BillingError as exc:
print(f" 🔴 Could not check the charge: {exc}")
return
state_str = status.get("status")
if state_str == "settled":
amt = status.get("amountUsd")
from agent.billing_view import parse_money
shown = format_money(parse_money(amt)) if amt else format_money(amount)
print(f"{shown} in credits added.")
return
if state_str == "failed":
self._billing_render_charge_failed(state, status.get("reason"))
return
# pending → wait and poll again
_time.sleep(interval)
# Past the cap with no terminal state = timeout (not an error).
print(" 🟡 Still processing after 5 minutes — this is a timeout, not a "
"failure. Check /billing or the portal shortly.")
self._billing_portal_hint(state)
def _billing_render_charge_failed(self, state, reason):
"""Branch the poll `failed` reasons to the right copy + portal funnel."""
reason = (reason or "").strip()
if reason == "authentication_required":
print(" 🔴 Your bank requires verification (3DS). Complete it on the "
"portal to finish this purchase.")
elif reason == "payment_method_expired":
print(" 🔴 Your card has expired. Update it on the portal.")
elif reason == "card_declined":
print(" 🔴 Your card was declined. Try another card on the portal.")
else:
print(f" 🔴 The charge didn't go through ({reason or 'processing_error'}).")
self._billing_portal_hint(state)
def _billing_render_charge_error(self, state, exc):
"""Render a typed BillingError at submit time (pre-poll)."""
from hermes_cli.nous_billing import BillingRateLimited
code = getattr(exc, "error", None)
portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None)
if code == "no_payment_method":
print(" 💳 No saved card for terminal charges yet. Set one up on the "
"portal (one-time credit buys don't save a reusable card).")
elif code == "cli_billing_disabled":
print(" 🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.")
elif code == "monthly_cap_exceeded":
remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd")
if remaining is not None:
print(f" 🔴 Monthly spend cap reached — ${remaining} headroom left.")
else:
print(" 🔴 Monthly spend cap reached.")
elif isinstance(exc, BillingRateLimited):
wait = getattr(exc, "retry_after", None)
mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else ""
print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.")
else:
print(f" 🔴 {exc}")
if portal_url:
print(f" Portal: {portal_url}")
def _billing_handle_scope_required(self, state):
"""403 insufficient_scope → lazy step-up re-auth (plan D-A)."""
print()
print(" 💳 Terminal billing needs an extra permission (billing:manage).")
_scope_msg = (
"An org admin/owner must tick \"Allow terminal billing\" during "
"login."
)
_cprint(f" {_d(_scope_msg)}")
if not getattr(self, "_app", None):
print(" Run `hermes portal` and approve terminal billing, then retry.")
return
confirm_choices = [
("yes", "Re-authorize now", "open the portal to grant billing access"),
("no", "Not now", "cancel"),
]
raw = self._prompt_text_input_modal(
title="💳 Grant terminal billing access?",
detail="Opens the portal device-authorization page.",
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "yes":
print(" 🟡 Cancelled.")
return
try:
from hermes_cli.auth import step_up_nous_billing_scope
granted = step_up_nous_billing_scope(open_browser=True)
except Exception as exc:
print(f" 🔴 Re-authorization failed: {exc}")
return
if granted:
print(" ✅ Billing permission granted.")
# Step-up only grants the billing:manage TOKEN scope; the ORG
# kill-switch (cli_billing_enabled) is a separate gate. Re-fetch
# /state so we don't over-promise when a charge would still hit
# cli_billing_disabled.
from agent.billing_view import build_billing_state
fresh = build_billing_state()
if fresh.logged_in and fresh.cli_billing_enabled:
print(" Run /billing buy again to continue.")
else:
print(" 🟡 Permission granted, but terminal billing is still turned "
"off for this org. Enable it in the portal, then run /billing again.")
self._billing_portal_hint(fresh)
else:
print(" 🟡 Terminal billing was not granted (an admin must tick the box).")
def _billing_auto_reload_flow(self, state):
"""Screen 4 — auto-reload config: threshold + reload-to → PATCH.
Prefills the current values from ``state.auto_reload``. Validates both
amounts (2dp, within bounds, ``reload_to > threshold``). When auto-reload
is already on, offers a "Turn off" path (PATCH ``enabled:false``).
"""
from agent.billing_view import format_money, validate_charge_amount
if not self._billing_require_admin(state):
return
card = state.card
ar = state.auto_reload
currently_on = bool(ar and ar.enabled)
print()
_cprint(f" 💳 {_b('Auto-reload')}")
print(f" {'' * 41}")
_cprint(f" {_d('Automatically buy more credits when your balance is low.')}")
if card:
print(f" Card on file: {card.masked}")
else:
print(" No saved card — set one up on the portal first.")
self._billing_portal_hint(state)
return
if currently_on:
print(
f" Currently: below {format_money(ar.threshold_usd)}"
f"reload to {format_money(ar.reload_to_usd)}"
)
if not getattr(self, "_app", None):
print(" Run in the interactive CLI to configure auto-reload.")
self._billing_portal_hint(state)
return
# When already enabled, let the user turn it off without re-entering values.
if currently_on:
top_choices = [
("edit", "Edit thresholds", "change when / how much to reload"),
("off", "Turn off", "disable auto-reload"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Auto-reload",
detail=(
f"On — below {format_money(ar.threshold_usd)}"
f"reload to {format_money(ar.reload_to_usd)}"
),
choices=top_choices,
)
top = self._normalize_slash_confirm_choice(raw, top_choices)
if top == "off":
self._billing_auto_reload_disable(state)
return
if top != "edit":
print(" 🟡 Cancelled.")
return
# Field 1 — threshold (prefilled when editing an existing config).
cur_thr = format_money(ar.threshold_usd) if currently_on else None
thr_prompt = " When balance falls below (USD)"
thr_prompt += f" [{cur_thr}]: " if cur_thr else ": "
threshold_raw = self._prompt_text_input(thr_prompt)
if threshold_raw is None:
# None = cancelled (e.g. slash-worker can't prompt off-thread).
print(" 🟡 Cancelled.")
return
if not (threshold_raw or "").strip() and currently_on:
threshold_amt = ar.threshold_usd # keep current value on empty input
else:
tv = validate_charge_amount(
threshold_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not tv.ok or tv.amount is None:
print(f" 🔴 {tv.error}")
return
threshold_amt = tv.amount
# Field 2 — reload-to (prefilled when editing an existing config).
cur_rel = format_money(ar.reload_to_usd) if currently_on else None
rel_prompt = " Reload balance to (USD)"
rel_prompt += f" [{cur_rel}]: " if cur_rel else ": "
reload_raw = self._prompt_text_input(rel_prompt)
if reload_raw is None:
print(" 🟡 Cancelled.")
return
if not (reload_raw or "").strip() and currently_on:
reload_amt = ar.reload_to_usd # keep current value on empty input
else:
rv = validate_charge_amount(
reload_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
)
if not rv.ok or rv.amount is None:
print(f" 🔴 {rv.error}")
return
reload_amt = rv.amount
if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt:
print(" 🔴 Reload-to amount must be greater than the threshold.")
return
print()
_ar_consent = (
f"By confirming, you authorize Nous Research to charge {card.masked} "
f"whenever your balance reaches {format_money(threshold_amt)}. "
f"Turn off any time here or on the portal."
)
_cprint(f" {_d(_ar_consent)}")
confirm_choices = [
("agree", "Agree and turn on", "enable auto-reload"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Turn on auto-reload?",
detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}",
choices=confirm_choices,
)
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
if choice != "agree":
print(" 🟡 Cancelled.")
return
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
patch_auto_top_up,
)
try:
patch_auto_top_up(
enabled=True, threshold=float(threshold_amt), top_up_amount=float(reload_amt)
)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
print(f" ✅ Auto-reload on: below {format_money(threshold_amt)}"
f"reload to {format_money(reload_amt)}.")
def _billing_auto_reload_disable(self, state):
"""Turn off auto-reload (PATCH ``enabled:false``).
The endpoint requires ``threshold``/``topUpAmount`` in the body even when
disabling, so we echo back the current values (falling back to 0).
"""
from hermes_cli.nous_billing import (
BillingError,
BillingScopeRequired,
patch_auto_top_up,
)
ar = state.auto_reload
thr = float(ar.threshold_usd) if ar and ar.threshold_usd is not None else 0.0
rel = float(ar.reload_to_usd) if ar and ar.reload_to_usd is not None else 0.0
try:
patch_auto_top_up(enabled=False, threshold=thr, top_up_amount=rel)
except BillingScopeRequired:
self._billing_handle_scope_required(state)
return
except BillingError as exc:
self._billing_render_charge_error(state, exc)
return
print(" ✅ Auto-reload turned off.")
def _billing_limit_screen(self, state):
"""Screen 5 — monthly spend limit (read-only; cap is portal-only)."""
from agent.billing_view import format_money
print()
_cprint(f" 💳 {_b('Monthly spend limit')}")
print(f" {'' * 41}")
cap = state.monthly_cap
if cap is None or cap.limit_usd is None:
_cprint(f" {_d('No monthly cap visible (managed on the portal).')}")
else:
spent = format_money(cap.spent_this_month_usd)
limit = format_money(cap.limit_usd)
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
print(f" {spent} of {limit} used this month{ceiling}")
_limit_note = (
"The monthly limit is set on the portal — the terminal shows "
"it read-only."
)
_cprint(f" {_d(_limit_note)}")
self._billing_portal_hint(state)
def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history."""
# Parse optional --days flag

171
docs/billing-lifecycle.md Normal file
View file

@ -0,0 +1,171 @@
# Billing lifecycle: client-side state, errors, and recovery
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).
| State shape | Render |
|---|---|
| Logged out (`s.logged_in === false`) | Overlay never opens. `sys`: `💳 Not logged into Nous Portal — run /portal to log in, then /topup.` |
| `billing.state` RPC fetch fails (transport/timeout) | **Fail-closed**: `.catch(ctx.guardedErr)` — overlay never opens, no state is assumed. `sys`: `error: <message or "request failed">`. Never renders "no card" or any other guessed state; user must retry `/topup`. |
| `card: null` (no saved card), full menu (`is_admin && cli_billing_enabled`) | Overview shows `No saved card on file — "Add funds" walks you through adding one.` "Add funds" opens the **add-card path**: `Add a card on the portal` / `I've added it — check again` / `Back` (never an amount picker, which would 403 `no_payment_method`). |
| `card` present, `resolved_via` set | `Card: {display}` (e.g. `Visa ····4242 — the card on your subscription`) using the provenance-aware `display` field. |
| `card` present, `resolved_via` absent (older NAS) | Falls back to the generic `Card: {masked}`; Confirm screen adds `Your card saved on the portal will be charged.` |
| `auto_reload: null` | No auto-reload line at all (`autoReloadLine` returns `null`) — the feature isn't surfaced. |
| `auto_reload.card.kind: 'canonical'` | No distinct-card warning; card line falls back to the card on file. |
| `auto_reload.card.kind: 'distinct'` | `⚠ Auto-refill is charging {brand} ••{last4} — not your card on file.` in the Auto-reload screen (the divergence notice). |
| `auto_reload.card.kind: 'none'` | Same as `canonical` rendering-wise — no distinct-card warning shown. |
| `monthly_cap` present, `limit_usd != null` | `{spent_display} of {limit_display} used this month` (+ ` (default ceiling)` iff `is_default_ceiling`). |
| `monthly_cap` absent or `limit_usd == null` | `No monthly cap visible (managed on the portal).` |
| Role without billing capability (`!is_admin`, menu collapses) | Note: `Billing actions need someone with billing permissions (owner, admin, or finance admin).` Menu collapses to `Manage on portal` / `Cancel`. |
| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Terminal billing is off for this org — manage it on the portal.` Same collapsed menu. |
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).
| `error` code | Copy | Portal URL | `retry_after` |
|---|---|:-:|:-:|
| `insufficient_scope` | `This needs terminal billing enabled. Start a top-up to enable it, then retry.` | if present | — |
| `remote_spending_revoked` (CF-4) | `{An admin turned off terminal billing for this terminal. \| You turned off terminal billing for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — |
| `session_revoked` | `Your session was logged out. Run /portal to log in again.` Also clears `billing` overlay state. | if present | — |
| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Terminal billing is off for this account — an admin must enable it on the portal.` | if present | — |
| `role_required` | `Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.` | if present | — |
| `consent_required` | `This action needs a one-time card confirmation and consent step on the portal before it can proceed.` | if present | — |
| `org_access_denied` | `This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.` | if present | — |
| `upgrade_cap_exceeded` | `🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.` | if present | — |
| `auto_top_up_disabled_failures` | `Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.` | if present | — |
| `idempotency_conflict` | `🔴 That charge key was already used for a different amount. Start a fresh top-up.` | if present | — |
| `no_payment_method` | `💳 No saved card for terminal charges yet. Set one up on the portal (one-time credit buys don't save a reusable card).` | if present | — |
| `monthly_cap_exceeded` | `🔴 Monthly spend cap reached — ${remainingUsd} headroom left.` if `payload.remainingUsd` present, else `🔴 Monthly spend cap reached.` | if present | — |
| `rate_limited` / `temporarily_unavailable` | `🟡 Too many charges right now{ (try again in ~N min)}. This isn't a payment failure.` | if present | **yes** — minutes computed as `max(1, round(retry_after/60))` |
| `stripe_unavailable` | `🟡 Stripe is having trouble right now — try again shortly{ (try again in ~N min)}.` | if present | **yes** (same formula) |
| *default (unknown/other)* | `🔴 {message \|\| error \|\| 'Billing request failed.'}` — still surfaces whatever the server said, never a blank toast. | if present | — |
## 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.
| Outcome | Copy | Notes |
|---|---|---|
| `status: 'settled'` | `✅ ${amount_usd} added.` (or `✅ Credits added.` if no amount) | Terminal success. |
| `status: 'failed'`, `reason: 'authentication_required'` | `🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase.` | + `Portal:` line if `portalUrl`. |
| `status: 'failed'`, `reason: 'payment_method_expired'` | `🔴 Your card has expired. Update it on the portal.` | + `Portal:` line. |
| `status: 'failed'`, `reason: 'card_declined'` | `🔴 Your card was declined. Try another card on the portal.` | + `Portal:` line. |
| `status: 'failed'`, `reason: 'processing_error'` | `🔴 The charge didn't go through (processing_error).` | + `Portal:` line. |
| `status: 'failed'`, unrecognized/missing `reason` | `🔴 The charge didn't go through ({reason \|\| 'processing_error'}).` | Same portal funnel — parity with `cli.py`'s `_billing_portal_hint`. |
| Poll timeout (still `pending` past the 5-min cap) | `🟡 Still processing after 5 minutes — this is a timeout, not a failure. Check /topup or the portal shortly.` | + `Portal:` line if `portalUrl`. Explicitly NOT called a failure. |
| Revocation mid-poll (`remote_spending_revoked` / `session_revoked` while polling) | Renders the matching §2 copy, **then** appends: `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` | CF-7 rule 4: a post-revoke 403 while polling is ambiguous (the charge may have already settled) — never call it "failed". |
| 429/503 while polling (`rate_limited`/`temporarily_unavailable`/`stripe_unavailable`) | No error shown; backs off using `retry_after` (default 5s, capped at 30s) and keeps polling until the 5-min cap, then reads as timeout. | Not a payment failure. |
| Other `!ok` status-check error | `🔴 Could not check the charge: {message \|\| error \|\| 'error'}` | |
| Transport loss (poll RPC throws/rejects) | `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` (`UNCONFIRMED_CHARGE_MESSAGE`) | Same "unconfirmed, check balance" framing as revocation mid-poll — a dropped connection can never be read as "failed". |
## 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):
| `effect` | Confirm screen copy | Primary action |
|---|---|---|
| `charge_now` | `Upgrade to {target}. You will be charged {amount} now (prorated).` (+ monthly-credits delta, + which card if resolver confidently knows) | `Pay {amount} & upgrade now` |
| `scheduled` | `Change to {target} — takes effect {date}. No charge now; you keep your current plan until then.` | `Schedule change to {target}` |
| `no_op` | `You are already on {target} — nothing to change.` | none (Back only) |
| `blocked` | `{preview.reason}` or fallback `That change cannot be made here — manage it on the portal.` | `Manage on portal` |
| Preview RPC returns `null`/transport failure | routes straight to Result: `Could not preview that change.` | — |
| Preview `!ok`, `insufficient_scope` | routes to `stepup` screen (`{kind:'preview', tierId}`) | — |
| Preview `!ok`, other error | routes to Result with `errorResult(p)` (`message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'`) | — |
**Pending-change apply outcomes** (`applyPendingAndRoute`):
| `pending.kind` | Success copy |
|---|---|
| `cancellation` | `Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.` |
| `tier_change` (downgrade/schedule) | `Scheduled — your plan doesn't change today. You keep your current plan until the end of the billing period, then it switches.` |
| `upgrade` | routed through `upgradeResult` (below) |
| any kind, mutation `insufficient_scope` | routes to stepup (`{kind:'apply'}`) |
**Upgrade `status` × `reason` matrix** (`upgradeResult`, checked in this
order — `reason` is checked *before* `status`):
| Condition | Result |
|---|---|
| `r === null` (transport failure on the charging route) | `Couldn't confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.` — ambiguous, never a blind retry. |
| `reason: 'authentication_required'` **or** `reason: 'subscription_payment_intent_requires_action'` | `Please verify your card in the portal to finish this upgrade.``recovery_url`. **Both reasons map to the same SCA copy** — the client branches on `reason`, not `status`, specifically so an SCA case that pre-#711 NAS mislabels with `status: 'payment_failed'` (no distinguishing reason yet) still routes to the correct "verify your card" copy instead of reading as a hard decline. |
| `reason: 'card_declined'` | `Your card was declined — try a different card on the portal.``recovery_url`. |
| `ok && status: 'already_on_tier'` | `You are already on {target_tier_name}.` (success) |
| `ok && status: 'upgraded'` | `Upgraded to {target_tier_name}. Your new monthly credits land in a moment.` — starts the eventual-consistency apply-poll (below). |
| `status: 'requires_action'` (no distinguishing reason) | `This upgrade needs extra verification (3DS). Finish it on the portal.``recovery_url`. |
| `status: 'payment_failed'` (no distinguishing reason) | `Your card was declined. Update your payment method on the portal and try again.``recovery_url`. |
| anything else | `errorResult(r)`: `message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'` |
**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):
| `error` | Copy |
|---|---|
| `session_revoked` | `Your session expired — run /portal to log in again, then retry the change.` |
| `remote_spending_revoked` | `{message}` or `Terminal spending was turned off for this session — reconnect from the portal, then retry.` |
| `rate_limited` | `Too many attempts — wait a moment, then try again.` |
| other/unknown | `{message}` or `Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.` |
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: `Terminal billing
still isn't enabled for this org — enable it on the portal, then retry.`
## 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.

View file

@ -10279,8 +10279,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "usage":
return await self._handle_usage_command(event)
if canonical == "credits":
return await self._handle_credits_command(event)
if canonical == "topup":
return await self._handle_topup_command(event)
if canonical == "insights":
return await self._handle_insights_command(event)

View file

@ -4095,15 +4095,15 @@ class GatewaySlashCommandsMixin:
key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many"
return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id)
async def _handle_credits_command(self, event: MessageEvent) -> str:
"""Handle /credits -- show Nous credit balance and the top-up handoff.
async def _handle_topup_command(self, event: MessageEvent) -> str:
"""Handle /topup -- show the Nous balance and hand off to the portal.
Renders the balance block + identity line + a tappable top-up URL that
opens the portal billing page with the modal open. The terminal does NOT
confirm, poll, or track payment (billing phase 2a) checkout happens in
the browser and the next /credits shows the new balance. The tappable URL
is the affordance: it works on every platform (button-capable or plain
text like SMS/email). Fetched off the event loop; fail-open.
Renders the balance block + identity line + a tappable portal URL that
opens the billing page. Terminal billing is managed on the portal: the
terminal does NOT charge, confirm, or track payment here everything
happens in the browser and the next /topup shows the new balance. The
tappable URL is the affordance and works on every platform (button-capable
or plain text like SMS/email). Fetched off the event loop; fail-open.
"""
from agent.account_usage import build_credits_view
@ -4115,7 +4115,7 @@ class GatewaySlashCommandsMixin:
if view is None or not view.logged_in:
return t("gateway.credits.not_logged_in")
lines: list[str] = ["💳 **Nous credits**"]
lines: list[str] = ["💳 **Nous balance**"]
for line in view.balance_lines:
if line.lstrip().startswith("📈"):
continue # drop the helper's header; we print our own
@ -4125,8 +4125,8 @@ class GatewaySlashCommandsMixin:
lines.append(view.identity_line)
if view.topup_url:
lines.append("")
lines.append(f"Top up: {view.topup_url}")
lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.")
lines.append(f"Manage billing on the portal: {view.topup_url}")
lines.append("Top up and manage billing in the browser — your balance updates here after.")
return "\n".join(lines)
def _context_breakdown_lines(self, agent, source) -> list[str]:

File diff suppressed because it is too large Load diff

View file

@ -230,9 +230,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
gateway_only=True),
CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info",
args_hint="[reset [--force]]"),
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info",
cli_only=True),
CommandDef("subscription", "View your Nous plan and change it in the browser", "Info",
cli_only=True, aliases=("upgrade",)),
CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
@ -1159,12 +1159,12 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# surface (CLI, TUI, Telegram, Discord). Keep this list TIGHT and intentional —
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
# - billing: the terminal-billing surface (buy/auto-reload/limit); /hermes billing.
# - topup: the billing/balance surface; reached via /hermes topup on Slack.
# (the rehaul folded the old /credits + /billing surfaces into /topup.)
# - moa: high-cost slash mode, available through /hermes moa to avoid
# displacing existing native Slack slash commands at the 50-command cap.
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug"})
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug"})
def _sanitize_slack_name(raw: str) -> str:

View file

@ -67,6 +67,9 @@ class BillingError(Exception):
portal_url: Optional[str] = None,
retry_after: Optional[int] = None,
payload: Optional[dict[str, Any]] = None,
actor: Optional[str] = None,
code: Optional[str] = None,
recovery: Optional[str] = None,
) -> None:
super().__init__(message)
self.status = status
@ -74,6 +77,13 @@ class BillingError(Exception):
self.portal_url = portal_url
self.retry_after = retry_after
self.payload = payload or {}
# Remote-Spending contract extras (NAS PR #481): `actor` (self|admin) on a
# revoke, `code` (the new machine code dual-emitted alongside `error`), and
# `recovery` (reconnect|login|enable_account_toggle). Additive — absent on
# older NAS / unrelated errors.
self.actor = actor
self.code = code
self.recovery = recovery
class BillingScopeRequired(BillingError):
@ -86,17 +96,73 @@ class BillingScopeRequired(BillingError):
"""
class BillingRateLimited(BillingError):
class BillingAuthError(BillingError):
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
class BillingRemoteSpendingRevoked(BillingError):
"""``403 remote_spending_revoked`` — THIS terminal's spending was revoked.
Distinct from ``insufficient_scope`` (never had the grant) and from
``session_revoked`` (full logout). The terminal stays logged in; only the
money path is cut. ``actor`` is ``"admin"`` or ``"self"`` (absent treat as
``"self"``); recovery is **reconnect** (re-consent device-auth). The terminal
MUST disable charge/auto-reload immediately, without waiting for the next
token refresh (the current token still claims the scope for ~15 min).
"""
class BillingSessionRevoked(BillingAuthError):
"""``401 session_revoked`` — the whole session was logged out.
Stronger than a spend-revoke: recovery is **re-login** (full device-auth),
not just reconnect. Subclass of :class:`BillingAuthError` so existing 401
handling still treats it as not-logged-in, but the typed code lets the
surface route to re-login with the right copy.
"""
class BillingTransient(BillingError):
"""A deterministic non-charge outcome: the request definitely did NOT
reach/complete at Stripe, so it's always safe to retry after backoff —
never the "maybe charged" ambiguity of a real 5xx/timeout. Covers
429 rate limiting, 503 gate-unavailable, Stripe being down, and the
daily upgrade cap distinct failure modes that share this one
contract property. Catch this (not the old ad-hoc subclass hierarchy)
wherever the intent is "any transient, definitely-not-charged billing
failure, back off and retry/poll".
"""
class BillingRateLimited(BillingTransient):
"""``429 rate_limited`` or ``503 temporarily_unavailable``.
NOT a payment failure. Carries ``retry_after`` (seconds) back off and tell
the user "try again in N min"; never auto-retry-spam (the limiter is
5/org/hr + 5/token/hr and easy to dig deeper into).
5/org/hr + 5/token/hr and easy to dig deeper into). A 503 is the gate backend
failing closed back off, do NOT treat as revoked.
"""
class BillingAuthError(BillingError):
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
class BillingStripeUnavailable(BillingTransient):
"""``503 stripe_unavailable`` — Stripe itself is down.
TRANSIENT: back off and retry using Retry-After; this is NOT the same as
being throttled by our own rate limiter, so surfaces must not render "rate
limited" copy for it — they should read ``.error`` to tell the two apart.
A BillingTransient sibling of BillingRateLimited (not a subclass) surfaces
must not render "rate limited" copy for it; read ``.error`` to distinguish it.
"""
class BillingUpgradeCapExceeded(BillingTransient):
"""``429 upgrade_cap_exceeded`` — the org hit its 5-upgrades/day cap.
Distinct from the hourly ``rate_limited`` charge cap (same HTTP status,
different meaning + no useful short-Retry-After backoff). A BillingTransient
sibling of BillingRateLimited (not a subclass) surfaces must read ``.error``
to distinguish the failure mode.
"""
# =============================================================================
@ -148,6 +214,20 @@ _TOKEN_CACHE_TTL_SECONDS = 30.0
_token_cache: tuple[float, str, str] | None = None # (cached_at, token, base)
def invalidate_cached_token() -> None:
"""Bust the 30s token cache so post-step-up replays use the freshly-scoped token.
``_request`` only self-busts the cache on a 401 (an expired/invalid
token), not on a 403 scope denial so after a step-up grant, the
cache would otherwise still hold the pre-grant unscoped token and
the immediate replay would 403 again. Callers outside this module
(e.g. the CLI's scope step-up flow) call this instead of poking
the private ``_token_cache`` global directly.
"""
global _token_cache
_token_cache = None
def _billing_not_logged_in(exc: Optional[BaseException] = None) -> "BillingAuthError":
"""Build the canonical 'not logged in' BillingAuthError (single source)."""
err = BillingAuthError(
@ -234,9 +314,21 @@ def _retry_after_seconds(headers: Any) -> Optional[int]:
def _raise_for_error(
status: int, payload: dict[str, Any], headers: Any = None
) -> None:
"""Map an HTTP error response to the right typed :class:`BillingError`."""
"""Map an HTTP error response to the right typed :class:`BillingError`.
Recognizes the Remote-Spending gate contract (NAS PR #481):
403 ``remote_spending_revoked`` (this terminal's spend revoked → reconnect),
401 ``session_revoked`` (full logout re-login), 503 ``temporarily_unavailable``
(gate fail-closed back off, NOT revoked). The business-denial codes
(``cli_billing_disabled`` + dual ``code:remote_spending_disabled``,
``role_required``, ``idempotency_conflict``, ) flow through as a generic
BillingError carrying ``error``/``code``/``recovery`` for the surface to map.
"""
error = payload.get("error") if isinstance(payload, dict) else None
message = payload.get("message") if isinstance(payload, dict) else None
code = payload.get("code") if isinstance(payload, dict) else None
actor = payload.get("actor") if isinstance(payload, dict) else None
recovery = payload.get("recovery") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(
payload.get("portalUrl") if isinstance(payload, dict) else None
)
@ -248,14 +340,42 @@ def _raise_for_error(
"portal_url": portal_url,
"retry_after": retry_after,
"payload": payload if isinstance(payload, dict) else None,
"actor": actor,
"code": code,
"recovery": recovery,
}
if status == 401:
raise BillingAuthError(message or "Authentication required.", **common)
if status == 403 and error == "insufficient_scope":
raise BillingScopeRequired(
message or "This action needs the billing:manage scope.", **common
if error == "stripe_unavailable":
raise BillingStripeUnavailable(
message or "Stripe is temporarily unavailable — try again shortly.", **common
)
if error == "upgrade_cap_exceeded":
raise BillingUpgradeCapExceeded(
message or "Daily plan-change limit reached — try again tomorrow.", **common
)
if status == 401:
# session_revoked is a full logout (→ re-login), stronger than a 401
# expired-token. Both stay BillingAuthError-compatible for legacy callers.
if error == "session_revoked":
raise BillingSessionRevoked(
message or "Your session was logged out — log in again.", **common
)
raise BillingAuthError(message or "Authentication required.", **common)
if status == 403:
# This terminal's spending was revoked (NOT the same as never having the
# scope). Disable spend UI immediately; recovery is reconnect.
if error == "remote_spending_revoked":
raise BillingRemoteSpendingRevoked(
message or "Remote Spending was revoked for this terminal.", **common
)
if error == "insufficient_scope":
raise BillingScopeRequired(
message or "This action needs the billing:manage scope.", **common
)
# Business 403s (cli_billing_disabled / role_required / no_payment_method /
# monthly_cap_exceeded / …) → generic BillingError with code/recovery.
raise BillingError(message or error or "Billing request denied.", **common)
if status in (429, 503):
raise BillingRateLimited(
message or "Rate limited — try again shortly.", **common
@ -296,7 +416,23 @@ def _request(
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw.strip() else {}
if not raw.strip():
return {}
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
# A 2xx with a non-JSON body means the endpoint isn't actually
# serving the billing API here — e.g. a reverse-proxy / SPA
# fallback HTML page when the route isn't deployed on this
# deployment. Surface it as a typed, non-auth error so callers
# degrade gracefully ("unavailable") instead of crashing with a
# raw JSONDecodeError that reads as "not logged in".
raise BillingError(
"Billing endpoint returned a non-JSON response "
"(it may not be available on this deployment).",
error="endpoint_unavailable",
status=getattr(resp, "status", None),
) from exc
except urllib.error.HTTPError as exc:
# A 401 on a cached token → drop the cache and retry once with a fresh
# (refresh-aware) resolve before surfacing the auth error.
@ -404,3 +540,133 @@ def get_charge_status(
# guard against a stray slash that would change the path shape.
safe_id = urllib.parse.quote(charge_id.strip(), safe="")
return _request("GET", f"/api/billing/charge/{safe_id}", timeout=timeout)
def get_subscription_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
"""``GET /api/billing/subscription`` — current plan, tiers, usage (no scope).
Returns the raw JSON dict from NAS (WS1 Phase A). Read-only no
``billing:manage`` scope required. Raises :class:`BillingAuthError`
on 401 and :class:`BillingError` on other non-2xx.
"""
return _request("GET", "/api/billing/subscription", timeout=timeout)
# =============================================================================
# Subscription change (V3) — preview + the pending-change resource + upgrade
# =============================================================================
#
# Mutating the plan splits into a chargeless lane and the single money route:
# - preview → a quote (no mutation, no charge) of what a change would do.
# - PUT/DELETE pending-change → schedule / clear a downgrade or cancellation
# (chargeless; takes effect at period end).
# - POST upgrade → the ONE route that charges (prorate + charge the card on the
# subscription + flip the plan, in one Stripe op).
# All require the ``billing:manage`` scope (a 403 insufficient_scope raises
# :class:`BillingScopeRequired`, driving the device step-up) — including preview,
# which issues live Stripe calls and reveals charge amounts.
def post_subscription_preview(
*, subscription_type_id: str, timeout: float = DEFAULT_TIMEOUT
) -> dict[str, Any]:
"""``POST /api/billing/subscription/preview`` — a chargeless effect quote.
Quotes a change to ``subscription_type_id`` without mutating anything:
``effect`` is ``charge_now`` (an upgrade ``amountDueNowCents`` is the prorated
upfront charge), ``scheduled`` (a downgrade ``effectiveAt`` is period end),
``no_op`` (already on the tier), or ``blocked`` (``reason`` says why the commit
would be refused). Also returns the current + target tier and the monthly-credit
delta. ``amountDueNowCents`` is ``None`` when not a charge or when the proration
quote is unavailable. Requires ``billing:manage`` (live Stripe calls + amounts).
"""
return _request(
"POST",
"/api/billing/subscription/preview",
body={"subscriptionTypeId": subscription_type_id},
timeout=timeout,
)
def put_subscription_pending_change(
*,
subscription_type_id: str | None = None,
cancel: bool = False,
timeout: float = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""``PUT /api/billing/subscription/pending-change`` — set the end-of-period intent.
A subscription has at most one pending disposition. Pass ``cancel=True`` to
schedule a cancellation, or a ``subscription_type_id`` to schedule a downgrade /
same-price change. UPGRADES are rejected here (they charge immediately use
:func:`post_subscription_upgrade`). Chargeless; requires ``billing:manage``.
Returns ``{rail, changeType, targetTierName, message}`` for a tier change, or
``{rail, cancelAtPeriodEnd, message}`` for a cancellation.
"""
if cancel:
body: dict[str, Any] = {"type": "cancellation"}
else:
if not (
isinstance(subscription_type_id, str) and subscription_type_id.strip()
):
raise BillingError(
"A subscription tier is required to schedule a plan change.",
error="invalid_subscription_type",
)
body = {
"type": "tier_change",
"subscriptionTypeId": subscription_type_id.strip(),
}
return _request(
"PUT",
"/api/billing/subscription/pending-change",
body=body,
timeout=timeout,
)
def delete_subscription_pending_change(
*, timeout: float = DEFAULT_TIMEOUT
) -> dict[str, Any]:
"""``DELETE /api/billing/subscription/pending-change`` — clear it (resume / undo).
Removes a scheduled downgrade OR cancellation in one call, restoring the live
active tier and recurring renewal. Chargeless, but it re-enables recurring
spend, so it requires ``billing:manage`` and is honored by the org kill-switch.
Returns ``{rail, cancelAtPeriodEnd: false, message}``.
"""
return _request(
"DELETE",
"/api/billing/subscription/pending-change",
timeout=timeout,
)
def post_subscription_upgrade(
*,
subscription_type_id: str,
idempotency_key: str,
timeout: float = DEFAULT_TIMEOUT,
) -> dict[str, Any]:
"""``POST /api/billing/subscription/upgrade`` — immediate paid upgrade.
The SINGLE money route: one Stripe op prorates, charges the card already on the
subscription, and flips the plan. ``Idempotency-Key`` is MANDATORY (a missing
header is a server 400, not a default) reuse the same key on retry so a replay
cannot double-charge. Returns ``{status:"upgraded"|"already_on_tier", ...}`` on
success, or ``{status:"requires_action"|"payment_failed", reason, recoveryUrl}``
when the charge needs 3DS / was declined and must be finished in the portal at
``recoveryUrl``. Requires ``billing:manage``.
"""
if not (isinstance(idempotency_key, str) and idempotency_key.strip()):
raise BillingError(
"Idempotency-Key is required for an upgrade.",
error="idempotency_key_required",
)
return _request(
"POST",
"/api/billing/subscription/upgrade",
body={"subscriptionTypeId": subscription_type_id},
extra_headers={"Idempotency-Key": idempotency_key.strip()},
timeout=timeout,
)

View file

@ -0,0 +1,145 @@
"""Tests for the shared dollar usage model (agent/billing_usage.py).
Behavior contracts: status classification, bar math, fail-open, and the
dollars-only / topup-split invariants the billing UX requires.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import pytest
from agent.billing_usage import LOW_BALANCE_THRESHOLD_USD, UsageBar, usage_model_from_account
# ── Lightweight stand-ins for the NousPortalAccountInfo shape ────────────────
@dataclass
class _Access:
subscription_credits_remaining: Optional[float] = None
purchased_credits_remaining: Optional[float] = None
total_usable_credits: Optional[float] = None
@dataclass
class _Sub:
plan: Optional[str] = None
monthly_credits: Optional[float] = None
current_period_end: Optional[str] = None
@dataclass
class _Account:
logged_in: bool = True
paid_service_access: Optional[bool] = None
paid_service_access_info: Optional[_Access] = None
subscription: Optional[_Sub] = None
def _acct(**over):
return _Account(**over)
class _Boom:
@property
def logged_in(self):
raise RuntimeError("kaboom")
@pytest.mark.parametrize("account", [None, _acct(logged_in=False), _Boom()])
def test_fails_open_to_unavailable(account):
assert usage_model_from_account(account).available is False
@pytest.mark.parametrize(
"account,expected",
[
# no plan, no balance -> free
(_acct(paid_service_access_info=_Access()), "free"),
# paid access explicitly lost -> depleted
(_acct(paid_service_access=False, subscription=_Sub(plan="Plus", monthly_credits=20.0),
paid_service_access_info=_Access(subscription_credits_remaining=0.0, total_usable_credits=0.0)), "depleted"),
# above threshold -> healthy
(_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0)), "healthy"),
# under $5 spendable -> low
(_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
paid_service_access_info=_Access(subscription_credits_remaining=3.4, total_usable_credits=3.4)), "low"),
# exactly $5 -> healthy (the threshold boundary is exclusive)
(_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
paid_service_access_info=_Access(subscription_credits_remaining=5.0, total_usable_credits=5.0)), "healthy"),
# top-up only, no plan -> usable (healthy), not free
(_acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=30.0, total_usable_credits=30.0)), "healthy"),
],
)
def test_status_classification(account, expected):
m = usage_model_from_account(account)
assert m.available is True
assert m.status == expected
def test_threshold_constant_is_five():
assert LOW_BALANCE_THRESHOLD_USD == 5.0
def test_healthy_carries_plan_name_and_renewal():
m = usage_model_from_account(
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0, current_period_end="2026-07-01"),
paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0))
)
assert m.plan_name == "Plus" and m.renews_at == "2026-07-01"
def test_plan_bar_spent_and_pct():
m = usage_model_from_account(
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0))
)
bar = m.plan_bar
assert bar is not None and bar.kind == "plan"
assert (bar.remaining_usd, bar.total_usd, bar.pct_used) == (14.0, 20.0, 30)
assert bar.spent_usd == pytest.approx(6.0)
def test_plan_bar_clamps_over_cap_to_zero_spent():
# Rollover/debt: remaining > cap clamps to the cap and reads as zero spent.
m = usage_model_from_account(
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
paid_service_access_info=_Access(subscription_credits_remaining=25.0, total_usable_credits=25.0))
)
assert m.plan_bar.remaining_usd == 20.0 and m.plan_bar.spent_usd == 0.0
def test_topup_bar_is_full_with_no_denominator():
m = usage_model_from_account(
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
paid_service_access_info=_Access(subscription_credits_remaining=14.0, purchased_credits_remaining=12.0, total_usable_credits=26.0))
)
tb = m.topup_bar
assert tb is not None and tb.kind == "topup"
assert tb.remaining_usd == 12.0 and tb.fill_fraction == 1.0 and tb.pct_used is None
assert m.total_spendable_usd == 26.0 and m.has_topup is True
def test_no_plan_bar_without_monthly_cap():
m = usage_model_from_account(
_acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=8.0, total_usable_credits=8.0))
)
assert m.plan_bar is None and m.topup_bar is not None
def test_non_finite_values_are_ignored():
m = usage_model_from_account(
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=float("nan")),
paid_service_access_info=_Access(subscription_credits_remaining=float("inf")))
)
assert m.plan_bar is None
def test_usage_bar_fill_fraction_clamped():
assert UsageBar(kind="plan", remaining_usd=30.0, total_usd=20.0).fill_fraction == 1.0
assert UsageBar(kind="plan", remaining_usd=-5.0, total_usd=20.0).fill_fraction == 0.0
assert UsageBar(kind="plan", remaining_usd=0.0, total_usd=0.0).fill_fraction == 0.0

View file

@ -21,6 +21,7 @@ import pytest
import agent.billing_view as bv
from agent.billing_view import (
AutoReload,
AutoReloadCard,
BillingState,
CardInfo,
MonthlyCap,
@ -37,6 +38,9 @@ from hermes_cli.nous_billing import (
BillingError,
BillingRateLimited,
BillingScopeRequired,
BillingStripeUnavailable,
BillingTransient,
BillingUpgradeCapExceeded,
_raise_for_error,
resolve_portal_base_url,
)
@ -130,6 +134,71 @@ def test_state_member_tier_parse():
assert s.can_charge is False # not admin
@pytest.mark.parametrize(
"role,can_change_plan_raw,is_admin,can_change_plan",
[
("OWNER", None, True, True),
("ADMIN", None, True, True),
("FINANCE_ADMIN", True, False, True),
("SECURITY_ADMIN", None, False, False),
("MEMBER", None, False, False),
],
)
def test_state_five_roles(
role, can_change_plan_raw, is_admin, can_change_plan
):
payload = _member_payload()
payload["org"]["role"] = role
if can_change_plan_raw is not None:
payload["canChangePlan"] = can_change_plan_raw
state = billing_state_from_payload(payload)
assert state.is_admin is is_admin
assert state.can_change_plan_raw is can_change_plan_raw
assert state.can_change_plan is can_change_plan
if role == "SECURITY_ADMIN":
assert state.card is None
assert state.monthly_cap is None
assert state.auto_reload is None
@pytest.mark.parametrize(
"role,server_capability",
[("MEMBER", True), ("OWNER", False)],
)
def test_state_can_change_plan_prefers_server_capability(role, server_capability):
payload = _member_payload()
payload["org"]["role"] = role
payload["canChangePlan"] = server_capability
state = billing_state_from_payload(payload)
assert state.can_change_plan is server_capability
def test_state_can_change_plan_falls_back_to_legacy_role_check():
owner = _member_payload()
owner["org"]["role"] = "OWNER"
member = _member_payload()
assert billing_state_from_payload(owner).can_change_plan is True
assert billing_state_from_payload(member).can_change_plan is False
def test_can_charge_finance_admin_with_server_capability():
"""Server capability can grant FINANCE_ADMIN charge access."""
payload = _member_payload()
payload["org"]["role"] = "FINANCE_ADMIN"
payload["canChangePlan"] = True
state = billing_state_from_payload(payload)
assert state.is_admin is False
assert state.can_change_plan is True
assert state.can_charge is True
def test_state_owner_tier_parse():
s = billing_state_from_payload(_owner_payload())
assert s.is_admin is True
@ -146,6 +215,54 @@ def test_state_owner_tier_parse():
)
@pytest.mark.parametrize(
"raw_card,expected",
[
(
{"kind": "canonical", "paymentMethodId": "ignored", "brand": "ignored"},
AutoReloadCard(kind="canonical"),
),
(
{
"kind": "distinct",
"paymentMethodId": "pm_auto",
"brand": None,
"last4": None,
},
AutoReloadCard(
kind="distinct",
payment_method_id="pm_auto",
brand=None,
last4=None,
),
),
(
{"kind": "none", "last4": "ignored"},
AutoReloadCard(kind="none"),
),
],
)
def test_state_parses_auto_reload_card_variants(raw_card, expected):
payload = _owner_payload()
payload["autoReload"]["card"] = raw_card
state = billing_state_from_payload(payload)
assert state.auto_reload is not None
assert state.auto_reload.card == expected
@pytest.mark.parametrize("raw_card", [None, "canonical", {}, {"kind": "future"}])
def test_state_ignores_unrecognized_auto_reload_card(raw_card):
payload = _owner_payload()
payload["autoReload"]["card"] = raw_card
state = billing_state_from_payload(payload)
assert state.auto_reload is not None
assert state.auto_reload.card is None
def test_state_can_charge_false_when_killswitch_off():
p = _owner_payload()
p["cliBillingEnabled"] = False
@ -205,6 +322,29 @@ def test_rate_limited_maps_with_retry_after(status):
assert isinstance(ei.value, BillingRateLimited)
@pytest.mark.parametrize(
"status,error,expected_type",
[
(503, "stripe_unavailable", BillingStripeUnavailable),
(429, "upgrade_cap_exceeded", BillingUpgradeCapExceeded),
],
)
def test_specific_billing_throttle_errors_remain_distinguishable(
status, error, expected_type
):
with pytest.raises(expected_type) as ei:
_raise_for_error(
status,
{"error": error},
_Headers({"Retry-After": "90"}),
)
assert ei.value.error == error
assert ei.value.retry_after == 90
assert isinstance(ei.value, BillingTransient)
assert not isinstance(ei.value, BillingRateLimited)
@pytest.mark.parametrize(
"error",
[
@ -375,3 +515,50 @@ def test_validate_amount_rejections(raw, err_substr):
v = validate_charge_amount(raw, min_usd=Decimal("10"), max_usd=Decimal("10000"))
assert not v.ok
assert err_substr.lower() in (v.error or "").lower()
# ---------------------------------------------------------------------------
# HERMES_DEV_BILLING_FIXTURE — offline card/scope state scaffolding (T0)
# ---------------------------------------------------------------------------
def test_billing_fixture_unset_returns_none(monkeypatch):
"""No env var → fixture is inert (the real portal path runs)."""
monkeypatch.delenv("HERMES_DEV_BILLING_FIXTURE", raising=False)
assert bv._dev_fixture_billing_state() is None
@pytest.mark.parametrize(
"name,has_card,is_admin,billing_on",
[
("nocard", False, True, True),
("card", True, True, True),
("card-autoreload", True, True, True),
("notadmin", True, False, True),
("billing-off", False, True, False),
],
)
def test_billing_fixture_card_and_gate_invariants(monkeypatch, name, has_card, is_admin, billing_on):
"""Each fixture state honors the card/admin/kill-switch contract the gate reads."""
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", name)
s = build_billing_state()
assert s.logged_in is True
assert (s.card is not None) is has_card
assert s.is_admin is is_admin
assert s.cli_billing_enabled is billing_on
def test_billing_fixture_autoreload_state(monkeypatch):
"""card-autoreload pairs a card with an enabled auto-reload (drives that screen)."""
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "card-autoreload")
s = build_billing_state()
assert s.card is not None
assert s.auto_reload is not None and s.auto_reload.enabled is True
def test_billing_fixture_logged_out_and_unknown(monkeypatch):
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "logged-out")
assert build_billing_state().logged_in is False
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "bogus-state")
s = build_billing_state()
assert s.logged_in is False and "bogus-state" in (s.error or "")

View file

@ -469,7 +469,7 @@ class TestNoticeCopy:
s = CreditsState(paid_access=False)
to_show, _ = evaluate_credits_notices(s, latch)
depleted_notice = next(n for n in to_show if n.key == "credits.depleted")
assert "/credits" in depleted_notice.text
assert "/topup" in depleted_notice.text
# ── Scenario 8: severity order in a single call ──────────────────────────────

View file

@ -131,7 +131,7 @@ def test_view_fetch_failure_is_logged_out(monkeypatch):
assert view.logged_in is False
# ── gateway _handle_credits_command ─────────────────────────────────────────
# ── gateway _handle_topup_command (the messaging billing surface) ────────────
class _FakeEvent:
@ -139,7 +139,7 @@ class _FakeEvent:
def _make_gateway_stub():
"""Minimal object exposing the mixin's _handle_credits_command."""
"""Minimal object exposing the mixin's _handle_topup_command."""
from gateway.slash_commands import GatewaySlashCommandsMixin
class _Stub(GatewaySlashCommandsMixin):
@ -149,7 +149,7 @@ def _make_gateway_stub():
return _Stub()
def test_gateway_credits_renders_block_and_url(monkeypatch):
def test_gateway_topup_renders_block_and_url(monkeypatch):
view = CreditsView(
logged_in=True,
balance_lines=("📈 Nous credits", "Total usable: $52.50"),
@ -160,101 +160,55 @@ def test_gateway_credits_renders_block_and_url(monkeypatch):
monkeypatch.setattr(account_usage, "build_credits_view", lambda *a, **kw: view)
stub = _make_gateway_stub()
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
out = asyncio.run(stub._handle_topup_command(_FakeEvent()))
assert "💳" in out
assert "Total usable: $52.50" in out
assert "Topping up as alice@example.test / org Acme" in out
assert "https://portal.example.test/orgs/acme/billing?topup=open" in out
assert "credits will appear in /credits shortly" in out
assert "Manage billing on the portal" in out
# The helper's own 📈 header line is dropped (we render our own 💳 header).
assert "📈 Nous credits" not in out
def test_gateway_credits_not_logged_in(monkeypatch):
def test_gateway_topup_not_logged_in(monkeypatch):
monkeypatch.setattr(
account_usage, "build_credits_view", lambda *a, **kw: CreditsView(logged_in=False)
)
stub = _make_gateway_stub()
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
out = asyncio.run(stub._handle_topup_command(_FakeEvent()))
assert "Not logged into Nous Portal" in out
def test_gateway_credits_fetch_exception_is_not_logged_in(monkeypatch):
def test_gateway_topup_fetch_exception_is_not_logged_in(monkeypatch):
def _boom(*a, **kw):
raise RuntimeError("boom")
monkeypatch.setattr(account_usage, "build_credits_view", _boom)
stub = _make_gateway_stub()
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
out = asyncio.run(stub._handle_topup_command(_FakeEvent()))
assert "Not logged into Nous Portal" in out
# ── command registry ────────────────────────────────────────────────────────
def test_credits_command_registered():
def test_credits_command_fully_removed():
"""`/credits` and the old `/billing` are gone entirely — not commands, not
aliases. Billing lives only on /topup, with NO aliases, on every platform."""
from hermes_cli.commands import resolve_command, COMMAND_REGISTRY
cmd = resolve_command("credits")
assert cmd is not None and cmd.name == "credits"
# Available on every surface (not cli_only / gateway_only).
entry = next(c for c in COMMAND_REGISTRY if c.name == "credits")
# Both old names resolve to nothing.
assert resolve_command("credits") is None
assert resolve_command("billing") is None
# No standalone command for either remains in the registry.
assert not any(c.name in ("credits", "billing") for c in COMMAND_REGISTRY)
# And no command carries either as an alias.
for c in COMMAND_REGISTRY:
assert "credits" not in (c.aliases or ())
assert "billing" not in (c.aliases or ())
# /topup is the billing surface, on every surface, and carries no aliases.
entry = next(c for c in COMMAND_REGISTRY if c.name == "topup")
assert entry.cli_only is False
assert entry.gateway_only is False
# ── CLI _show_credits non-interactive (TUI slash-worker) path ───────────────
def test_cli_show_credits_non_interactive_renders_text_not_modal(monkeypatch, capsys):
"""In the TUI slash-worker (no self._app), /credits must render the text
variant never invoke the prompt_toolkit modal, which would read the
worker's JSON-RPC stdin and crash the command (only the depleted banner
would survive). Regression for that exact failure.
"""
import agent.account_usage as account_usage
from cli import HermesCLI
monkeypatch.setattr(
account_usage,
"build_credits_view",
lambda *a, **k: CreditsView(
logged_in=True,
balance_lines=("📈 Nous credits", "Total usable: $0.00"),
identity_line="Topping up as a@b.c / org Acme",
topup_url="https://prev.test/orgs/acme/billing?topup=open",
depleted=True,
),
)
cli = HermesCLI.__new__(HermesCLI)
cli._app = None # non-interactive, like the slash worker
# Must NOT call the modal in this context.
def _boom_modal(*a, **k):
raise AssertionError("modal must not run without a live app")
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
cli._show_credits()
out = capsys.readouterr().out
assert "💳 Nous credits" in out
assert "Total usable: $0.00" in out
assert "Topping up as a@b.c / org Acme" in out
assert "https://prev.test/orgs/acme/billing?topup=open" in out
assert "credits will appear in /credits shortly" in out
def test_cli_show_credits_logged_out(monkeypatch, capsys):
import agent.account_usage as account_usage
from cli import HermesCLI
monkeypatch.setattr(
account_usage, "build_credits_view", lambda *a, **k: CreditsView(logged_in=False)
)
cli = HermesCLI.__new__(HermesCLI)
cli._app = None
cli._show_credits()
assert "Not logged into Nous Portal" in capsys.readouterr().out
assert not entry.aliases

View file

@ -143,7 +143,7 @@ def test_topup_line_is_org_pinned_when_slug_present():
blob = "\n".join(_all_lines(snap))
# The /usage top-up link auto-opens the modal and is org-pinned.
assert "https://portal.example.test/orgs/acme/billing?topup=open" in blob
assert "/credits" in blob
assert "/topup" in blob
def test_topup_line_falls_back_to_legacy_when_slug_null():

View file

@ -0,0 +1,290 @@
"""Tests for agent.subscription_view — the surface-agnostic /subscription core.
Behavior contracts (not change-detectors): the manage-URL builder's shape, the
payload parser's field mapping + fail-open posture, and the dev-fixture states
that drive the CLI/TUI without a live portal.
"""
from decimal import Decimal
import pytest
from agent.subscription_view import (
SubscriptionState,
build_subscription_state,
dev_fixture_subscription_state,
subscription_change_preview_from_payload,
subscription_manage_url,
subscription_state_from_payload,
)
# ── subscription_manage_url ──────────────────────────────────────────
def test_manage_url_attaches_org_and_path_to_portal_origin():
s = SubscriptionState(
logged_in=True,
org_id="org_x",
portal_url="https://portal.nousresearch.com/billing/whatever",
)
# Path is replaced with /manage-subscription; org_id is pinned; origin kept.
assert (
subscription_manage_url(s)
== "https://portal.nousresearch.com/manage-subscription?org_id=org_x"
)
def test_manage_url_omits_org_when_absent():
s = SubscriptionState(logged_in=True, org_id=None, portal_url="https://p.example.com/")
url = subscription_manage_url(s)
assert url == "https://p.example.com/manage-subscription"
assert "org_id" not in url
def test_manage_url_none_without_portal():
assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url=None)) is None
def test_manage_url_none_for_garbage_portal():
# No scheme/netloc → can't build a deep-link; fail closed (None), not crash.
assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url="not a url")) is None
# ── payload parser ───────────────────────────────────────────────────
def test_parser_maps_camelCase_payload_fields():
payload = {
"org": {"name": "Acme", "id": "org_1", "role": "ADMIN"},
"context": "personal",
"current": {
"tierId": "plus",
"tierName": "Plus",
"monthlyCredits": "1000",
"creditsRemaining": "420",
"cycleEndsAt": "2026-07-01",
"cancelAtPeriodEnd": True,
"cancellationEffectiveAt": "2026-07-01",
},
}
s = subscription_state_from_payload(payload, portal_url="https://p/billing")
assert s.logged_in is True
assert s.org_name == "Acme" and s.org_id == "org_1"
assert s.is_admin is True and s.can_change_plan is True
assert s.current is not None
assert s.current.tier_name == "Plus"
assert s.current.cancel_at_period_end is True
assert s.current.monthly_credits == Decimal("1000")
def test_parser_no_plan_is_none_not_all_null_object():
# "No plan" is current:null on the wire; a current-shaped dict with no
# tierId must parse to None (not an all-null CurrentSubscription).
s = subscription_state_from_payload({"current": {"tierId": None}}, portal_url=None)
assert s.current is None
def test_parser_member_role_cannot_change_plan():
s = subscription_state_from_payload({"org": {"role": "MEMBER"}}, portal_url=None)
assert s.is_admin is False
assert s.can_change_plan is False
@pytest.mark.parametrize(
"role,can_change_plan_raw,is_admin,can_change_plan",
[
("OWNER", None, True, True),
("ADMIN", None, True, True),
("FINANCE_ADMIN", True, False, True),
("SECURITY_ADMIN", None, False, False),
("MEMBER", None, False, False),
],
)
def test_parser_five_roles(
role, can_change_plan_raw, is_admin, can_change_plan
):
payload = {"org": {"role": role}}
if can_change_plan_raw is not None:
payload["canChangePlan"] = can_change_plan_raw
state = subscription_state_from_payload(payload, portal_url=None)
assert state.is_admin is is_admin
assert state.can_change_plan_raw is can_change_plan_raw
assert state.can_change_plan is can_change_plan
@pytest.mark.parametrize(
"role,server_capability",
[("MEMBER", True), ("OWNER", False)],
)
def test_parser_can_change_plan_prefers_server_capability(role, server_capability):
state = subscription_state_from_payload(
{"org": {"role": role}, "canChangePlan": server_capability},
portal_url=None,
)
assert state.can_change_plan is server_capability
def test_parser_can_change_plan_falls_back_to_legacy_role_check():
owner = subscription_state_from_payload(
{"org": {"role": "OWNER"}}, portal_url=None
)
member = subscription_state_from_payload(
{"org": {"role": "MEMBER"}}, portal_url=None
)
assert owner.can_change_plan is True
assert member.can_change_plan is False
def test_parser_defaults_unknown_context_to_personal():
s = subscription_state_from_payload({"context": "wat"}, portal_url=None)
assert s.context == "personal"
# ── tier catalog parsing (the picker) ────────────────────────────────
def test_parser_maps_tiers_catalog():
payload = {
"tiers": [
{
"tierId": "free",
"name": "Free",
"tierOrder": 0,
"dollarsPerMonthDisplay": "0",
"monthlyCredits": "0",
"isCurrent": False,
"isEnabled": True,
},
{
"tierId": "plus",
"name": "Plus",
"tierOrder": 1,
"dollarsPerMonthDisplay": "20",
"monthlyCredits": "1000",
"isCurrent": True,
"isEnabled": True,
},
],
}
s = subscription_state_from_payload(payload, portal_url=None)
assert len(s.tiers) == 2
free, plus = s.tiers
# The free tier's 0s must survive (coalesce-on-None, not falsy `or`).
assert free.tier_id == "free" and free.tier_order == 0
assert free.dollars_per_month == Decimal("0")
assert plus.is_current is True
assert plus.dollars_per_month == Decimal("20") and plus.monthly_credits == Decimal("1000")
def test_parser_tiers_absent_is_empty_tuple():
assert subscription_state_from_payload({}, portal_url=None).tiers == ()
# ── preview parser (POST /preview) ───────────────────────────────────
def test_preview_parser_charge_now():
p = subscription_change_preview_from_payload(
{
"effect": "charge_now",
"reason": None,
"currentTierId": "plus",
"currentTierName": "Plus",
"targetTierId": "ultra",
"targetTierName": "Ultra",
"monthlyCreditsDelta": "6000",
"amountDueNowCents": 1234,
"effectiveAt": None,
}
)
assert p.effect == "charge_now"
assert p.amount_due_now_cents == 1234
assert p.target_tier_name == "Ultra"
assert p.monthly_credits_delta == Decimal("6000")
def test_preview_parser_scheduled_has_effective_at_and_no_charge():
p = subscription_change_preview_from_payload(
{"effect": "scheduled", "amountDueNowCents": None, "effectiveAt": "2026-08-01"}
)
assert p.effect == "scheduled"
assert p.amount_due_now_cents is None
assert p.effective_at == "2026-08-01"
def test_preview_parser_blocked_carries_reason():
p = subscription_change_preview_from_payload(
{"effect": "blocked", "reason": "Retract the cancellation before upgrading."}
)
assert p.effect == "blocked"
assert p.reason and "Retract" in p.reason
def test_preview_parser_missing_effect_fails_safe_to_blocked():
# A malformed quote must never read as a charge — default to blocked.
p = subscription_change_preview_from_payload({})
assert p.effect == "blocked"
assert p.amount_due_now_cents is None
# ── dev fixtures (env-driven, no live portal) ────────────────────────
def test_no_fixture_when_env_unset(monkeypatch):
monkeypatch.delenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", raising=False)
assert dev_fixture_subscription_state() is None
@pytest.mark.parametrize(
"name,checker",
[
("free", lambda s: s.logged_in and s.current is None),
("mid", lambda s: s.current and s.current.tier_id == "plus"),
("top", lambda s: s.current and s.current.tier_id == "ultra"),
("not-admin", lambda s: s.role == "MEMBER" and not s.can_change_plan),
("downgrade", lambda s: s.current and s.current.pending_downgrade_tier_name == "Plus"),
("cancel", lambda s: s.current and s.current.cancel_at_period_end),
("team", lambda s: s.context == "team" and s.current is None),
("logged-out", lambda s: not s.logged_in),
],
)
def test_dev_fixture_states(monkeypatch, name, checker):
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", name)
s = dev_fixture_subscription_state()
assert s is not None
assert checker(s)
def test_dev_fixture_exposes_tier_catalog(monkeypatch):
# A picker needs a catalog: the mid fixture lists tiers with the active one flagged.
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid")
s = dev_fixture_subscription_state()
assert s is not None and len(s.tiers) >= 2
current = [t for t in s.tiers if t.is_current]
assert len(current) == 1 and current[0].tier_id == "plus"
def test_dev_fixture_unknown_name_fails_safe(monkeypatch):
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "bogus")
s = dev_fixture_subscription_state()
assert s is not None
assert s.logged_in is False
assert s.error and "bogus" in s.error
def test_build_subscription_state_uses_fixture(monkeypatch):
# build_subscription_state must short-circuit to the fixture (no portal call).
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid")
s = build_subscription_state()
assert s.logged_in is True
assert s.current is not None and s.current.tier_id == "plus"
# The manage URL is buildable from the fixture's portal_url + org_id.
url = subscription_manage_url(s)
assert url is not None
assert url.endswith("/manage-subscription?org_id=org_acme")

View file

@ -52,11 +52,9 @@ def test_billing_overview_non_interactive_renders_text_not_modal(cli, monkeypatc
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing")
out = capsys.readouterr().out
assert "Usage credits" in out
assert "$142.50" in out
assert "$180 of $1000 used (default ceiling)" in out
# New design: a spend bar with a percentage on the overview.
assert "%" in out and ("" in out or "" in out)
# Balance now leads in the title; dollars, never "credits".
assert "Top up · balance $142.50" in out
assert "credits" not in out.lower()
# ZERO sub-commands: no /billing buy|auto-reload|limit advertising.
assert "/billing buy" not in out
assert "Actions:" not in out
@ -115,8 +113,10 @@ def test_billing_sub_arg_ignored_opens_overview(cli, monkeypatch, capsys):
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/billing buy") # arg is ignored
out = capsys.readouterr().out
assert "Usage credits" in out # overview, NOT the buy screen
assert "Buy usage credits" not in out
assert "Top up · balance" in out # overview, NOT the buy screen
# The buy screen's preset list isn't shown. (The overview's no-card hint may
# legitimately mention the "Add funds" menu item, so key on presets instead.)
assert "Presets:" not in out
def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys):
@ -131,6 +131,106 @@ def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys):
# Reached via the menu in real use; non-interactively it defers to the portal.
cli._billing_buy_flow(state)
out = capsys.readouterr().out
assert "Buy usage credits" in out
assert "Add funds" in out
assert "$25" in out and "$50" in out and "$100" in out
assert "interactive CLI" in out # defers; no charge attempted non-interactively
# ── Card visibility + the add-card path (inline w/ NAS card-resolver) ──
def _scripted(*responses):
it = iter(responses)
def _modal(self, **kw):
return next(it)
return _modal
def test_overview_shows_card_with_provenance(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin"),
portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/topup")
out = capsys.readouterr().out
assert "Card: Visa ····4242 — the card on your subscription" in out
def test_overview_shows_no_card_hint(cli, monkeypatch, capsys):
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
card=None, portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/topup")
out = capsys.readouterr().out
assert "No saved card on file" in out
assert "Add funds" in out # the hint names the path
def test_link_card_renders_brand_alone(cli, monkeypatch, capsys):
# A Link payment method has no card number (last4 = "") — never "Link ····".
state = BillingState(
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
card=CardInfo(brand="Link", last4=""), portal_url="https://portal/billing",
)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
cli._show_billing("/topup")
out = capsys.readouterr().out
assert "Card: Link" in out
assert "Link ····" not in out
def test_buy_flow_no_card_guides_then_continues_after_recheck(cli, monkeypatch, capsys):
# No card → the guided add-card path; "check again" re-fetches state and,
# once the card exists, continues straight into the preset menu.
cli._app = object()
common = dict(
logged_in=True, role="OWNER", cli_billing_enabled=True,
charge_presets=(Decimal("25"), Decimal("50")),
min_usd=Decimal("5"), max_usd=Decimal("500"),
portal_url="https://portal/billing",
)
nocard = BillingState(card=None, **common)
withcard = BillingState(card=CardInfo(brand="Visa", last4="4242", resolved_via="customerDefault"), **common)
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: withcard)
# add-card modal → "recheck"; preset modal → "cancel" (we only test the routing)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("recheck", "cancel"), raising=False)
cli._billing_buy_flow(nocard)
out = capsys.readouterr().out
assert "Add a card first" in out
assert "Card found: Visa ····4242 — your default card saved on the portal" in out
assert "Cancelled. No funds added." in out # reached the preset menu, then bailed
def test_buy_flow_no_card_back_abandons(cli, monkeypatch, capsys):
cli._app = object()
nocard = BillingState(
logged_in=True, role="OWNER", cli_billing_enabled=True,
charge_presets=(Decimal("25"),), card=None,
portal_url="https://portal/billing",
)
calls = {"n": 0}
def _no_fetch(*a, **kw):
calls["n"] += 1
return nocard
monkeypatch.setattr(bv, "build_billing_state", _no_fetch)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
cli._billing_buy_flow(nocard)
out = capsys.readouterr().out
assert "Add a card first" in out
assert "Cancelled. No funds added." in out
assert calls["n"] == 0 # backed out before any re-check

View file

@ -0,0 +1,161 @@
"""Tests for the hermes_cli.nous_billing HTTP client's response handling.
Focus: a 2xx response with a NON-JSON body (e.g. a reverse-proxy / SPA fallback
HTML page when a route isn't actually serving the billing API) must surface as a
typed BillingError, NOT a raw json.JSONDecodeError that escapes the typed-error
contract and reads downstream as "not logged in".
"""
from __future__ import annotations
import io
import json
from contextlib import contextmanager
import pytest
from hermes_cli import nous_billing as nb
class _FakeResp(io.BytesIO):
"""Minimal urlopen() context-manager stand-in with a .status attribute."""
def __init__(self, body: bytes, status: int = 200):
super().__init__(body)
self.status = status
def __enter__(self):
return self
def __exit__(self, *a):
self.close()
@contextmanager
def _stub(monkeypatch, body: bytes, status: int = 200):
# Bypass auth/token resolution entirely — we only exercise response parsing.
monkeypatch.setattr(nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example"))
monkeypatch.setattr(nb, "_token_cache", None, raising=False)
monkeypatch.setattr(nb.urllib.request, "urlopen", lambda req, timeout=None: _FakeResp(body, status))
yield
def test_non_json_2xx_body_raises_typed_billing_error(monkeypatch):
# A 200 that returns an HTML page (route not actually mounted) must NOT crash
# with json.JSONDecodeError — it becomes a typed, non-auth BillingError.
html = b"<!DOCTYPE html><html><head><title>Not Found</title></head></html>"
with _stub(monkeypatch, html, status=200):
with pytest.raises(nb.BillingError) as ei:
nb.get_subscription_state()
exc = ei.value
# Not the auth subclass — this is "endpoint unavailable", not "logged out".
assert not isinstance(exc, nb.BillingAuthError)
assert getattr(exc, "error", None) == "endpoint_unavailable"
def test_empty_2xx_body_returns_empty_dict(monkeypatch):
with _stub(monkeypatch, b"", status=200):
assert nb.get_billing_state() == {}
def test_valid_json_2xx_body_parses(monkeypatch):
payload = {"org": {"name": "Acme"}, "balanceUsd": "10"}
with _stub(monkeypatch, json.dumps(payload).encode(), status=200):
assert nb.get_billing_state() == payload
def test_transient_siblings_not_parent_child():
assert issubclass(nb.BillingRateLimited, nb.BillingTransient)
assert issubclass(nb.BillingStripeUnavailable, nb.BillingTransient)
assert issubclass(nb.BillingUpgradeCapExceeded, nb.BillingTransient)
assert not issubclass(nb.BillingStripeUnavailable, nb.BillingRateLimited)
assert not issubclass(nb.BillingUpgradeCapExceeded, nb.BillingRateLimited)
assert not issubclass(nb.BillingRateLimited, nb.BillingStripeUnavailable)
# ---------------------------------------------------------------------------
# Subscription change (V3): the request the client actually puts on the wire.
# ---------------------------------------------------------------------------
@contextmanager
def _capture(monkeypatch, body: bytes = b"{}", status: int = 200):
"""Stub urlopen, recording the urllib.request.Request the client built."""
seen: dict[str, object] = {}
monkeypatch.setattr(
nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example")
)
def _fake_urlopen(req, timeout=None):
seen["method"] = req.get_method()
seen["url"] = req.full_url
seen["data"] = json.loads(req.data.decode()) if req.data else None
seen["headers"] = {k.lower(): v for k, v in req.header_items()}
return _FakeResp(body, status)
monkeypatch.setattr(nb.urllib.request, "urlopen", _fake_urlopen)
yield seen
def test_post_subscription_preview_request(monkeypatch):
with _capture(monkeypatch) as seen:
nb.post_subscription_preview(subscription_type_id="nous-chat-plan-40")
assert seen["method"] == "POST"
assert seen["url"] == "https://portal.example/api/billing/subscription/preview"
assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"}
def test_put_pending_change_tier_change_request(monkeypatch):
with _capture(monkeypatch) as seen:
nb.put_subscription_pending_change(subscription_type_id="nous-chat-plan-10")
assert seen["method"] == "PUT"
assert (
seen["url"] == "https://portal.example/api/billing/subscription/pending-change"
)
assert seen["data"] == {
"type": "tier_change",
"subscriptionTypeId": "nous-chat-plan-10",
}
def test_put_pending_change_cancellation_request(monkeypatch):
with _capture(monkeypatch) as seen:
nb.put_subscription_pending_change(cancel=True)
assert seen["method"] == "PUT"
assert seen["data"] == {"type": "cancellation"}
def test_put_pending_change_without_tier_or_cancel_raises():
# No urlopen stub: a bad call must fail BEFORE any network I/O.
with pytest.raises(nb.BillingError) as ei:
nb.put_subscription_pending_change()
assert getattr(ei.value, "error", None) == "invalid_subscription_type"
def test_delete_pending_change_request(monkeypatch):
with _capture(monkeypatch) as seen:
nb.delete_subscription_pending_change()
assert seen["method"] == "DELETE"
assert (
seen["url"] == "https://portal.example/api/billing/subscription/pending-change"
)
assert seen["data"] is None
def test_post_subscription_upgrade_sends_idempotency_key(monkeypatch):
with _capture(monkeypatch) as seen:
nb.post_subscription_upgrade(
subscription_type_id="nous-chat-plan-40", idempotency_key="abc-123"
)
assert seen["method"] == "POST"
assert seen["url"] == "https://portal.example/api/billing/subscription/upgrade"
assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"}
assert seen["headers"].get("idempotency-key") == "abc-123"
def test_post_subscription_upgrade_blank_key_raises():
with pytest.raises(nb.BillingError) as ei:
nb.post_subscription_upgrade(
subscription_type_id="nous-chat-plan-40", idempotency_key=" "
)
assert getattr(ei.value, "error", None) == "idempotency_key_required"

View file

@ -0,0 +1,120 @@
"""Tests for the Remote-Spending gate denial contract (NAS PR #481).
Behavior contracts: the HTTPexception mapping in
``hermes_cli.nous_billing._raise_for_error`` and the
``tui_gateway.server._serialize_billing_error`` envelope the TUI branches on.
These assert the wire contract (CF-4) error code, actor, recovery, retry
not specific copy.
"""
import pytest
from hermes_cli.nous_billing import (
BillingError,
BillingRateLimited,
BillingRemoteSpendingRevoked,
BillingScopeRequired,
BillingSessionRevoked,
_raise_for_error,
)
def _raise(status, payload, headers=None):
"""Run _raise_for_error and return the exception it raises."""
with pytest.raises(BillingError) as ei:
_raise_for_error(status, payload, headers)
return ei.value
# ── exception mapping (hermes_cli.nous_billing) ──────────────────────
def test_403_remote_spending_revoked_maps_to_typed_exc_with_actor():
exc = _raise(403, {"error": "remote_spending_revoked", "recovery": "reconnect", "actor": "admin"})
assert isinstance(exc, BillingRemoteSpendingRevoked)
assert exc.actor == "admin"
assert exc.recovery == "reconnect"
def test_403_revoked_absent_actor_is_none_not_crash():
exc = _raise(403, {"error": "remote_spending_revoked"})
assert isinstance(exc, BillingRemoteSpendingRevoked)
assert exc.actor is None # surface treats absent as "self"
def test_401_session_revoked_is_distinct_from_plain_401():
revoked = _raise(401, {"error": "session_revoked", "recovery": "login"})
assert isinstance(revoked, BillingSessionRevoked)
assert revoked.recovery == "login"
plain = _raise(401, {"error": "invalid_token"})
assert not isinstance(plain, BillingSessionRevoked)
def test_403_insufficient_scope_still_maps_to_scope_required():
exc = _raise(403, {"error": "insufficient_scope"})
assert isinstance(exc, BillingScopeRequired)
# NOT mistaken for a revoke.
assert not isinstance(exc, BillingRemoteSpendingRevoked)
def test_503_is_rate_limited_not_revoked_and_carries_retry_after():
exc = _raise(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"})
assert isinstance(exc, BillingRateLimited)
assert not isinstance(exc, BillingRemoteSpendingRevoked)
assert exc.retry_after == 30
def test_403_business_denial_carries_code_and_recovery():
exc = _raise(403, {
"error": "cli_billing_disabled",
"code": "remote_spending_disabled",
"recovery": "enable_account_toggle",
"portalUrl": "/billing",
})
# Generic BillingError (not a typed revoke) — the surface maps on code.
assert type(exc) is BillingError
assert exc.error == "cli_billing_disabled"
assert exc.code == "remote_spending_disabled"
assert exc.recovery == "enable_account_toggle"
def test_409_idempotency_conflict_passes_through():
exc = _raise(409, {"error": "idempotency_conflict", "message": "same key, different amount"})
assert exc.error == "idempotency_conflict"
# ── envelope serialization (tui_gateway.server) ──────────────────────
def _serialize(status, payload, headers=None):
import tui_gateway.server as srv
return srv._serialize_billing_error(_raise(status, payload, headers))
def test_envelope_threads_actor_code_recovery():
env = _serialize(403, {"error": "remote_spending_revoked", "actor": "admin", "recovery": "reconnect"})
assert env["error"] == "remote_spending_revoked"
assert env["actor"] == "admin"
assert env["recovery"] == "reconnect"
assert env["ok"] is False
def test_envelope_session_revoked_kind():
env = _serialize(401, {"error": "session_revoked", "recovery": "login"})
assert env["error"] == "session_revoked"
assert env["recovery"] == "login"
def test_envelope_503_preserves_server_code_with_retry():
env = _serialize(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"})
assert env["error"] == "temporarily_unavailable"
assert env["retry_after"] == 30
def test_envelope_business_code_survives():
env = _serialize(403, {"error": "cli_billing_disabled", "code": "remote_spending_disabled", "recovery": "enable_account_toggle"})
assert env["error"] == "cli_billing_disabled"
assert env["code"] == "remote_spending_disabled"
assert env["recovery"] == "enable_account_toggle"

View file

@ -0,0 +1,357 @@
"""Tests for the /subscription CLI change flow (cli.py::_show_subscription).
Parity with the TUI overlay: the classic CLI now previews + applies a plan change
in-terminal (picker preview confirm apply), grants terminal billing inline on
insufficient_scope, and leads a scheduled downgrade/cancel with a prominent banner.
Interactive screens are driven by mocking `_prompt_text_input_modal`.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
import agent.billing_usage as bu
import agent.subscription_view as sv
import hermes_cli.nous_billing as nb
from agent.subscription_view import CurrentSubscription, SubscriptionState, SubscriptionTier
from cli import HermesCLI
@pytest.fixture
def cli():
obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed)
obj._app = None # non-interactive by default; tests flip it on
return obj
_TIERS = (
SubscriptionTier(tier_id="free", name="Free", tier_order=0, dollars_per_month=Decimal("0"), monthly_credits=Decimal("0"), is_current=False, is_enabled=True),
SubscriptionTier(tier_id="plus", name="Plus", tier_order=1, dollars_per_month=Decimal("20"), monthly_credits=Decimal("22"), is_current=False, is_enabled=True),
SubscriptionTier(tier_id="ultra", name="Ultra", tier_order=3, dollars_per_month=Decimal("200"), monthly_credits=Decimal("220"), is_current=True, is_enabled=True),
)
def _sub_state(**current_over) -> SubscriptionState:
current_fields = dict(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("220"), cycle_ends_at="2026-07-28")
current_fields.update(current_over)
current = CurrentSubscription(**current_fields)
return SubscriptionState(
logged_in=True,
org_name="Acme",
org_id="org_1",
role="OWNER",
context="personal",
current=current,
tiers=_TIERS,
portal_url="https://portal.example/billing",
)
def _scripted_modal(*responses):
it = iter(responses)
def _modal(self, **kw):
return next(it)
return _modal
@pytest.fixture(autouse=True)
def _no_usage_model(monkeypatch):
# The overview's usage model needs a live portal; None → the plan-field fallback.
monkeypatch.setattr(bu, "build_usage_model", lambda *a, **kw: None, raising=False)
def test_overview_leads_with_scheduled_downgrade_banner(cli, monkeypatch, capsys):
st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28")
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
cli._show_subscription()
out = capsys.readouterr().out
assert "Scheduled change" in out
assert "──▶" in out
assert "Plus" in out
# the status line itself echoes the transition
assert "Plan: Ultra → Plus" in out
def test_change_flow_schedules_a_downgrade(cli, monkeypatch, capsys):
cli._app = object() # interactive
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
# change menu → "change"; picker → "plus" (only selectable); confirm → "yes"
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes"), raising=False)
monkeypatch.setattr(
nb, "post_subscription_preview",
lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28T00:00:00Z", "monthlyCreditsDelta": "-198"},
)
seen = {}
monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: seen.update(kw) or {"message": "Scheduled."})
cli._show_subscription()
out = capsys.readouterr().out
assert seen.get("subscription_type_id") == "plus"
assert "doesn't change today" in out
def test_change_flow_upgrade_charges_now(cli, monkeypatch, capsys):
cli._app = object()
# Current = Plus so Ultra is a selectable upgrade.
st = _sub_state(tier_id="plus", tier_name="Plus")
tiers = tuple(SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) for t in _TIERS)
object.__setattr__(st, "tiers", tiers)
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
seen = {}
monkeypatch.setattr(nb, "post_subscription_upgrade", lambda **kw: seen.update(kw) or {"status": "upgraded", "targetTierName": "Ultra"})
cli._show_subscription()
out = capsys.readouterr().out
assert seen.get("subscription_type_id") == "ultra"
assert seen.get("idempotency_key") # minted
assert "$46.30" in out
assert "Upgraded to Ultra" in out
def test_change_menu_cancel_schedules_cancellation(cli, monkeypatch, capsys):
cli._app = object()
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("cancel_sub", "yes"), raising=False)
seen = {}
monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: seen.update(kw) or {"message": "Cancelled."})
cli._show_subscription()
out = capsys.readouterr().out
assert seen.get("cancel") is True
assert "cancels" in out.lower()
def test_pending_change_menu_offers_undo(cli, monkeypatch, capsys):
cli._app = object()
st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28")
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("keep"), raising=False)
called = {"n": 0}
monkeypatch.setattr(nb, "delete_subscription_pending_change", lambda **kw: called.update(n=called["n"] + 1) or {"message": "Resumed."})
cli._show_subscription()
out = capsys.readouterr().out
assert called["n"] == 1
assert "Undone" in out
def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsys):
cli._app = object()
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
# menu → change; picker → plus; confirm → yes; step-up → yes
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"})
calls = {"n": 0}
def _put(**kw):
calls["n"] += 1
if calls["n"] == 1:
raise nb.BillingScopeRequired("terminal billing required")
return {"message": "Scheduled."}
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
import hermes_cli.auth as auth
monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True, raising=False)
cli._show_subscription()
out = capsys.readouterr().out
# applied once (scope-denied), granted, replayed → applied again
assert calls["n"] == 2
assert "Terminal billing enabled" in out
def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys):
cli._app = object()
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"})
calls = {"n": 0}
def _put(**kw):
calls["n"] += 1
raise nb.BillingScopeRequired("terminal billing required")
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
import hermes_cli.auth as auth
monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: False, raising=False)
cli._show_subscription()
out = capsys.readouterr().out
assert calls["n"] == 1 # applied once, grant denied, no replay
assert "Couldn't enable terminal billing" in out
def test_unknown_preview_effect_fails_safe(cli, monkeypatch, capsys):
# An unrecognized effect string must NOT schedule a real change (fail safe).
cli._app = object()
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "weird_unknown", "targetTierName": "Plus"})
put = {"n": 0}
monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: put.update(n=put["n"] + 1) or {})
cli._show_subscription()
out = capsys.readouterr().out
assert put["n"] == 0 # no mutation on an unknown effect
assert "portal" in out.lower()
def test_bounded_stepup_does_not_loop_on_repeat_denial(cli, monkeypatch, capsys):
# Grant "succeeds" but the scope stays denied → replay ONCE (allow_stepup=False),
# then stop — no re-prompt / re-open-browser loop.
cli._app = object()
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"})
calls = {"n": 0}
def _put(**kw):
calls["n"] += 1
raise nb.BillingScopeRequired("still no scope")
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
import hermes_cli.auth as auth
monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True, raising=False)
cli._show_subscription()
out = capsys.readouterr().out
assert calls["n"] == 2 # applied, granted, replayed once — no third attempt
assert "still isn't enabled" in out
def test_upgrade_transport_failure_is_ambiguous_not_flat_failure(cli, monkeypatch, capsys):
# BUG B: a charge-route failure must warn "may or may not have been charged"
# (steer to a re-check), never a flat failure that invites a blind retry (which
# would mint a fresh idempotency key the server can't dedup → a real 2nd charge).
cli._app = object()
st = _sub_state(tier_id="plus", tier_name="Plus")
tiers = tuple(
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
for t in _TIERS
)
object.__setattr__(st, "tiers", tiers)
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
def _boom(**kw):
raise nb.BillingError("Could not reach Nous Portal", error="endpoint_unavailable")
monkeypatch.setattr(nb, "post_subscription_upgrade", _boom)
cli._show_subscription()
out = capsys.readouterr().out
assert "may or may not have been charged" in out
assert "Re-run /subscription" in out
assert "could not be completed" not in out # not the old flat failure
def test_upgrade_rate_limit_is_deterministic_not_ambiguous(cli, monkeypatch, capsys):
# R2: a typed PRE-charge rejection (429 rate-limit) must NOT be mislabeled
# "may or may not have been charged" — it never reached Stripe. It gets the
# normal error copy, not the ambiguous one.
cli._app = object()
st = _sub_state(tier_id="plus", tier_name="Plus")
tiers = tuple(
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
for t in _TIERS
)
object.__setattr__(st, "tiers", tiers)
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
def _rl(**kw):
raise nb.BillingRateLimited("Slow down — too many requests.", error="rate_limited", status=429, retry_after=30)
monkeypatch.setattr(nb, "post_subscription_upgrade", _rl)
cli._show_subscription()
out = capsys.readouterr().out
assert "may or may not have been charged" not in out # NOT the ambiguous copy
assert "Slow down" in out # the real, deterministic error
def test_upgrade_transport_failure_still_ambiguous_after_narrowing(cli, monkeypatch, capsys):
# Regression floor for R2: a genuine transport failure (network_error, no status)
# must STILL be ambiguous after the narrowing.
cli._app = object()
st = _sub_state(tier_id="plus", tier_name="Plus")
tiers = tuple(
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
for t in _TIERS
)
object.__setattr__(st, "tiers", tiers)
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
def _net(**kw):
raise nb.BillingError("Could not reach Nous Portal: timeout", error="network_error")
monkeypatch.setattr(nb, "post_subscription_upgrade", _net)
cli._show_subscription()
out = capsys.readouterr().out
assert "may or may not have been charged" in out
@pytest.fixture(autouse=True)
def _no_card_lookup(monkeypatch):
# The charge_now confirm best-effort-fetches billing state to NAME the card
# being charged; keep unit tests offline (generic line) unless overridden.
import agent.billing_view as bv
def _offline(*a, **kw):
raise RuntimeError("offline")
monkeypatch.setattr(bv, "build_billing_state", _offline, raising=False)
def test_upgrade_confirm_names_the_subscription_card(cli, monkeypatch, capsys):
# Post-card-resolver NAS: the confirm names the exact card when it resolved
# via the subscription rung (what the upgrade actually charges).
cli._app = object()
st = _sub_state(tier_id="plus", tier_name="Plus")
tiers = tuple(
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
for t in _TIERS
)
object.__setattr__(st, "tiers", tiers)
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
# picker → ultra; charge confirm → back out (we only assert the card line)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "cancel"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
import agent.billing_view as bv
from agent.billing_view import BillingState as _BS
from agent.billing_view import CardInfo as _CI
_state = _BS(logged_in=True, card=_CI(brand="Visa", last4="4242", resolved_via="subPin"))
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: _state, raising=False)
cli._show_subscription()
out = capsys.readouterr().out
assert "Visa ····4242 — the card on your subscription — will be charged." in out

View file

@ -9908,6 +9908,242 @@ def test_start_agent_build_passes_session_model_override(monkeypatch):
server._sessions.clear()
# ── billing/subscription state + error serialization ─────────────────
@pytest.mark.parametrize(
"card,expected",
[
("canonical", {"kind": "canonical"}),
(
"distinct",
{
"kind": "distinct",
"payment_method_id": "pm_auto",
"brand": None,
"last4": None,
},
),
("none", {"kind": "none"}),
],
)
def test_billing_state_serializes_auto_reload_card_union(monkeypatch, card, expected):
from agent.billing_view import AutoReload, AutoReloadCard, BillingState
monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False})
auto_reload_card = AutoReloadCard(
kind=card,
payment_method_id="pm_auto" if card == "distinct" else None,
)
state = BillingState(
logged_in=True,
auto_reload=AutoReload(enabled=True, card=auto_reload_card),
)
result = server._serialize_billing_state(state)
assert result["auto_reload"]["card"] == expected
def test_billing_state_serializes_server_plan_capability(monkeypatch):
from agent.billing_view import BillingState
monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False})
state = BillingState(
logged_in=True,
role="MEMBER",
can_change_plan_raw=True,
)
result = server._serialize_billing_state(state)
assert result["is_admin"] is False
assert result["can_change_plan"] is True
class _BillingHeaders:
def __init__(self, values):
self._values = values
def get(self, key):
return self._values.get(key)
@pytest.mark.parametrize(
"status,error,retry_after",
[
(503, "stripe_unavailable", 75),
(429, "upgrade_cap_exceeded", None),
(429, "rate_limited", None),
],
)
def test_billing_error_serialization_preserves_server_code(
status, error, retry_after
):
import hermes_cli.nous_billing as nb
headers = _BillingHeaders({"Retry-After": str(retry_after)}) if retry_after else None
with pytest.raises(nb.BillingTransient) as ei:
nb._raise_for_error(status, {"error": error}, headers)
result = server._serialize_billing_error(ei.value)
assert result["error"] == error
assert ei.value.error == error
assert result["retry_after"] == retry_after
def test_billing_rate_limit_without_error_defaults_wire_code():
import hermes_cli.nous_billing as nb
exc = nb.BillingRateLimited("slow down", status=429, retry_after=10)
result = server._serialize_billing_error(exc)
assert result["error"] == "rate_limited"
# ── subscription change RPCs (V3): preview + pending-change + upgrade ──
def _sub_rpc(method, params):
# These RPCs are in _LONG_HANDLERS (pool-routed → dispatch returns None and the
# worker writes via the transport), so drive the inline handler directly.
return server.handle_request({"id": "1", "method": method, "params": params})["result"]
def test_subscription_preview_serializes_quote(monkeypatch):
import hermes_cli.nous_billing as nb
monkeypatch.setattr(
nb,
"post_subscription_preview",
lambda subscription_type_id: {
"effect": "charge_now",
"reason": None,
"currentTierId": "plus",
"currentTierName": "Plus",
"targetTierId": "ultra",
"targetTierName": "Ultra",
"monthlyCreditsDelta": "6000",
"amountDueNowCents": 1234,
"effectiveAt": None,
},
)
res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"})
assert res["ok"] is True
assert res["effect"] == "charge_now"
assert res["amount_due_now_cents"] == 1234
assert res["target_tier_name"] == "Ultra"
assert res["monthly_credits_delta"] == "6000"
def test_subscription_preview_requires_tier():
res = _sub_rpc("subscription.preview", {})
assert res["ok"] is False
assert res["error"] == "invalid_request"
def test_subscription_preview_scope_error_maps_to_step_up(monkeypatch):
import hermes_cli.nous_billing as nb
def _raise(subscription_type_id):
raise nb.BillingScopeRequired("billing:manage required")
monkeypatch.setattr(nb, "post_subscription_preview", _raise)
res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"})
assert res["ok"] is False
assert res["error"] == "insufficient_scope"
def test_subscription_change_cancellation(monkeypatch):
import hermes_cli.nous_billing as nb
seen = {}
def _put(*, subscription_type_id=None, cancel=False):
seen["tier"] = subscription_type_id
seen["cancel"] = cancel
return {"rail": "stripe", "cancelAtPeriodEnd": True, "message": "Scheduled to cancel."}
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
res = _sub_rpc("subscription.change", {"cancel": True})
assert res["ok"] is True
assert seen == {"tier": None, "cancel": True}
assert res["message"] == "Scheduled to cancel."
def test_subscription_change_tier_downgrade(monkeypatch):
import hermes_cli.nous_billing as nb
seen = {}
def _put(*, subscription_type_id=None, cancel=False):
seen["tier"] = subscription_type_id
seen["cancel"] = cancel
return {"rail": "stripe", "changeType": "downgrade", "targetTierName": "Plus", "message": "Scheduled."}
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
res = _sub_rpc("subscription.change", {"subscription_type_id": "plus"})
assert res["ok"] is True
assert seen == {"tier": "plus", "cancel": False}
def test_subscription_change_requires_tier_or_cancel():
res = _sub_rpc("subscription.change", {})
assert res["ok"] is False
assert res["error"] == "invalid_request"
def test_subscription_resume(monkeypatch):
import hermes_cli.nous_billing as nb
monkeypatch.setattr(
nb,
"delete_subscription_pending_change",
lambda: {"rail": "stripe", "cancelAtPeriodEnd": False, "message": "Resumed."},
)
res = _sub_rpc("subscription.resume", {})
assert res["ok"] is True
assert res["message"] == "Resumed."
def test_subscription_upgrade_echoes_status_and_idempotency(monkeypatch):
import hermes_cli.nous_billing as nb
seen = {}
def _upgrade(*, subscription_type_id, idempotency_key):
seen["key"] = idempotency_key
return {"status": "upgraded", "targetTierId": "ultra", "targetTierName": "Ultra"}
monkeypatch.setattr(nb, "post_subscription_upgrade", _upgrade)
res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra", "idempotency_key": "k-1"})
assert res["ok"] is True
assert res["status"] == "upgraded"
assert res["target_tier_name"] == "Ultra"
assert res["idempotency_key"] == "k-1"
assert seen["key"] == "k-1"
def test_subscription_upgrade_requires_action_surfaces_recovery(monkeypatch):
import hermes_cli.nous_billing as nb
monkeypatch.setattr(
nb,
"post_subscription_upgrade",
lambda *, subscription_type_id, idempotency_key: {
"status": "requires_action",
"reason": "authentication_required",
"recoveryUrl": "https://portal.example/subscription?org_id=o",
},
)
res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra"})
# The RPC succeeds; the CHARGE needs 3DS → status + recovery_url for the portal.
assert res["ok"] is True
assert res["status"] == "requires_action"
assert res["recovery_url"].startswith("https://portal.example")
assert res["idempotency_key"] # minted when the caller omits one
# ── _get_usage active_subagents (TUI status-bar ⛓ indicator) ──────────────
# Mirrors the classic CLI status bar: _get_usage embeds a live count of
# background/async subagents from tools.async_delegation.active_count() so the

View file

@ -177,6 +177,20 @@ _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
# response writes are safe.
_LONG_HANDLERS = frozenset(
{
# Billing/usage reads each do a blocking portal HTTP fetch (state + usage
# is two serial round-trips); keep them off the main stdin loop so a slow
# portal can't stall approval.respond / session.interrupt / other RPCs.
"billing.state",
"subscription.state",
# Subscription change (V3): preview + the pending-change mutations + upgrade
# each do a blocking portal round-trip (preview + upgrade also hit Stripe,
# which can take seconds) — keep them off the main stdin loop.
"subscription.preview",
"subscription.change",
"subscription.resume",
"subscription.upgrade",
"usage.bars",
"session.usage",
"billing.step_up",
"browser.manage",
"cli.exec",
@ -7882,37 +7896,6 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5031, f"pet.hatch failed: {exc}")
@method("credits.view")
def _(rid, params: dict) -> dict:
"""Structured Nous credit view for the TUI /credits command.
Account-independent (a portal fetch gated on "a Nous account is logged in"),
so it works with no live agent / on a resumed session same as the /usage
credits block. Returns the surface-agnostic CreditsView fields so the TUI can
render a clickable top-up <Link>. Fail-open: a portal hiccup or logged-out
account yields {logged_in: false}, never an error the user has to parse.
"""
try:
from agent.account_usage import build_credits_view
view = build_credits_view()
return _ok(
rid,
{
"logged_in": bool(view.logged_in),
"balance_lines": [
line for line in view.balance_lines if not line.lstrip().startswith("📈")
],
"identity_line": view.identity_line,
"topup_url": view.topup_url,
"depleted": bool(view.depleted),
},
)
except Exception:
# Fail-open: TUI treats this as "not logged in" and shows the prompt.
return _ok(rid, {"logged_in": False, "balance_lines": [], "identity_line": None, "topup_url": None, "depleted": False})
# ===========================================================================
# Phase 2b terminal billing RPC methods
# ===========================================================================
@ -7922,21 +7905,27 @@ def _(rid, params: dict) -> dict:
# Ink side can branch on the typed billing error code (insufficient_scope,
# rate_limited, no_payment_method, …) to render the right affordance instead of
# landing in a generic catch. The data-building lives in the shared core
# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /credits.
# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /topup.
def _serialize_billing_error(exc) -> dict:
"""Map a BillingError into the result.error envelope the TUI branches on."""
from hermes_cli.nous_billing import (
BillingRateLimited,
BillingRemoteSpendingRevoked,
BillingScopeRequired,
BillingSessionRevoked,
BillingTransient,
)
kind = "error"
if isinstance(exc, BillingScopeRequired):
if isinstance(exc, BillingRemoteSpendingRevoked):
kind = "remote_spending_revoked"
elif isinstance(exc, BillingSessionRevoked):
kind = "session_revoked"
elif isinstance(exc, BillingScopeRequired):
kind = "insufficient_scope"
elif isinstance(exc, BillingRateLimited):
kind = "rate_limited"
elif isinstance(exc, BillingTransient):
kind = str(exc.error) if getattr(exc, "error", None) else "rate_limited"
elif getattr(exc, "error", None):
kind = str(exc.error)
return {
@ -7946,6 +7935,11 @@ def _serialize_billing_error(exc) -> dict:
"portal_url": getattr(exc, "portal_url", None),
"retry_after": getattr(exc, "retry_after", None),
"payload": getattr(exc, "payload", {}) or {},
# Remote-Spending contract extras (threaded so the TUI can render
# actor-aware copy + route recovery without re-parsing the message).
"actor": getattr(exc, "actor", None),
"code": getattr(exc, "code", None),
"recovery": getattr(exc, "recovery", None),
}
@ -7958,7 +7952,18 @@ def _serialize_billing_state(state) -> dict:
card = None
if state.card is not None:
card = {"brand": state.card.brand, "last4": state.card.last4, "masked": state.card.masked}
card = {
"brand": state.card.brand,
"last4": state.card.last4,
"masked": state.card.masked,
# Post-card-resolver fields (None/False on older NAS payloads):
# display = "Visa ····4242 — the card on your subscription";
# resolved_via = the raw resolution rung, for rung-gated surfaces
# (the /subscription confirm only shows the card when the rung
# matches what a subscription charge would use).
"display": state.card.display,
"resolved_via": state.card.resolved_via,
}
monthly_cap = None
if state.monthly_cap is not None:
mc = state.monthly_cap
@ -7972,12 +7977,24 @@ def _serialize_billing_state(state) -> dict:
auto_reload = None
if state.auto_reload is not None:
ar = state.auto_reload
card_out = None
if ar.card is not None:
if ar.card.kind == "distinct":
card_out = {
"kind": "distinct",
"payment_method_id": ar.card.payment_method_id,
"brand": ar.card.brand,
"last4": ar.card.last4,
}
else:
card_out = {"kind": ar.card.kind}
auto_reload = {
"enabled": ar.enabled,
"threshold_usd": _s(ar.threshold_usd),
"threshold_display": format_money(ar.threshold_usd),
"reload_to_usd": _s(ar.reload_to_usd),
"reload_to_display": format_money(ar.reload_to_usd),
"card": card_out,
}
return {
"ok": True,
@ -7986,6 +8003,7 @@ def _serialize_billing_state(state) -> dict:
"org_slug": state.org_slug,
"role": state.role,
"is_admin": state.is_admin,
"can_change_plan": state.can_change_plan,
"can_charge": state.can_charge,
"balance_usd": _s(state.balance_usd),
"balance_display": format_money(state.balance_usd),
@ -7999,14 +8017,35 @@ def _serialize_billing_state(state) -> dict:
"auto_reload": auto_reload,
"portal_url": state.portal_url,
"error": state.error,
# Shared dollar usage model (two-bar view) embedded so /topup renders the
# same plan + top-up bars as /usage and /subscription from its single
# fetch. Built from the separate account-info path; fail-open when logged
# out or the portal is down.
"usage": _usage_payload(state),
}
def _usage_payload(state) -> dict:
"""Best-effort shared usage model for the /topup + /subscription overlay bars.
Only fetched when logged in; fail-open to {available:false} so the overview
still renders if the account-info path is down.
"""
if not getattr(state, "logged_in", False):
return {"available": False}
try:
from agent.billing_usage import build_usage_model
return _serialize_usage_model(build_usage_model())
except Exception:
return {"available": False}
@method("billing.state")
def _(rid, params: dict) -> dict:
"""GET /api/billing/state → serialized BillingState (Screen 1 + 5).
Fail-open like credits.view: a logged-out / unreachable portal yields
Fail-open like the other billing RPCs: a logged-out / unreachable portal yields
{ok:true, logged_in:false}. No scope required for this endpoint.
"""
try:
@ -8018,6 +8057,269 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"})
def _serialize_usage_bar(bar) -> Optional[dict]:
"""Serialize a UsageBar (dollar magnitudes → display strings + fractions)."""
if bar is None:
return None
from agent.billing_usage import _fmt_usd
return {
"kind": bar.kind,
"remaining_display": _fmt_usd(bar.remaining_usd),
"total_display": _fmt_usd(bar.total_usd),
"spent_display": _fmt_usd(bar.spent_usd),
"pct_used": bar.pct_used,
"fill_fraction": bar.fill_fraction,
}
def _serialize_usage_model(model) -> dict:
"""Serialize a UsageModel for the wire — the shared two-bar dollar view.
Dollars-only (no 'credits'); fail-open shape mirrors the other billing RPCs
({ok, available:false} when logged out / unreachable).
"""
from agent.billing_usage import _fmt_usd, format_renews
if model is None or not getattr(model, "available", False):
return {"ok": True, "available": False}
return {
"ok": True,
"available": True,
"status": model.status,
"plan_name": model.plan_name,
"renews_at": model.renews_at,
"renews_display": getattr(model, "renews_display", None) or format_renews(model.renews_at),
"subscription_remaining_display": (
None if model.subscription_remaining_usd is None else _fmt_usd(model.subscription_remaining_usd)
),
"topup_remaining_display": (
None if model.topup_remaining_usd is None else _fmt_usd(model.topup_remaining_usd)
),
"total_spendable_display": (
None if model.total_spendable_usd is None else _fmt_usd(model.total_spendable_usd)
),
"has_topup": model.has_topup,
"plan_bar": _serialize_usage_bar(model.plan_bar),
"topup_bar": _serialize_usage_bar(model.topup_bar),
}
@method("usage.bars")
def _(rid, params: dict) -> dict:
"""Shared dollar usage model (two-bar view) for /usage + /subscription.
Fail-open: logged-out / unreachable portal {ok:true, available:false}.
No scope required (read-only).
"""
try:
from agent.billing_usage import build_usage_model
return _ok(rid, _serialize_usage_model(build_usage_model()))
except Exception:
return _ok(rid, {"ok": True, "available": False})
def _serialize_subscription_state(state) -> dict:
"""Serialize a SubscriptionState for the wire (Decimals → strings)."""
from agent.billing_usage import format_renews
from agent.billing_view import format_money
def _s(value):
return None if value is None else str(value)
current = None
if state.current is not None:
c = state.current
current = {
"tier_id": c.tier_id,
"tier_name": c.tier_name,
"monthly_credits": _s(c.monthly_credits),
"credits_remaining": _s(c.credits_remaining),
"cycle_ends_at": c.cycle_ends_at,
"pending_downgrade_tier_name": c.pending_downgrade_tier_name,
"pending_downgrade_at": c.pending_downgrade_at,
"pending_downgrade_display": format_renews(c.pending_downgrade_at),
"cancel_at_period_end": c.cancel_at_period_end,
"cancellation_effective_at": c.cancellation_effective_at,
"cancellation_effective_display": format_renews(c.cancellation_effective_at),
}
# Selectable catalog for the in-terminal tier picker; price is pre-formatted
# ($X / $X.YY) so the TUI renders it directly.
tiers = [
{
"tier_id": t.tier_id,
"name": t.name,
"tier_order": t.tier_order,
"dollars_per_month_display": format_money(t.dollars_per_month),
"monthly_credits": _s(t.monthly_credits),
"is_current": t.is_current,
"is_enabled": t.is_enabled,
}
for t in state.tiers
]
return {
"ok": True,
"logged_in": state.logged_in,
"is_admin": state.is_admin,
"can_change_plan": state.can_change_plan,
"org_name": state.org_name,
"org_id": state.org_id,
"role": state.role,
"context": state.context,
"current": current,
"tiers": tiers,
"portal_url": state.portal_url,
"error": state.error,
# Shared dollar usage model (two-bar view) embedded so /subscription
# renders the same bars as /usage from its single fetch. Built from the
# separate account-info path (the only source with top-up dollars);
# fail-open → {available:false}. Computed lazily so a logged-out state
# adds no cost.
"usage": _usage_payload(state),
}
@method("subscription.state")
def _(rid, params: dict) -> dict:
"""GET /api/billing/subscription → serialized SubscriptionState.
Fail-open like billing.state: logged-out / unreachable portal
{ok:true, logged_in:false}. No scope required (read-only).
"""
try:
from agent.subscription_view import build_subscription_state
state = build_subscription_state()
return _ok(rid, _serialize_subscription_state(state))
except Exception:
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"})
def _serialize_subscription_preview(p) -> dict:
"""Serialize a SubscriptionChangePreview for the wire (Decimal → string)."""
return {
"ok": True,
"effect": p.effect,
"reason": p.reason,
"current_tier_id": p.current_tier_id,
"current_tier_name": p.current_tier_name,
"target_tier_id": p.target_tier_id,
"target_tier_name": p.target_tier_name,
"monthly_credits_delta": (
None if p.monthly_credits_delta is None else str(p.monthly_credits_delta)
),
"amount_due_now_cents": p.amount_due_now_cents,
"effective_at": p.effective_at,
}
@method("subscription.preview")
def _(rid, params: dict) -> dict:
"""POST /api/billing/subscription/preview → serialized quote or typed error.
params: {subscription_type_id: str}. Chargeless effect quote. Requires
billing:manage (live Stripe calls + amounts), so a 403 insufficient_scope
drives the device step-up exactly like the mutations.
"""
from agent.subscription_view import subscription_change_preview_from_payload
from hermes_cli.nous_billing import BillingError, post_subscription_preview
tier_id = params.get("subscription_type_id")
if not tier_id:
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"})
try:
preview = subscription_change_preview_from_payload(
post_subscription_preview(subscription_type_id=tier_id)
)
return _ok(rid, _serialize_subscription_preview(preview))
except BillingError as exc:
return _ok(rid, _serialize_billing_error(exc))
except Exception as exc:
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
@method("subscription.change")
def _(rid, params: dict) -> dict:
"""PUT /api/billing/subscription/pending-change → {ok, message} or typed error.
params: {subscription_type_id?: str, cancel?: bool}. Schedules a downgrade /
same-price change OR a cancellation at period end (chargeless). Requires
billing:manage.
"""
from hermes_cli.nous_billing import BillingError, put_subscription_pending_change
cancel = bool(params.get("cancel"))
tier_id = params.get("subscription_type_id")
if not cancel and not tier_id:
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id or cancel is required"})
try:
result = put_subscription_pending_change(subscription_type_id=tier_id, cancel=cancel)
return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result})
except BillingError as exc:
return _ok(rid, _serialize_billing_error(exc))
except Exception as exc:
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
@method("subscription.resume")
def _(rid, params: dict) -> dict:
"""DELETE /api/billing/subscription/pending-change → {ok, message} or typed error.
Clears a scheduled downgrade or cancellation (resume / undo). Chargeless, but it
re-enables recurring spend requires billing:manage and honors the kill-switch.
"""
from hermes_cli.nous_billing import BillingError, delete_subscription_pending_change
try:
result = delete_subscription_pending_change()
return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result})
except BillingError as exc:
return _ok(rid, _serialize_billing_error(exc))
except Exception as exc:
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
@method("subscription.upgrade")
def _(rid, params: dict) -> dict:
"""POST /api/billing/subscription/upgrade → {ok, status, ...} or typed error.
params: {subscription_type_id: str, idempotency_key?: str}. The single money
route: prorate + charge the card on the subscription + flip the plan. SCA /
decline come back as status requires_action / payment_failed with a recovery_url
to finish in the portal. The idempotency key is minted if absent and echoed so
the TUI reuses it on retry of the SAME upgrade. Requires billing:manage.
"""
from agent.billing_view import new_idempotency_key
from hermes_cli.nous_billing import BillingError, post_subscription_upgrade
tier_id = params.get("subscription_type_id")
if not tier_id:
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"})
key = params.get("idempotency_key") or new_idempotency_key()
try:
result = post_subscription_upgrade(subscription_type_id=tier_id, idempotency_key=key)
return _ok(
rid,
{
"ok": True,
"status": result.get("status"),
"target_tier_name": result.get("targetTierName"),
"recovery_url": result.get("recoveryUrl"),
"reason": result.get("reason"),
"idempotency_key": key,
},
)
except BillingError as exc:
env = _serialize_billing_error(exc)
env["idempotency_key"] = key # so the TUI can reuse on retry
return _ok(rid, env)
except Exception as exc:
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key})
@method("billing.charge")
def _(rid, params: dict) -> dict:
"""POST /api/billing/charge → {ok, chargeId} or a typed error envelope.
@ -8112,6 +8414,7 @@ def _(rid, params: dict) -> dict:
sid = params.get("session_id") or ""
try:
from hermes_cli.auth import step_up_nous_billing_scope
from hermes_cli.nous_billing import BillingError
def _on_verification(url: str, code: str) -> None:
_emit(
@ -8124,6 +8427,13 @@ def _(rid, params: dict) -> dict:
open_browser=False, on_verification=_on_verification
)
return _ok(rid, {"ok": True, "granted": bool(granted)})
except BillingError as exc:
# Route typed billing errors (e.g. session_revoked when the token expires
# mid-device-flow) through the shared spine like the other write handlers,
# so the TUI maps them to the right copy instead of a generic failure.
env = _serialize_billing_error(exc)
env["granted"] = False
return _ok(rid, env)
except Exception as exc:
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "granted": False})

View file

@ -0,0 +1,242 @@
/**
* Billing/Subscription TUI fixture harness renders any single overlay STATE
* live in the terminal so it can be screenshotted (tmux) and UX-reviewed.
*
* This is a DEV/REVIEW tool, not shipped behaviour. It bypasses the gateway and
* mounts the real Ink overlay components directly with a hand-built state object,
* exactly the way the vitest render tests do so what you see is pixel-identical
* to what `/subscription` and `/topup` draw at runtime.
*
* Usage:
* npx tsx scripts/billing-fixtures.tsx <fixture-name>
* npx tsx scripts/billing-fixtures.tsx --list
*
* Drive a specific screen of a fixture with SCREEN=<screen>, e.g.:
* SCREEN=confirm npx tsx scripts/billing-fixtures.tsx sub-free
* SCREEN=handoff npx tsx scripts/billing-fixtures.tsx sub-mid
*
* The selection cursor can be moved with / once it's live (the components own
* their own useInput); Esc/Enter behave as in production. Ctrl-C to exit.
*/
import { render } from '@hermes/ink'
import React from 'react'
import type { BillingOverlayState, SubscriptionOverlayState, SubscriptionScreen } from '../src/app/interfaces.js'
import { BillingOverlay } from '../src/components/billingOverlay.js'
import { SubscriptionOverlay } from '../src/components/subscriptionOverlay.js'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from '../src/gatewayTypes.js'
import { DEFAULT_THEME } from '../src/theme.js'
const t = DEFAULT_THEME
// ── helpers ──────────────────────────────────────────────────────────
const tier = (o: Partial<SubscriptionTierOption> = {}): SubscriptionTierOption => ({
tier_id: 'free',
name: 'Free',
tier_order: 0,
dollars_per_month_display: '$0',
monthly_credits: '0',
is_current: false,
is_enabled: true,
...o
})
const TIERS = {
free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }),
plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }),
super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }),
ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' })
}
const tierList = (currentId?: string): SubscriptionTierOption[] =>
Object.values(TIERS).map(x => ({ ...x, is_current: x.tier_id === currentId }))
const subState = (o: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme Inc',
org_id: 'org_acme',
role: 'OWNER',
context: 'personal',
current: null,
tiers: tierList(),
portal_url: 'https://portal.nousresearch.com/billing',
...o
})
const cur = (o: Record<string, unknown> = {}) => ({
tier_id: 'plus',
tier_name: 'Plus',
monthly_credits: '1000',
credits_remaining: '420',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null,
cancel_at_period_end: false,
cancellation_effective_at: null,
...o
})
const subCtx: SubscriptionOverlayState['ctx'] = {
openManageLink: () => Promise.resolve(true),
refreshState: () => Promise.resolve(null),
sys: () => {}
}
const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pendingTargetTierId: string | null = null): SubscriptionOverlayState => ({
ctx: subCtx,
screen,
state: s,
pendingTargetTierId
})
// ── billing/topup fixtures ───────────────────────────────────────────
const billState = (o: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
cli_billing_enabled: true,
can_charge: true,
card: { brand: 'Visa', last4: '4242', masked: 'Visa •••• 4242' },
balance_display: '$12.00',
balance_usd: '12.00',
min_usd: '5',
max_usd: '500',
monthly_cap: {
is_default_ceiling: false,
limit_display: '$20',
limit_usd: '20',
spent_display: '$8.00',
spent_this_month_usd: '8'
},
auto_reload: { enabled: false, reload_to_display: '$25', reload_to_usd: '25', threshold_display: '$5', threshold_usd: '5' },
org_name: 'Acme Inc',
role: 'OWNER',
portal_url: 'https://portal.nousresearch.com/billing',
charge_presets: ['10', '25', '50', '100'],
charge_presets_display: ['$10', '$25', '$50', '$100'],
...o
})
const billCtx = {
applyAutoReload: () => Promise.resolve(true),
charge: () => Promise.resolve('submitted' as const),
openPortal: () => {},
requestRemoteSpending: () => Promise.resolve(true),
sys: () => {},
validate: (raw: string) => ({ amount: raw })
}
const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({
ctx: billCtx,
pendingCharge: screen === 'confirm' || screen === 'stepup' ? { amount: '100' } : null,
screen,
state: s
})
// ── fixture registry ─────────────────────────────────────────────────
type Fixture = { desc: string; node: React.ReactElement }
const subEl = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pending: string | null = null) =>
React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay: sub(s, screen, pending), t })
const billEl = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview') =>
React.createElement(BillingOverlay, { onClose: () => {}, onPatch: () => {}, overlay: bill(s, screen), t })
const FIXTURES: Record<string, Fixture> = {
// /subscription — overview states
'sub-free': {
desc: 'Free / no sub — upgradeable (primary conversion state)',
node: subEl(subState({ current: null }))
},
'sub-mid': {
desc: 'Subscriber mid-tier (Plus) — usage bar + up/downgrade targets',
node: subEl(subState({ current: cur(), tiers: tierList('plus') }))
},
'sub-top': {
desc: 'Subscriber top-tier (Ultra) — "on the top plan"',
node: subEl(subState({ current: cur({ tier_id: 'ultra', tier_name: 'Ultra', monthly_credits: '7000', credits_remaining: '5000' }), tiers: tierList('ultra') }))
},
'sub-not-admin': {
desc: 'Member (not admin/owner) — read-only, no tier picker',
node: subEl(subState({ is_admin: false, can_change_plan: false, role: 'MEMBER', current: cur(), tiers: tierList('plus') }))
},
'sub-downgrade': {
desc: 'Downgrade scheduled — pending-switch banner',
node: subEl(subState({ current: cur({ pending_downgrade_tier_name: 'Plus', pending_downgrade_at: '2026-07-15' }), tiers: tierList('super') }))
},
'sub-cancel': {
desc: 'Cancellation scheduled — stays active until effective date',
node: subEl(subState({ current: cur({ cancel_at_period_end: true, cancellation_effective_at: '2026-07-01' }), tiers: tierList('plus') }))
},
'sub-team': {
desc: 'Team org context — shared credits, redirect to /topup',
node: subEl(subState({ context: 'team', current: null, org_name: 'Acme Engineering' }))
},
// /subscription — non-overview screens
'sub-confirm': {
desc: 'Confirm plan change (deep-link, no in-terminal charge)',
node: subEl(subState({ current: cur(), tiers: tierList('plus') }), 'confirm', 'super')
},
'sub-confirm-new': {
desc: 'Confirm first subscription (free → paid)',
node: subEl(subState({ current: null }), 'confirm', 'plus')
},
'sub-handoff': {
desc: 'Handoff transient — opening subscription page in browser',
node: subEl(subState({ current: cur() }), 'handoff')
},
// /topup (renamed /billing)
'topup-overview': {
desc: '/topup overview — admin, card on file, full menu',
node: billEl(billState())
},
'topup-no-card': {
desc: '/topup overview — admin, NO saved card (card hint)',
node: billEl(billState({ card: null }))
},
'topup-not-admin': {
desc: '/topup overview — member, read-only',
node: billEl(billState({ is_admin: false }))
},
'topup-disabled': {
desc: '/topup overview — terminal billing OFF for org',
node: billEl(billState({ cli_billing_enabled: false }))
},
'topup-buy': {
desc: '/topup buy screen — presets',
node: billEl(billState(), 'buy')
},
'topup-stepup': {
desc: '/topup step-up — "Allow Remote Spending" (resumable, holds $100 buy)',
node: billEl(billState(), 'stepup')
}
}
// ── driver ───────────────────────────────────────────────────────────
const arg = process.argv[2]
if (!arg || arg === '--list' || arg === '-l') {
const names = Object.keys(FIXTURES)
process.stdout.write('Billing/Subscription TUI fixtures:\n\n')
for (const name of names) {
process.stdout.write(` ${name.padEnd(18)} ${FIXTURES[name]!.desc}\n`)
}
process.stdout.write(`\n ${names.length} fixtures. Run: npx tsx scripts/billing-fixtures.tsx <name>\n`)
process.exit(0)
}
const fixture = FIXTURES[arg]
if (!fixture) {
process.stderr.write(`Unknown fixture: ${arg}\nRun with --list to see all.\n`)
process.exit(1)
}
render(fixture.node)

View file

@ -0,0 +1,185 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
// Stub useInput so the overlay doesn't enter raw mode under renderSync.
vi.mock('@hermes/ink', async importOriginal => {
const mod = await importOriginal()
return { ...mod, useInput: () => {} }
})
import type { BillingOverlayState } from '../app/interfaces.js'
import { BillingOverlay } from '../components/billingOverlay.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
function render(overlay: BillingOverlayState): string {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
React.createElement(BillingOverlay, {
onClose: () => {},
onPatch: () => {},
overlay,
t
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
return stripAnsi(output)
}
const billState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse =>
({
auto_reload: null,
balance_display: '$12.00',
balance_usd: '12',
can_charge: true,
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
charge_presets: ['25', '50'],
charge_presets_display: ['$25', '$50'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '1000',
min_usd: '10',
monthly_cap: null,
ok: true,
org_name: 'Acme',
portal_url: 'https://portal/billing',
role: 'OWNER',
...overrides
}) as BillingStateResponse
const ctx = {
applyAutoReload: vi.fn(() => Promise.resolve(true)),
charge: vi.fn(() => Promise.resolve('submitted' as const)),
openPortal: vi.fn(),
refreshState: vi.fn(() => Promise.resolve(null)),
requestRemoteSpending: vi.fn(() => Promise.resolve(true)),
sys: vi.fn(),
validate: vi.fn((raw: string) => ({ amount: raw }))
}
const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => ({
ctx,
pendingCharge: { amount: '100' },
screen,
state: billState()
})
describe('BillingOverlay — step-up screen (Enable terminal billing)', () => {
it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => {
const out = render(overlay('stepup'))
expect(out).toContain('One-time setup')
expect(out).toContain('Enable terminal billing')
expect(out).toContain('$100') // resumes the held purchase
expect(out).toContain('Not now')
expect(out).not.toContain('billing:manage')
})
})
describe('BillingOverlay — overview (reordered, dollars)', () => {
it('leads with balance in the title, Add funds first, no "credits"', () => {
const out = render(overlay('overview'))
expect(out).toContain('Top up · balance $12.00') // balance in the title
expect(out).toContain('Add funds') // buy action, renamed
expect(out).toContain('Auto-reload')
expect(out).toContain('Manage on portal')
expect(out.toLowerCase()).not.toContain('credits') // dollars only
// No standalone "Enable terminal billing" item — discovered at pay time.
expect(out).not.toContain('Enable terminal billing')
})
it('renders the two-bar dollar usage when a usage model is present', () => {
const withUsage: BillingOverlayState = {
...overlay('overview'),
state: {
...billState(),
usage: {
available: true,
status: 'healthy',
plan_name: 'Plus',
has_topup: true,
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
}
}
}
const out = render(withUsage)
expect(out).toContain('$14.00 left of $20.00')
expect(out).toContain('30% used')
expect(out).toContain('never expires')
})
})
describe('BillingOverlay — auto-reload card divergence', () => {
const autoReload = (card: NonNullable<BillingStateResponse['auto_reload']>['card']) => ({
card,
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$20',
threshold_usd: '20'
})
it('warns when auto-reload charges a distinct card and offers the portal hand-off', () => {
const out = render({
...overlay('autoreload'),
state: billState({
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: 'Visa', last4: '9999' })
})
})
expect(out).toContain('Auto-refill is charging Visa ••9999 — not your card on file')
expect(out).toContain('authorize Nous Research to charge Visa ••9999')
expect(out).toContain('Use your card on file — manage on portal')
})
it('uses generic distinct-card copy when metadata is unresolved', () => {
const out = render({
...overlay('autoreload'),
state: billState({
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: null, last4: null })
})
})
expect(out).toContain('Auto-refill is charging a different card — not your card on file')
})
it.each(['canonical', 'none'] as const)('does not warn for a %s auto-reload card', kind => {
const out = render({
...overlay('autoreload'),
state: billState({ auto_reload: autoReload({ kind }) })
})
expect(out).not.toContain('not your card on file')
expect(out).not.toContain('Use your card on file — manage on portal')
})
})

View file

@ -1,142 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { creditsCommands } from '../app/slash/commands/credits.js'
import type { CreditsViewResponse } from '../gatewayTypes.js'
// The command opens the top-up URL through this helper on confirm. Mock it so
// the test never shells out to a real browser/`xdg-open` and we can assert the
// success/failure messaging deterministically.
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
import { openExternalUrl } from '../lib/openExternalUrl.js'
const openExternalUrlMock = vi.mocked(openExternalUrl)
const creditsCommand = creditsCommands.find(cmd => cmd.name === 'credits')!
const buildView = (overrides: Partial<CreditsViewResponse> = {}): CreditsViewResponse => ({
balance_lines: ['Grant: $9.50 left', 'Top-up: $25.00'],
depleted: false,
identity_line: 'Signed in as ada@example.com',
logged_in: true,
topup_url: 'https://portal.nousresearch.com/billing/topup',
...overrides
})
// Mirror createSlashHandler's real `guarded` wrapper: skip the handler when the
// command is stale OR the response is falsy. Tests stay non-stale, so this is a
// straightforward "run the handler when we got a response" shim.
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
const buildCtx = (rpcResult: CreditsViewResponse) => {
const sys = vi.fn()
const rpc = vi.fn(() => Promise.resolve(rpcResult))
const guardedErr = vi.fn()
const ctx = {
gateway: { rpc },
guarded,
guardedErr,
sid: 'sid-abc',
stale: () => false,
transcript: { page: vi.fn(), panel: vi.fn(), sys }
}
// Run the command, then await the rpc promise so the .then() handler has
// flushed before assertions — deterministic, no polling/timeouts.
const run = async () => {
creditsCommand.run('', ctx as any, 'credits')
await rpc.mock.results[0]?.value
// Allow the chained .then() microtask to settle.
await Promise.resolve()
}
return { ctx, rpc, run, sys }
}
describe('/credits slash command', () => {
beforeEach(() => {
resetOverlayState()
openExternalUrlMock.mockClear()
openExternalUrlMock.mockReturnValue(true)
})
it('renders the balance (including top-up URL) and arms the confirm overlay', async () => {
const view = buildView()
const { rpc, run, sys } = buildCtx(view)
await run()
expect(rpc).toHaveBeenCalledWith('credits.view', { session_id: 'sid-abc' })
// (a) sys received the balance text including the topup_url
const printed = sys.mock.calls.map(call => call[0]).join('\n')
expect(printed).toContain('💳 Nous credits')
expect(printed).toContain('Grant: $9.50 left')
expect(printed).toContain('Signed in as ada@example.com')
expect(printed).toContain(view.topup_url)
// (b) confirm overlay set with the expected label + detail
const confirm = getOverlayState().confirm
expect(confirm).toBeTruthy()
expect(confirm?.confirmLabel).toBe('Open top-up in browser')
expect(confirm?.cancelLabel).toBe('Cancel')
expect(confirm?.title).toBe('Add credits?')
expect(confirm?.detail).toBe(view.topup_url)
// onConfirm opens the URL and reports success back to the transcript
confirm?.onConfirm()
expect(openExternalUrlMock).toHaveBeenCalledWith(view.topup_url)
expect(sys).toHaveBeenCalledWith('Complete your top-up in the browser — credits will appear in /credits shortly.')
})
it('falls back to printing the URL when the browser open is rejected', async () => {
openExternalUrlMock.mockReturnValue(false)
const view = buildView()
const { run, sys } = buildCtx(view)
await run()
const confirm = getOverlayState().confirm
expect(confirm).toBeTruthy()
confirm?.onConfirm()
expect(sys).toHaveBeenCalledWith(`Open this URL to top up: ${view.topup_url}`)
})
it('does not arm the confirm overlay when there is no top-up URL', async () => {
const view = buildView({ topup_url: null })
const { run, sys } = buildCtx(view)
await run()
const printed = sys.mock.calls.map(call => call[0]).join('\n')
expect(printed).toContain('💳 Nous credits')
expect(getOverlayState().confirm).toBeNull()
})
it('shows the not-logged-in message and does NOT arm the confirm overlay', async () => {
const view = buildView({
balance_lines: [],
identity_line: null,
logged_in: false,
topup_url: null
})
const { run, sys } = buildCtx(view)
await run()
expect(sys).toHaveBeenCalledWith('💳 Not logged into Nous Portal — run /portal to log in.')
expect(getOverlayState().confirm).toBeNull()
expect(openExternalUrlMock).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { subscriptionCommands } from '../app/slash/commands/subscription.js'
import { findSlashCommand } from '../app/slash/registry.js'
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
const subscriptionCommand = subscriptionCommands.find(cmd => cmd.name === 'subscription')!
const loggedInState = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme',
role: 'OWNER',
current: null,
portal_url: 'https://portal.nousresearch.com/billing',
...overrides
})
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const calls: Array<{ method: string; params: unknown }> = []
const rpc = vi.fn((method: string, params: unknown) => {
calls.push({ method, params })
return Promise.resolve(results[method])
})
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), panel: vi.fn(), sys }
}
const run = async (arg: string) => {
subscriptionCommand.run(arg, ctx as any, 'subscription')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { calls, ctx, rpc, run, sys }
}
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
describe('/subscription slash command', () => {
beforeEach(() => {
resetOverlayState()
})
it('fetches subscription.state and opens the overlay', async () => {
const { run } = buildCtx({
'subscription.state': loggedInState()
})
await run('')
const overlay = getOverlayState().subscription
expect(overlay).not.toBeNull()
expect(overlay?.screen).toBe('overview')
})
it('shows portal-login sys line when not logged in', async () => {
const { run, sys } = buildCtx({
'subscription.state': loggedInState({ logged_in: false })
})
await run('')
expect(printed(sys)).toContain('Not logged into Nous Portal')
expect(getOverlayState().subscription).toBeNull()
})
it('/upgrade alias resolves to the same command', () => {
expect(findSlashCommand('upgrade')).toBe(subscriptionCommand)
})
it('/subscription resolves to the same command', () => {
expect(findSlashCommand('subscription')).toBe(subscriptionCommand)
})
})

View file

@ -0,0 +1,481 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
const inputHarness = vi.hoisted(() => ({
handler: undefined as undefined | ((input: string, key: Record<string, boolean>) => void)
}))
// Stub useInput so the overlay doesn't try to enter raw mode under renderSync
// (PassThrough stdin doesn't support it). Box/Text pass through to real Ink.
vi.mock('@hermes/ink', async importOriginal => {
const mod = await importOriginal()
return {
...mod,
useInput: (handler: (input: string, key: Record<string, boolean>) => void) => {
inputHarness.handler = handler
}
}
})
import type { SubscriptionOverlayState } from '../app/interfaces.js'
import { SubscriptionOverlay } from '../components/subscriptionOverlay.js'
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
/** Mount a SubscriptionOverlay via renderSync + PassThrough. */
function mount(overlay: SubscriptionOverlayState, onPatch: (next: Partial<SubscriptionOverlayState>) => void = () => {}) {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
inputHarness.handler = undefined
const element = React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch, overlay, t })
const instance = renderSync(
element,
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
return {
cleanup: () => {
instance.unmount()
instance.cleanup()
},
output: () => stripAnsi(output),
rerender: () => instance.rerender(element)
}
}
/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */
function render(overlay: SubscriptionOverlayState): string {
const mounted = mount(overlay)
const output = mounted.output()
mounted.cleanup()
return output
}
const TIERS = [
{ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0', is_current: false, is_enabled: true },
{ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true, is_enabled: true },
{ tier_id: 'ultra', name: 'Ultra', tier_order: 2, dollars_per_month_display: '$40', monthly_credits: '3000', is_current: false, is_enabled: true }
]
const state = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme',
org_id: 'org_acme',
role: 'OWNER',
current: null,
tiers: [],
portal_url: 'https://portal.nousresearch.com/billing',
...overrides
})
const ctx = {
fetchCard: vi.fn(() => Promise.resolve(null)),
openManageLink: vi.fn(() => Promise.resolve(true)),
openPortal: vi.fn(),
preview: vi.fn(() => Promise.resolve(null)),
refreshState: vi.fn(() => Promise.resolve(null)),
requestRemoteSpending: vi.fn(() => Promise.resolve({ granted: true })),
resume: vi.fn(() => Promise.resolve(null)),
scheduleCancellation: vi.fn(() => Promise.resolve(null)),
scheduleChange: vi.fn(() => Promise.resolve(null)),
sys: vi.fn(),
upgrade: vi.fn(() => Promise.resolve(null))
}
const overlay = (s: SubscriptionStateResponse): SubscriptionOverlayState => ({ ctx, screen: 'overview', state: s })
// Overview: the entry screen across every account state (plan + usage + the
// actions that enter the in-terminal change flow).
describe('SubscriptionOverlay — overview', () => {
it('free: upsell + "Start a subscription", no tier list, no "credits"', () => {
const out = render(overlay(state({ current: null, usage: { available: true, status: 'free', plan_name: null } })))
expect(out).toContain('Plan: Free · free models only')
expect(out).toContain('Paid models need a subscription')
expect(out).toContain('Start a subscription')
expect(out).not.toContain('$20/mo')
expect(out.toLowerCase()).not.toContain('credits')
})
it('subscriber: status line + plan bar + top-up bar, no "credits"', () => {
const out = render(
overlay(
state({
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '700', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
usage: {
available: true,
status: 'healthy',
plan_name: 'Pro',
renews_display: 'Jul 1, 2026',
total_spendable_display: '$26.00',
has_topup: true,
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
}
})
)
)
expect(out).toContain('Plan: Pro')
expect(out).toContain('$14.00 left of $20.00')
expect(out).toContain('30% used')
expect(out).toContain('top-up')
expect(out).toContain('never expires')
expect(out.toLowerCase()).not.toContain('credits')
})
it('low balance: shows alert nudge', () => {
const out = render(
overlay(
state({
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '170', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
usage: { available: true, status: 'low', plan_name: 'Pro', total_spendable_display: '$3.40', plan_bar: { kind: 'plan', remaining_display: '$3.40', total_display: '$20.00', spent_display: '$16.60', pct_used: 83, fill_fraction: 0.17 } }
})
)
)
expect(out).toContain('Plan: Pro · $3.40 left')
expect(out).toContain('Low balance')
})
it('not-admin: shows read-only note', () => {
const out = render(
overlay(
state({
is_admin: false,
can_change_plan: false,
role: 'MEMBER',
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
})
)
)
expect(out).toContain('view only')
expect(out).toContain('Manage on portal')
})
it('downgrade-pending: leads with a Pro ──▶ Free banner + status echo', () => {
const out = render(
overlay(
state({
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: 'Free', pending_downgrade_at: '2026-07-15', pending_downgrade_display: 'Jul 15, 2026' },
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
})
)
)
expect(out).toContain('Scheduled change')
expect(out).toContain('──▶')
expect(out).toContain('Free')
expect(out).toContain('Jul 15, 2026')
// the status line itself echoes the transition
expect(out).toContain('Plan: Pro → Free')
})
it('team context: redirects to /topup, no tier picker', () => {
const out = render(overlay(state({ context: 'team', current: null })))
expect(out).toContain('shared balance')
expect(out).toContain('/topup')
})
})
// In-terminal change flow (V3): picker → confirm → result. useInput is mocked
// (no key simulation), so these assert each screen's rendered content.
const subscriber = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse =>
state({
current: {
tier_id: 'plus',
tier_name: 'Plus',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
tiers: TIERS,
usage: { available: true, status: 'healthy', plan_name: 'Plus' },
...overrides
})
const at = (
screen: SubscriptionOverlayState['screen'],
s: SubscriptionStateResponse,
extra: Partial<SubscriptionOverlayState> = {}
): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra })
describe('SubscriptionOverlay — overview actions', () => {
it('admin subscriber: offers Change plan + Cancel subscription', () => {
const out = render(overlay(subscriber()))
expect(out).toContain('Change plan')
expect(out).toContain('Cancel subscription')
})
it('pending change: offers undo instead of cancel', () => {
const out = render(
overlay(
subscriber({
current: {
tier_id: 'plus',
tier_name: 'Plus',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01',
cancel_at_period_end: true,
cancellation_effective_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
}
})
)
)
// undo is promoted to the first action; the banner shows the pending cancel
expect(out).toContain('Keep Plus (undo this change)')
expect(out).toContain('cancels')
expect(out).not.toContain('Cancel subscription')
})
})
describe('SubscriptionOverlay — step-up', () => {
it('prompts to enable terminal billing (never leaks the raw scope)', () => {
const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } }))
expect(out).toContain('Terminal billing')
expect(out).toContain('Enable terminal billing')
expect(out).not.toContain('billing:manage')
})
})
describe('SubscriptionOverlay — picker', () => {
it('lists other paid tiers with direction hints; hides current + free', () => {
const out = render(at('picker', subscriber()))
expect(out).toContain('Ultra')
expect(out).toContain('$40/mo')
expect(out).toContain('upgrade') // ultra (order 2) > plus (order 1)
expect(out).not.toContain('Plus · $20/mo') // current tier is not selectable
expect(out).not.toContain('$0/mo') // free tier excluded — use Cancel instead
})
})
describe('SubscriptionOverlay — confirm', () => {
it('charge_now: shows the prorated charge + upgrade copy', () => {
const out = render(
at('confirm', subscriber(), {
pending: {
kind: 'upgrade',
targetTierId: 'ultra',
preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234, monthly_credits_delta: '2000' }
}
})
)
expect(out).toContain('Pay $12.34 & upgrade now')
expect(out).toContain('Upgrade to Ultra')
})
it('scheduled: shows effective date + no charge now', () => {
const out = render(
at('confirm', subscriber(), {
pending: {
kind: 'tier_change',
targetTierId: 'plus',
preview: { ok: true, effect: 'scheduled', target_tier_name: 'Plus', effective_at: '2026-08-01T00:00:00Z', amount_due_now_cents: null }
}
})
)
expect(out).toContain('Schedule change to Plus')
expect(out).toContain('2026-08-01')
expect(out).toContain('No charge now')
})
it('cancellation: shows cancel-at-period-end copy', () => {
const out = render(at('confirm', subscriber(), { pending: { kind: 'cancellation', targetTierId: null, preview: null } }))
expect(out).toContain('Confirm cancellation')
expect(out).toContain('will not renew')
})
it('blocked: shows the reason + Manage on portal', () => {
const out = render(
at('confirm', subscriber(), {
pending: { kind: 'tier_change', targetTierId: 'ultra', preview: { ok: true, effect: 'blocked', reason: 'Retract the cancellation before upgrading.' } }
})
)
expect(out).toContain('Retract the cancellation')
expect(out).toContain('Manage on portal')
})
})
describe('SubscriptionOverlay — result', () => {
it('ok: shows Done + the re-run hint', () => {
const out = render(at('result', subscriber(), { result: { ok: true, message: 'Upgraded to Ultra.' } }))
expect(out).toContain('Done')
expect(out).toContain('Upgraded to Ultra.')
expect(out).toContain('Re-run /subscription')
})
it('error with recovery: shows the message + Open the portal', () => {
const out = render(
at('result', subscriber(), {
result: { ok: false, message: 'This upgrade needs extra verification (3DS).', recoveryUrl: 'https://portal.example/x' }
})
)
expect(out).toContain('Could not complete')
expect(out).toContain('3DS')
expect(out).toContain('Open the portal to finish')
})
})
describe('SubscriptionOverlay — upgrade response mapping', () => {
const applyUpgrade = async (response: unknown) => {
const onPatch = vi.fn()
const upgrade = vi.fn(() => Promise.resolve(response))
const mounted = mount(
at('confirm', subscriber(), {
ctx: { ...ctx, upgrade } as SubscriptionOverlayState['ctx'],
pending: {
idempotencyKey: 'upgrade-key',
kind: 'upgrade',
preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234 },
targetTierId: 'ultra'
}
}),
onPatch
)
inputHarness.handler?.('', { return: true })
await vi.waitFor(() => expect(onPatch).toHaveBeenCalled())
mounted.cleanup()
return onPatch.mock.calls.at(-1)?.[0] as Partial<SubscriptionOverlayState>
}
it.each([
['authentication_required', 'upgraded'],
['subscription_payment_intent_requires_action', 'payment_failed']
])('reason %s routes to card verification regardless of status %s', async (reason, status) => {
const patch = await applyUpgrade({
ok: status === 'upgraded',
reason,
recovery_url: 'https://portal.example/verify',
status,
target_tier_name: 'Ultra'
})
expect(patch.screen).toBe('result')
expect(patch.result?.ok).toBe(false)
expect(patch.result?.message).toContain('verify your card in the portal')
expect(patch.result?.recoveryUrl).toBe('https://portal.example/verify')
})
it('card_declined reason routes to a different-card recovery', async () => {
const patch = await applyUpgrade({
ok: false,
reason: 'card_declined',
recovery_url: 'https://portal.example/card',
status: 'requires_action'
})
expect(patch.result?.message).toContain('try a different card on the portal')
expect(patch.result?.recoveryUrl).toBe('https://portal.example/card')
})
it('already_on_tier remains an immediate success', async () => {
const patch = await applyUpgrade({ ok: true, status: 'already_on_tier', target_tier_name: 'Ultra' })
expect(patch.result).toMatchObject({ message: 'You are already on Ultra.', ok: true })
expect(patch.result).not.toHaveProperty('pendingTierId')
})
it('upgraded marks the result as applying to the target tier', async () => {
const patch = await applyUpgrade({ ok: true, status: 'upgraded', target_tier_name: 'Ultra' })
expect(patch.result).toMatchObject({ ok: true, pendingTierId: 'ultra' })
})
it('shows Applying, then Done once refreshed state reaches the upgraded tier', async () => {
vi.useFakeTimers()
try {
const refreshState = vi.fn(() => Promise.resolve(subscriber({
current: {
tier_id: 'ultra',
tier_name: 'Ultra',
monthly_credits: '3000',
credits_remaining: '3000',
cycle_ends_at: '2026-08-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
}
})))
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))
expect(mounted.output()).toContain('Applying…')
await vi.advanceTimersByTimeAsync(2000)
mounted.rerender()
expect(refreshState).toHaveBeenCalledTimes(1)
expect(mounted.output()).toContain('Done')
expect(mounted.output()).toContain('Upgraded to Ultra.')
mounted.cleanup()
} finally {
vi.useRealTimers()
}
})
it('keeps a successful upgrade soft-pending after the bounded confirmation window', async () => {
vi.useFakeTimers()
try {
const refreshState = vi.fn(() => Promise.resolve(subscriber()))
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))
await vi.advanceTimersByTimeAsync(30_000)
mounted.rerender()
expect(refreshState).toHaveBeenCalledTimes(15)
expect(mounted.output()).toContain('Still applying')
expect(mounted.output()).toContain('refresh in a moment')
expect(mounted.output()).not.toContain('Could not complete')
mounted.cleanup()
} finally {
vi.useRealTimers()
}
})
})

View file

@ -1,17 +1,18 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { billingCommands } from '../app/slash/commands/billing.js'
import { topupCommands } from '../app/slash/commands/topup.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
const billingCommand = billingCommands.find(cmd => cmd.name === 'billing')!
const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')!
const ownerState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
auto_reload: {
card: { kind: 'canonical' },
enabled: false,
reload_to_display: '—',
reload_to_usd: null,
@ -72,7 +73,7 @@ const buildCtx = (results: Record<string, unknown>) => {
}
const run = async (arg: string) => {
billingCommand.run(arg, ctx as any, 'billing')
topupCommand.run(arg, ctx as any, 'topup')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
@ -231,19 +232,157 @@ describe('/billing slash command (overlay-driven)', () => {
expect(out).toContain('Portal: /billing?topup=open')
})
it('ctx.charge insufficient_scope → arms step-up confirm', async () => {
it('ctx.charge consent_required → one-time portal confirmation copy + portal funnel', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'consent_required',
portal_url: '/billing/consent',
idempotency_key: 'k'
}
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
const out = printed(sys)
expect(out).toContain('one-time card confirmation')
expect(out).toContain('Portal: /billing/consent')
})
it.each([
['org_access_denied', "This token isn't bound to an org you can manage"],
['upgrade_cap_exceeded', 'Daily plan-change limit reached'],
['auto_top_up_disabled_failures', 'Auto-reload was turned off after repeated charge failures']
])('ctx.charge %s → typed recovery copy', async (error, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error, idempotency_key: 'k' }
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
expect(printed(sys)).toContain(copy)
})
it.each([
[undefined, 'Stripe is having trouble right now — try again shortly.'],
[120, 'Stripe is having trouble right now — try again shortly (try again in ~2 min).']
])('ctx.charge stripe_unavailable (retry_after=%s) → transient Stripe copy', async (retryAfter, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'stripe_unavailable',
idempotency_key: 'k',
...(retryAfter == null ? {} : { retry_after: retryAfter })
}
})
await run('')
await getOverlayState().billing!.ctx.charge('100')
const out = printed(sys)
expect(out).toContain(copy)
expect(out).not.toContain('Too many charges')
})
it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => {
const { run } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' }
})
await run('')
const outcome = await getOverlayState().billing!.ctx.charge('100')
// No separate confirm overlay is armed anymore — the overlay's stepup
// screen owns the UX; the ctx just reports the outcome.
expect(outcome).toBe('needs_remote_spending')
expect(getOverlayState().confirm).toBeNull()
})
it.each([[true], [false]])('ctx.requestRemoteSpending → billing.step_up resolves %s', async granted => {
const { run, calls } = buildCtx({ 'billing.state': ownerState(), 'billing.step_up': { ok: true, granted } })
await run('')
expect(await getOverlayState().billing!.ctx.requestRemoteSpending()).toBe(granted)
expect(calls.find(c => c.method === 'billing.step_up')).toBeTruthy()
})
// ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ──
it.each([
['admin', 'An admin turned off terminal billing for this terminal'],
['self', 'You turned off terminal billing for this terminal']
])('ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy', async (actor, copy) => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'remote_spending_revoked', actor, recovery: 'reconnect', idempotency_key: 'k' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
// The charge failed with insufficient_scope → a NEW confirm (step-up) is armed.
const stepUp = getOverlayState().confirm
expect(stepUp?.title).toBe('Grant terminal billing access?')
expect(printed(sys)).toContain(copy)
expect(getOverlayState().billing).toBeNull()
})
it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain('Your session was logged out')
expect(getOverlayState().billing).toBeNull()
})
it('ctx.charge → poll transport loss reports an unconfirmed outcome', async () => {
const { ctx, rpc, run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }
})
await run('')
rpc.mockImplementation((method: string) => {
if (method === 'billing.charge_status') {
return Promise.reject(new Error('socket closed'))
}
return Promise.resolve(
method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null
)
})
await getOverlayState().billing!.ctx.charge('100')
await vi.waitFor(() => expect(printed(sys)).toContain('outcome is unconfirmed'))
expect(ctx.guardedErr).toHaveBeenCalled()
})
it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'cli_billing_disabled',
code: 'remote_spending_disabled',
recovery: 'enable_account_toggle',
portal_url: '/billing',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('Terminal billing is off for this account')
// Account-wide switch is NOT a per-terminal revoke — overlay stays open.
expect(getOverlayState().billing).toBeTruthy()
})
it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => {
@ -263,6 +402,7 @@ describe('/billing slash command (overlay-driven)', () => {
const { run, calls } = buildCtx({
'billing.state': ownerState({
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
@ -292,6 +432,25 @@ describe('/billing slash command (overlay-driven)', () => {
expect(printed(sys)).toContain('Monthly spend cap reached.')
})
it('ctx.charge → poll → processing_error has intentional failure copy', async () => {
vi.useFakeTimers()
try {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
'billing.charge_status': { ok: true, status: 'failed', reason: 'processing_error' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await vi.runAllTimersAsync()
expect(printed(sys)).toContain("The charge didn't go through (processing_error).")
} finally {
vi.useRealTimers()
}
})
it('ctx.openPortal opens the URL + echoes a transcript line', async () => {
const { run, sys } = buildCtx({ 'billing.state': ownerState() })
await run('')

View file

@ -35,12 +35,7 @@ describe('turnController.startMessage — flash-and-yield notices clear on next
it('leaves a sticky credits.depleted notice across a new turn', () => {
patchUiState({
notice: {
key: 'credits.depleted',
kind: 'sticky',
level: 'error',
text: '✕ Credit access paused · run /credits to top up'
}
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' }
})
turnController.startMessage()
expect(getUiState().notice?.key).toBe('credits.depleted')

View file

@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sessionCommands } from '../app/slash/commands/session.js'
import type { SessionUsageResponse } from '../gatewayTypes.js'
const usageCommand = sessionCommands.find(cmd => cmd.name === 'usage')!
const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const panel = vi.fn()
const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method]))
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), panel, sys }
}
const run = async (arg: string) => {
usageCommand.run(arg, ctx as any, 'usage')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { ctx, panel, run, sys }
}
const baseUsage = (overrides: Partial<SessionUsageResponse> = {}): SessionUsageResponse =>
({ calls: 0, input: 0, output: 0, total: 0, ...overrides }) as SessionUsageResponse
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
const balancePanel = (panel: ReturnType<typeof vi.fn>) => {
const sections = panel.mock.calls.find(c => c[0] === 'Balance')?.[1] as { text?: string }[] | undefined
return (sections ?? []).map(s => s.text ?? '').join('\n')
}
describe('/usage slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('always shows the CTA; "no API calls yet" only when there is no balance', async () => {
const empty = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: [] }) })
await empty.run('')
expect(printed(empty.sys)).toContain('no API calls yet')
expect(printed(empty.sys)).toContain(USAGE_CTA)
const withBalance = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] }) })
await withBalance.run('')
expect(printed(withBalance.sys)).not.toContain('no API calls yet')
expect(printed(withBalance.sys)).toContain(USAGE_CTA)
})
it('renders the dollar two-bar model (no "credits" wording) when available', async () => {
const { panel, run } = buildCtx({
'session.usage': baseUsage({
usage: {
available: true,
status: 'healthy',
plan_name: 'Plus',
renews_display: 'Jul 1, 2026',
total_spendable_display: '$26.00',
has_topup: true,
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
}
})
})
await run('')
const body = balancePanel(panel)
expect(body).toContain('Plus')
expect(body).toContain('$14.00 left of $20.00')
expect(body).toContain('30% used')
expect(body).toContain('top-up')
expect(body).toContain('$12.00')
expect(body.toLowerCase()).not.toContain('credits')
})
it('shows the free-models upsell for a free account', async () => {
const { panel, run } = buildCtx({
'session.usage': baseUsage({ usage: { available: true, status: 'free', plan_name: null } })
})
await run('')
const body = balancePanel(panel)
expect(body).toContain('free models only')
expect(body).toContain('/subscription')
})
})

View file

@ -3,7 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { BillingStateResponse, ImageAttachResponse, SessionCloseResponse } from '../gatewayTypes.js'
import type { BillingCardInfo, BillingMutationResponse, BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../gatewayTypes.js'
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
@ -92,7 +92,13 @@ export interface GatewayProviderProps {
// the SAME RPCs as the old slash flows (billing.charge / charge_status /
// auto_reload / step_up). Backend is unchanged & shared with the CLI.
export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview'
export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview' | 'stepup'
/** Outcome of a charge attempt — lets the overlay route without tearing down. */
export type BillingChargeOutcome =
| 'submitted' // 202 accepted; settlement is reported via transcript lines
| 'needs_remote_spending' // insufficient_scope → route to the stepup screen
| 'error' // any other failure (already surfaced via sys)
/**
* The functions the overlay needs to talk to the gateway and emit
@ -104,10 +110,27 @@ export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overvi
export interface BillingOverlayCtx {
/** Run `billing.auto_reload` (enabled/threshold/top_up) → resolve ok/false. */
applyAutoReload: (enabled: boolean, threshold?: number, topUp?: number) => Promise<boolean>
/** Submit `billing.charge` for `amount` and poll to settlement (non-blocking). */
charge: (amount: string) => void
/**
* Submit `billing.charge` for `amount` and poll to settlement. Resolves a
* discriminated outcome so the overlay can route to the resumable step-up on
* `needs_remote_spending` instead of tearing down. Settlement/most errors are
* still reported via transcript lines (the poll is non-blocking).
*/
charge: (amount: string, idempotencyKey?: string) => Promise<BillingChargeOutcome>
/**
* Run the `billing.step_up` device flow (grant Remote Spending). Resolves
* `true` when the grant lands. The browser opens via the gateway's
* out-of-band `billing.step_up.verification` event the overlay just awaits.
*/
requestRemoteSpending: () => Promise<boolean>
/** Open the portal in the browser + echo a transcript line. */
openPortal: (url: string) => void
/**
* Re-fetch billing state (`billing.state`) used by the add-card path's
* "I've added it — check again" so a card saved on the portal appears without
* re-running /topup. Resolves null on failure (caller keeps the old state).
*/
refreshState: () => Promise<BillingStateResponse | null>
/** Emit a transcript system line. */
sys: (text: string) => void
/** Validate a custom amount against state bounds + 2dp (mirrors the server). */
@ -117,6 +140,12 @@ export interface BillingOverlayCtx {
/** Pending confirm built when leaving the buy/autoreload screen. */
export interface BillingPendingCharge {
amount: string
/**
* Stable idempotency key for THIS purchase, minted when the amount is chosen.
* Reused across the step-up replay so a re-charge after the grant dedups
* server-side (and a double-submit collapses to one charge).
*/
idempotencyKey?: string
}
export interface BillingOverlayState {
@ -127,6 +156,106 @@ export interface BillingOverlayState {
state: BillingStateResponse
}
// ── Subscription overlay (in-terminal plan change, V3) ──
// A small state machine: overview → picker → confirm → result, with a stepup
// screen spliced in on demand.
// overview — plan + status, entry to the picker / resume / manage-on-portal.
// picker — the tier catalog (up/down direction hints; current tier shown,
// not selectable).
// confirm — the previewed effect of the chosen change (charge $X now /
// scheduled at date / no-op / blocked) + the apply action.
// result — the outcome, including an SCA/decline upgrade handed off to the
// portal.
// stepup — reached when a mutation returns insufficient_scope: grants the
// terminal-billing scope in place, then auto-replays the held action.
export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' | 'stepup'
// The action held while the stepup screen grants terminal billing, replayed on
// grant: re-preview a tier, re-apply the confirmed pending change, or re-resume.
export type SubscriptionStepUpRetry =
| { kind: 'apply' }
| { kind: 'preview'; tierId: string }
| { kind: 'resume' }
/** Outcome of a terminal-billing step-up: granted, plus the typed denial (for copy). */
export interface StepUpResult {
granted: boolean
error?: string
message?: string
}
export interface SubscriptionOverlayCtx {
/**
* Best-effort card lookup (`billing.state`) for the upgrade confirm shows
* WHICH card the upgrade will charge. Resolves null on any failure or when
* the server doesn't say (older NAS): the confirm keeps its generic line.
*/
fetchCard: () => Promise<BillingCardInfo | null>
/** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */
openManageLink: () => Promise<boolean>
/** Open an arbitrary portal recovery URL (e.g. an upgrade's SCA handoff). */
openPortal: (url: string) => void
/** Re-fetch subscription.state. */
refreshState: () => Promise<SubscriptionStateResponse | null>
/** POST /preview a change to `tierId` → the chargeless effect quote (or typed error). */
preview: (tierId: string) => Promise<SubscriptionPreviewResponse | null>
/** PUT pending-change: schedule a downgrade / same-price change to `tierId`. */
scheduleChange: (tierId: string) => Promise<BillingMutationResponse | null>
/** PUT pending-change: schedule a cancellation at period end. */
scheduleCancellation: () => Promise<BillingMutationResponse | null>
/** DELETE pending-change: clear a scheduled downgrade / cancellation (resume). */
resume: () => Promise<BillingMutationResponse | null>
/** POST /upgrade: charge the card on the subscription + flip the plan now. */
upgrade: (tierId: string, idempotencyKey?: string) => Promise<SubscriptionUpgradeResponse | null>
/**
* Run the `billing.step_up` device flow (grant terminal billing / "Remote
* Spending"). Resolves `{granted}` plus the typed denial (`error`/`message`) so
* the stepup screen shows the right recovery. The browser opens via the
* gateway's out-of-band verification event the stepup screen just awaits.
*/
requestRemoteSpending: () => Promise<StepUpResult>
/** Emit a transcript system line. */
sys: (text: string) => void
}
/** What the confirm screen is about to apply, plus its preview quote. */
export interface SubscriptionPendingChange {
/** The target tier (null for a cancellation). */
targetTierId: string | null
/** How it will be applied — drives which ctx call confirm makes. */
kind: 'cancellation' | 'tier_change' | 'upgrade'
/** The preview quote shown on confirm (null = the quote call failed). */
preview?: null | SubscriptionPreviewResponse
/**
* Stable idempotency key for an upgrade charge, minted when confirm opens.
* Reused on retry so a re-submit dedups server-side.
*/
idempotencyKey?: string
}
/** The outcome rendered on the result screen. */
export interface SubscriptionResult {
message: string
ok: boolean
/** Set on a successful upgrade; drives the ResultScreen apply-poll. */
pendingTierId?: null | string
/** A portal URL to finish an SCA/declined upgrade, when present. */
recoveryUrl?: null | string
}
export interface SubscriptionOverlayState {
ctx: SubscriptionOverlayCtx
/** Set on the 'confirm' screen: the change being confirmed + its preview. */
pending?: null | SubscriptionPendingChange
/** Set on the 'result' screen: the outcome to render. */
result?: null | SubscriptionResult
screen: SubscriptionScreen
state: SubscriptionStateResponse
/** Held while on the 'stepup' screen: the action to replay once the grant lands. */
stepUpRetry?: null | SubscriptionStepUpRetry
}
export interface OverlayState {
agents: boolean
agentsInitialHistoryIndex: number
@ -142,6 +271,7 @@ export interface OverlayState {
secret: null | SecretReq
sessions: boolean
skillsHub: boolean
subscription: SubscriptionOverlayState | null
sudo: null | SudoReq
}

View file

@ -17,6 +17,7 @@ const buildOverlayState = (): OverlayState => ({
secret: null,
sessions: false,
skillsHub: false,
subscription: null,
sudo: null
})
@ -38,23 +39,25 @@ export const $isBlocked = computed(
secret,
sessions,
skillsHub,
subscription,
sudo
}) =>
Boolean(
agents ||
approval ||
billing ||
clarify ||
confirm ||
journey ||
modelPicker ||
pager ||
petPicker ||
pluginsHub ||
secret ||
sessions ||
skillsHub ||
sudo
approval ||
billing ||
clarify ||
confirm ||
journey ||
modelPicker ||
pager ||
petPicker ||
pluginsHub ||
secret ||
sessions ||
skillsHub ||
subscription ||
sudo
)
)

View file

@ -1,58 +0,0 @@
import type { CreditsViewResponse } from '../../../gatewayTypes.js'
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand } from '../types.js'
export const creditsCommands: SlashCommand[] = [
{
help: 'Show Nous credit balance and top up',
name: 'credits',
run: (_arg, ctx) => {
ctx.gateway
.rpc<CreditsViewResponse>('credits.view', { session_id: ctx.sid })
.then(
ctx.guarded<CreditsViewResponse>(view => {
if (!view.logged_in) {
ctx.transcript.sys('💳 Not logged into Nous Portal — run /portal to log in.')
return
}
const lines = ['💳 Nous credits', ...view.balance_lines]
if (view.identity_line) {
lines.push('', view.identity_line)
}
if (view.topup_url) {
lines.push('', `Top up: ${view.topup_url}`)
}
ctx.transcript.sys(lines.join('\n'))
const url = view.topup_url
if (url) {
patchOverlayState({
confirm: {
cancelLabel: 'Cancel',
confirmLabel: 'Open top-up in browser',
detail: url,
onConfirm: () => {
const ok = openExternalUrl(url)
ctx.transcript.sys(
ok
? 'Complete your top-up in the browser — credits will appear in /credits shortly.'
: `Open this URL to top up: ${url}`
)
},
title: 'Add credits?'
}
})
}
})
)
.catch(ctx.guardedErr)
}
}
]

View file

@ -1,3 +1,4 @@
import { usageBarsText } from '../../../components/overlayPrimitives.js'
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
import type {
@ -19,6 +20,8 @@ import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
const modelValueForConfigSet = (arg: string) => {
@ -574,26 +577,60 @@ export const sessionCommands: SlashCommand[] = [
return
}
const sys = ctx.transcript.sys
if (r) {
patchUiState({
usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 }
})
}
// Nous credits block is agent-independent (a portal fetch), so it shows
// even with zero API calls or on a resumed session. Render it whenever
// present, before the token panel.
const creditsLines = r?.credits_lines ?? []
// Nous balance block is agent-independent (a portal fetch), so it shows
// even with zero API calls or on a resumed session. Prefer the shared
// dollar usage model (two-bar view, dollars-only); fall back to the
// legacy text lines only when the model is unavailable.
const usageModel = r?.usage
const barLines = usageBarsText(usageModel)
let showedBalance = false
if (creditsLines.length) {
ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }])
if (usageModel?.available && (barLines.length || usageModel.status === 'free')) {
const sections: PanelSection[] = []
const plan = usageModel.plan_name ?? (usageModel.status === 'free' ? 'Free' : null)
if (plan) {
sections.push({ text: `Plan: ${plan}${usageModel.renews_display ? ` · renews ${usageModel.renews_display}` : ''}` })
}
if (barLines.length) {
sections.push({ text: barLines.join('\n') })
}
if (usageModel.status === 'free') {
sections.push({ text: '> Free · free models only. Run /subscription to reach paid models.' })
} else if (usageModel.status === 'low') {
sections.push({
text: `! Low balance · ${usageModel.total_spendable_display ?? 'under $5'} left. Run /topup or /subscription.`
})
}
ctx.transcript.panel('Balance', sections)
showedBalance = true
} else {
const creditsLines = r?.credits_lines ?? []
if (creditsLines.length) {
ctx.transcript.panel('Nous balance', [{ text: creditsLines.join('\n') }])
showedBalance = true
}
}
if (!r?.calls) {
if (!creditsLines.length) {
ctx.transcript.sys('no API calls yet')
if (!showedBalance) {
sys('no API calls yet')
}
sys(USAGE_CTA)
return
}
@ -618,6 +655,8 @@ export const sessionCommands: SlashCommand[] = [
}
ctx.transcript.panel('Usage', sections)
sys(USAGE_CTA)
})
}
}

View file

@ -0,0 +1,169 @@
import type {
BillingMutationResponse,
BillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionUpgradeResponse
} from '../../../gatewayTypes.js'
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
import type { SubscriptionOverlayCtx } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
type Sys = (text: string) => void
/**
* Build the manage-subscription URL locally from the loaded subscription state.
*
* Uses `portal_url` (the resolved portal base URL carried in the state) and
* `org_id` to construct `{portal_base}/manage-subscription?org_id=<id>`.
* `org_id` pins the page to the correct account in multi-org situations.
* Falls back to bare `/manage-subscription` if org_id is absent.
*/
function buildManageUrl(s: SubscriptionStateResponse): string | null {
// portal_url is already an absolute URL resolved by resolve_portal_base_url()
// on the Python side (e.g. https://portal.nousresearch.com/billing). Strip any
// path so we can attach /manage-subscription cleanly.
let base: string | null = null
if (s.portal_url) {
try {
base = new URL(s.portal_url).origin
} catch {
// A malformed portal_url must not throw out of the Ink key handler
// (it would crash the overlay) — treat it as "no manage URL".
return null
}
}
if (!base) {
return null
}
const url = new URL('/manage-subscription', base)
if (s.org_id) {
url.searchParams.set('org_id', s.org_id)
}
return url.toString()
}
/**
* Build the ctx the overlay uses to talk to the gateway + emit transcript
* lines. Mirrors topup.ts's buildOverlayCtx all RPC + error-mapping logic
* lives here (single source of truth); the overlay only renders + routes keys.
*/
const buildSubscriptionCtx = (
ctx: SlashRunCtx,
sys: Sys,
initialState: SubscriptionStateResponse
): SubscriptionOverlayCtx => ({
fetchCard: () =>
ctx.gateway
.rpc<BillingStateResponse>('billing.state', {})
.then(r => (r?.ok ? (r.card ?? null) : null))
.catch(() => null),
openManageLink: () => {
const url = buildManageUrl(initialState)
if (!url) {
sys('Could not build manage URL — is your portal configured?')
return Promise.resolve(false)
}
const opened = openExternalUrl(url)
if (opened) {
sys('Opening your subscription page in the browser — finish there, then re-run /subscription.')
} else {
sys('Could not open browser — visit your subscription page manually at ' + url)
}
return Promise.resolve(opened)
},
openPortal: (url: string) => {
if (openExternalUrl(url)) {
sys('Opening the portal in your browser — finish there, then re-run /subscription.')
} else {
sys('Could not open browser — visit ' + url + ' to finish.')
}
},
preview: tierId =>
ctx.gateway
.rpc<SubscriptionPreviewResponse>('subscription.preview', { subscription_type_id: tierId })
.then(r => r ?? null)
.catch(() => null),
refreshState: () =>
ctx.gateway
.rpc<SubscriptionStateResponse>('subscription.state', {})
.then(r => r ?? null)
.catch(() => null),
requestRemoteSpending: () =>
ctx.gateway
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
// Carry the typed denial (session_revoked / remote_spending_revoked /
// rate_limited / …) so the stepup screen shows the right recovery.
.then(r => ({ error: r?.error, granted: !!(r && r.ok && r.granted), message: r?.message }))
.catch(() => ({ granted: false, message: 'Could not reach the billing service — check your connection, then retry.' })),
resume: () =>
ctx.gateway
.rpc<BillingMutationResponse>('subscription.resume', {})
.then(r => r ?? null)
.catch(() => null),
scheduleCancellation: () =>
ctx.gateway
.rpc<BillingMutationResponse>('subscription.change', { cancel: true })
.then(r => r ?? null)
.catch(() => null),
scheduleChange: tierId =>
ctx.gateway
.rpc<BillingMutationResponse>('subscription.change', { subscription_type_id: tierId })
.then(r => r ?? null)
.catch(() => null),
sys,
upgrade: (tierId, idempotencyKey) =>
ctx.gateway
.rpc<SubscriptionUpgradeResponse>('subscription.upgrade', {
subscription_type_id: tierId,
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {})
})
.then(r => r ?? null)
.catch(() => null)
})
export const subscriptionCommands: SlashCommand[] = [
{
help: 'View or change your Nous subscription plan',
name: 'subscription',
aliases: ['upgrade'],
// ZERO sub-commands: bare `/subscription` fetches state and opens the
// overlay's in-terminal change flow (only /upgrade's charge_now confirm
// moves money, via the V3 upgrade route).
run: (_arg, ctx) => {
const sys: Sys = ctx.transcript.sys
ctx.gateway
.rpc<SubscriptionStateResponse>('subscription.state', {})
.then(
ctx.guarded<SubscriptionStateResponse>(s => {
if (!s.logged_in) {
sys('Not logged into Nous Portal — run /portal to log in, then /subscription.')
return
}
patchOverlayState({
subscription: {
ctx: buildSubscriptionCtx(ctx, sys, s),
screen: 'overview',
state: s
}
})
})
)
.catch(ctx.guardedErr)
}
}
]

View file

@ -6,13 +6,14 @@ import type {
BillingStateResponse
} from '../../../gatewayTypes.js'
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
import type { BillingOverlayCtx } from '../../interfaces.js'
import type { BillingChargeOutcome, BillingOverlayCtx } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
// Poll cadence (plan §5, frozen): 2s interval, 5-minute cap.
const POLL_INTERVAL_MS = 2000
const POLL_CAP_MS = 5 * 60 * 1000
const UNCONFIRMED_CHARGE_MESSAGE = '🟡 Your last charges outcome is unconfirmed — check your balance/history before retrying.'
type Sys = (text: string) => void
@ -21,10 +22,13 @@ const renderBillingError = (
sys: Sys,
ctx: SlashRunCtx,
env: {
actor?: string
code?: string
error?: string
message?: string
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
): void => {
@ -32,9 +36,72 @@ const renderBillingError = (
switch (env.error) {
case 'insufficient_scope':
armStepUp(sys, ctx)
// Reached by non-charge mutations (e.g. auto-reload config) that need
// terminal billing enabled. The resumable step-up lives on the buy/charge
// path; point the user there rather than leaking the raw scope name.
sys('This needs terminal billing enabled. Start a top-up to enable it, then retry.')
break
case 'remote_spending_revoked': {
// CF-4: this terminal's spend was revoked. Kill the spend UI NOW (don't
// wait for the token refresh ~15 min away) and tell the user who did it.
patchOverlayState({ billing: null })
const who =
env.actor === 'admin'
? 'An admin turned off terminal billing for this terminal.'
: 'You turned off terminal billing for this terminal.'
sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`)
return
}
case 'session_revoked':
// Stronger than a spend-revoke: the whole session is gone → full re-login.
patchOverlayState({ billing: null })
sys('Your session was logged out. Run /portal to log in again.')
return
case 'cli_billing_disabled':
case 'remote_spending_disabled':
// Account-wide switch is OFF (dual-emitted error/code). An admin must flip
// it on the portal; this is NOT a per-terminal revoke.
sys('Terminal billing is off for this account — an admin must enable it on the portal.')
break
case 'role_required':
sys('Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.')
break
case 'consent_required':
sys('This action needs a one-time card confirmation and consent step on the portal before it can proceed.')
break
case 'org_access_denied':
sys("This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.")
break
case 'upgrade_cap_exceeded':
sys('🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.')
break
case 'auto_top_up_disabled_failures':
sys('Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.')
break
case 'idempotency_conflict':
sys('🔴 That charge key was already used for a different amount. Start a fresh top-up.')
break
case 'no_payment_method':
sys(
@ -42,11 +109,6 @@ const renderBillingError = (
"(one-time credit buys don't save a reusable card)."
)
break
case 'cli_billing_disabled':
sys('🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.')
break
case 'monthly_cap_exceeded': {
// Surface the remaining headroom the server attaches (parity with the CLI).
@ -60,13 +122,23 @@ const renderBillingError = (
break
}
case 'rate_limited': {
case 'rate_limited':
case 'temporarily_unavailable': {
// 429 throttle OR 503 gate-fail-closed: NOT a payment failure, NOT a
// revoke. Back off and tell the user to retry.
const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : ''
sys(`🟡 Too many charges right now${mins}. This isn't a payment failure.`)
break
}
case 'stripe_unavailable': {
const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : ''
sys(`🟡 Stripe is having trouble right now — try again shortly${mins}.`)
break
}
default:
sys(`🔴 ${env.message || env.error || 'Billing request failed.'}`)
}
@ -76,71 +148,46 @@ const renderBillingError = (
}
}
/** 403 insufficient_scope → arm a ConfirmReq that runs the lazy step-up. */
const armStepUp = (sys: Sys, ctx: SlashRunCtx): void => {
sys('💳 Terminal billing needs an extra permission (billing:manage).')
patchOverlayState({
confirm: {
cancelLabel: 'Not now',
confirmLabel: 'Re-authorize',
detail: 'An org admin/owner must tick "Allow terminal billing" in the portal.',
onConfirm: () => {
// session_id lets the gateway route the billing.step_up.verification
// event (the verification link) back to this session — the device flow
// runs headless in the gateway, so the link can't be printed there.
ctx.gateway
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
.then(
ctx.guarded<BillingMutationResponse>(r => {
if (r.ok && r.granted) {
// Step-up only grants the billing:manage TOKEN scope — the ORG
// kill-switch (cli_billing_enabled) is a separate gate. Re-fetch
// /state so we don't over-promise "enabled" when a charge would
// still hit cli_billing_disabled.
sys('✅ Billing permission granted.')
ctx.gateway
.rpc<BillingStateResponse>('billing.state', {})
.then(
ctx.guarded<BillingStateResponse>(s => {
if (s.cli_billing_enabled) {
sys('Run /billing again to continue.')
} else {
sys(
'🟡 Permission granted, but terminal billing is still turned off ' +
'for this org. Enable it in the portal, then run /billing again.'
)
if (s.portal_url) {
sys(`Portal: ${s.portal_url}`)
}
}
})
)
.catch(() => {
sys('Run /billing again to continue.')
})
} else {
sys('🟡 Terminal billing was not granted (an admin must tick the box).')
}
})
)
.catch(() => {
// The device flow can outlive the RPC's 120s timeout while the user
// is still authorizing in the browser. A reject here is NOT a hard
// failure — the grant (if it lands) is persisted gateway-side; tell
// the user to re-run /billing rather than reporting an error.
sys('🟡 Still waiting on approval — finish in the browser, then run /billing again.')
})
},
title: 'Grant terminal billing access?'
}
})
}
/**
* Run the Remote-Spending device flow and resolve whether the grant landed.
*
* The browser opens via the gateway's out-of-band `billing.step_up.verification`
* event (handled globally in createGatewayEventHandler), so this just kicks the
* blocking `billing.step_up` RPC and awaits its result. A reject (the device
* flow can outlive the RPC's timeout while the user is still authorizing) is
* treated as "not yet granted" non-fatal; the grant persists gateway-side.
*
* NOTE: never surface the raw `billing:manage` scope the user-facing concept
* is "Remote Spending".
*/
const requestRemoteSpending = (ctx: SlashRunCtx): Promise<boolean> =>
ctx.gateway
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
.then(r => !!(r && r.ok && r.granted))
.catch(() => false)
/** Poll a charge to a terminal state (settled/failed/timeout). Non-blocking. */
const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: string | null): void => {
const start = Date.now()
// The 5-min cap, honored on EVERY non-terminal path (pending AND throttled)
// so a sustained 429/503 can't keep the poll alive forever.
const timedOut = (): boolean => {
if (Date.now() - start < POLL_CAP_MS) {
return false
}
sys(
'🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + 'Check /topup or the portal shortly.'
)
if (portalUrl) {
sys(`Portal: ${portalUrl}`)
}
return true
}
const tick = (): void => {
if (ctx.stale()) {
return
@ -152,13 +199,27 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st
ctx.guarded<BillingChargeStatusResponse>(r => {
if (!r.ok) {
// 429/503 while polling = retry-after, NOT a failure. Back off + continue.
if (r.error === 'rate_limited') {
if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable' || r.error === 'stripe_unavailable') {
if (timedOut()) {
return
}
const wait = (r.retry_after ?? 5) * 1000
setTimeout(tick, Math.min(wait, 30000))
return
}
// CF-7 rule 4: a post-revoke 403 (or session loss) while polling means
// the prior charge's outcome is AMBIGUOUS — it may have settled. Do not
// call it failed; surface the revoke + tell the user to verify balance.
if (r.error === 'remote_spending_revoked' || r.error === 'session_revoked') {
renderBillingError(sys, ctx, r)
sys(UNCONFIRMED_CHARGE_MESSAGE)
return
}
sys(`🔴 Could not check the charge: ${r.message || r.error || 'error'}`)
return
@ -177,23 +238,20 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st
}
// pending → keep polling until the 5-min cap, then call it a timeout.
if (Date.now() - start >= POLL_CAP_MS) {
sys(
'🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' +
'Check /billing or the portal shortly.'
)
if (portalUrl) {
sys(`Portal: ${portalUrl}`)
}
if (timedOut()) {
return
}
setTimeout(tick, POLL_INTERVAL_MS)
})
)
.catch(ctx.guardedErr)
.catch(e => {
ctx.guardedErr(e)
if (!ctx.stale()) {
sys(UNCONFIRMED_CHARGE_MESSAGE)
}
})
}
tick()
@ -216,6 +274,11 @@ const renderChargeFailed = (sys: Sys, reason?: string | null, portalUrl?: string
break
case 'processing_error':
sys("🔴 The charge didn't go through (processing_error).")
break
default:
sys(`🔴 The charge didn't go through (${reason || 'processing_error'}).`)
}
@ -280,34 +343,60 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B
return false
}),
charge: (amount: string) => {
charge: (amount: string, idempotencyKey?: string): Promise<BillingChargeOutcome> => {
sys('💳 Charge submitted — confirming settlement…')
ctx.gateway
.rpc<BillingChargeResponse>('billing.charge', { amount_usd: amount })
.then(
ctx.guarded<BillingChargeResponse>(r => {
if (r.ok && r.charge_id) {
pollCharge(sys, ctx, r.charge_id, s.portal_url)
} else {
renderBillingError(sys, ctx, r)
}
})
)
.catch(ctx.guardedErr)
return ctx.gateway
.rpc<BillingChargeResponse>('billing.charge', {
amount_usd: amount,
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {})
})
.then((r): BillingChargeOutcome => {
if (!r) {
return 'error'
}
if (r.ok && r.charge_id) {
pollCharge(sys, ctx, r.charge_id, s.portal_url)
return 'submitted'
}
// insufficient_scope → the overlay routes to the resumable step-up
// (no error line here; the stepup screen owns that UX).
if (r.error === 'insufficient_scope') {
return 'needs_remote_spending'
}
renderBillingError(sys, ctx, r)
return 'error'
})
.catch((e): BillingChargeOutcome => {
ctx.guardedErr(e)
return 'error'
})
},
requestRemoteSpending: () => requestRemoteSpending(ctx),
openPortal: (url: string) => {
openExternalUrl(url)
sys(`Opening portal: ${url}`)
},
refreshState: () =>
ctx.gateway
.rpc<BillingStateResponse>('billing.state', {})
.then(r => (r?.ok ? r : null))
.catch(() => null),
sys,
validate: (raw: string) => validateAmount(raw, s)
})
export const billingCommands: SlashCommand[] = [
export const topupCommands: SlashCommand[] = [
{
help: 'Manage Nous terminal billing — buy credits, auto-reload, limits',
name: 'billing',
// ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/billing`
help: 'Show your balance and manage billing — add funds, auto-reload, limits',
name: 'topup',
// ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/topup`
// fetches state and opens the interactive overlay (CLI/TUI parity).
run: (_arg, ctx) => {
const sys: Sys = ctx.transcript.sys
@ -317,7 +406,7 @@ export const billingCommands: SlashCommand[] = [
.then(
ctx.guarded<BillingStateResponse>(s => {
if (!s.logged_in) {
sys('💳 Not logged into Nous Portal — run /portal to log in, then /billing.')
sys('💳 Not logged into Nous Portal — run /portal to log in, then /topup.')
return
}

View file

@ -1,17 +1,17 @@
import { billingCommands } from './commands/billing.js'
import { coreCommands } from './commands/core.js'
import { creditsCommands } from './commands/credits.js'
import { debugCommands } from './commands/debug.js'
import { opsCommands } from './commands/ops.js'
import { sessionCommands } from './commands/session.js'
import { setupCommands } from './commands/setup.js'
import { subscriptionCommands } from './commands/subscription.js'
import { topupCommands } from './commands/topup.js'
import type { SlashCommand } from './types.js'
export const SLASH_COMMANDS: SlashCommand[] = [
...coreCommands,
...billingCommands,
...creditsCommands,
...topupCommands,
...sessionCommands,
...subscriptionCommands,
...opsCommands,
...setupCommands,
...debugCommands

View file

@ -195,6 +195,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ billing: null })
}
if (overlay.subscription) {
return patchOverlayState({ subscription: null })
}
if (overlay.skillsHub) {
return patchOverlayState({ skillsHub: false })
}
@ -324,7 +328,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
// answering felt like the prompt had locked the entire UI. Explicitly
// skip the prompt-overlay early-return for scroll keys so they fall
// through to the wheel / PageUp / Shift+arrow handlers below.
const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm
const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm || overlay.subscription
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
if (promptOverlay && !fallThroughForScroll) {

View file

@ -16,6 +16,7 @@ import { PetPicker } from './petPicker.js'
import { PluginsHub } from './pluginsHub.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SkillsHub } from './skillsHub.js'
import { SubscriptionOverlay } from './subscriptionOverlay.js'
const COMPLETION_WINDOW = 16
@ -52,6 +53,21 @@ export function PromptZone({
)
}
if (overlay.subscription) {
const current = overlay.subscription
const onPatch = (next: Partial<typeof current>) =>
patchOverlayState(prev => (prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev))
const onClose = () => patchOverlayState({ subscription: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<SubscriptionOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
</Box>
)
}
if (overlay.confirm) {
const req = overlay.confirm

View file

@ -1,14 +1,15 @@
import { randomUUID } from 'node:crypto'
import { Box, Text, useInput } from '@hermes/ink'
import { useState } from 'react'
import { useRef, useState } from 'react'
import type { BillingOverlayState } from '../app/interfaces.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
import type { Theme } from '../theme.js'
import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
const SPEND_BAR_CELLS = 10
interface BillingOverlayProps {
/** Replace the overlay slot (screen transitions + pending data). */
onPatch: (next: Partial<BillingOverlayState>) => void
@ -18,54 +19,6 @@ interface BillingOverlayProps {
t: Theme
}
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */
function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
return (
<Text>
<Text bold={active} color={active ? t.color.label : t.color.muted} inverse={active}>
{active ? '▸ ' : ' '}
{index}. {label}
</Text>
</Text>
)
}
/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */
function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) {
return (
<Text>
<Text color={active ? t.color.accent : t.color.muted}>{active ? '▸ ' : ' '}</Text>
<Text bold={active} color={active ? (color ?? t.color.text) : t.color.muted}>
{label}
</Text>
</Text>
)
}
/** 10-cell spend bar + percent (omit entirely when there's no usable cap). */
function spendBar(s: BillingStateResponse): null | string {
const cap = s.monthly_cap
if (!cap || cap.limit_usd == null) {
return null
}
const limit = Number(cap.limit_usd)
const spent = Number(cap.spent_this_month_usd ?? '0')
if (!(limit > 0) || Number.isNaN(spent)) {
return null
}
const ratio = Math.max(0, Math.min(1, spent / limit))
const filled = Math.round(ratio * SPEND_BAR_CELLS)
const bar = '█'.repeat(filled) + '░'.repeat(SPEND_BAR_CELLS - filled)
const pct = Math.round(ratio * 100)
const ceiling = cap.is_default_ceiling ? ' (default ceiling)' : ''
return `${cap.spent_display} of ${cap.limit_display} used ${bar} ${pct}%${ceiling}`
}
function autoReloadLine(s: BillingStateResponse): null | string {
if (!s.auto_reload) {
return null
@ -76,8 +29,6 @@ function autoReloadLine(s: BillingStateResponse): null | string {
: 'Auto-reload: off'
}
const footer = (extra: string, t: Theme) => <Text color={t.color.muted}>{extra}</Text>
/**
* The /billing modal. A self-contained state machine:
* overview buy | autoreload | limit (and buy confirm).
@ -96,14 +47,25 @@ export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayP
<ConfirmScreen
amount={overlay.pendingCharge?.amount ?? ''}
ctx={ctx}
idempotencyKey={overlay.pendingCharge?.idempotencyKey}
onBack={() => onPatch({ pendingCharge: null, screen: 'buy' })}
onClose={onClose}
onPatch={onPatch}
s={s}
t={t}
/>
)}
{screen === 'autoreload' && <AutoReloadScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'limit' && <LimitScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'stepup' && (
<StepUpScreen
amount={overlay.pendingCharge?.amount ?? ''}
ctx={ctx}
idempotencyKey={overlay.pendingCharge?.idempotencyKey}
onClose={onClose}
t={t}
/>
)}
</Box>
)
}
@ -119,27 +81,29 @@ interface ScreenProps {
}
function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
// Gate: full menu only for an admin with the kill-switch on. Otherwise the
// menu collapses to Manage-on-portal / Cancel + a one-line note.
// Full charge menu only for an admin with the org kill-switch on; otherwise it
// collapses to Manage-on-portal / Close + a one-line note. NOTE: this is the
// ORG-level gate (cli_billing_enabled), NOT the per-terminal billing scope —
// that's discovered reactively at pay time (a charge 403s insufficient_scope
// and the confirm screen routes into the resumable step-up). We deliberately
// do NOT preflight the scope here.
const full = s.is_admin && s.cli_billing_enabled
const note = !s.is_admin
? 'Billing actions need an org admin/owner.'
? 'Billing actions need someone with billing permissions (owner, admin, or finance admin).'
: !s.cli_billing_enabled
? 'Terminal billing is off for this org — enable it on the portal.'
? 'Terminal billing is off for this org — manage it on the portal.'
: null
// Optimistic funnel: admin + kill-switch on but no saved card → a charge will
// 403 no_payment_method. Advise up front (Buy stays available — /state.card
// can't fully prove CLI-chargeability, so we hint rather than hide).
const cardHint = full && !s.card ? 'No saved card for terminal charges yet — set one up on the portal first.' : null
// Always show the full billing menu for an admin/billing-on org — a missing
// card does NOT mean nothing can be done (the org may already have balance,
// auto-reload, a limit). The card only matters at CHARGE time: with no card
// on file, "Add funds" opens the guided add-card path (portal + check-again)
// instead of an amount picker that would 403 no_payment_method.
const items = full
? ['Buy credits', 'Adjust auto-reload', 'Adjust monthly limit', 'Manage on portal', 'Cancel']
? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel']
: ['Manage on portal', 'Cancel']
const [sel, setSel] = useState(0)
const choose = (i: number) => {
if (full) {
if (i === 0) {
@ -148,76 +112,59 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
onPatch({ screen: 'autoreload' })
} else if (i === 2) {
onPatch({ screen: 'limit' })
} else if (i === 3) {
if (s.portal_url) {
} else {
if (i === 3 && s.portal_url) {
ctx.openPortal(s.portal_url)
}
onClose()
} else {
onClose()
}
} else {
if (i === 0 && s.portal_url) {
ctx.openPortal(s.portal_url)
}
onClose()
return
}
if (i === 0 && s.portal_url) {
ctx.openPortal(s.portal_url)
}
onClose()
}
useInput((ch, key) => {
if (key.escape) {
return onClose()
}
const rows: MenuRowSpec[] = items.map((label, i) => ({ label, run: () => choose(i) }))
const sel = useMenu(rows, onClose)
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
}
if (key.downArrow && sel < items.length - 1) {
setSel(v => v + 1)
}
if (key.return) {
return choose(sel)
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= items.length) {
return choose(n - 1)
}
})
const bar = spendBar(s)
const auto = autoReloadLine(s)
// Balance leads, in the title — the first thing seen (review feedback).
const title = `Top up · balance ${s.balance_display}`
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Usage credits
{title}
</Text>
{bar && <Text color={t.color.text}>{bar}</Text>}
<Text color={t.color.text}>Balance: {s.balance_display}</Text>
{auto && <Text color={t.color.muted}>{auto}</Text>}
{s.org_name && (
<Text color={t.color.muted}>
Org: {s.org_name}
{s.role ? ` · ${s.role}` : ''}
</Text>
)}
{/* The shared two-bar dollar usage (plan + top-up), same as /usage and
/subscription. Renders nothing when no usage model is available. */}
<UsageBars model={s.usage} t={t} />
{auto && <Text color={t.color.muted}>{auto}</Text>}
{/* Card presence at a glance: which card a charge would use (with why
"the card on your subscription"), or that none is saved. Only for the
full menu members/billing-off get the portal note instead. */}
{full && (
<Text color={t.color.muted}>
{s.card ? `Card: ${s.card.display ?? s.card.masked}` : 'No saved card on file — “Add funds” walks you through adding one.'}
</Text>
)}
{note && (
<Box marginTop={1}>
<Text color={t.color.warn}>{note}</Text>
</Box>
)}
{cardHint && (
<Box marginTop={1}>
<Text color={t.color.warn}>{cardHint}</Text>
</Box>
)}
{cardHint && s.portal_url && <Text color={t.color.muted}>Portal: {s.portal_url}</Text>}
<Text />
{items.map((label, i) => (
@ -235,17 +182,60 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
const presets = s.charge_presets_display
const rawPresets = s.charge_presets
// rows: [...presets, 'Custom amount…', 'Cancel']
const rows = [...presets, 'Custom amount…', 'Cancel']
// No card on file → the buy screen becomes the ADD-CARD path: cards are added
// on the portal (never in-terminal), and "check again" re-fetches state so the
// flow continues right here once the card is saved. Card present → the normal
// preset menu. (The card display is best-effort server-side, so "check again"
// also recovers a transient miss.)
const noCard = !s.card
const rows = noCard
? ['Add a card on the portal', 'Ive added it — check again', 'Back']
: [...presets, 'Custom amount…', 'Cancel']
const customIdx = presets.length
const [sel, setSel] = useState(0)
const [typing, setTyping] = useState(false)
const [custom, setCustom] = useState('')
const [error, setError] = useState<null | string>(null)
const [checking, setChecking] = useState(false)
// Synchronous guard: double-Enter on "check again" must not stack re-fetches.
const checkingRef = useRef(false)
const recheck = () => {
if (checkingRef.current) {
return
}
checkingRef.current = true
setChecking(true)
void ctx.refreshState().then(fresh => {
checkingRef.current = false
setChecking(false)
if (!fresh) {
return setError('Could not refresh billing state — try again in a moment.')
}
setError(null)
// Re-render with the fresh state: if the card is now on file, this same
// screen flips into the preset menu and the purchase continues here.
onPatch({ state: fresh })
if (fresh.card) {
ctx.sys(`✓ Card found: ${fresh.card.display ?? fresh.card.masked} — pick an amount.`)
} else {
ctx.sys('Still no card on file — finish adding it on the portal, then check again.')
}
})
}
const toConfirm = (amount: string) => {
onPatch({ pendingCharge: { amount }, screen: 'confirm' })
// Mint the idempotency key here (purchase identity = this amount). It rides
// pendingCharge into Confirm AND the step-up replay, so a retried charge
// dedups server-side; a fresh amount selection gets a fresh key.
onPatch({ pendingCharge: { amount, idempotencyKey: randomUUID() }, screen: 'confirm' })
}
const pickPreset = (i: number) => {
@ -275,6 +265,25 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
}
const choose = (i: number) => {
if (noCard) {
if (i === 0) {
if (s.portal_url) {
ctx.openPortal(s.portal_url)
ctx.sys('Add a card on the billing page, then come back and pick “check again”.')
} else {
setError('Could not build the portal link — is your portal configured?')
}
return
}
if (i === 1) {
return recheck()
}
return onPatch({ screen: 'overview' })
}
if (i < presets.length) {
pickPreset(i)
} else if (i === customIdx) {
@ -303,7 +312,7 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
}
if (key.return) {
return choose(sel)
return choose(Math.min(sel, rows.length - 1))
}
const n = parseInt(ch, 10)
@ -313,13 +322,16 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
}
})
const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file'
// sel can go stale when a refresh flips the row set (3 add-card rows ↔ N
// preset rows) — clamp for render + Enter.
const cSel = Math.min(sel, rows.length - 1)
const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file'
if (typing) {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Buy usage credits
Add funds
</Text>
<Text color={t.color.muted}>{payLine}</Text>
<Text />
@ -335,15 +347,34 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
)
}
if (noCard) {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Add funds
</Text>
<Text color={t.color.text}>No saved card on file.</Text>
<Text color={t.color.muted}>Add a card once on the portal billing page after that you can top up right from the terminal.</Text>
<Text />
{rows.map((label, i) => (
<MenuRow active={cSel === i} index={i + 1} key={label} label={label} t={t} />
))}
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
{footer(checking ? 'Checking for a card…' : `↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`, t)}
</Box>
)
}
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Buy usage credits
Add funds
</Text>
<Text color={t.color.muted}>{payLine}</Text>
<Text />
{rows.map((label, i) => (
<MenuRow active={sel === i} index={i + 1} key={label} label={label} t={t} />
<MenuRow active={cSel === i} index={i + 1} key={label} label={label} t={t} />
))}
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
@ -357,25 +388,50 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
function ConfirmScreen({
amount,
ctx,
idempotencyKey,
onBack,
onClose,
onPatch,
s,
t
}: {
amount: string
ctx: BillingOverlayState['ctx']
idempotencyKey?: string
onBack: () => void
onClose: () => void
onPatch: (next: Partial<BillingOverlayState>) => void
s: BillingStateResponse
t: Theme
}) {
// rows: Pay $X now / Cancel
const [sel, setSel] = useState(0)
const [submitting, setSubmitting] = useState(false)
// Synchronous guard: two key events can both observe `submitting === false`
// before React commits the state update, double-firing the charge (and the
// gateway mints a fresh idempotency key per call → two charges).
const submittingRef = useRef(false)
const pay = () => {
ctx.charge(amount)
// Settlement is reported via transcript lines; close the overlay now.
onClose()
if (submittingRef.current || submitting) {
return
}
submittingRef.current = true
setSubmitting(true)
void ctx.charge(amount, idempotencyKey).then(outcome => {
if (outcome === 'needs_remote_spending') {
// Resumable step-up: keep the modal MOUNTED, switch to the stepup
// screen (which holds pendingCharge.amount for the post-grant replay).
onPatch({ screen: 'stepup' })
return
}
// submitted (settlement reported via transcript) or error (already
// surfaced) → close the overlay. The transcript carries the outcome.
onClose()
})
}
const back = () => onBack()
@ -408,7 +464,7 @@ function ConfirmScreen({
}
})
const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file'
const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file'
return (
<Box flexDirection="column">
@ -417,6 +473,9 @@ function ConfirmScreen({
</Text>
<Text color={t.color.text}>Total: ${amount}</Text>
<Text color={t.color.muted}>{payLine}</Text>
{/* Provenance-less payloads (older NAS) keep the generic line; when the
resolver says WHY this card, payLine already carries it. */}
{s.card && !s.card.resolved_via && <Text color={t.color.muted}>Your card saved on the portal will be charged.</Text>}
<Text color={t.color.muted}>By confirming, you allow Nous Research to charge your card.</Text>
<Text />
<ActionRow active={sel === 0} color={t.color.ok} label={`Pay $${amount} now`} t={t} />
@ -427,11 +486,201 @@ function ConfirmScreen({
)
}
// ── Screen: Step-up (resumable "Enable terminal billing") ────────────
// Reached ONLY when a charge returns insufficient_scope — there is no preflight
// or scope check anywhere; the buy path discovers it reactively. The modal stays
// MOUNTED through the browser device-flow:
// prompt (heads-up) → waiting (browser authorize) → granted (press Enter to
// resume) → replay the held charge (pendingCharge.amount) → settle → close.
// Never leaks the raw billing:manage scope — the user-facing concept is
// "terminal billing".
function StepUpScreen({
amount,
ctx,
idempotencyKey,
onClose,
t
}: {
amount: string
ctx: BillingOverlayState['ctx']
idempotencyKey?: string
onClose: () => void
t: Theme
}) {
const [sel, setSel] = useState(0)
const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt')
const allow = () => {
if (phase !== 'prompt') {
return
}
setPhase('waiting')
ctx.sys('Opening your browser to enable terminal billing…')
void ctx.requestRemoteSpending().then(granted => {
if (!granted) {
ctx.sys(
"! Couldn't enable terminal billing — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged."
)
onClose()
return
}
// Granted → hold here and wait for an explicit Enter to resume the held
// purchase (the reassuring "you're back, press Enter" beat).
setPhase('granted')
})
}
const resume = () => {
if (phase !== 'granted') {
return
}
setPhase('resuming')
ctx.sys('✓ Terminal billing enabled — resuming your purchase.')
void ctx.charge(amount, idempotencyKey).then(outcome => {
// If the replay STILL can't spend (grant raced/expired or downscoped),
// say so — don't close on a reassuring line with no charge made.
if (outcome === 'needs_remote_spending') {
ctx.sys('! Terminal billing still needs approval — run /topup to try again. Your card was not charged.')
}
onClose()
})
}
const decline = () => {
ctx.sys('No charge made. Run /topup when you want to enable terminal billing.')
onClose()
}
useInput((ch, key) => {
if (phase === 'waiting' || phase === 'resuming') {
// While the device flow / replay runs, only Esc (give up) is live.
if (key.escape) {
onClose()
}
return
}
if (phase === 'granted') {
// Back from the browser — Enter resumes, Esc abandons.
if (key.escape) {
return onClose()
}
if (key.return) {
return resume()
}
return
}
// phase === 'prompt'
if (key.escape) {
return decline()
}
const lower = ch.toLowerCase()
if (lower === 'y') {
return allow()
}
if (lower === 'n') {
return decline()
}
if (key.upArrow) {
setSel(0)
}
if (key.downArrow) {
setSel(1)
}
if (key.return) {
return sel === 0 ? allow() : decline()
}
})
if (phase === 'waiting') {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Enable terminal billing
</Text>
<Text color={t.color.warn}>Waiting for your browser</Text>
<Text color={t.color.muted}>Approve in the page that just opened.</Text>
<Text color={t.color.muted}>Your ${amount} top-up is held here and resumes when you&apos;re done.</Text>
<Text />
{footer('Esc cancel', t)}
</Box>
)
}
if (phase === 'granted') {
return (
<Box flexDirection="column">
<Text bold color={t.color.ok}>
Terminal billing enabled
</Text>
<Text color={t.color.text}>Your ${amount} top-up is ready to finish.</Text>
<Text />
<ActionRow active color={t.color.ok} label="Press Enter to resume" t={t} />
<Text />
{footer('Enter resume · Esc cancel', t)}
</Box>
)
}
if (phase === 'resuming') {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Enable terminal billing
</Text>
<Text color={t.color.muted}>Resuming your ${amount} top-up</Text>
<Text />
{footer('Esc cancel', t)}
</Box>
)
}
// phase === 'prompt' — the one heads-up, triggered only by the 403.
return (
<Box flexDirection="column">
<Text bold color={t.color.warn}>
One-time setup
</Text>
<Text color={t.color.text}>To charge this terminal, enable terminal billing once.</Text>
<Text color={t.color.muted}>
It opens your browser to authorize, then your ${amount} top-up picks up right here.
</Text>
<Text />
<ActionRow active={sel === 0} color={t.color.ok} label="Enable terminal billing" t={t} />
<ActionRow active={sel === 1} label="Not now" t={t} />
<Text />
{footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)}
</Box>
)
}
// ── Screen 4: Auto-reload (the 2-field form) ──────────────────────────
function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const ar = s.auto_reload
const enabled = Boolean(ar?.enabled)
const distinctCard = ar?.card.kind === 'distinct' ? ar.card : null
const distinctCardName = distinctCard
? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') || 'a different card'
: null
const manageCardLabel = 'Use your card on file — manage on portal'
// Prefill from state (strip the $ from the *_usd raw fields if present).
const prefill = (raw?: null | string) => (raw == null ? '' : String(raw).replace(/^\$/, '').trim())
@ -440,7 +689,15 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const [field, setField] = useState<'reloadTo' | 'threshold'>('threshold')
const [error, setError] = useState<null | string>(null)
// focusRow: 0=threshold field, 1=reloadTo field, 2=Agree, 3=Turn off (if enabled), last=Cancel
const actionRows = enabled ? ['Agree and turn on', 'Turn off', 'Cancel'] : ['Agree and turn on', 'Cancel']
const manageCardRows = distinctCard && s.portal_url ? [manageCardLabel] : []
const actionRows = enabled
? ['Agree and turn on', 'Turn off', ...manageCardRows, 'Cancel']
: ['Agree and turn on', ...manageCardRows, 'Cancel']
const actionColors: Record<string, string> = {
'Agree and turn on': t.color.ok,
'Turn off': t.color.warn,
[manageCardLabel]: t.color.accent
}
const FIELD_ROWS = 2
const [row, setRow] = useState(0)
@ -476,7 +733,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const turnOn = () => {
if (noCard) {
ctx.sys('🔴 No saved card — set one up on the portal first.')
ctx.sys('🔴 No saved card — manage billing on the portal.')
if (s.portal_url) {
ctx.openPortal(s.portal_url)
@ -502,7 +759,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
}
const turnOff = () => {
void ctx.applyAutoReload(false).then(ok => {
// The PATCH requires threshold/top_up_amount even when disabling (parity
// with the CLI's _billing_auto_reload_disable) — echo the current values,
// else the gateway rejects with invalid_request and auto-reload stays ON.
const thr = Number(prefill(ar?.threshold_usd)) || 0
const rel = Number(prefill(ar?.reload_to_usd)) || 0
void ctx.applyAutoReload(false, thr, rel).then(ok => {
if (ok) {
ctx.sys('✅ Auto-reload turned off.')
}
@ -515,6 +777,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
turnOn()
} else if (label === 'Turn off') {
turnOff()
} else if (label === manageCardLabel) {
if (s.portal_url) {
ctx.openPortal(s.portal_url)
}
onClose()
} else {
onPatch({ screen: 'overview' })
}
@ -561,6 +829,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
})
const cardLine = s.card ? `Card on file: ${s.card.masked}` : 'No saved card on file'
const chargeCardName = distinctCardName ?? (s.card ? s.card.masked : 'your card')
const fieldBox = (label: string, value: string, onChange: (v: string) => void, focused: boolean, key: string) => (
<Box flexDirection="column" key={key}>
@ -592,22 +861,25 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
<Text bold color={t.color.accent}>
Auto-reload
</Text>
<Text color={t.color.muted}>Automatically buy more credits when your balance is low.</Text>
<Text color={t.color.muted}>Automatically add funds when your balance is low.</Text>
<Text color={t.color.muted}>{cardLine}</Text>
{distinctCardName && (
<Text color={t.color.warn}> Auto-refill is charging {distinctCardName} not your card on file.</Text>
)}
<Text />
{fieldBox('When balance falls below:', threshold, setThreshold, row === 0, 'threshold')}
{fieldBox('Reload balance to:', reloadTo, setReloadTo, row === 1, 'reloadTo')}
<Text />
<Text color={t.color.muted}>
By confirming, you authorize Nous Research to charge {s.card ? s.card.masked : 'your card'} whenever your
balance falls below the threshold. Turn off any time here or on the portal.
By confirming, you authorize Nous Research to charge {chargeCardName} whenever your balance falls below the
threshold. Turn off any time here or on the portal.
</Text>
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
{actionRows.map((label, i) => (
<ActionRow
active={!editingField && row - FIELD_ROWS === i}
color={label === 'Turn off' ? t.color.warn : label === 'Agree and turn on' ? t.color.ok : t.color.text}
color={actionColors[label] ?? t.color.text}
key={label}
label={label}
t={t}
@ -622,8 +894,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
// ── Screen 5: Monthly spend limit (read-only) ─────────────────────────
function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
const rows = ['Manage on portal', 'Cancel']
const [sel, setSel] = useState(0)
const labels = ['Manage on portal', 'Cancel']
const choose = (i: number) => {
if (i === 0 && s.portal_url) {
@ -635,29 +906,8 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
onPatch({ screen: 'overview' })
}
useInput((ch, key) => {
if (key.escape) {
return onPatch({ screen: 'overview' })
}
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
}
if (key.downArrow && sel < rows.length - 1) {
setSel(v => v + 1)
}
if (key.return) {
return choose(sel)
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= rows.length) {
return choose(n - 1)
}
})
const rows: MenuRowSpec[] = labels.map((label, i) => ({ label, run: () => choose(i) }))
const sel = useMenu(rows, () => onPatch({ screen: 'overview' }))
const cap = s.monthly_cap
@ -674,11 +924,11 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
<Text color={t.color.text}>{usageLine}</Text>
<Text color={t.color.muted}>The monthly limit is set on the portal shown here read-only.</Text>
<Text />
{rows.map((label, i) => (
{labels.map((label, i) => (
<MenuRow active={sel === i} index={i + 1} key={label} label={label} t={t} />
))}
<Text />
{footer(`↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`, t)}
{footer(`↑/↓ select · 1-${labels.length} quick pick · Enter confirm · Esc back`, t)}
</Box>
)
}

View file

@ -0,0 +1,186 @@
import type { Key } from '@hermes/ink'
import { Text, useInput } from '@hermes/ink'
import { type ReactNode, useState } from 'react'
import type { UsageModelData } from '../gatewayTypes.js'
import type { Theme } from '../theme.js'
export interface MenuRowSpec {
color?: string
label: string
run: () => void
}
/**
* / + Enter + number-key selection over `rows`; Esc runs `onEscape`.
* `onKey`, when given, runs first on every keypress return `true` to mark
* the key fully handled and skip the default escape/arrow/enter/number
* handling for that keypress (e.g. a screen with a text-input sub-mode).
*/
export function useMenu(
rows: MenuRowSpec[],
onEscape: () => void,
onKey?: (ch: string, key: Key) => boolean
): number {
const [sel, setSel] = useState(0)
useInput((ch, key) => {
if (onKey?.(ch, key)) {
return
}
if (key.escape) {
return onEscape()
}
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
}
if (key.downArrow && sel < rows.length - 1) {
setSel(v => v + 1)
}
if (key.return) {
return rows[sel]?.run()
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= rows.length) {
return rows[n - 1]?.run()
}
})
return Math.min(sel, Math.max(0, rows.length - 1))
}
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */
export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
return (
<Text>
<Text bold={active} color={active ? t.color.label : t.color.muted} inverse={active}>
{active ? '▸ ' : ' '}
{index}. {label}
</Text>
</Text>
)
}
/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */
export function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) {
return (
<Text>
<Text color={active ? t.color.accent : t.color.muted}>{active ? '▸ ' : ' '}</Text>
<Text bold={active} color={active ? (color ?? t.color.text) : t.color.muted}>
{label}
</Text>
</Text>
)
}
export const BAR_CELLS = 10
/** ratio in [0,1] -> { bar: '█…░…', pct: 0-100 } using `cells` cells. */
export function barCells(ratio: number, cells: number = BAR_CELLS): { bar: string; pct: number } {
const r = Math.max(0, Math.min(1, ratio))
const filled = Math.round(r * cells)
return { bar: '█'.repeat(filled) + '░'.repeat(cells - filled), pct: Math.round(r * 100) }
}
/**
* Two-bar dollar usage view (decided with the user over a crammed three-segment
* bar: at terminal widths a single fill glyph per full-resolution bar is the
* only legible option). The plan bar is labeled with the plan name and shows
* the allowance detail + % used; the top-up bar shows purchased dollars (no
* denominator, renders full = balance, rolls over). Dollars only never
* "credits". Each row:
* `Plus [██████░░░░] $14.00 of $20.00 · 30% used`
* Renders nothing for a free account (no bars to draw caller shows upsell).
*/
export function UsageBars({ model, t }: { model: undefined | UsageModelData; t: Theme }) {
if (!model || !model.available) {
return null
}
const rows: ReactNode[] = []
// Label the plan bar with the plan name (padded for column alignment with the
// top-up row). Falls back to 'plan' when the name is absent.
const planLabel = (model.plan_name || 'plan').padEnd(8).slice(0, 8)
if (model.plan_bar) {
const b = model.plan_bar
const { bar } = barCells(b.fill_fraction)
const pct = b.pct_used == null ? '' : ` · ${b.pct_used}% used`
rows.push(
<Text color={t.color.text} key="plan">
{planLabel}
<Text color={t.color.muted}>[</Text>
<Text color={t.color.accent}>{bar}</Text>
<Text color={t.color.muted}>]</Text>
{` ${b.remaining_display} left of ${b.total_display}${pct}`}
</Text>
)
}
if (model.topup_bar) {
const b = model.topup_bar
const { bar } = barCells(1)
rows.push(
<Text color={t.color.text} key="topup">
{'top-up '}
<Text color={t.color.muted}>[</Text>
<Text color={t.color.ok}>{bar}</Text>
<Text color={t.color.muted}>]</Text>
{` ${b.remaining_display} · never expires`}
</Text>
)
}
if (rows.length === 0) {
return null
}
return <>{rows}</>
}
/**
* Plain-text version of the two-bar usage view, for text-only surfaces (the
* /usage transcript panel). Returns one string per line: a plan bar, a top-up
* bar, and a total-spendable summary, whichever apply. Dollars only.
*/
export function usageBarsText(model: undefined | UsageModelData): string[] {
if (!model || !model.available) {
return []
}
const lines: string[] = []
const planLabel = (model.plan_name || 'plan').padEnd(8).slice(0, 8)
if (model.plan_bar) {
const b = model.plan_bar
const { bar } = barCells(b.fill_fraction)
const pct = b.pct_used == null ? '' : ` · ${b.pct_used}% used`
lines.push(`${planLabel}[${bar}] ${b.remaining_display} left of ${b.total_display}${pct}`)
}
if (model.topup_bar) {
const b = model.topup_bar
const { bar } = barCells(1)
lines.push(`top-up [${bar}] ${b.remaining_display} · never expires`)
}
if (model.total_spendable_display && model.has_topup) {
lines.push(`Total spendable: ${model.total_spendable_display}`)
}
return lines
}
export const footer = (extra: string, t: Theme) => <Text color={t.color.muted}>{extra}</Text>

View file

@ -0,0 +1,930 @@
import { randomUUID } from 'node:crypto'
import { Box, Text, useInput } from '@hermes/ink'
import { useEffect, useRef, useState } from 'react'
import type {
SubscriptionOverlayState,
SubscriptionPendingChange,
SubscriptionResult,
SubscriptionStepUpRetry
} from '../app/interfaces.js'
import type {
SubscriptionStateResponse,
SubscriptionTierOption,
SubscriptionUpgradeResponse
} from '../gatewayTypes.js'
import type { Theme } from '../theme.js'
import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js'
const UPGRADE_CONFIRM_INTERVAL_MS = 2000
const UPGRADE_CONFIRM_ATTEMPTS = 15
interface SubscriptionOverlayProps {
/** Close the overlay entirely. */
onClose: () => void
/** Merge a partial into the overlay state (screen transitions + pending/result). */
onPatch: (next: Partial<SubscriptionOverlayState>) => void
overlay: SubscriptionOverlayState
t: Theme
}
/**
* The /subscription modal an in-terminal plan-change flow (V3). A small state
* machine: overview picker confirm result, with a stepup screen spliced in
* when a mutation needs terminal billing. Downgrades / cancellations / resume are
* chargeless; an upgrade charges the card on the subscription, and an SCA/decline
* is handed off to the portal. Starting a NEW subscription still deep-links (needs
* a fresh card). All RPCs live in subscription.ts, reached via `overlay.ctx`.
*/
export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) {
const { screen, state: s } = overlay
// Teams have no personal subscription — dead-end to /topup, no picker.
if (s.context === 'team') {
return (
<Box borderColor={t.color.accent} borderStyle="round" flexDirection="column" paddingX={1}>
<TeamContextScreen onClose={onClose} s={s} t={t} />
</Box>
)
}
return (
<Box borderColor={t.color.accent} borderStyle="round" flexDirection="column" paddingX={1}>
{screen === 'picker' && <PickerScreen onClose={onClose} onPatch={onPatch} overlay={overlay} t={t} />}
{screen === 'confirm' && <ConfirmScreen onClose={onClose} onPatch={onPatch} overlay={overlay} t={t} />}
{screen === 'result' && <ResultScreen onClose={onClose} overlay={overlay} t={t} />}
{screen === 'stepup' && <StepUpScreen onClose={onClose} onPatch={onPatch} overlay={overlay} t={t} />}
{screen === 'overview' && <OverviewScreen onClose={onClose} onPatch={onPatch} overlay={overlay} t={t} />}
</Box>
)
}
// ── Shared helpers ───────────────────────────────────────────────────
interface ScreenProps {
onClose: () => void
onPatch: (next: Partial<SubscriptionOverlayState>) => void
overlay: SubscriptionOverlayState
t: Theme
}
/** ISO datetime → YYYY-MM-DD for display, or a soft fallback. */
function shortDate(iso?: null | string): string {
return iso && iso.length >= 10 ? iso.slice(0, 10) : 'the end of the billing period'
}
/** Integer cents → "$X.YY", or null when no amount is quoted. */
function centsDisplay(cents?: null | number): null | string {
return typeof cents === 'number' ? `$${(cents / 100).toFixed(2)}` : null
}
/** True when a response is the insufficient_scope denial (route to step-up). */
function isScopeDenial(r: { error?: string; ok?: boolean } | null): boolean {
return !!r && !r.ok && r.error === 'insufficient_scope'
}
/**
* Map a failed RPC envelope to a result. (insufficient_scope is intercepted
* earlier and routed to the step-up screen, so it should not reach here.)
*/
function errorResult(r: { error?: string; message?: string; portal_url?: null | string } | null): SubscriptionResult {
return {
message: r?.message || r?.error || 'Something went wrong. Try again, or manage on the portal.',
ok: false,
recoveryUrl: r?.portal_url ?? null
}
}
/** Map a chargeless pending-change mutation (schedule / cancel / resume). */
function mutationResult(r: null | { message?: string; ok?: boolean }, okMessage: string): SubscriptionResult {
return r?.ok ? { message: r.message || okMessage, ok: true } : errorResult(r)
}
/** Map an upgrade response, routing SCA / decline to a portal recovery. */
function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: null | string): SubscriptionResult {
if (!r) {
// null = a transport failure (WS drop / request timeout) on the CHARGING
// route — NAS may have already prorated + charged. Report it as ambiguous and
// steer to a safe re-check, never a blind retry (which #2's dedup can't cover
// once the key is lost).
return {
message:
'Couldnt confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.',
ok: false
}
}
if (r.reason === 'authentication_required' || r.reason === 'subscription_payment_intent_requires_action') {
return {
message: 'Please verify your card in the portal to finish this upgrade.',
ok: false,
recoveryUrl: r.recovery_url ?? null
}
}
if (r.reason === 'card_declined') {
return {
message: 'Your card was declined — try a different card on the portal.',
ok: false,
recoveryUrl: r.recovery_url ?? null
}
}
if (r.ok && r.status === 'already_on_tier') {
return {
message: `You are already on ${r.target_tier_name ?? 'this plan'}.`,
ok: true
}
}
if (r.ok && r.status === 'upgraded') {
return {
message: `Upgraded to ${r.target_tier_name ?? 'your new plan'}. Your new monthly credits land in a moment.`,
ok: true,
pendingTierId: pendingTierId ?? null
}
}
if (r.status === 'requires_action') {
return {
message: 'This upgrade needs extra verification (3DS). Finish it on the portal.',
ok: false,
recoveryUrl: r.recovery_url ?? null
}
}
if (r.status === 'payment_failed') {
return {
message: 'Your card was declined. Update your payment method on the portal and try again.',
ok: false,
recoveryUrl: r.recovery_url ?? null
}
}
return errorResult(r)
}
/** Map a failed terminal-billing step-up to the right recovery copy (typed). */
function stepUpDenialResult(res: { error?: string; message?: string }): SubscriptionResult {
if (res.error === 'session_revoked') {
return { message: 'Your session expired — run /portal to log in again, then retry the change.', ok: false }
}
if (res.error === 'remote_spending_revoked') {
return { message: res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.', ok: false }
}
if (res.error === 'rate_limited') {
return { message: 'Too many attempts — wait a moment, then try again.', ok: false }
}
return {
message:
res.message || 'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.',
ok: false
}
}
// ── Scope-aware routing (shared by the picker, confirm, overview + step-up) ──
// A REPEAT scope denial during a post-grant replay must NOT route back to the
// stepup screen: we're already mounted there (in the 'resuming' phase), so an
// onPatch({screen:'stepup'}) is a no-op that never remounts → the screen freezes.
// Post-grant replays pass allowStepUp=false and surface this instead (mirrors the
// CLI's allow_stepup=False cap).
const scopeStillDeniedResult: SubscriptionResult = {
message: 'Terminal billing still isnt enabled for this org — enable it on the portal, then retry.',
ok: false
}
/** Preview a tier and route: confirm (ok), stepup (scope), or result (other error). */
function previewAndRoute(
ctx: SubscriptionOverlayState['ctx'],
tierId: string,
onPatch: ScreenProps['onPatch'],
allowStepUp = true
): Promise<void> {
return ctx.preview(tierId).then(p => {
if (!p) {
return onPatch({ result: { message: 'Could not preview that change.', ok: false }, screen: 'result' })
}
if (!p.ok) {
if (isScopeDenial(p)) {
return allowStepUp
? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'preview', tierId } })
: onPatch({ result: scopeStillDeniedResult, screen: 'result' })
}
return onPatch({ result: errorResult(p), screen: 'result' })
}
// charge_now ⇒ an upgrade (charges now); everything else schedules at period
// end. blocked/no_op still go to confirm, which shows why + no apply.
const kind = p.effect === 'charge_now' ? 'upgrade' : 'tier_change'
// Mint the upgrade idempotency key HERE so it rides `pending` into confirm AND
// the step-up replay — a re-submit / post-grant replay dedups server-side
// (mirrors billingOverlay's pendingCharge.idempotencyKey).
const pending: SubscriptionPendingChange =
kind === 'upgrade'
? { idempotencyKey: randomUUID(), kind, preview: p, targetTierId: tierId }
: { kind, preview: p, targetTierId: tierId }
onPatch({ pending, screen: 'confirm' })
})
}
/** Apply the confirmed pending change and route: result (ok/err) or stepup (scope). */
function applyPendingAndRoute(
ctx: SubscriptionOverlayState['ctx'],
pending: null | SubscriptionPendingChange,
onPatch: ScreenProps['onPatch'],
allowStepUp = true
): Promise<void> {
if (!pending) {
// Nothing to apply (defensive) — return to the overview rather than stranding.
onPatch({ screen: 'overview' })
return Promise.resolve()
}
const toStepUp = () =>
allowStepUp
? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'apply' } })
: onPatch({ result: scopeStillDeniedResult, screen: 'result' })
const finish = (result: SubscriptionResult) => onPatch({ result, screen: 'result' })
if (pending.kind === 'cancellation') {
return ctx.scheduleCancellation().then(r =>
isScopeDenial(r)
? toStepUp()
: finish(mutationResult(r, 'Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.'))
)
}
if (pending.kind === 'upgrade') {
return ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r =>
isScopeDenial(r) ? toStepUp() : finish(upgradeResult(r, pending.targetTierId))
)
}
return ctx.scheduleChange(pending.targetTierId ?? '').then(r =>
isScopeDenial(r)
? toStepUp()
: finish(mutationResult(r, 'Scheduled — your plan doesnt change today. You keep your current plan until the end of the billing period, then it switches.'))
)
}
/** Resume (undo the pending change) and route: result (ok/err) or stepup (scope). */
function resumeAndRoute(ctx: SubscriptionOverlayState['ctx'], onPatch: ScreenProps['onPatch'], allowStepUp = true): Promise<void> {
return ctx.resume().then(r => {
if (isScopeDenial(r)) {
return allowStepUp
? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'resume' } })
: onPatch({ result: scopeStillDeniedResult, screen: 'result' })
}
return onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' })
})
}
// ── The pending scheduled change (drives the banner + status echo) ──
interface PendingTransition {
to: string
when: string
}
/** The scheduled downgrade/cancel as a from→to transition, or null. */
function pendingTransition(c: SubscriptionStateResponse['current']): null | PendingTransition {
if (!c) {
return null
}
if (c.cancel_at_period_end) {
return { to: 'cancels', when: c.cancellation_effective_display ?? shortDate(c.cancellation_effective_at) }
}
if (c.pending_downgrade_tier_name) {
return { to: c.pending_downgrade_tier_name, when: c.pending_downgrade_display ?? shortDate(c.pending_downgrade_at) }
}
return null
}
// ── Screen: Overview (plan + usage + entry to the change flow) ────────
/** Status line — dollars-only, and echoes a pending "Ultra → Plus" transition. */
function statusLine(s: SubscriptionStateResponse): string {
const u = s.usage
const c = s.current
const plan = c?.tier_name ?? u?.plan_name ?? null
const trans = pendingTransition(c)
const flip = plan && trans ? `${trans.to}` : ''
const renewsRaw = u?.renews_display ?? null
const renews = renewsRaw ? ` · renews ${renewsRaw}` : ''
const viewOnly = !s.can_change_plan
if (!plan) {
return 'Plan: Free · free models only'
}
if (u?.status === 'low' && u.total_spendable_display) {
return `Plan: ${plan}${flip} · ${u.total_spendable_display} left`
}
const left = u?.total_spendable_display ? ` · ${u.total_spendable_display} left` : ''
return `Plan: ${plan}${flip}${left}${viewOnly ? ' · view only' : renews}`
}
function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
const { ctx, state: s } = overlay
const c = s.current
const isFree = !c?.tier_id
const currentName = c?.tier_name ?? 'your plan'
const trans = pendingTransition(c)
const hasPendingChange = !!trans
// Admin/owner on a personal paid plan can change it in-terminal; otherwise the
// portal enforces who can act (members) / starting a new sub needs a card.
const canChange = s.can_change_plan && !isFree
// Guard the async resume so a double-press cannot fire two DELETEs mid-await.
const busyRef = useRef(false)
const u = s.usage
const freeNudge = isFree ? 'Paid models need a subscription. Start one to reach them.' : null
const lowNudge =
u?.status === 'low'
? `Low balance · ${u.total_spendable_display ?? 'under $5'} left. Top up or upgrade before a mid-run cutoff.`
: null
const doManage = () => {
if (s.portal_url) {
void ctx.openManageLink()
} else {
ctx.sys('🔴 No portal URL available — manage your subscription on the Nous portal.')
}
return onClose()
}
const doResume = () => {
if (busyRef.current) {
return
}
busyRef.current = true
void resumeAndRoute(ctx, onPatch)
}
const rows: MenuRowSpec[] = []
if (canChange) {
// When a change is already scheduled, undo is the most likely next intent —
// promote it to the first, highlighted action.
if (hasPendingChange) {
rows.push({ color: t.color.ok, label: `Keep ${currentName} (undo this change)`, run: doResume })
rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) })
} else {
rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) })
rows.push({
label: 'Cancel subscription',
run: () => onPatch({ pending: { kind: 'cancellation', preview: null, targetTierId: null }, screen: 'confirm' })
})
}
}
rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage })
rows.push({ label: 'Close', run: onClose })
const sel = useMenu(rows, onClose)
return (
<Box flexDirection="column">
{/* Lead with the scheduled change so it can't read as "nothing happened". */}
{trans && (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={t.color.warn}>
Scheduled change
</Text>
<Box>
<Text color={t.color.text}>{currentName} </Text>
<Text color={t.color.warn}> </Text>
<Text color={t.color.text}>{trans.to}</Text>
<Text color={t.color.muted}> · {trans.when}</Text>
</Box>
<Text color={t.color.muted}>You keep {currentName} (and its credits) until then.</Text>
</Box>
)}
<Text bold color={t.color.accent}>
{statusLine(s)}
</Text>
<UsageBars model={s.usage} t={t} />
{freeNudge && (
<Box marginTop={1}>
<Text color={t.color.warn}>
{'> '}
{freeNudge}
</Text>
</Box>
)}
{lowNudge && (
<Box marginTop={1}>
<Text color={t.color.warn}>
{'! '}
{lowNudge}
</Text>
</Box>
)}
{s.org_name && (
<Text color={t.color.muted}>
Org: {s.org_name}
{s.role ? ` · ${s.role}` : ''}
</Text>
)}
<Text />
{rows.map((row, i) => (
<MenuRow active={sel === i} index={i + 1} key={row.label} label={row.label} t={t} />
))}
<Text />
{footer('↑/↓ select · Enter confirm · Esc close', t)}
</Box>
)
}
// ── Screen: Picker (choose a tier → preview → confirm) ───────────────
function PickerScreen({ onPatch, overlay, t }: ScreenProps) {
const { ctx, state: s } = overlay
const currentOrder = s.tiers.find(tier => tier.is_current)?.tier_order ?? 0
// Selectable = enabled, not the current plan, and not the free/no-sub tier
// (going to free is a cancellation, offered on the overview). Sorted by price.
const choices: SubscriptionTierOption[] = s.tiers
.filter(tier => tier.is_enabled && !tier.is_current && tier.tier_order > 0)
.sort((a, b) => a.tier_order - b.tier_order)
// Guard the async preview so a double-press cannot fire two quotes.
const busyRef = useRef(false)
const pick = (tier: SubscriptionTierOption) => {
if (busyRef.current) {
return
}
busyRef.current = true
void previewAndRoute(ctx, tier.tier_id, onPatch)
}
const back = () => onPatch({ screen: 'overview' })
const rows: MenuRowSpec[] = choices.map(tier => {
const direction = tier.tier_order > currentOrder ? 'upgrade' : 'downgrade'
return {
label: `${tier.name} · ${tier.dollars_per_month_display}/mo · ${direction}`,
run: () => pick(tier)
}
})
rows.push({ label: 'Back', run: back })
const sel = useMenu(rows, back)
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Change plan
</Text>
<Text color={t.color.muted}>
Current: {s.current?.tier_name ?? 'Free'}. Pick a plan to see the effect before confirming.
</Text>
<Text />
{choices.length === 0 && <Text color={t.color.muted}>No other plans are available to switch to right now.</Text>}
{rows.map((row, i) => (
<MenuRow active={sel === i} index={i + 1} key={row.label} label={row.label} t={t} />
))}
<Text />
{footer('↑/↓ select · Enter preview · Esc back', t)}
</Box>
)
}
// ── Screen: Confirm (show the previewed effect, then apply) ──────────
function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
const { ctx, state: s } = overlay
const pending: null | SubscriptionPendingChange = overlay.pending ?? null
const preview = pending?.preview ?? null
const isCancellation = pending?.kind === 'cancellation'
// Cancellation is always a scheduled (chargeless) effect; otherwise trust the
// quote (default to blocked so a missing quote never offers an apply).
const effect = isCancellation ? 'scheduled' : (preview?.effect ?? 'blocked')
const [submitting, setSubmitting] = useState(false)
// Synchronous guard: two key events can both see submitting===false before
// React commits, double-firing the mutation/charge.
const submittingRef = useRef(false)
const back = () => {
// Don't navigate away while an apply is in flight: the screen hasn't changed
// yet (applyPendingAndRoute patches only after the RPC resolves), so a fresh
// re-mount would re-fire the mutation — a second charge on the upgrade path.
if (submittingRef.current) {
return
}
onPatch({ pending: null, screen: isCancellation ? 'overview' : 'picker' })
}
const apply = () => {
if (submittingRef.current || !pending) {
return
}
submittingRef.current = true
setSubmitting(true)
void applyPendingAndRoute(ctx, pending, onPatch)
}
const manage = () => {
void ctx.openManageLink()
return onClose()
}
// WHICH card the upgrade will charge (brand + last4) — best-effort via
// billing.state, shown only when the resolver rung matches what a
// subscription charge actually uses (subPin / customerDefault, mirroring
// Stripe's own precedence). Anything else → the generic line stands.
const [chargeCard, setChargeCard] = useState<null | string>(null)
useEffect(() => {
if (isCancellation || effect !== 'charge_now') {
return
}
let cancelled = false
void ctx.fetchCard().then(card => {
if (!cancelled && card && (card.resolved_via === 'subPin' || card.resolved_via === 'customerDefault')) {
setChargeCard(card.masked)
}
})
return () => {
cancelled = true
}
}, [ctx, effect, isCancellation])
const amount = centsDisplay(preview?.amount_due_now_cents)
const targetName = isCancellation ? null : (preview?.target_tier_name ?? 'the selected plan')
let primary: MenuRowSpec | null = null
if (isCancellation) {
primary = { color: t.color.warn, label: 'Cancel subscription', run: apply }
} else if (effect === 'charge_now') {
primary = { color: t.color.ok, label: amount ? `Pay ${amount} & upgrade now` : 'Upgrade now (prorated charge)', run: apply }
} else if (effect === 'scheduled') {
primary = { color: t.color.ok, label: `Schedule change to ${targetName}`, run: apply }
} else if (effect === 'blocked') {
primary = { label: 'Manage on portal', run: manage }
}
const rows: MenuRowSpec[] = primary ? [primary, { label: 'Back', run: back }] : [{ label: 'Back', run: back }]
const sel = useMenu(rows, back)
// Chip contrasts an immediate charge vs a period-end schedule at a glance.
const chip = effect === 'charge_now' ? { color: t.color.ok, label: 'charged now' } : effect === 'scheduled' ? { color: t.color.warn, label: 'scheduled · not today' } : null
return (
<Box flexDirection="column">
<Box>
<Text bold color={t.color.accent}>
{isCancellation ? 'Confirm cancellation' : 'Confirm plan change'}
</Text>
{chip && <Text color={chip.color}> · {chip.label}</Text>}
</Box>
{submitting && <Text color={t.color.muted}>Working</Text>}
{isCancellation && (
<>
<Text color={t.color.text}>
Cancel {s.current?.tier_name ?? 'your plan'} it stays active until {shortDate(s.current?.cycle_ends_at)}, then
will not renew.
</Text>
<Text color={t.color.muted}>You keep your remaining credits for this period. You can resume before it ends.</Text>
</>
)}
{effect === 'charge_now' && !isCancellation && (
<>
<Text color={t.color.text}>
Upgrade to {targetName}.{' '}
{amount ? `You will be charged ${amount} now (prorated).` : 'You will be charged the prorated amount now.'}
</Text>
{preview?.monthly_credits_delta && (
<Text color={t.color.muted}>Monthly credits change: {preview.monthly_credits_delta}.</Text>
)}
<Text color={t.color.muted}>
{chargeCard ? `${chargeCard} — the card on your subscription — will be charged.` : 'The card on your subscription will be charged.'}
</Text>
</>
)}
{effect === 'scheduled' && !isCancellation && (
<>
<Text color={t.color.text}>
Change to {targetName} takes effect {shortDate(preview?.effective_at)}. No charge now; you keep your current
plan until then.
</Text>
{preview?.monthly_credits_delta && (
<Text color={t.color.muted}>Monthly credits change: {preview.monthly_credits_delta}.</Text>
)}
</>
)}
{effect === 'no_op' && !isCancellation && (
<Text color={t.color.muted}>You are already on {targetName} nothing to change.</Text>
)}
{effect === 'blocked' && !isCancellation && (
<Text color={t.color.warn}>{preview?.reason ?? 'That change cannot be made here — manage it on the portal.'}</Text>
)}
<Text />
{rows.map((row, i) => (
<ActionRow active={sel === i} color={row.color} key={row.label} label={row.label} t={t} />
))}
<Text />
{footer('↑/↓ select · Enter confirm · Esc back', t)}
</Box>
)
}
// ── Screen: Result (outcome + optional portal recovery) ──────────────
function ResultScreen({ onClose, overlay, t }: Omit<ScreenProps, 'onPatch'>) {
const { ctx } = overlay
const result = overlay.result ?? null
const recoveryUrl = result?.recoveryUrl ?? null
const pendingTierId = result?.pendingTierId ?? null
const [applyState, setApplyState] = useState<'applying' | 'confirmed' | 'timed_out'>(
pendingTierId ? 'applying' : 'confirmed'
)
useEffect(() => {
if (!pendingTierId) {
return
}
let attempts = 0
let cancelled = false
let timer: ReturnType<typeof setTimeout> | undefined
const scheduleOrFinish = () => {
if (cancelled) {
return
}
if (attempts >= UPGRADE_CONFIRM_ATTEMPTS) {
setApplyState('timed_out')
return
}
timer = setTimeout(tick, UPGRADE_CONFIRM_INTERVAL_MS)
}
const tick = () => {
attempts += 1
void ctx
.refreshState()
.then(fresh => {
if (cancelled) {
return
}
if (fresh?.current?.tier_id === pendingTierId) {
setApplyState('confirmed')
return
}
scheduleOrFinish()
})
.catch(scheduleOrFinish)
}
timer = setTimeout(tick, UPGRADE_CONFIRM_INTERVAL_MS)
return () => {
cancelled = true
if (timer) {
clearTimeout(timer)
}
}
}, [ctx, pendingTierId])
const applying = result?.ok && applyState === 'applying'
const timedOut = result?.ok && applyState === 'timed_out'
const message = timedOut
? 'Your upgrade succeeded and is still applying — refresh in a moment.'
: (result?.message ?? '')
const openRecovery = () => {
if (recoveryUrl) {
ctx.openPortal(recoveryUrl)
}
return onClose()
}
const rows: MenuRowSpec[] = recoveryUrl
? [{ color: t.color.accent, label: 'Open the portal to finish', run: openRecovery }, { label: 'Close', run: onClose }]
: [{ label: 'Close', run: onClose }]
const sel = useMenu(rows, onClose)
return (
<Box flexDirection="column">
<Text bold color={result?.ok ? t.color.ok : t.color.warn}>
{applying ? 'Applying…' : timedOut ? 'Still applying' : result?.ok ? 'Done' : 'Could not complete'}
</Text>
<Text color={t.color.text}>{message}</Text>
{result?.ok && !applying && !timedOut && <Text color={t.color.muted}>Re-run /subscription anytime to review it.</Text>}
<Text />
{rows.map((row, i) => (
<ActionRow active={sel === i} color={row.color} key={row.label} label={row.label} t={t} />
))}
<Text />
{footer('↑/↓ select · Enter · Esc close', t)}
</Box>
)
}
// ── Screen: Step-up (grant terminal billing inline, then replay) ──────
function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
const { ctx } = overlay
const retry: null | SubscriptionStepUpRetry = overlay.stepUpRetry ?? null
const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt')
const startedRef = useRef(false)
// Set when the user cancels while the browser grant is still in flight. The
// grant's late `.then` MUST NOT fire the held change after a cancel — otherwise
// a cancel-then-approve charges the card the user just declined.
const abortedRef = useRef(false)
// Guards the post-grant replay from double-firing (double-Enter on the default
// 'Continue' row) — mirrors billingOverlay.resume()'s phase flip.
const resumingRef = useRef(false)
const enable = () => {
if (startedRef.current) {
return
}
startedRef.current = true
setPhase('waiting')
void ctx.requestRemoteSpending().then(res => {
if (abortedRef.current) {
return
}
if (res.granted) {
// HOLD — do not auto-fire the held change. Require an explicit Continue so
// a cancelled/late grant can never charge (mirrors billingOverlay's
// 'granted' phase). The user already consented at confirm; this reconfirms.
return setPhase('granted')
}
// Typed denial (session_revoked / remote_spending_revoked / rate_limited /
// admin-approval) → the right recovery copy, not a flat "admin must allow".
onPatch({ result: stepUpDenialResult(res), screen: 'result', stepUpRetry: null })
})
}
const resume = () => {
// Fire the held replay at most once. Without this, a double-Enter on the
// default 'Continue' row sends two mutations (the upgrade dedups on the shared
// idempotency key, but schedule/cancel/resume replays carry none).
if (resumingRef.current || phase !== 'granted') {
return
}
resumingRef.current = true
setPhase('resuming')
onPatch({ stepUpRetry: null })
if (!retry) {
return onPatch({ screen: 'overview' })
}
// allowStepUp=false: a repeat scope denial surfaces a result, never a frozen
// re-entry into this (already-mounted) stepup screen.
if (retry.kind === 'preview') {
return void previewAndRoute(ctx, retry.tierId, onPatch, false)
}
if (retry.kind === 'resume') {
return void resumeAndRoute(ctx, onPatch, false)
}
return void applyPendingAndRoute(ctx, overlay.pending ?? null, onPatch, false)
}
const back = () => {
// Once a replay is firing, block abandon — the mutation/charge is in flight and
// re-mounting confirm would let a second submit through.
if (resumingRef.current) {
return
}
// Abandon. If a grant is in flight, mark it aborted so its .then no-ops (no
// un-consented charge); if already granted, just leave without replaying.
abortedRef.current = true
onPatch({ screen: retry?.kind === 'apply' ? 'confirm' : 'overview', stepUpRetry: null })
}
const rows: MenuRowSpec[] =
phase === 'granted'
? [{ color: t.color.ok, label: retry?.kind === 'apply' ? 'Continue the change' : 'Continue', run: resume }, { label: 'Cancel', run: back }]
: phase === 'prompt'
? [{ color: t.color.ok, label: 'Enable terminal billing', run: enable }, { label: 'Cancel', run: back }]
: []
const sel = useMenu(rows, back)
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Terminal billing
</Text>
{phase === 'prompt' && (
<>
<Text color={t.color.text}>Changing your plan needs terminal billing enabled for this org. Enable it here, then continue.</Text>
<Text color={t.color.muted}>Someone with billing permissions (owner, admin, or finance admin) approves it once in the browser.</Text>
</>
)}
{phase === 'waiting' && (
<Text color={t.color.muted}>Opening your browser to approve finish there, then come back nothing is charged until you continue.</Text>
)}
{phase === 'granted' && <Text color={t.color.ok}>Terminal billing enabled. Continue to finish your change.</Text>}
{phase === 'resuming' && <Text color={t.color.muted}>Applying your change</Text>}
<Text />
{rows.map((row, i) => (
<ActionRow active={sel === i} color={row.color} key={row.label} label={row.label} t={t} />
))}
<Text />
{footer(phase === 'waiting' ? 'Waiting for approval… · Esc to cancel' : phase === 'resuming' ? 'Working…' : '↑/↓ select · Enter · Esc back', t)}
</Box>
)
}
// ── Screen: Team context (no tier picker — teams use shared credits) ──
interface TeamContextScreenProps {
onClose: () => void
s: SubscriptionStateResponse
t: Theme
}
function TeamContextScreen({ onClose, s, t }: TeamContextScreenProps) {
useInput((_ch, key) => {
if (key.escape || key.return) {
return onClose()
}
})
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Team subscription
</Text>
{s.org_name && (
<Text color={t.color.muted}>
Org: {s.org_name}
{s.role ? ` · ${s.role}` : ''}
</Text>
)}
<Text />
<Text color={t.color.text}>
This terminal is connected to {s.org_name ?? 'a team org'}. Teams run on a shared balance · use /topup to add
funds.
</Text>
<Text color={t.color.muted}>Personal subscriptions live on your personal account.</Text>
<Text />
{footer('Enter/Esc close', t)}
</Box>
)
}

View file

@ -43,22 +43,16 @@ export interface SlashExecResponse {
warning?: string
}
// ── Credits / top-up ─────────────────────────────────────────────────
export interface CreditsViewResponse {
balance_lines: string[]
depleted: boolean
identity_line: string | null
logged_in: boolean
topup_url: string | null
}
// ── Terminal billing (Phase 2b) ──────────────────────────────────────
export interface BillingCardInfo {
brand: string
last4: string
masked: string
/** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */
display?: string
/** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */
resolved_via?: null | string
}
export interface BillingMonthlyCap {
@ -70,6 +64,15 @@ export interface BillingMonthlyCap {
}
export interface BillingAutoReload {
card:
| { kind: 'canonical' }
| {
kind: 'distinct'
payment_method_id: string
brand: string | null
last4: string | null
}
| { kind: 'none' }
enabled: boolean
reload_to_display: string
reload_to_usd: string | null
@ -96,6 +99,9 @@ export interface BillingStateResponse {
org_name: string | null
portal_url: string | null
role: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so /topup
// renders the same bars as /usage and /subscription from this single fetch.
usage?: UsageModelData
}
/**
@ -109,13 +115,16 @@ export interface BillingErrorPayload {
}
export interface BillingChargeResponse {
actor?: string
charge_id?: string
code?: string
error?: string
idempotency_key?: string
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
@ -133,15 +142,104 @@ export interface BillingChargeStatusResponse {
}
export interface BillingMutationResponse {
actor?: string
code?: string
error?: string
granted?: boolean
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
export interface SubscriptionTierOption {
tier_id: string
name: string
tier_order: number // sorts the picker + upgrade/downgrade hint
dollars_per_month_display: string // pre-formatted ($X / $X.YY)
monthly_credits: string | null
is_current: boolean // the active plan: shown, not selectable
is_enabled: boolean // false = grandfathered current tier
}
export interface SubscriptionStateResponse {
ok: boolean
logged_in: boolean
is_admin: boolean
can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS
org_name: string | null
org_id: string | null // org.id from the NAS response
role: string | null
context: 'personal' | 'team' // personal account vs team/org terminal
current: {
tier_id: string | null // null = free (no active sub)
tier_name: string | null
monthly_credits: string | null
credits_remaining: string | null
cycle_ends_at: string | null // ISO
pending_downgrade_tier_name: string | null
pending_downgrade_at: string | null
pending_downgrade_display: string | null // formatted pending_downgrade_at
cancel_at_period_end: boolean // subscription scheduled to cancel at period end
cancellation_effective_at: string | null // ISO when cancellation takes effect
cancellation_effective_display: string | null // formatted cancellation_effective_at
} | null
tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker
portal_url: string | null
error?: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so the
// overlay renders the same bars as /usage from this single fetch.
usage?: UsageModelData
}
// A chargeless quote (POST /subscription/preview) of what a change would do.
// `effect` drives the confirm copy; a failed preview reuses the typed-error
// envelope fields (same as the mutations) so a 403 still triggers the step-up.
export interface SubscriptionPreviewResponse {
ok: boolean
effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked'
reason?: string | null
current_tier_id?: string | null
current_tier_name?: string | null
target_tier_id?: string | null
target_tier_name?: string | null
monthly_credits_delta?: string | null
amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade
effective_at?: string | null // ISO, when a scheduled change lands
// typed-error envelope (present when ok=false)
error?: string
message?: string
portal_url?: string | null
retry_after?: number | null
payload?: BillingErrorPayload
actor?: string
code?: string
recovery?: string
}
// The single money route (POST /subscription/upgrade). `status` distinguishes a
// completed upgrade from an SCA/decline that must finish in the portal at
// `recovery_url`. `idempotency_key` is echoed so a retry reuses it.
export interface SubscriptionUpgradeResponse {
ok: boolean
status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed'
target_tier_name?: string | null
recovery_url?: string | null
reason?: string | null
idempotency_key?: string
// typed-error envelope (present when ok=false)
error?: string
message?: string
portal_url?: string | null
retry_after?: number | null
payload?: BillingErrorPayload
actor?: string
code?: string
recovery?: string
}
export type CommandDispatchResponse =
| { output?: string; type: 'exec' | 'plugin' }
| { target: string; type: 'alias' }
@ -330,6 +428,34 @@ export interface SessionUsageResponse {
model?: string
output?: number
total?: number
// Shared dollar usage model (two-bar view) so /usage renders the same bars
// as /subscription. Dollars only — never "credits".
usage?: UsageModelData
}
/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */
export interface UsageBarData {
kind: 'plan' | 'topup'
remaining_display: string
total_display: string
spent_display: string
pct_used: null | number
fill_fraction: number
}
/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */
export interface UsageModelData {
available: boolean
status?: string
plan_name?: null | string
renews_at?: null | string
renews_display?: null | string
subscription_remaining_display?: null | string
topup_remaining_display?: null | string
total_spendable_display?: null | string
has_topup?: boolean
plan_bar?: null | UsageBarData
topup_bar?: null | UsageBarData
}
export interface SessionStatusResponse {