hermes-agent/docs/billing-lifecycle.md
Siddharth Balyan 9baad4e0aa
feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689)
* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split

Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI
billing changes:

- /subscription on Free (admin/owner, interactive) prints the plan catalog
  (name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly
  credits render as dollars). A numbered pick opens the manage-subscription
  deep-link directly with plan=<tier_id> appended.
- subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable
  tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=.
  The paid change flow's blocked/unknown-preview portal fallback carries plan=
  for upgrades only; downgrades stay generic/native.
- /topup overview splits one-time top-up from automatic refill, the distinction
  stated in each first sentence ("Add funds now — a single charge…" vs "Refill
  when low — charges … automatically …"), keeping "credits" out of the
  dollars-only surface.
- Downgrades remain native (chargeless scheduled change), unchanged.

Updates the CLI-parity section of docs/billing-lifecycle.md and tests under
tests/hermes_cli + tests/agent.

* refactor(billing): share plan-catalog helpers + harden manage-url builder

- subscription_manage_url now preserves unrelated portal query params (parse_qsl,
  popping only the contract-owned org_id/plan) and restricts to http/https schemes,
  matching the desktop URL builder — the function owns the contract.
- Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free
  catalog and the paid picker/blocked-preview branch share one implementation:
  selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo
  · $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix
  hidden when absent/zero), and is_upgrade(state, tier_id).

* fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy

- Free catalog: accept a bare digit as a pick (the shared normalizer only knows the
  confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The
  Nth digit maps to the Nth printed row.
- Extract one _open_url_in_browser used by every "open the portal" path, applying the
  device-code flows' console-browser / remote-session guard (webbrowser.open returns
  True even for lynx/w3m over SSH) and returning whether a real browser opened.
- Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the
  Free catalog, the paid picker, and the blocked-preview branch.
- /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when
  both amounts are present and finite; otherwise the generic sentence.

* docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant)

Remove the other-repo PR reference from the manage-URL row, and state the real
downgrade invariant: a blocked downgrade may print the generic manage URL but never
carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions
and upgrades.
2026-07-22 08:11:09 +05:30

179 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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: `Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page.` 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 Remote Spending allowed. Start a top-up to allow it, then retry.` | if present | — |
| `remote_spending_revoked` (CF-4) | `{An admin stopped remote spending for this terminal. \| You stopped remote spending 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) | `Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.` | 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 `Remote spending was stopped for this terminal — reconnect from the portal, then retry.` |
| `rate_limited` | `Too many attempts — wait a moment, then try again.` |
| other/unknown | `{message}` or `Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. 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: `Remote Spending still
isnt active for this terminal — the authorization didnt take. Retry, or make
this change on the portal.`
## Text-mode (CLI) parity
`cli.py`'s `_show_billing` / `_billing_overview` and `_show_subscription` /
`_subscription_overview` render the same state shapes (balance title, two-bar
dollar usage, auto-reload line, card line, monthly cap) and share the
"fail-open on logged-out/portal-hiccup, never crash" discipline. The CLI's
`/subscription` gives a paid admin/owner in an interactive context the **full
in-terminal change flow** (tier picker → preview → confirm → apply, parity
with the TUI overlay); members and non-interactive contexts fall back to
`_billing_portal_hint`'s deep-link to `subscription_manage_url`. `/topup`'s
interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and
non-interactive contexts fall back to the same text + portal-link rendering,
never prompting.
| CLI surface / state | Behavior (parity with TUI / desktop) |
| --- | --- |
| `/subscription` on **Free** + admin/owner + interactive | `_subscription_free_catalog` prints the plan catalog from the same `tiers[]` data the TUI uses — one row per enabled paid tier, cheapest first, `name · $/mo · $credits/mo` (monthly credits are DOLLARS → `$22 credits/mo`, never a bare number). A numbered pick opens the `/manage-subscription` deep-link with `plan=<tier_id>` appended so the portal preselects the chosen plan. Starting a new subscription needs a fresh card, so the only action is the portal hand-off (the terminal never charges here). |
| Any CLI-built manage/subscribe URL | `subscription_manage_url(state, tier_id=…)` appends `plan=<tier_id>` (the stable `tiers[]` id, never a name/slug) **only when a tier was picked** (the Free catalog). The portal validates it server-side and ignores an unknown tier, so the CLI appends unconditionally on a pick, mirroring the TUI's `?plan=`. `org_id` is emitted first, `plan` second. |
| **Downgrades** in the CLI | Stay **native / in-app** for normal changes (chargeless scheduling via `put_subscription_pending_change`). A blocked downgrade may still print the generic manage URL, but it never carries `plan=<tier_id>` — selected-tier deep-links are reserved for new subscriptions and upgrades. |
| `/topup` overview action copy | Splits one-time top-up from automatic refill, the distinction stated up front in each first sentence: `Add funds now — a single charge, added to your balance today.` vs `Refill when low — charges $X automatically when your balance falls below $Y.` ("credits" stays out of the dollars-only `/topup` surface — "Add funds now" carries the one-time meaning without it). When auto-reload is off, the automatic line omits concrete amounts. |
## 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.