NAS is a private repo — remove all references to it from the public PR:
- drop the cross-repo planning doc (planning scaffolding, not a deliverable;
the PR description documents the design)
- replace 'NAS' / 'PR #412 preview' mentions in code + test comments with
generic 'the server' / 'a preview deployment'
Build the **hermes-agent (TUI/CLI) client half** of Phase 2b terminal billing so the whole flow can run end-to-end against the **preview deployment** of `nous-account-service` PR #412 — before that PR merges. The NAS server side (4 `/api/billing/*` endpoints + `billing:manage` scope + per-org kill-switch + settlement polling) is built and CI-green on the preview. This is the missing client half: 5 interactive screens (CLI + TUI) + one new auth wire (`billing:manage` on the device-flow scope, lazy/step-up) + a 9-case E2E run with Stripe test cards.
This extends an **explicitly-stubbed seam**: the merged Phase 2a `/credits` handler docstring (`cli.py`) says *"the terminal never confirms or polls payment (billing phase 2a)."* Phase 2b adds exactly that. We build on `origin/main` which already contains the merged Phase 2a code (`agent/account_usage.py::build_credits_view`, `credits.view` RPC, `credits.ts`).
The 5 design-target screenshots (`/tmp/screenshots/{1..4}.png` + `SCR-20260612-ohvp.png`) are **Claude Code's** terminal billing UI — the layout/copy template. Re-skin for Nous: drop "Save 10/20/30%" volume-discount labels (Nous has no discount), swap "Anthropic"→"Nous Research" consent copy, claude.ai links → Nous portal deep-links.
---
## Step 0 — Contract is live-verified (LOCKED 2026-06-13)
The NAS side is live-verified and out of draft. Server half exercised end-to-end against the preview (real device-flow token, fork DB, sandbox Stripe) — see the [PR #412 E2E comment](https://github.com/NousResearch/nous-account-service/pull/412). The contract below is **observed behavior**.
### ✅ Live probe results (this session)
| Probe | Result |
|-------|--------|
| PR #412 | OPEN, not draft, MERGEABLE; title "feat(billing): phase 2b terminal-native billing…"; **all CI green** (Jest, E2E Browser, Migration Safety, Preview DB Fork, Vercel) |
**Reusable NAS-side runbook:**`docs/specs/phase-2b-e2e-testing-runbook.md` (PR branch) — curl + psql recipes against the fork.
**Cosmetic gotcha:** fork columns are `TIMESTAMP WITHOUT TIME ZONE` (MigrationPilot MP040) → ~7h DB-vs-UTC display skew. If `settledAt` looks off by hours during preview testing, it's this, not a bug — render with explicit TZ handling.
---
## Architecture: reuse the `/credits` pattern verbatim
`/credits` (merged Phase 2a) already touches every layer the billing screens need. Mirror its shape (the `debugging-hermes-tui-commands` skill calls this "one surface-agnostic core + thin per-surface adapters").
| Test | `ui-tui/src/__tests__/creditsCommand.test.ts` | `billingCommand.test.ts` + python tests |
**Key reuse facts**
- Auth token: `get_provider_auth_state("nous")["access_token"]` (`account_usage.py:370`) is the bearer for all `/api/billing/*` calls.
- Portal base URL resolution order (copy from `auth.py:7515`): `HERMES_PORTAL_BASE_URL` → `NOUS_PORTAL_BASE_URL` → `PROVIDER_REGISTRY["nous"].portal_base_url`. **Pointing hermes at the preview needs zero code** — just `export HERMES_PORTAL_BASE_URL=https://nous-account-service-git-phase-2b-billing-fixtures.nousresearch.wtf`.
- Fail-open discipline: every core builder returns a logged-out/empty struct on any exception (`account_usage.py:366,373,388`). Billing does the same — a portal hiccup degrades to a clear message, never a crash.
- **Decimal strings, not 2dp**: `/state` returns `"142.5"`. Parse with `decimal.Decimal`, never float, never assume cents. Mirror money-safe parsing in `agent/credits_tracker.py:62 _safe_int` / `:83 _validate_usd`.
- TUI slash-worker is non-interactive (`_app is None`): the CLI handler must gate the prompt path on `if getattr(self, "_app", None):` and render text otherwise — see `cli.py:8398`. A handler that prompts in the worker silently produces nothing.
---
## The one new auth wire: `billing:manage` scope + lazy step-up (handoff §3)
`billing:manage` is privileged, off by default. The device-flow scope already plumbs cleanly:
| Persist granted scope | already handled — `auth.py:7592` stores `token_data.get("scope") or scope` (server downscopes silently if admin didn't tick the box) |
| Step-up trigger | new in `nous_billing.py` | on `403 {error:"insufficient_scope"}` → raise typed `BillingScopeRequired`; CLI/Ink catches → re-run device-connect requesting `billing:manage` + tell user "an ADMIN must tick 'Allow terminal billing'." |
| Mid-session strip | same | scope stripped on refresh if user loses ADMIN → same 403 path re-auths |
**D-A RESOLVED → LAZY/step-up.** Normal `hermes portal` login stays byte-identical (`inference:invoke tool:invoke`, no billing checkbox shown). The first billing action that needs the scope gets `403 insufficient_scope` → triggers the device-connect requesting `billing:manage`. Relies on the step-up handler that must exist anyway (scope stripped on refresh if ADMIN lost). **No eager scope request added to the normal login path.**
---
## The HTTP contract (exact — from handoff §2, live-verified)
All under `{portal}/api/billing/*`, `Authorization: Bearer *** JWT>`. No API-key auth.
| Endpoint | Method | Scope | Notes |
|----------|--------|-------|-------|
| `/api/billing/state` | GET | none | role-tiered; `card/monthlyCap/autoReload` null for MEMBER |
| `/api/billing/auto-top-up` | PATCH | `billing:manage` | strict body `{enabled, threshold>0, topUpAmount>0}`; NO `maxMonthlySpend`/payment-method (400s) |
| `/api/billing/charge` | POST | `billing:manage` | **`Idempotency-Key` header REQUIRED**; body `{amountUsd>0, multipleOf 0.01}`; returns **`202 {chargeId}`** (NOT settled) |
| `/api/billing/charge/{id}` | GET | `billing:manage` | poll; unknown/foreign id → `{status:"pending"}` (never 404) |
### Live-run findings that change the TUI (from the PR #412 E2E pass — handoff §8)
1.**`no_payment_method` is a MAINLINE case, not an edge.** A user who only *bought credits* (one-time) has a Stripe card but **no reusable `AutoTopUpSettings.paymentMethodId`** — so `POST /charge` returns `403 no_payment_method`**even right after they paid**. Accepted existing-flow behavior. **Build impact:** Screen 4 handles `no_payment_method` as a *likely* outcome; copy = "set up a saved card on the portal" (the `portalUrl` is the funnel), never "you have no card."
2.**Charge rate limit (5/org/hr + 5/token/hr) is real and easy to trip.** Live, the 6th charge in an hour returned `429`. `429`/`503` carry `Retry-After`, are NOT payment failures — surface "try again in N min," never "charge failed," and **never auto-retry-spam**.
3.**`monthly_cap_exceeded` carries `remainingUsd` + `isDefaultCeiling`; capless orgs hit a default ceiling.** `isDefaultCeiling:true` (e.g. `"1000"`) exposed on `/state.monthlyCap` — Screen 5 shows "X of $1,000 used" with no charge attempt. (Resolves D-B: read-only.)
4.**BetterStack triangulation dead on preview** — verify settlement via the **fork DB directly**, not preview logs.
| `403 role_required` | not ADMIN/OWNER | "ask an admin"; portal link |
| `403 cli_billing_disabled` | kill-switch off | "enable terminal billing on portal" + link |
| `403 no_payment_method` | no **reusable** card (live-finding #1) | "set up a saved card on the portal" + link — **expected even for users with billing history** |
| `403 monthly_cap_exceeded` | carries `remainingUsd`,`isDefaultCeiling` | show headroom + portal link |
| `409 idempotency_conflict` | same key, diff amount | bug-guard: never reuse key across amounts |
| `429 rate_limited` (+Retry-After) | limiter | back off; while polling = retry, NOT failure |
| `503 temporarily_unavailable` (+Retry-After:60) | limiter outage | distinct retry-after, NOT a failed payment |
Render style (all 5 share it): blue top rule, blue bold title, dim secondary lines, `❯` cursor on numbered options, footer `Enter to confirm · Esc to cancel`.
**D-C RESOLVED → reuse existing primitives.** CLI: `_prompt_text_input_modal` (`cli.py:8409`). TUI: existing confirm/input overlay (`patchOverlayState`, `ConfirmReq` in `ui-tui/src/types.ts`). No bespoke multi-field form component.
- Title "Usage credits". Spend bar: `$X spent [████░░░] N% used` + `Resets <date> · $Y monthly limit` (from `monthlyCap`).
- Line 1 (cursor): `$<balanceUsd> balance · auto-reload <on|off>`.
- Menu: `2. Buy more``3. Continue with usage credits``4. Adjust monthly limit``5. Manage on portal`.
- Render for **everyone**; gate admin actions (2/4/auto-reload) on `org.role ∈ {ADMIN,OWNER}` + `cliBillingEnabled`. MEMBER or kill-switch-off → those rows dimmed/omitted, "Manage on portal" always present.
- Title "Auto-reload". `Card on file: <brand> ····<last4>` (from `/state.card`).
- Two inputs: "When balance falls below: $<threshold>" and "Reload balance to: $<topUpAmount>". Validate both >0; `reloadTo > threshold`.
- Consent (Nous): "By selecting Agree, you authorize Nous Research to automatically charge <card> whenever your balance reaches the threshold… Turn off any time here or at <portal>."
- Display cap from `/state.monthlyCap` + "Manage on portal" deep-link. TUI can **never set** the cap (D2). Screenshot's `$20` input + buttons are portal-only — do NOT wire.
- Capless orgs hit default ceiling (`isDefaultCeiling:true`, e.g. `"1000"`); render "X of $1,000 used this month" with no charge attempt (finding #3). `monthlyCap` null (MEMBER) → "managed on portal."
---
## Requirements
- R1. `/billing` (+ `buy|auto-reload|limit`) renders all 5 screens, role- and kill-switch-gated, in **CLI + TUI**, byte-identical via one shared core.
- R2. All money parsed/displayed as `Decimal` from server decimal strings — never float, never assumed-2dp.
- R3. `POST /charge` always sends fresh per-purchase `Idempotency-Key`; reused on retry of same purchase; never across amounts.
Stripe **test** cards, sandbox. Point hermes via `HERMES_PORTAL_BASE_URL`. Unique test org; key assertions on **your**`chargeId`/`pi_` (shared sandbox webhooks fan out — never "latest row"). **Charge rate limit 5/org/hr + 5/token/hr is easy to trip — do NOT burn charges in loops; space runs out or lock yourself out for an hour (finding #2).**
| # | Case | Setup | Expected | Live status (server side) |
**Legend:** ✅ server-verified · ⏳ masked by limiter (space runs out / stage values) · ⚠️ never exercised live — **the TUI E2E run is the FIRST real test** (1b, 3, 4, 9 + cross-org poll + scope-strip-on-refresh). Budget a 2nd test account + a 3DS card.
Triangulate settlement via the **fork DB directly** — BetterStack triangulation does NOT work on the preview (finding #4). See NAS `debugging-billing-and-credits` skill.
---
## Decisions (RESOLVED 2026-06-13)
- **D-A** scope timing → **LAZY/step-up**. Normal login unchanged; first billing action triggers `403 insufficient_scope` → device-connect requesting `billing:manage`.
- **D-D** Ink screens → **built in-session** (not delegated). **Wire the CLI fully too** — both surfaces ship off the shared core.
---
## Gotchas (pinned)
- Decimal strings not 2dp; `Idempotency-Key` mandatory (missing=400); first-time card capture portal-only; terminal pins one org (no switcher); confirmation = poll `settled` not "Stripe charged" not balance-watch.
- **Editable-install routing trap** (`debugging-hermes-tui-commands` skill): the installed `hermes` runs from `~/.hermes/hermes-agent/`, a DIFFERENT tree than this worktree. Test CLI via `python -m hermes_cli.main` from the worktree; test TUI with `HERMES_PYTHON_SRC_ROOT=<worktree>` + rebuild Ink (`npm --prefix ui-tui run build`). Profile/preview selection via `HERMES_HOME` (not `HERMES_PROFILE`).
- Slack 50-slash cap: adding `CommandDef("billing")` may clamp a different low-priority command off Slack and break `test_telegram_parity`. If so, add `billing` (or the victim) to `_SLACK_VIA_HERMES_ONLY` in `commands.py`.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.