mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
* 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.
179 lines
17 KiB
Markdown
179 lines
17 KiB
Markdown
# 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
|
||
isn’t active for this terminal — the authorization didn’t take. Retry, or make
|
||
this change on the portal.`
|
||
|
||
## Text-mode (CLI) parity
|
||
|
||
`cli.py`'s `_show_billing` / `_billing_overview` and `_show_subscription` /
|
||
`_subscription_overview` render the same state shapes (balance title, two-bar
|
||
dollar usage, auto-reload line, card line, monthly cap) and share the
|
||
"fail-open on logged-out/portal-hiccup, never crash" discipline. The CLI's
|
||
`/subscription` gives a paid admin/owner in an interactive context the **full
|
||
in-terminal change flow** (tier picker → preview → confirm → apply, parity
|
||
with the TUI overlay); members and non-interactive contexts fall back to
|
||
`_billing_portal_hint`'s deep-link to `subscription_manage_url`. `/topup`'s
|
||
interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and
|
||
non-interactive contexts fall back to the same text + portal-link rendering,
|
||
never prompting.
|
||
|
||
| 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.
|