mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
* 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.
1455 lines
68 KiB
Python
1455 lines
68 KiB
Python
"""Billing and subscription handlers for the interactive CLI (god-file decomposition).
|
|
|
|
This module hosts the Nous billing/subscription methods lifted out of
|
|
``cli.py``'s ``HermesCLI`` class. ``HermesCLI`` inherits
|
|
``CLIBillingMixin`` so every ``self.<handler>`` call resolves unchanged
|
|
via the MRO — behavior-neutral apart from focused billing fixes.
|
|
|
|
Import discipline mirrors ``hermes_cli.cli_commands_mixin``:
|
|
* Neutral, non-cyclic dependencies are imported at module top level below.
|
|
* cli.py-internal symbols (the ``_cprint``/``_b``/``_d`` helpers and
|
|
display constants) are imported LAZILY inside each method via
|
|
``from cli import ...``. The mixin never imports ``cli`` at module load
|
|
time, avoiding the cycle created when ``cli.py`` imports this mixin.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
|
|
class CLIBillingMixin:
|
|
"""Mixin holding interactive-CLI billing and subscription handlers."""
|
|
|
|
def _print_nous_credits_block(self) -> bool:
|
|
"""Print the Nous dollar balance block (two-bar view) when a Nous account
|
|
is logged in. Returns True if it printed anything.
|
|
|
|
Prefers the shared dollar usage model (``agent.billing_usage`` — two-bar
|
|
plan/top-up view, dollars-only, the /usage + /subscription source of
|
|
truth). Falls back to the legacy ``nous_credits_lines`` text only when the
|
|
model is unavailable. Agent-independent (a portal fetch gated on "a Nous
|
|
account is logged in"), so /usage shows the block even in the TUI
|
|
slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and
|
|
wall-clock-bounded; honors HERMES_DEV_CREDITS_FIXTURE for offline testing.
|
|
"""
|
|
from cli import _cprint, _b, _d
|
|
|
|
try:
|
|
from agent.billing_usage import build_usage_model, format_renews
|
|
|
|
usage = build_usage_model()
|
|
except Exception:
|
|
usage = None
|
|
format_renews = None # type: ignore
|
|
|
|
if usage is not None and usage.available and format_renews is not None:
|
|
printed_any = False
|
|
plan = usage.plan_name or ("Free" if usage.status == "free" else None)
|
|
renews_display = getattr(usage, "renews_display", None) or format_renews(usage.renews_at)
|
|
renews = f" · renews {renews_display}" if renews_display else ""
|
|
if plan:
|
|
print()
|
|
_cprint(f" {_b(f'Plan: {plan}{renews}')}")
|
|
printed_any = True
|
|
|
|
# All lines below go through _cprint (same renderer as the Plan line) so
|
|
# ordering is deterministic: raw print() and _cprint() flush to different
|
|
# buffers under patch_stdout and interleave nondeterministically (the bar
|
|
# would race above/below the Plan line across states). Keep one path.
|
|
for _bar_ln in self._usage_bar_lines(usage, usage.plan_name):
|
|
_cprint(_bar_ln)
|
|
printed_any = True
|
|
if usage.has_topup and usage.total_spendable_usd is not None:
|
|
_cprint(f" Total spendable: ${usage.total_spendable_usd:,.2f}")
|
|
|
|
if usage.status == "free":
|
|
_cprint(f" {_d('> Free · free models only. Run /subscription to reach paid models.')}")
|
|
printed_any = True
|
|
elif usage.status == "low":
|
|
_amt = f"${usage.total_spendable_usd:,.2f}" if usage.total_spendable_usd is not None else "under $5"
|
|
_low = f"! Low balance · {_amt} left. Run /topup or /subscription."
|
|
_cprint(f" {_low}")
|
|
printed_any = True
|
|
|
|
if printed_any:
|
|
return True
|
|
|
|
# Fallback: legacy text lines (only when the model is unavailable).
|
|
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 _print_usage_cta(self) -> None:
|
|
"""Print the `/usage` call-to-action pointing at /subscription + /topup.
|
|
|
|
Mirrors the TUI's ``USAGE_CTA`` (``session.ts``) so every surface ends a
|
|
usage read with the same nudge. Only called when a Nous account is logged
|
|
in (the balance block printed), since both commands are Nous-account only.
|
|
"""
|
|
from cli import _cprint, _d
|
|
|
|
_cprint(f" {_d('Run /subscription to change plan · /topup to add to your balance')}")
|
|
|
|
# ------------------------------------------------------------------
|
|
# /subscription — view plan + change it in the browser (CLI surface)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _show_subscription(self):
|
|
"""`/subscription` (alias `/upgrade`) — view the Nous plan + browser hand-off.
|
|
|
|
The CLI mirror of the TUI ``SubscriptionOverlay``: a read of the current
|
|
plan, this cycle's subscription credits, renewal date, and the plans you
|
|
could switch to — then a deep-link to NAS's own ``/manage-subscription``
|
|
page (NOT the Stripe portal; that page routes upgrade→Checkout /
|
|
downgrade→scheduled internally). The terminal NEVER charges for a
|
|
subscription. Fail-open: logged-out / portal hiccup degrades to a clear
|
|
message, never a crash. Mirrors ``_show_billing``
|
|
discipline for the interactive-vs-text split.
|
|
"""
|
|
from cli import _cprint, _b, _d
|
|
|
|
from agent.subscription_view import build_subscription_state, subscription_manage_url
|
|
|
|
state = build_subscription_state()
|
|
|
|
if not state.logged_in:
|
|
print()
|
|
if state.error:
|
|
_cprint(f" 💳 {_d(f'Could not load subscription: {state.error}')}")
|
|
else:
|
|
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
|
|
print(" Run `hermes portal` to log in, then /subscription.")
|
|
return
|
|
|
|
# Team context: no personal plan — teams run on a shared balance.
|
|
if state.context == "team":
|
|
print()
|
|
_cprint(f" ⚕ {_b('Team subscription')}")
|
|
print(f" {'─' * 41}")
|
|
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)}")
|
|
org = state.org_name or "a team org"
|
|
print(f" This terminal is connected to {org}. Teams run on a shared")
|
|
print(" balance · use /topup to add funds.")
|
|
_cprint(f" {_d('Personal subscriptions live on your personal account.')}")
|
|
return
|
|
|
|
self._subscription_overview(state, subscription_manage_url(state))
|
|
|
|
def _subscription_overview(self, state, manage_url):
|
|
"""Print the plan read block, then the browser hand-off to manage it.
|
|
|
|
Dollars-only (no "credits") — mirrors the TUI overlay: a status line, the
|
|
shared two-bar dollar usage view (plan + top-up) with the plan name on
|
|
the bar, and state-matched free/low nudges. No in-terminal tier picker —
|
|
the only action is managing the subscription on the portal.
|
|
"""
|
|
from cli import _cprint, _b, _d
|
|
|
|
# Shared dollar usage model (the only source with top-up dollars).
|
|
from agent.billing_usage import format_renews
|
|
try:
|
|
from agent.billing_usage import build_usage_model
|
|
|
|
usage = build_usage_model()
|
|
except Exception:
|
|
usage = None
|
|
|
|
c = state.current
|
|
is_free = not (c and c.tier_id)
|
|
can_change = state.can_change_plan
|
|
|
|
plan_name = (c.tier_name or c.tier_id) if c else (usage.plan_name if usage else None)
|
|
u_status = getattr(usage, "status", None) if usage else None
|
|
view_only = not can_change
|
|
renews_display = getattr(usage, "renews_display", None) if usage else None
|
|
if not renews_display and c and c.cycle_ends_at:
|
|
renews_display = format_renews(c.cycle_ends_at)
|
|
|
|
# Status line — dollars-only, with a "→ Plus" echo of a pending change so
|
|
# the headline itself carries a scheduled downgrade/cancellation.
|
|
_flip = ""
|
|
if c and c.cancel_at_period_end:
|
|
_flip = " → cancels"
|
|
elif c and c.pending_downgrade_tier_name:
|
|
_flip = f" → {c.pending_downgrade_tier_name}"
|
|
if not plan_name:
|
|
status = "Plan: Free · free models only"
|
|
elif usage is not None and u_status == "low" and usage.total_spendable_usd is not None:
|
|
_tot = f"${usage.total_spendable_usd:,.2f}"
|
|
status = f"Plan: {plan_name}{_flip} · {_tot} left"
|
|
else:
|
|
_spend = getattr(usage, "total_spendable_usd", None) if usage else None
|
|
_left = f" · ${_spend:,.2f} left" if _spend is not None else ""
|
|
_tail = " · view only" if view_only else (f" · renews {renews_display}" if renews_display else "")
|
|
status = f"Plan: {plan_name}{_flip}{_left}{_tail}"
|
|
|
|
# Lead with the scheduled change (cancel > downgrade) so it can't read as
|
|
# "nothing happened" — mirrors the TUI banner. All-`_cprint` (blanks
|
|
# included) so the block orders deterministically even when piped.
|
|
_trans = None
|
|
if c and c.cancel_at_period_end:
|
|
_when = format_renews(c.cancellation_effective_at) or "the end of the billing period"
|
|
_trans = ((c.tier_name or "your plan"), "cancels", _when)
|
|
elif c and c.pending_downgrade_tier_name:
|
|
_when = format_renews(c.pending_downgrade_at) or "the end of the cycle"
|
|
_trans = ((c.tier_name or "your plan"), c.pending_downgrade_tier_name, _when)
|
|
_cprint("")
|
|
if _trans:
|
|
_from, _to, _when = _trans
|
|
_cprint(f" ⏳ {_b('Scheduled change')}")
|
|
_cprint(f" {_from} ──▶ {_to} {_d('· ' + _when)}")
|
|
_cprint(f" {_d(f'You keep {_from} (and its credits) until then.')}")
|
|
_cprint("")
|
|
|
|
_cprint(f" ⚕ {_b(status)}")
|
|
print(f" {'─' * 41}")
|
|
|
|
# Two-bar dollar usage view — plan name labels the plan bar.
|
|
for _bar_ln in self._usage_bar_lines(usage, plan_name):
|
|
print(_bar_ln)
|
|
if usage and getattr(usage, "has_topup", False) and getattr(usage, "total_spendable_usd", None) is not None:
|
|
print(f" Total spendable: ${usage.total_spendable_usd:,.2f}")
|
|
|
|
# State-matched nudge (free upsell / low alert; healthy stays silent).
|
|
if is_free:
|
|
_cprint(f" {_d('> Paid models need a subscription. Start one to reach them.')}")
|
|
elif u_status == "low":
|
|
_amt = f"${usage.total_spendable_usd:,.2f}" if usage is not None and usage.total_spendable_usd is not None else "under $5"
|
|
_low = f"! Low balance · {_amt} left. Top up or upgrade before a mid-run cutoff."
|
|
_cprint(f" {_low}")
|
|
|
|
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}")
|
|
|
|
# ── Actions ── Members (non-admin) and non-interactive contexts fall back
|
|
# to the portal hand-off; a paid admin/owner gets the full in-terminal
|
|
# change flow (parity with the TUI overlay).
|
|
if not can_change:
|
|
print()
|
|
_cprint(f" {_d('Plan changes need an org admin/owner.')}")
|
|
if manage_url:
|
|
print(f" Manage on portal: {manage_url}")
|
|
return
|
|
|
|
if not getattr(self, "_app", None):
|
|
# Non-interactive (TUI slash-worker / piped): the modal can't run.
|
|
print()
|
|
if manage_url:
|
|
print(f" Manage your subscription: {manage_url}")
|
|
print(" Open it in your browser, then re-run /subscription.")
|
|
return
|
|
|
|
if is_free:
|
|
# Starting a NEW subscription needs a fresh card — deep-link only.
|
|
self._subscription_open_portal(state, manage_url, verb="Start a subscription")
|
|
return
|
|
|
|
# Paid + admin/owner + interactive → the in-terminal change flow.
|
|
self._subscription_change_menu(state, manage_url)
|
|
|
|
def _subscription_open_portal(self, state, manage_url, *, verb="Manage your subscription"):
|
|
"""Open / copy the manage-subscription URL — the portal hand-off."""
|
|
from cli import _cprint, _d
|
|
|
|
if not manage_url:
|
|
print()
|
|
_cprint(f" {_d('No manage URL available — is your portal configured?')}")
|
|
return
|
|
print()
|
|
choices = [
|
|
("open", verb, "open the subscription page in your browser"),
|
|
("copy", "Copy link", "copy the manage-subscription URL to your clipboard"),
|
|
("cancel", "Cancel", "do nothing"),
|
|
]
|
|
raw = self._prompt_text_input_modal(title=verb, detail="", choices=choices)
|
|
choice = self._normalize_slash_confirm_choice(raw, choices)
|
|
if choice == "open":
|
|
opened = False
|
|
try:
|
|
import webbrowser
|
|
|
|
opened = webbrowser.open(manage_url)
|
|
except Exception:
|
|
opened = False
|
|
if not opened:
|
|
print(f" Open this URL: {manage_url}")
|
|
print()
|
|
print(" Finish in your browser, then re-run /subscription.")
|
|
elif choice == "copy":
|
|
try:
|
|
self._write_osc52_clipboard(manage_url)
|
|
print(f" 📋 Copied: {manage_url}")
|
|
except Exception:
|
|
print(f" Manage URL: {manage_url}")
|
|
else:
|
|
print(" 🟡 Cancelled.")
|
|
|
|
def _subscription_change_menu(self, state, manage_url):
|
|
"""The in-terminal change menu for a paid admin/owner (interactive)."""
|
|
c = state.current
|
|
has_pending = bool(c and (c.cancel_at_period_end or c.pending_downgrade_tier_name))
|
|
keep_name = (c.tier_name if c else None) or "your plan"
|
|
# When a change is already scheduled, undo is the most likely next intent →
|
|
# promote it first (parity with the TUI). The Close row uses value "close"
|
|
# (not "cancel") so typing the word "cancel" — which the alias table would
|
|
# map to a Close row — can't be confused with "Cancel subscription".
|
|
if has_pending:
|
|
choices = [
|
|
("keep", f"Keep {keep_name} (undo the scheduled change)", "cancel the pending change"),
|
|
("change", "Change plan", "upgrade or downgrade in the terminal"),
|
|
]
|
|
else:
|
|
choices = [
|
|
("change", "Change plan", "upgrade or downgrade in the terminal"),
|
|
("cancel_sub", "Cancel subscription", "schedule cancellation at period end"),
|
|
]
|
|
choices.append(("portal", "Manage on portal", "open the billing page in your browser"))
|
|
choices.append(("close", "Close", "do nothing"))
|
|
raw = self._prompt_text_input_modal(title="Manage your subscription", detail="", choices=choices)
|
|
choice = self._normalize_slash_confirm_choice(raw, choices)
|
|
if choice == "change":
|
|
self._subscription_pick_tier(state)
|
|
elif choice == "keep":
|
|
self._subscription_apply(state, ("resume", None))
|
|
elif choice == "cancel_sub":
|
|
self._subscription_confirm_cancel(state)
|
|
elif choice == "portal":
|
|
self._subscription_open_portal(state, manage_url)
|
|
else:
|
|
print(" 🟡 Closed. No plan change.")
|
|
|
|
def _subscription_pick_tier(self, state):
|
|
"""Tier picker → preview → confirm (mirrors the TUI picker screen)."""
|
|
from agent.billing_view import format_money
|
|
|
|
c = state.current
|
|
tiers = tuple(state.tiers or ())
|
|
cur_order = next((t.tier_order for t in tiers if t.is_current), 0)
|
|
# Selectable = enabled paid tiers other than current (free/no-sub excluded;
|
|
# dropping to free is a cancellation, on the change menu). Sorted by price.
|
|
selectable = sorted(
|
|
[t for t in tiers if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0],
|
|
key=lambda t: t.tier_order or 0,
|
|
)
|
|
if not selectable:
|
|
print(" No other plans are available to switch to right now.")
|
|
return
|
|
choices = []
|
|
for t in selectable:
|
|
direction = "upgrade" if (t.tier_order or 0) > cur_order else "downgrade"
|
|
choices.append((t.tier_id, f"{t.name} · {format_money(t.dollars_per_month)}/mo · {direction}", f"switch to {t.name}"))
|
|
choices.append(("cancel", "Back", "do nothing"))
|
|
raw = self._prompt_text_input_modal(
|
|
title="Change plan",
|
|
detail=f"Current: {c.tier_name if c else 'Free'}. Pick a plan to preview the effect.",
|
|
choices=choices,
|
|
)
|
|
choice = self._normalize_slash_confirm_choice(raw, choices)
|
|
if not choice or choice == "cancel":
|
|
print(" 🟡 Cancelled. No plan change.")
|
|
return
|
|
self._subscription_preview_and_confirm(state, choice)
|
|
|
|
def _subscription_preview_and_confirm(self, state, tier_id, *, allow_stepup=True):
|
|
"""Preview the change (chargeless quote), show the effect, then confirm+apply.
|
|
|
|
``allow_stepup=False`` (a post-grant replay) declines a second step-up on a
|
|
repeated scope denial so the flow can't re-prompt/re-open the browser in a
|
|
loop.
|
|
"""
|
|
from cli import _cprint, _b, _d
|
|
|
|
from agent.subscription_view import subscription_change_preview_from_payload
|
|
from hermes_cli.nous_billing import BillingError, BillingScopeRequired, post_subscription_preview
|
|
|
|
_cprint(f" {_d('Checking the change…')}")
|
|
try:
|
|
payload = post_subscription_preview(subscription_type_id=tier_id)
|
|
except BillingScopeRequired:
|
|
if allow_stepup:
|
|
self._subscription_handle_scope_required(state, retry=("preview", tier_id))
|
|
else:
|
|
print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.")
|
|
return
|
|
except BillingError as exc:
|
|
self._subscription_render_error(state, exc)
|
|
return
|
|
p = subscription_change_preview_from_payload(payload)
|
|
effect = p.effect
|
|
target = p.target_tier_name or "the selected plan"
|
|
print()
|
|
if effect == "no_op":
|
|
_cprint(f" {_d(f'You are already on {target} — nothing to change.')}")
|
|
return
|
|
if effect not in ("charge_now", "scheduled"):
|
|
# blocked OR an unknown/unexpected effect → fail SAFE (never schedule a
|
|
# real change on an unrecognized string, unlike a bare `else`), and
|
|
# re-offer the portal hand-off like the TUI's blocked branch.
|
|
from agent.subscription_view import subscription_manage_url
|
|
|
|
_cprint(f" 🟡 {p.reason or 'This change cannot be confirmed here — manage it on the portal.'}")
|
|
_mu = subscription_manage_url(state)
|
|
if _mu:
|
|
print(f" Manage on portal: {_mu}")
|
|
return
|
|
if effect == "charge_now":
|
|
_amt = f"${p.amount_due_now_cents / 100:.2f}" if p.amount_due_now_cents is not None else None
|
|
_cprint(f" {_b('Confirm plan change')} {_d('· charged now')}")
|
|
if _amt:
|
|
_cprint(f" Upgrade to {target}. You will be charged {_amt} now (prorated).")
|
|
else:
|
|
_cprint(f" Upgrade to {target}. You will be charged the prorated amount now.")
|
|
# Best-effort: name the exact card (billing.state), but only when the
|
|
# resolver rung matches what a subscription charge actually uses
|
|
# (subPin / customerDefault — Stripe's own precedence). Any failure or
|
|
# older NAS → the generic line stands.
|
|
_card_line = "The card on your subscription will be charged."
|
|
try:
|
|
from agent.billing_view import build_billing_state
|
|
|
|
_bs = build_billing_state(timeout=6.0)
|
|
_c = _bs.card if _bs.logged_in else None
|
|
if _c is not None and _c.resolved_via in ("subPin", "customerDefault"):
|
|
_card_line = f"{_c.masked} — the card on your subscription — will be charged."
|
|
except Exception:
|
|
pass
|
|
_cprint(f" {_d(_card_line)}")
|
|
pay_label = f"Pay {_amt} & upgrade now" if _amt else "Upgrade now (prorated charge)"
|
|
action = ("upgrade", tier_id)
|
|
# The money-moving row is NOT the default — a bare Enter hits "Go back",
|
|
# so a single stray keystroke can't charge the card.
|
|
confirm_choices = [
|
|
("cancel", "Go back", "do not charge"),
|
|
("yes", pay_label, "charge + upgrade now"),
|
|
]
|
|
else: # scheduled (whitelisted above)
|
|
_when = p.effective_at[:10] if (p.effective_at and len(p.effective_at) >= 10) else "the end of the billing period"
|
|
_cprint(f" {_b('Confirm plan change')} {_d('· scheduled · not today')}")
|
|
_cprint(f" Change to {target} — takes effect {_when}. No charge now; you keep your current plan until then.")
|
|
pay_label = f"Schedule change to {target}"
|
|
action = ("schedule", tier_id)
|
|
confirm_choices = [
|
|
("yes", pay_label, "apply this change"),
|
|
("cancel", "Go back", "do not change"),
|
|
]
|
|
if p.monthly_credits_delta:
|
|
_cprint(f" {_d(f'Monthly credits change: {p.monthly_credits_delta}.')}")
|
|
raw = self._prompt_text_input_modal(title=pay_label, detail="", choices=confirm_choices)
|
|
if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes":
|
|
print(" 🟡 Cancelled. No plan change.")
|
|
return
|
|
self._subscription_apply(state, action, allow_stepup=allow_stepup)
|
|
|
|
def _subscription_confirm_cancel(self, state):
|
|
"""Confirm, then schedule a cancellation at period end."""
|
|
from cli import _cprint, _b, _d
|
|
|
|
from agent.billing_usage import format_renews
|
|
|
|
c = state.current
|
|
_end = (format_renews(c.cycle_ends_at) if (c and c.cycle_ends_at) else None) or "the end of the billing period"
|
|
print()
|
|
_cprint(f" {_b('Confirm cancellation')} {_d('· scheduled · not today')}")
|
|
_cprint(f" Cancel {(c.tier_name if c else 'your plan')} — it stays active until {_end}, then won't renew.")
|
|
_cprint(f" {_d('You keep your remaining credits for this period. You can resume before it ends.')}")
|
|
confirm_choices = [
|
|
("yes", "Cancel subscription", "schedule cancellation at period end"),
|
|
("cancel", "Go back", "keep your plan"),
|
|
]
|
|
raw = self._prompt_text_input_modal(title="Cancel subscription?", detail="", choices=confirm_choices)
|
|
if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes":
|
|
print(" 🟡 Cancelled. Your plan is unchanged.")
|
|
return
|
|
self._subscription_apply(state, ("cancel", None))
|
|
|
|
def _subscription_apply(self, state, action, idempotency_key=None, *, allow_stepup=True):
|
|
"""Run the mutation for `action`, handling the scope step-up + the result.
|
|
|
|
`action` is one of ("upgrade", tier_id) / ("schedule", tier_id) /
|
|
("cancel", None) / ("resume", None). insufficient_scope routes to the
|
|
step-up and replays; the upgrade idempotency key is reused across the replay.
|
|
``allow_stepup=False`` (a post-grant replay) declines a second step-up on a
|
|
repeated scope denial so the flow can't re-prompt/re-open the browser in a loop.
|
|
"""
|
|
from cli import _cprint, _d, _DIM, _RST
|
|
|
|
from hermes_cli.nous_billing import (
|
|
BillingError,
|
|
BillingTransient,
|
|
BillingRemoteSpendingRevoked,
|
|
BillingScopeRequired,
|
|
BillingSessionRevoked,
|
|
delete_subscription_pending_change,
|
|
post_subscription_upgrade,
|
|
put_subscription_pending_change,
|
|
)
|
|
|
|
kind, arg = action
|
|
key = None
|
|
if kind == "upgrade":
|
|
from agent.billing_view import new_idempotency_key
|
|
|
|
key = idempotency_key or new_idempotency_key()
|
|
try:
|
|
if kind == "upgrade":
|
|
try:
|
|
res = post_subscription_upgrade(subscription_type_id=arg, idempotency_key=key) or {}
|
|
except BillingScopeRequired:
|
|
raise # a scope denial rejects BEFORE charging → route to the step-up
|
|
except (BillingTransient, BillingSessionRevoked, BillingRemoteSpendingRevoked) as exc:
|
|
# Deterministic PRE-charge typed rejections (429 / 401 / 403) never
|
|
# reached Stripe → surface the CORRECT recovery (retry_after / re-login /
|
|
# reconnect), NOT the "maybe charged" ambiguity copy.
|
|
self._subscription_render_error(state, exc)
|
|
return
|
|
except BillingError as exc:
|
|
_status = getattr(exc, "status", None)
|
|
_code = getattr(exc, "error", None)
|
|
if _code in ("network_error", "endpoint_unavailable") or _status is None or _status >= 500:
|
|
# Genuinely INDETERMINATE — transport / unparseable 2xx / a 5xx the
|
|
# server hit mid-request: NAS may have already prorated + charged.
|
|
# Steer to a re-check, never a blind retry (a fresh key can't dedup →
|
|
# a real second charge).
|
|
self._subscription_render_upgrade_ambiguous(exc)
|
|
else:
|
|
# A deterministic 4xx (role_required / no_payment_method / …) → the
|
|
# normal error copy, not "maybe charged".
|
|
self._subscription_render_error(state, exc)
|
|
return
|
|
status = res.get("status")
|
|
name = res.get("targetTierName") or "your new plan"
|
|
_url = res.get("recoveryUrl")
|
|
if status == "already_on_tier":
|
|
_cprint(f" {_DIM}✓ You are already on {name}.{_RST}")
|
|
elif status == "upgraded":
|
|
_cprint(f" {_DIM}✓ Upgraded to {name}. Your new monthly credits land in a moment.{_RST}")
|
|
elif status == "requires_action":
|
|
_cprint(" 🟡 This upgrade needs extra verification (3DS). Finish it on the portal.")
|
|
if _url:
|
|
_cprint(f" Portal: {_url}")
|
|
elif status == "payment_failed":
|
|
_cprint(" 🔴 Your card was declined. Update your payment method on the portal and try again.")
|
|
if _url:
|
|
_cprint(f" Portal: {_url}")
|
|
else:
|
|
# Unknown / absent 2xx status → also ambiguous, not a flat failure.
|
|
self._subscription_render_upgrade_ambiguous(None)
|
|
return
|
|
if kind == "schedule":
|
|
put_subscription_pending_change(subscription_type_id=arg)
|
|
_cprint(f" {_DIM}✓ Scheduled — your plan doesn't change today. You keep it until the end of the billing period, then it switches.{_RST}")
|
|
elif kind == "cancel":
|
|
put_subscription_pending_change(cancel=True)
|
|
_cprint(f" {_DIM}✓ Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.{_RST}")
|
|
elif kind == "resume":
|
|
delete_subscription_pending_change()
|
|
_cprint(f" {_DIM}✓ Undone — you stay on your current plan.{_RST}")
|
|
_cprint(f" {_d('Re-run /subscription anytime to review it.')}")
|
|
except BillingScopeRequired:
|
|
if allow_stepup:
|
|
self._subscription_handle_scope_required(state, retry=action, idempotency_key=key)
|
|
else:
|
|
print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.")
|
|
except BillingError as exc:
|
|
self._subscription_render_error(state, exc)
|
|
|
|
def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=None):
|
|
"""insufficient_scope → grant terminal billing (step-up), then replay `retry`.
|
|
|
|
Mirrors _billing_handle_scope_required: the classic CLI calls
|
|
step_up_nous_billing_scope directly (it opens the browser + blocks), then
|
|
replays the held preview/mutation so the user never re-runs the command.
|
|
"""
|
|
from cli import _cprint, _d, _DIM, _RST
|
|
|
|
print()
|
|
print(" ! One-time setup")
|
|
_cprint(f" {_d('To change your plan from the terminal, enable terminal billing once. It opens your browser to authorize, then your change picks up right here.')}")
|
|
if not getattr(self, "_app", None):
|
|
print(" Run `hermes portal` and enable terminal billing, then re-run /subscription.")
|
|
return
|
|
confirm_choices = [
|
|
("yes", "Enable terminal billing", "open your browser to authorize"),
|
|
("no", "Not now", "cancel"),
|
|
]
|
|
raw = self._prompt_text_input_modal(
|
|
title="Enable terminal billing",
|
|
detail="Opens your browser to authorize this terminal.",
|
|
choices=confirm_choices,
|
|
)
|
|
if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes":
|
|
print(" No change made. Enable terminal billing when you're ready.")
|
|
return
|
|
print(" Opening your browser to enable terminal billing…")
|
|
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" Couldn't enable terminal billing: {exc}")
|
|
return
|
|
if not granted:
|
|
print(" Couldn't enable terminal billing — an org admin or owner has to approve it for this org.")
|
|
return
|
|
_cprint(f" {_DIM}✓ Terminal billing enabled.{_RST}")
|
|
# Bust the 30s token cache so the replay uses the freshly-scoped token. The
|
|
# cache still holds the pre-grant unscoped token, and _request only busts it
|
|
# on a 401 (not a 403 scope denial) — without this, the replay would 403
|
|
# again and (before the allow_stepup guard) re-prompt in a loop.
|
|
try:
|
|
from hermes_cli import nous_billing as _nb
|
|
|
|
_nb.invalidate_cached_token()
|
|
except Exception:
|
|
pass
|
|
# Re-fetch fresh state, then replay the held action ONCE (allow_stepup=False
|
|
# so a repeated scope denial can't re-enter the step-up).
|
|
from agent.subscription_view import build_subscription_state
|
|
|
|
try:
|
|
fresh = build_subscription_state()
|
|
except Exception:
|
|
fresh = state
|
|
rkind, rarg = retry
|
|
if rkind == "preview":
|
|
self._subscription_preview_and_confirm(fresh, rarg, allow_stepup=False)
|
|
else:
|
|
self._subscription_apply(fresh, retry, idempotency_key=idempotency_key, allow_stepup=False)
|
|
|
|
def _subscription_render_error(self, state, exc):
|
|
"""Render a subscription BillingError (a lighter _billing_render_charge_error)."""
|
|
from cli import _cprint
|
|
|
|
code = getattr(exc, "error", None)
|
|
msg = str(exc) or "Something went wrong."
|
|
if code == "insufficient_scope":
|
|
# Defensive: the flow routes scope to the step-up before reaching here.
|
|
_cprint(" 🟡 Terminal billing isn't enabled. Enable it, then retry.")
|
|
elif code in ("subscription_mutation_rejected", "preview_rejected"):
|
|
_cprint(f" 🟡 {msg}")
|
|
else:
|
|
_cprint(f" 🔴 {msg}")
|
|
_url = getattr(exc, "portal_url", None)
|
|
if _url:
|
|
_cprint(f" Portal: {_url}")
|
|
|
|
def _subscription_render_upgrade_ambiguous(self, exc):
|
|
"""A charge-route failure (transport / timeout / 500 / unknown status) is
|
|
AMBIGUOUS — NAS may have already prorated + charged. Steer to a re-check,
|
|
never a flat failure that invites a blind retry (mirrors the TUI's
|
|
upgradeResult(null) — the CLI can't persist the key across a command re-run,
|
|
so a re-check is the safe path)."""
|
|
from cli import _cprint, _d
|
|
|
|
_cprint(" 🟡 Couldn't confirm the upgrade — your card may or may not have been charged.")
|
|
_cprint(f" {_d('Re-run /subscription to check your plan before trying again.')}")
|
|
_url = getattr(exc, "portal_url", None) if exc is not None else None
|
|
if _url:
|
|
_cprint(f" Portal: {_url}")
|
|
|
|
# ------------------------------------------------------------------
|
|
# /billing — Phase 2b terminal billing (CLI surface, all 5 screens)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _show_billing(self, command: str = "/topup"):
|
|
"""`/topup` — terminal billing for Nous (one interactive modal).
|
|
|
|
ZERO sub-commands: any argument is ignored. Bare ``/topup`` 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 — ``/topup 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_subscription``.
|
|
All money is Decimal end-to-end; the terminal never collects card details.
|
|
"""
|
|
from cli import _cprint, _d
|
|
|
|
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 /topup.")
|
|
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 in title, two-bar dollar usage, action menu.
|
|
|
|
Dollars-only (no "credits") — mirrors the TUI /topup overlay: balance
|
|
leads in the title, the shared plan + top-up bars render below, then the
|
|
reordered menu (Add funds first). No scope preflight — terminal billing
|
|
is discovered reactively when a charge 403s insufficient_scope.
|
|
"""
|
|
from cli import _cprint, _b, _d
|
|
|
|
from agent.billing_view import format_money
|
|
|
|
# Shared dollar usage model (plan + top-up bars), same source as /usage.
|
|
try:
|
|
from agent.billing_usage import build_usage_model
|
|
|
|
usage = build_usage_model()
|
|
except Exception:
|
|
usage = None
|
|
|
|
print()
|
|
_cprint(f" 💳 {_b(f'Top up · balance {format_money(state.balance_usd)}')}")
|
|
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}")
|
|
|
|
# Two-bar dollar usage view (plan name on the plan bar; top-up below).
|
|
for _bar_ln in self._usage_bar_lines(usage, getattr(usage, "plan_name", None)):
|
|
print(_bar_ln)
|
|
|
|
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")
|
|
# 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 case (admin + billing on) — others get the portal note below.
|
|
if state.can_change_plan and state.cli_billing_enabled:
|
|
if state.card is not None:
|
|
print(f" Card: {state.card.display}")
|
|
else:
|
|
_cprint(f" {_d('No saved card on file — “Add funds” walks you through adding one.')}")
|
|
print(f" {'─' * 41}")
|
|
|
|
# Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal.
|
|
if not state.can_change_plan:
|
|
_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 add funds here.")
|
|
return
|
|
|
|
# A missing card does NOT gate the whole overview — the org may already have
|
|
# balance, auto-reload, or a limit to view/manage. The card only matters at
|
|
# CHARGE time: "Add funds" -> _billing_buy_flow, which detects no card and
|
|
# hands off to the portal there. So always show the full menu below.
|
|
|
|
# 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
|
|
|
|
# Add funds first, then settings, then the scopeless browser handoff.
|
|
# No "Enable terminal billing" item — that's discovered at pay time.
|
|
# "Add funds" charges in-terminal against the org's portal-saved card
|
|
# (server-held via POST /charge — no card ref leaves the client). A
|
|
# missing card is NOT gated here: the buy flow reacts to the server's
|
|
# no_payment_method 403 and hands off to the portal at charge time.
|
|
choices = [
|
|
("buy", "Add funds", "add money to your balance"),
|
|
("auto", "Auto-reload", "configure automatic top-ups"),
|
|
("limit", "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="Top up your balance", 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 _usage_bar_lines(self, usage, plan_name) -> list:
|
|
"""The plan + top-up dollar bars as ready-to-print lines (filled = remaining).
|
|
|
|
Returns [] when there's nothing to draw. The caller resolves ``plan_name``
|
|
(the plan-bar label) and picks its own print fn — block ordering differs
|
|
per surface (``_cprint`` vs ``print`` under patch_stdout). One source of
|
|
truth for the bar format across /usage, /subscription, and /topup.
|
|
"""
|
|
lines: list = []
|
|
pb = getattr(usage, "plan_bar", None) if usage else None
|
|
if pb is not None and pb.total_usd > 0:
|
|
filled = max(0, min(10, round(pb.fill_fraction * 10)))
|
|
bar = ("█" * filled) + ("░" * (10 - filled))
|
|
pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else ""
|
|
label = (plan_name or "plan").ljust(8)[:8]
|
|
lines.append(f" {label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{pct_s}")
|
|
tb = getattr(usage, "topup_bar", None) if usage else None
|
|
if tb is not None and tb.remaining_usd > 0:
|
|
lines.append(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires")
|
|
return lines
|
|
|
|
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."""
|
|
from cli import _cprint, _d
|
|
|
|
if not state.can_change_plan:
|
|
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_add_card_flow(self, state):
|
|
"""No saved card → guide adding one on the portal, with a re-check loop.
|
|
|
|
Cards are added on the portal (never in-terminal). "I've added it" re-fetches
|
|
billing state so the purchase continues right here once the card is saved —
|
|
this also recovers a transient miss (the card display is best-effort
|
|
server-side). Returns the refreshed state (card present), or None to abandon.
|
|
"""
|
|
from cli import _cprint, _b, _d, _DIM, _RST
|
|
|
|
print()
|
|
_cprint(f" 💳 {_b('Add a card first')}")
|
|
_cprint(" No saved card on file.")
|
|
_cprint(f" {_d('Add a card once on the portal billing page — after that you can top up right from the terminal.')}")
|
|
choices = [
|
|
("portal", "Add a card on the portal", "opens the billing page in your browser"),
|
|
("recheck", "I've added it — check again", "re-check for the card and continue"),
|
|
("cancel", "Back", "do nothing"),
|
|
]
|
|
for _ in range(8): # bounded: portal-open plus a handful of re-checks
|
|
raw = self._prompt_text_input_modal(title="Add a card", detail="", choices=choices)
|
|
choice = self._normalize_slash_confirm_choice(raw, choices)
|
|
if choice == "portal":
|
|
self._billing_open_portal(state)
|
|
_cprint(f" {_d('Add the card on the billing page, then pick “check again” here.')}")
|
|
continue
|
|
if choice == "recheck":
|
|
from agent.billing_view import build_billing_state
|
|
|
|
try:
|
|
fresh = build_billing_state()
|
|
except Exception:
|
|
fresh = None
|
|
if fresh is not None and fresh.logged_in:
|
|
state = fresh
|
|
if state.card is not None:
|
|
_cprint(f" {_DIM}✓ Card found: {state.card.display} — continuing.{_RST}")
|
|
return state
|
|
print(" Still no card on file — finish adding it on the portal, then check again.")
|
|
continue
|
|
break
|
|
print(" Cancelled. No funds added.")
|
|
return None
|
|
|
|
def _billing_buy_flow(self, state):
|
|
"""Screen 2 (preset select) → Screen 3 (confirm + charge + poll)."""
|
|
from cli import _cprint, _b
|
|
|
|
from agent.billing_view import format_money, validate_charge_amount
|
|
|
|
if not self._billing_require_admin(state):
|
|
return
|
|
|
|
# No card / scope preflight here — that's the rejected anti-pattern. We let
|
|
# the charge fly and react to whatever 403 the server returns: scope first
|
|
# (insufficient_scope → in-flight reauth), then card (no_payment_method →
|
|
# portal handoff via _billing_render_charge_error). Mirrors the server's gate
|
|
# order; the user only hits the flow they actually need.
|
|
|
|
# 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('Add funds')}")
|
|
print(f" Presets: {presets}")
|
|
print(" Run this in the interactive CLI to complete a purchase.")
|
|
self._billing_portal_hint(state)
|
|
return
|
|
|
|
# No card on file → the guided ADD-CARD path first (portal + re-check),
|
|
# so the user isn't walked through picking an amount that will 403.
|
|
# Returns refreshed state with a card, or None (abandoned).
|
|
if state.card is None:
|
|
state = self._billing_add_card_flow(state)
|
|
if state is None or state.card is None:
|
|
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.display}" if card else "No saved card on file"
|
|
raw = self._prompt_text_input_modal(
|
|
title="Add funds", detail=detail, choices=preset_choices,
|
|
)
|
|
choice = self._normalize_slash_confirm_choice(raw, preset_choices)
|
|
if not choice or choice == "cancel":
|
|
print(" Cancelled. No funds 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 funds 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 cli import _cprint, _b, _d
|
|
|
|
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.display}")
|
|
# Provenance-less payloads (older NAS) keep the generic line; when
|
|
# the resolver says WHY this card, the Payment line carries it.
|
|
if card.provenance is None:
|
|
_cprint(f" {_d('Your card saved on the portal will be charged.')}")
|
|
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"),
|
|
("portal", "Manage on portal", "manage your card / billing in the browser"),
|
|
("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.display if card else "no saved card"),
|
|
choices=confirm_choices,
|
|
)
|
|
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
|
|
if choice == "portal":
|
|
self._billing_open_portal(state)
|
|
return
|
|
if choice != "pay":
|
|
print(" Cancelled. No funds 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:
|
|
# In-flight reauth: enable terminal billing, then resume THIS charge
|
|
# (press-Enter beat) — no command re-run. Reuses the same idem key.
|
|
self._billing_handle_scope_required(state, amount=amount, idempotency_key=key)
|
|
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,
|
|
BillingTransient,
|
|
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 BillingTransient 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} added to your balance.")
|
|
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 (
|
|
BillingTransient,
|
|
BillingRemoteSpendingRevoked,
|
|
BillingSessionRevoked,
|
|
)
|
|
|
|
code = getattr(exc, "error", None)
|
|
actor = getattr(exc, "actor", None)
|
|
portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None)
|
|
if isinstance(exc, BillingRemoteSpendingRevoked) or code == "remote_spending_revoked":
|
|
# CF-4: this terminal's spend was revoked. Recovery is reconnect.
|
|
who = ("An admin stopped this terminal's spending."
|
|
if actor == "admin"
|
|
else "You stopped this terminal's spending.")
|
|
print(f" 🔴 {who} Reconnect to restore — run `hermes portal` to re-authorize.")
|
|
elif isinstance(exc, BillingSessionRevoked) or code == "session_revoked":
|
|
print(" 🔴 Your session was logged out. Run `hermes portal` to log in again.")
|
|
elif code == "no_payment_method":
|
|
print(" 💳 No card on file — top up and manage billing on the portal.")
|
|
elif code in ("cli_billing_disabled", "remote_spending_disabled") or \
|
|
getattr(exc, "code", None) == "remote_spending_disabled":
|
|
print(" Terminal billing is off for this account — an admin must enable it on the portal.")
|
|
elif code == "role_required":
|
|
print(" Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.")
|
|
elif code == "idempotency_conflict":
|
|
print(" 🔴 That charge key was already used for a different amount. Start a fresh top-up.")
|
|
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, BillingTransient):
|
|
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.")
|
|
elif code == "insufficient_scope":
|
|
# Never leak the raw billing:manage scope (the post-grant replay can
|
|
# re-raise it if the grant raced) — the concept is "terminal billing".
|
|
print(" 🔴 Terminal billing needs approval — run /topup to enable it, then retry.")
|
|
else:
|
|
print(f" 🔴 {exc}")
|
|
if portal_url:
|
|
print(f" Portal: {portal_url}")
|
|
|
|
def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key=None):
|
|
"""403 insufficient_scope → in-flight reauth, then resume the held charge.
|
|
|
|
The buy path discovers terminal billing isn't enabled only when the
|
|
charge 403s — there is no preflight. We enable it in-flight ("Enable
|
|
terminal billing" → browser device-flow), then on return ask the user to
|
|
press Enter to resume the held ``amount`` (reusing ``idempotency_key`` so
|
|
the resumed charge collapses with the original). Never leaks the raw
|
|
billing:manage scope.
|
|
"""
|
|
from cli import _cprint, _d
|
|
|
|
from agent.billing_view import format_money
|
|
|
|
amount_str = format_money(amount) if amount is not None else "your top-up"
|
|
print()
|
|
print(" ! One-time setup")
|
|
_cprint(f" {_d(f'To charge this terminal, enable terminal billing once. It opens your browser to authorize, then {amount_str} picks up right here.')}")
|
|
if not getattr(self, "_app", None):
|
|
print(" Run `hermes portal` and enable terminal billing, then retry.")
|
|
return
|
|
confirm_choices = [
|
|
("yes", "Enable terminal billing", "open your browser to authorize"),
|
|
("no", "Not now", "cancel"),
|
|
]
|
|
raw = self._prompt_text_input_modal(
|
|
title="Enable terminal billing",
|
|
detail="Opens your browser to authorize this terminal.",
|
|
choices=confirm_choices,
|
|
)
|
|
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
|
|
if choice != "yes":
|
|
print(" No charge made. Run /topup when you want to enable terminal billing.")
|
|
return
|
|
print(" Opening your browser to enable terminal billing…")
|
|
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" Couldn't enable terminal billing: {exc}")
|
|
return
|
|
if not granted:
|
|
print(" Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.")
|
|
return
|
|
|
|
# Granted. The token now carries the scope, but 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 not (fresh.logged_in and fresh.cli_billing_enabled):
|
|
print(" Terminal billing was enabled for this terminal, but it's still turned off for this org. Enable it in the portal, then run /topup again.")
|
|
self._billing_portal_hint(fresh)
|
|
return
|
|
|
|
# Scope granted + org kill-switch on — but a charge still needs a card on
|
|
# file. If there's none, this is a half-done state: say so and route to the
|
|
# portal to top up / manage billing, rather than a bare "✓ enabled" that reads as done.
|
|
if fresh.card is None:
|
|
print(" ✓ Terminal billing enabled — but there's no card on file yet.")
|
|
_cprint(f" {_d('Top up and manage billing on the portal to continue.')}")
|
|
self._billing_portal_hint(fresh)
|
|
return
|
|
|
|
# Nothing to resume (scope-required hit outside a charge, e.g. auto-reload
|
|
# config) → just tell the user it's ready.
|
|
if amount is None:
|
|
print(" ✓ Terminal billing enabled. Run /topup to continue.")
|
|
return
|
|
|
|
# Press-Enter beat: the user is back from the browser; resume the held
|
|
# purchase on an explicit confirm (reassuring, not silent).
|
|
print(" ✓ Terminal billing enabled.")
|
|
resume_choices = [
|
|
("resume", f"Resume {format_money(amount)} top-up", "finish the held purchase"),
|
|
("cancel", "Cancel", "do not charge"),
|
|
]
|
|
raw = self._prompt_text_input_modal(
|
|
title="Resume your top-up",
|
|
detail=f"{format_money(amount)} is ready to finish — press Enter to resume.",
|
|
choices=resume_choices,
|
|
)
|
|
if self._normalize_slash_confirm_choice(raw, resume_choices) != "resume":
|
|
print(" Cancelled. No funds added.")
|
|
return
|
|
|
|
# Replay the held charge, reusing the original idempotency key so a
|
|
# double-submit collapses to one charge.
|
|
from hermes_cli.nous_billing import BillingError, post_charge
|
|
|
|
from agent.billing_view import new_idempotency_key
|
|
|
|
key = idempotency_key or new_idempotency_key()
|
|
try:
|
|
result = post_charge(amount_usd=amount, idempotency_key=key)
|
|
except BillingError as exc:
|
|
self._billing_render_charge_error(fresh, exc)
|
|
return
|
|
charge_id = result.get("chargeId")
|
|
if not charge_id:
|
|
print(" No charge id returned; please check the portal.")
|
|
return
|
|
_cprint(f" {_d('Resuming your top-up — confirming settlement…')}")
|
|
self._billing_poll_charge(fresh, charge_id, amount)
|
|
|
|
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 cli import _cprint, _b, _d
|
|
|
|
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 add funds when your balance is low.')}")
|
|
if card:
|
|
print(f" Card on file: {card.masked}")
|
|
else:
|
|
print(" No saved card — manage billing on the portal.")
|
|
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 cli import _cprint, _b, _d
|
|
|
|
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)
|