mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(tui+cli): change your Nous plan from the terminal (/subscription, /topup, terminal-billing UX) (#51639)
* feat(tui): rename /billing slash command to /topup
Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.
* refactor(tui): extract overlay primitives to shared module
Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.
* feat(tui): add /subscription + /topup CTAs to /usage output
Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.
* feat(tui): add subscription wire types
Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.
* feat(gateway): add subscription.state + subscription.manage_link RPCs
- agent/subscription_view.py: SubscriptionState dataclass + fail-open
build_subscription_state() (mirrors billing_view pattern) +
get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
post_subscription_manage_link() HTTP helpers for the two NAS endpoints
(WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
subscription.state RPC (fail-open) + subscription.manage_link RPC
(returns {ok,kind,url} or typed error envelope via
_serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
HTTP round-trip, not a device flow.
* feat(tui): add subscription overlay state types + store slot
Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.
* feat(tui): build SubscriptionOverlay — overview + confirm + handoff
Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.
* feat(tui): add /subscription command + overlay wiring
- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
refreshState, requestRemoteSpending) + run handler that fetches
subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
/upgrade alias, /subscription resolves).
* fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type
Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).
* feat(tui/subscription): render cancellation-scheduled note with headline precedence
Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'
Headline precedence when multiple flags co-occur:
past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.
* feat(tui/subscription): team-context screen — redirect to /topup for team orgs
Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:
'This terminal is connected to {org_name}. Teams run on shared
credits — use /topup to add funds. Personal subscriptions live
on your personal account.'
The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.
* fix(subscription): drop manage-link gateway RPC, build URL locally
The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.
- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
/manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)
* chore(subscription): drop unused format_money import
* feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs
Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).
* feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan
- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
(a card-failing subscriber returns as a normal plan now), and treat no-plan as
current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
drive every state (CLI + live TUI) with no portal.
Verified against handoff 2026-06-24_subscription-tui-handoff.md.
* feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)
Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
immediately (no 15-min zombie button), handles session_revoked, the dual-
emitted cli_billing_disabled/remote_spending_disabled, role_required,
idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.
Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.
* refactor(subscription): remove dead step-up scaffolding from /subscription
/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.
* feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path
Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
opens via the existing out-of-band billing.step_up.verification event) →
replay the held charge (pendingCharge.amount) and settle, with no command
re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.
Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).
* feat(billing): shared dollar usage model + two-bar view (drop "credits")
Single source of truth for the /usage and /subscription usage bars across
TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total
remaining, monthly allowance, renewal) and produces a surface-agnostic model:
two full-resolution bars (plan allowance + purchased top-up), a status
classification (free | healthy | low | depleted), and a human renewal date.
- agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account
(fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware),
format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold.
- tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a
usage.bars RPC, and the model embedded into subscription.state so the overlay
renders the same bars from its single fetch.
- Dollars only, never "credits"; two separate bars (not a crammed
three-segment one) for legibility at terminal widths.
- tests/agent/test_billing_usage.py: status classification, bar math
(clamp/over-cap), NaN/Inf rejection, fail-open invariants.
* feat(tui): dollar usage bars on /usage + /subscription, drop tier picker
Render the shared two-bar dollar model in both overlays; strip "credits" and
the in-terminal tier selection per UX feedback.
- overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance,
green top-up) + usageBarsText for the /usage panel. Plan name labels the
bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up
"never expires".
- subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the
breakdown), human renewal date, state-matched nudges (free upsell / <$5
low alert) with box-safe ASCII markers (! / >) instead of the width-unstable
emoji that broke the border. Tier picker removed — overview shows usage +
plan, then "Manage on portal" / "Close" (free users get "Start a
subscription"). No "credits" anywhere.
- session.ts: /usage renders the dollar bars + balance summary, falling back
to the legacy credits lines only when the model is unavailable; CTA reworded.
- gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on
SessionUsageResponse/SubscriptionStateResponse.
- Tests updated to the new contract (no "credits", "left of", dedup, markers).
* feat(cli): mirror dollar usage bars on /usage + /subscription
CLI parity with the TUI billing rework, from the same shared usage model.
- _print_nous_credits_block (/usage) and _subscription_overview render the
two-bar dollar view (plan name on the bar, "$X left of $Y · N% used",
top-up "never expires", total spendable) instead of the credits-worded block.
- Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and
every user-facing "credits"; team copy says "shared balance".
- Human renewal date via the shared format_renews; status line dedupes the
"$X left"; free upsell + <$5 low alert with ASCII markers.
- /subscription manage modal no longer dumps the raw manage-subscription URL
in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it.
Title is "Manage your subscription" (no in-terminal plan change). The raw URL
stays only in the non-interactive / not-admin fallbacks, which have no menu.
- /usage token-usage panel (model, tokens, cost, context) left untouched.
* feat(billing): embed dollar usage model into billing.state for /topup
The /topup overview renders the same two-bar dollar usage (plan + top-up) as
/usage and /subscription. Embed the shared usage model into the billing.state
RPC payload (mirrors subscription.state) so the overlay gets the bars from its
single fetch, and add the `usage` field to BillingStateResponse.
* feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume
Reworks the /topup overlay per the Jun 19 review and the no-preflight decision.
Overview:
- Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar
usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar.
- "Add funds" is the first action (was "Buy credits"); auto-reload / monthly
limit / manage-on-portal follow. Dollars only — no "credits" anywhere.
- No "Enable terminal billing" menu item and NO scope preflight: whether the
terminal can charge is discovered reactively at pay time. (We deliberately do
not read/refresh the OAuth token to gate UI.)
Step-up (reached only on a charge's insufficient_scope 403):
- New 4-phase flow that keeps the modal mounted: prompt (one-time-setup
heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to
resume") → replay the held charge → settle. The press-Enter beat is the
reassuring "you're back, finish your purchase" moment.
- Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never
leaks the raw billing:manage scope (guarded by the render test).
- topup.ts error copy de-crufted to terminal-billing wording, emoji removed.
Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests
(balance-in-title, Add-funds-first, two-bar usage, no "credits").
* feat(cli/topup): mirror overview reorder + in-flight reauth resume
CLI parity with the TUI /topup rehaul, from the same shared usage model.
- _billing_overview: balance in the title, the two-bar dollar usage (plan name
on the plan bar, top-up "never expires") in place of the old cap spend bar,
"Add funds" first, dollars throughout — no "credits", no scope preflight.
- _billing_handle_scope_required: now takes the held amount + idempotency key
and runs the in-flight flow — "Enable terminal billing" → browser device-flow
→ re-check the org kill-switch → press-Enter to resume → replay the held
charge (reusing the key so a double-submit collapses to one). Stops leaking
the raw billing:manage scope.
- Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars.
- Tests updated to the new overview + buy copy.
* fix(billing): guard non-JSON 2xx responses in the billing HTTP client
A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML
page served when a billing route isn't actually mounted on a deployment — hit
json.loads() on the success path of _request() and raised a raw
json.JSONDecodeError. That escaped the typed-BillingError contract, so callers'
`except BillingError` missed it and fell through to a generic fail-open that
rendered as a misleading "not logged in" (observed when /api/billing/subscription
was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]).
Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable")
so surfaces degrade gracefully ("could not load …") instead of crashing or
mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its
.json(); this closes the same hole on the success path.
Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed
error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON
parses.
* feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing
build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE
is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States:
nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the
card-on-file gate, admin role, and kill-switch paths are exercisable offline
without a live portal. Env-var gated; returns None when unset (no prod leak).
Adds 8 behavior tests asserting the card/admin/billing-on contract per state.
* refactor(billing): fold /credits into /topup
/credits is redundant now that /topup shows the dollar balance + portal handoff.
Make 'credits' (and 'billing') aliases of /topup so typing /credits still works,
resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help).
Remove the standalone /credits surface across 6 places:
- CLI _show_credits handler + dispatch
- gateway _handle_credits_command -> renamed _handle_topup_command, copy softened
to 'Manage billing on the portal' (the messaging billing surface; /topup is now
gateway-available so messaging keeps billing — credits was the only one before)
- TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry
- tui_gateway credits.view RPC + the CreditsViewResponse type
- Slack _SLACK_VIA_HERMES_ONLY: credits -> topup
Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and
stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests
updated (test_credits_folds_into_topup) or pruned for the removed symbols.
* fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph
In-terminal charge (POST /charge against the org's server-held card, no card ref
leaves the client):
- card present: confirm screen shows 'Your card saved on the portal will be
charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI)
- no card on file: /topup overview + buy flow detect it and route to the portal
to add a card, instead of offering a charge that 403s no_payment_method
/usage bar ordering: route the dollar block through _cprint consistently. The
Plan: line (_cprint) and the bar (raw print) flushed to different buffers under
patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA
is stable across all states.
Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal
titles — it measures 1 char but renders 2 columns, shifting the box's right
border (the stray '|'). Includes the f-string 'Pay $X?' title.
Small /credits -> /topup string bits in cli.py ride along with the surrounding
charge edits (the fold lives in the sibling refactor commit).
* refactor(billing): apply safe simplify-pass fixes
Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency):
- dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real
mismatch vs subscription_view's _DEV_FIXTURE_PORTAL)
- TUI billingOverlay choose(): collapse two byte-identical branches (needsCard +
the not-full else both = portal-or-close at index 0) into one tail; the only
divergent path (full && !needsCard → buy/auto/limit) stays explicit
- /topup overview comment: correct the stale 'buy_flow detects no_payment_method'
note (the overview's no-card gate fires first, so reaching Add funds implies a
card on file)
Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live
dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge
(cheap correct defense on the money path), and folding the no-card handoff into a
shared helper (touches 4 money-path sites for tidiness — not worth the risk here).
* fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal)
* refactor(billing): drop the /credits alias entirely
The /credits fold made it an alias of /topup; now remove that too. Typing
/credits is an unknown command, not a silent redirect — billing lives only on
/topup (with /billing kept as the old command's back-compat name). Dropped the
alias from the registry CommandDef and the TUI topup.ts; updated the test to
assert /credits resolves to nothing (no command, no alias).
* docs(billing): fix stale comment in _billing_overview — describe reactive no-card path
The comment still described the removed overview-level card gate ('no-card case
handled above'). Corrected to: the buy flow reacts to the server's
no_payment_method 403 and hands off to the portal at charge time (no preflight).
* refactor(billing): simplify-pass — share usage-payload helper, drop dead bar wire fields + redundant admin gate
* refactor(billing): drop the /billing alias too — /topup is the only billing command
Following /credits removal, retire the old /billing name as well. /topup now has
NO aliases — both /credits and /billing are unknown commands. Dropped the alias
from the registry CommandDef and TUI topup.ts; fixed the one live user-facing
straggler (the not-logged-in message said 'then /billing' → /topup) and the
_show_billing docstring/default-arg references. Test asserts /topup carries no
aliases and neither old name resolves.
* fix(billing): code-review fixes — money-path + parity bugs
Money path (TUI):
- auto-reload "Turn off" now echoes current threshold/top_up_amount so the
PATCH succeeds (was sending {enabled:false} → invalid_request → stayed ON)
- charge poll honors the 5-min cap on the 429/503 throttle branch too (was
rescheduling forever); cap folded into one timedOut() helper
- step-up resume reacts to the replay outcome instead of unconditionally
closing on a reassuring line with no charge made
- synchronous submit guard on Confirm so two key events can't double-charge
Gateway:
- billing.step_up routes typed errors through _serialize_billing_error (was a
raw {error:'error'} dict → generic copy for session_revoked)
- billing.state / subscription.state / usage.bars / session.usage moved to
_LONG_HANDLERS (blocking portal HTTP no longer stalls the main stdin loop)
CLI:
- _billing_render_charge_error handles insufficient_scope without leaking the
raw billing:manage scope name on a post-grant replay re-raise
Python model:
- subscription_view tier parse None-coalesces tierOrder/dollarsPerMonth so a
free tier's 0 survives ($0, not "—"; correct sort order)
TUI parity/robustness:
- /usage shows formatted renews_display, not raw ISO renews_at
- subscription overview guards a null pending_downgrade_at (was "on null.")
- subscription overview surfaces a message instead of silently closing when
portal_url is missing
- buildManageUrl wraps new URL() so a malformed portal_url can't throw out of
the Ink key handler
* fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating
- CLI plan bar now fills by REMAINING (fuel-gauge), matching the shared model's
fill_fraction, the top-up bar, and the TUI — same account renders identically
on both surfaces (#8)
- subscription serializer emits cancellation_effective_display /
pending_downgrade_display (format_renews); TUI shows 'Jul 1, 2026' not raw ISO (#14b)
- _SLACK_VIA_HERMES_ONLY now includes the 'billing' alias so it follows its
canonical /topup via /hermes instead of leaking a native Slack slot (#9)
* fix(billing): thread idempotency key through the TUI step-up replay (#2)
Mint a stable idempotency key when the purchase amount is chosen; it rides
pendingCharge into both the Confirm charge and the post-grant step-up replay,
so a retried charge dedups server-side (the gateway already echoes the key).
A fresh amount selection gets a fresh key. Combined with the sync submit guard,
a double-submit now collapses to one charge.
* refactor(billing): remove dead /subscription tier-picker scaffolding (#18)
The in-terminal plan picker was cut (deep-link only), leaving a whole unreached
state machine. Removed end-to-end:
- TUI: ConfirmScreen, HandoffScreen, the 'confirm'/'handoff' screen types,
pendingTargetTierId, and the now-dead onPatch threading (collapsed the dispatch
to a single overview screen + folded the duplicate Box wrapper)
- gateway: the tiers serialization + SubscriptionTierOption wire type
- model: SubscriptionTier, _parse_tier, _coalesce, _dev_tiers and the tiers field
(never displayed on either surface, so this supersedes the tier-parse fix)
- tests: dropped the confirm/handoff/tier-passthrough tests; slimmed the overview
render tests
Net: a large dead-code cull (no behavior change — the picker never ran).
* test(billing): parametrize usage-model tests; drop dead is_low/is_free props
Collapse the fail-open + status-classification cases into parametrized tables
(same coverage, ~80 fewer lines) and remove the now-unused UsageModel.is_low /
is_free properties (only a test pinned them).
* fix(billing): revert dead 'billing' Slack-via-hermes entry — the alias was dropped
#9 was based on a stale review diff: /billing is no longer an alias of /topup
(dropped earlier), so routing it via /hermes filtered a name that doesn't exist.
* test(billing): cull redundant TUI billing tests (parametrize, merge dupes)
usageCommand: collapse 3 CTA tests into one + a panel helper.
billingStepUp: merge the two step-up render asserts.
topupCommand: parametrize requestRemoteSpending + the revoked-actor pair, drop
the redundant happy-path-submitted test. Money-path + error-mapping coverage
preserved.
* refactor(billing): extract _usage_bar_lines — one source of truth for the CLI bars
The plan + top-up bar format was copy-pasted across _print_nous_credits_block,
_subscription_overview, and _billing_overview. Extract a helper returning the
ready-to-print lines; each caller keeps its own print fn (the _cprint-ordering
constraint stays) and resolves its plan-name label. Centralizes the format so
the three surfaces can't drift.
* feat(billing): NAS V3 subscription-change HTTP client wrappers
Add the four write-side wrappers for the V3 subscription contract to nous_billing,
each a thin _request() call (reusing auth, JSON, 401-retry, typed errors):
- post_subscription_preview → POST /subscription/preview (chargeless quote)
- put_subscription_pending_change→ PUT /subscription/pending-change (downgrade/cancel)
- delete_subscription_pending_change → DELETE .../pending-change (resume/undo)
- post_subscription_upgrade → POST /subscription/upgrade (the money route)
pending-change takes a discriminated body (tier_change | cancellation); upgrade
requires an Idempotency-Key (mandatory, validated client-side before any I/O).
Tests assert the exact method/path/body/header each wrapper puts on the wire.
* feat(billing): subscription tier catalog + change-preview models
Reinstate the catalog the in-terminal picker needs (was culled when /subscription
was deep-link-only): SubscriptionTier + SubscriptionState.tiers + _parse_tier, with
_coalesce so the free tier's 0 tierOrder/price survives a falsy-or. Parse the
catalog from GET /subscription's tiers and seed _dev_tiers into every fixture.
Add SubscriptionChangePreview + subscription_change_preview_from_payload for the
POST /preview quote (effect/amountDueNowCents/effectiveAt/reason + tier delta); a
malformed/missing effect fails safe to 'blocked' so a bad quote never reads as a
charge. Module docstring updated: the overlay is no longer deep-link-only.
* feat(billing): gateway RPCs for the V3 subscription change flow
Add subscription.preview / .change / .resume / .upgrade RPCs, each wrapping its
nous_billing call and reusing _serialize_billing_error for the typed envelope
(so a 403 still drives the device step-up). upgrade mints + echoes the
idempotency key and surfaces status + recovery_url so the TUI can route an
SCA/decline to the portal. Re-add the tier catalog to _serialize_subscription_state
(price pre-formatted) for the picker. All four are pool-routed (_LONG_HANDLERS) —
preview + upgrade hit Stripe and must not stall the main stdin loop.
* feat(billing): in-terminal subscription change flow (TUI)
/subscription is no longer deep-link-only: it drives the change in-terminal
against the V3 contract via the new gateway RPCs. The overlay is a state machine
overview → picker → confirm → result:
- picker lists the tier catalog with upgrade/downgrade hints (current + free
excluded; free=cancel, on the overview);
- confirm shows the previewed effect — pay $X now (upgrade) / scheduled at date
(downgrade) / cancel at period end / blocked-with-reason — then applies it;
- an upgrade's SCA/decline routes to the portal via the result screen's recovery
link; resume/cancel/downgrade are chargeless.
Starting a NEW subscription still deep-links (needs a fresh card). insufficient_scope
points to /topup (the step-up stays there, not duplicated here). Adds the wire
types (tiers + preview/upgrade responses), widens the overlay ctx + screen state,
and threads onPatch. Render tests cover every screen.
* feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI)
Two improvements to the /subscription overlay:
Step-up re-auth in place. When a mutation (preview/change/upgrade/resume) returns
insufficient_scope, route to a new 'stepup' screen that grants terminal billing
via billing.step_up and AUTO-REPLAYS the held action on grant — no bounce to
/topup. Scope routing is centralized in previewAndRoute/applyPendingAndRoute/
resumeAndRoute (shared by the picker, confirm, overview + the step-up replay). The
browser opens via the shared global verification handler; copy never leaks the raw
billing:manage scope.
Make a scheduled change unmissable. A downgrade/cancel was one buried warn line
that read as 'nothing happened'. Now the overview leads with a banner
(⏳ Scheduled change · Ultra ──▶ Plus · <date> · you keep Ultra until then), the
status line echoes the transition (Plan: Ultra → Plus), 'Keep <tier> (undo)' is
promoted to the first olive action, the result screen says 'your plan doesn't
change today', and confirm gets a charged-now / scheduled chip.
* feat(billing): full in-terminal subscription change flow in the classic CLI
Bring the CLI to parity with the TUI overlay — /subscription is no longer
deep-link-only. A paid admin/owner gets picker → preview → confirm → apply,
mirroring the /topup buy flow's modal idioms:
- _subscription_change_menu (change / undo-or-cancel / manage-on-portal),
- _subscription_pick_tier (catalog with upgrade/downgrade hints),
- _subscription_preview_and_confirm (POST /preview → effect-aware confirm),
- _subscription_apply (schedule / cancel / resume chargeless; upgrade charges
the sub's card, SCA/decline → portal),
- _subscription_handle_scope_required (insufficient_scope → step_up_nous_billing_scope
inline, then replays the held preview/mutation — reusing the upgrade idempotency key).
Also the scheduled-change UX fix: the overview leads with a prominent banner
(⏳ Scheduled change · Super ──▶ Plus · <date> · you keep Super until then) and the
status line echoes the transition, matching the TUI. Members / non-interactive /
free still deep-link. Tests drive every branch via a mocked modal + nous_billing.
* fix(billing): close TUI subscription money-path holes (ultracode review)
- Un-consented charge (P1): the step-up now HOLDS at a 'granted' phase requiring
an explicit Continue, and an abortedRef gates the grant's late .then — a cancel
during the browser flow can no longer replay the held upgrade + charge.
- Missing idempotency key (P2): mint it when building an upgrade 'pending' so it
rides into confirm AND the step-up replay (was always undefined → gateway minted
a fresh key per call, defeating dedup).
- Navigate-away re-charge (P2): confirm 'back' is guarded by submittingRef while an
apply is in flight.
- Ambiguous charge (P2): a transport-null upgrade is reported as 'may or may not
have charged — re-check', never a flat failure that invites a blind retry.
- Typed step-up denial (P2): requestRemoteSpending returns {granted,error,message};
the screen maps session_revoked / remote_spending_revoked / rate_limited to the
right recovery instead of always 'an admin must allow it'.
* fix(billing): close CLI subscription money-path holes (ultracode review)
- Bounded step-up (P2): bust the 30s token cache after a grant (it held the
pre-grant unscoped token; _request only busts on 401, not 403) and replay ONCE
with allow_stepup=False so a still-denied scope can't re-prompt/re-open in a loop.
- Stray-keystroke charge (P3→near-P2): the upgrade confirm defaults to 'Go back',
not 'Pay ' — a bare Enter can't move money.
- Fail-open on unknown effect (P3→near-P2): an unrecognized preview effect now
fails SAFE (portal hand-off) instead of scheduling a real PUT.
- 'cancel' word collision (P3): the Close row uses value 'close' so typing 'cancel'
can't hit it and falsely report 'Cancelled'.
- blocked effect re-offers the portal; undo is promoted to the first row when a
change is pending (TUI parity).
* fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A)
The P1 fix split the auto-replay into a user-triggered resume() on the granted
screen, where the default row is the charging action — but resume() had no
re-entrancy guard, so a double-Enter fired two replays (the upgrade dedups on the
shared key, but schedule/cancel/resume replays carry none → duplicate PUT/DELETEs).
Mirror billingOverlay.resume(): flip to a 'resuming' phase + a resumingRef so it
fires at most once, and block 'back' once resuming (no re-mount → no second submit).
* fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B)
The TUI hardened upgradeResult(null) but the CLI charging route did not: a
transport/timeout/500 (or unknown 2xx status) on post_subscription_upgrade — after
NAS may have already prorated + charged — printed a flat failure, and a manual
re-run mints a FRESH idempotency key the server can't dedup → a real second charge.
Now the charge route reports 'your card may or may not have been charged — re-run
/subscription to check before trying again' and steers away from a blind retry
(the CLI can't persist the key across a command re-run). Also thread allow_stepup
through the preview→apply replay (BUG C.1) and route the requires_action/
payment_failed portal lines through _cprint for deterministic ordering.
* fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1)
The round-2 resume guard ('resuming' phase + resumingRef) could deadlock: on a
REPEAT insufficient_scope during the post-grant replay, the route helpers did
onPatch({screen:'stepup'}) — a no-op since we're already mounted on stepup (no key
→ no remount) — leaving phase='resuming'/resumingRef=true frozen on 'Applying your
change…'. Thread allowStepUp through previewAndRoute/applyPendingAndRoute/
resumeAndRoute; the resume() replay passes false, so a repeat scope denial surfaces
a 'still isn't enabled' result instead (mirrors the CLI's allow_stepup=False cap).
Also: applyPendingAndRoute(pending=null) now routes to overview, not a stranded
Promise.resolve().
* fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2)
The round-2 fix caught EVERY non-scope BillingError as 'may or may not have been
charged' — but typed pre-charge rejections (BillingRateLimited 429, BillingSessionRevoked
401, BillingRemoteSpendingRevoked 403, role_required/no_payment_method 4xx) never
reached Stripe, so the ambiguity copy was wrong and dropped their real recovery hints.
Now route those to _subscription_render_error, and reserve the ambiguous copy for
genuinely indeterminate outcomes (network_error / endpoint_unavailable / status None /
5xx). Tests: rate-limit stays deterministic; a real transport failure stays ambiguous.
* feat(billing): card visibility + guided add-card path in /topup and /subscription
Consume the NAS card-resolver contract (card.resolvedVia + chargeability) across
both surfaces, degrading cleanly on today's NAS (fields absent → prior behavior):
- WHICH card: the payment lines render provenance — 'Visa ····4242 — the card on
your subscription' (resolvedVia → label; unknown rung/older NAS → masked card +
the old generic line). Link payment methods render the brand alone (last4 is
empty — never 'Link ····').
- Presence at a glance: the /topup overview now shows 'Card: …' or 'No saved
card on file' for the full-menu case, plus a warning when the resolver marks
the card needs_repair (failing auto-reloads) on overview/buy/confirm.
- Add-card path: with no card on file, 'Add funds' becomes a guided screen —
open the portal billing page, then 'I've added it — check again' re-fetches
billing state and continues straight into the purchase (also recovers a
transient display miss). Cards are never entered in-terminal.
- /subscription upgrade confirm names the exact card ('Visa ····4242 — the card
on your subscription — will be charged'), best-effort via billing.state and
only when the resolution rung matches what a subscription charge actually
uses (subPin/customerDefault, mirroring Stripe's precedence); otherwise the
generic line stands. Fail-soft: any lookup error keeps the generic line.
- Gateway serializes display/resolved_via/needs_repair; TUI ctx gains
refreshState (topup) + fetchCard (subscription); new offline fixtures
card-sub / card-repair.
Tests: TUI ctx mocks extended; CLI suites cover provenance + repair-warning
render, the Link guard, the add-card path (continue-after-recheck + abandon),
the sub-confirm card line, and keep the confirm-time lookup offline in tests.
* fix(billing): consume server canChangePlan, preserve distinct refusal codes, drop dead chargeability
- Parse canChangePlan verbatim from NAS payloads into BillingState and
SubscriptionState; fall back to the legacy OWNER/ADMIN check only when the
server omits the field (FINANCE_ADMIN stops being locked out where NAS
authorizes it). Role model updated to the 5-role enum.
- Add the autoReload.card union (canonical | distinct | none) end-to-end:
parse + gateway serialization, distinct carries payment_method_id/brand/last4
with nullable display fields.
- stripe_unavailable (503, transient) and upgrade_cap_exceeded (429, daily cap)
now survive to the wire as their own codes instead of collapsing into
rate_limited; new exception types subclass BillingRateLimited so existing
backoff call sites keep working.
- Remove card.chargeability / needs_repair parsing, serialization, fixtures and
the cli warning blocks: NAS #670 removed the field, so the repair path was
permanently dead. The future card-health signal belongs to the NAS W1/W3 work.
- Tests: five-role fixtures, canChangePlan override/fallback, all three
auto-reload card variants, 429-vs-503 code preservation end-to-end.
* feat(tui): render the full NAS billing refusal surface
- billingOverlay: divergence notice when auto-refill charges a distinct card
(portal deep-link to reconcile); needs_repair warnings removed with the field.
- topup: explicit copy for consent_required, org_access_denied,
upgrade_cap_exceeded, auto_top_up_disabled_failures and stripe_unavailable
(honors retry_after); processing_error is an explicit charge-failure case;
transport loss during charge polling now reads as an unconfirmed outcome
(check balance before retrying), matching the revocation path.
- subscriptionOverlay: branch on upgrade reason, not status, so an SCA-needing
upgrade routes to portal verification even while NAS pre-#711 labels it
payment_failed; after an upgrade, poll subscription state until the tier
flips (bounded), rendering applying/still-applying rather than assuming
immediacy.
- Capability-neutral refusal copy (owner, admin, or finance admin) replaces
the stale org admin/owner wording.
- gatewayTypes: BillingAutoReload.card union added, needs_repair removed.
* docs(billing): client-side billing state and refusal lifecycle table
Enumerates, from the code, every billing.state shape and typed refusal the
gateway serves and the exact TUI copy + recovery each renders. Acceptance from
the billing-integration handoff: no NAS billing state or typed refusal falls
through to a generic toast; unknown codes still degrade to the default branch
that surfaces the server message.
This commit is contained in:
parent
5402cb5531
commit
b51fbc738b
45 changed files with 8357 additions and 1400 deletions
|
|
@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
|||
return None
|
||||
|
||||
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
|
||||
details.append("(or run /credits)")
|
||||
details.append("(or run /topup)")
|
||||
|
||||
plan = getattr(sub, "plan", None) if sub is not None else None
|
||||
return AccountUsageSnapshot(
|
||||
|
|
@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
|||
|
||||
@dataclass(frozen=True)
|
||||
class CreditsView:
|
||||
"""Surface-agnostic data for the ``/credits`` command.
|
||||
"""Surface-agnostic data for the ``/topup`` balance view.
|
||||
|
||||
One portal fetch, one parse — consumed identically by the CLI panel, the
|
||||
gateway button, and any other money surface. Fail-open: when not logged in
|
||||
|
|
@ -356,11 +356,11 @@ class CreditsView:
|
|||
|
||||
|
||||
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
|
||||
"""Build the /credits view: balance block + identity line + top-up URL.
|
||||
"""Build the /topup balance view: balance block + identity line + top-up URL.
|
||||
|
||||
Reuses the same account fetch + snapshot + URL builder as the /usage credits
|
||||
block, so the numbers always match. The balance block is the rendered
|
||||
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
|
||||
snapshot MINUS its trailing top-up/command-hint lines (the /topup surface
|
||||
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
|
||||
"""
|
||||
not_logged_in = CreditsView(logged_in=False)
|
||||
|
|
@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
|
|||
timeout=timeout
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
|
||||
logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True)
|
||||
return not_logged_in
|
||||
|
||||
if account is None or not getattr(account, "logged_in", False):
|
||||
|
|
@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
|
|||
|
||||
snapshot = build_nous_credits_snapshot(account)
|
||||
# Balance lines = the snapshot block minus the two trailing affordance lines
|
||||
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
|
||||
# appends for the /usage surface. /credits renders its own button/panel.
|
||||
# ("Top up: <url>" + "(or run /topup)") that build_nous_credits_snapshot
|
||||
# appends for the /usage surface. /topup renders its own button/panel.
|
||||
balance_lines: list[str] = []
|
||||
if snapshot is not None:
|
||||
rendered = render_account_usage_lines(snapshot, markdown=markdown)
|
||||
|
|
|
|||
323
agent/billing_usage.py
Normal file
323
agent/billing_usage.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""Shared dollar-denominated usage model for the billing/subscription surfaces.
|
||||
|
||||
The single source of truth behind the ``/usage`` and ``/subscription`` usage
|
||||
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
|
||||
**dollars**, never "credits", and every usage bar must make the monthly
|
||||
subscription allowance and separately-purchased top-up dollars distinctly
|
||||
visible.
|
||||
|
||||
Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
|
||||
``paid_service_access_info`` carries the three dollar magnitudes we render
|
||||
(despite the legacy ``*_credits`` field names, these are USD floats):
|
||||
|
||||
- ``subscription_credits_remaining`` -> plan dollars left this month
|
||||
- ``purchased_credits_remaining`` -> top-up dollars left (rolls over)
|
||||
- ``total_usable_credits`` -> total spendable
|
||||
|
||||
plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
|
||||
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).
|
||||
|
||||
Design: two SEPARATE bars (decided with the user) rather than one crammed
|
||||
three-segment bar — at terminal widths three same-glyph density segments are
|
||||
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
|
||||
the top-up bar is "money you bought, doesn't expire". Each gets full
|
||||
resolution and a single fill glyph, so the bar is never ambiguous and never
|
||||
relies on color.
|
||||
|
||||
Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
|
||||
magnitudes-only view; a logged-out / unreachable portal yields
|
||||
``available=False`` and the surface shows nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
|
||||
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
|
||||
# (user feedback): "any amount below $5 should be an alert status."
|
||||
LOW_BALANCE_THRESHOLD_USD = 5.0
|
||||
|
||||
|
||||
def _finite(value: Any) -> Optional[float]:
|
||||
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
f = float(value)
|
||||
return f if math.isfinite(f) else None
|
||||
|
||||
|
||||
def _fmt_usd(value: Optional[float]) -> str:
|
||||
"""``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
|
||||
return f"${(value or 0.0):,.2f}"
|
||||
|
||||
|
||||
def format_renews(value: Optional[str]) -> Optional[str]:
|
||||
"""Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.
|
||||
|
||||
Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
|
||||
string unchanged if it can't be parsed (never raises), and ``None`` for
|
||||
empty input.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime
|
||||
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso)
|
||||
except ValueError:
|
||||
# Fall back to a bare date prefix (YYYY-MM-DD) if present.
|
||||
try:
|
||||
dt = datetime.strptime(text[:10], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return text
|
||||
# %-d isn't portable to Windows; build the day without a leading zero.
|
||||
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageBar:
|
||||
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
|
||||
|
||||
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
|
||||
(purchased dollars, no denominator — ``spent`` is 0 and ``total`` ==
|
||||
``remaining`` so it renders as a full bar of available balance).
|
||||
"""
|
||||
|
||||
kind: str # "plan" | "topup"
|
||||
remaining_usd: float
|
||||
total_usd: float
|
||||
spent_usd: float = 0.0
|
||||
|
||||
@property
|
||||
def pct_used(self) -> Optional[int]:
|
||||
if self.kind != "plan" or self.total_usd <= 0:
|
||||
return None
|
||||
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
|
||||
|
||||
@property
|
||||
def fill_fraction(self) -> float:
|
||||
"""Fraction of the bar that should read as 'remaining' (filled)."""
|
||||
if self.total_usd <= 0:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsageModel:
|
||||
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
|
||||
|
||||
``status`` classifies the account for copy selection:
|
||||
- ``"free"`` : no paid access / no subscription (free models only)
|
||||
- ``"low"`` : paid, but total spendable < $5 (ALERT)
|
||||
- ``"healthy"`` : paid, total spendable >= $5
|
||||
- ``"depleted"`` : paid access lost (balance exhausted)
|
||||
"""
|
||||
|
||||
available: bool
|
||||
status: str = "free"
|
||||
plan_name: Optional[str] = None
|
||||
renews_at: Optional[str] = None
|
||||
renews_display: Optional[str] = None
|
||||
subscription_remaining_usd: Optional[float] = None
|
||||
topup_remaining_usd: Optional[float] = None
|
||||
total_spendable_usd: Optional[float] = None
|
||||
plan_bar: Optional[UsageBar] = None
|
||||
topup_bar: Optional[UsageBar] = None
|
||||
|
||||
@property
|
||||
def has_topup(self) -> bool:
|
||||
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
|
||||
|
||||
|
||||
def usage_model_from_account(account_info: Any) -> UsageModel:
|
||||
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.
|
||||
|
||||
Returns ``UsageModel(available=False)`` when there's no usable account info
|
||||
(logged out, no entitlement block). Never raises.
|
||||
"""
|
||||
try:
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return UsageModel(available=False)
|
||||
|
||||
access = getattr(account_info, "paid_service_access_info", None)
|
||||
sub = getattr(account_info, "subscription", None)
|
||||
paid = getattr(account_info, "paid_service_access", None)
|
||||
|
||||
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
|
||||
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
|
||||
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
|
||||
|
||||
plan_name = getattr(sub, "plan", None) if sub is not None else None
|
||||
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
|
||||
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
|
||||
|
||||
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
|
||||
|
||||
# Total spendable: prefer the server's total; else sum the parts we have.
|
||||
if total_usable is not None:
|
||||
total_spendable = total_usable
|
||||
else:
|
||||
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
|
||||
total_spendable = sum(parts) if parts else None
|
||||
|
||||
# Status classification.
|
||||
if paid is False:
|
||||
status = "depleted"
|
||||
elif not has_subscription and not (topup_remaining and topup_remaining > 0):
|
||||
# No plan and no purchased balance -> free-models-only.
|
||||
status = "free"
|
||||
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
|
||||
status = "low"
|
||||
else:
|
||||
status = "healthy"
|
||||
|
||||
# Plan bar — only with a positive monthly allowance AND a remaining we
|
||||
# can place on it. spent = cap - remaining, clamped (a debt/over-cap
|
||||
# balance reads as fully spent rather than a nonsensical negative).
|
||||
plan_bar: Optional[UsageBar] = None
|
||||
if monthly is not None and monthly > 0 and sub_remaining is not None:
|
||||
remaining = max(0.0, min(monthly, sub_remaining))
|
||||
plan_bar = UsageBar(
|
||||
kind="plan",
|
||||
remaining_usd=remaining,
|
||||
total_usd=monthly,
|
||||
spent_usd=max(0.0, monthly - sub_remaining),
|
||||
)
|
||||
|
||||
# Top-up bar — only when there are purchased dollars to show. No
|
||||
# denominator (top-up has no monthly cap), so it renders full = balance.
|
||||
topup_bar: Optional[UsageBar] = None
|
||||
if topup_remaining is not None and topup_remaining > 0:
|
||||
topup_bar = UsageBar(
|
||||
kind="topup",
|
||||
remaining_usd=topup_remaining,
|
||||
total_usd=topup_remaining,
|
||||
spent_usd=0.0,
|
||||
)
|
||||
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status=status,
|
||||
plan_name=plan_name,
|
||||
renews_at=renews_at,
|
||||
renews_display=format_renews(renews_at),
|
||||
subscription_remaining_usd=sub_remaining,
|
||||
topup_remaining_usd=topup_remaining,
|
||||
total_spendable_usd=total_spendable,
|
||||
plan_bar=plan_bar,
|
||||
topup_bar=topup_bar,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
|
||||
return UsageModel(available=False)
|
||||
|
||||
|
||||
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
|
||||
"""Fetch account-info and build the shared usage model. Fail-open.
|
||||
|
||||
Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
|
||||
every usage state is testable without a live account (mirrors the existing
|
||||
``/usage`` credits-block fixture path).
|
||||
"""
|
||||
fixture = _dev_fixture_usage_model()
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
||||
if not (isinstance(tok, str) and tok.strip()):
|
||||
return UsageModel(available=False)
|
||||
except Exception:
|
||||
return UsageModel(available=False)
|
||||
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
|
||||
return usage_model_from_account(account)
|
||||
except Exception:
|
||||
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
|
||||
return UsageModel(available=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _dev_fixture_usage_model() -> Optional[UsageModel]:
|
||||
"""Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.
|
||||
|
||||
Recognized names: ``free | healthy | low | topup | depleted``. Returns
|
||||
``None`` when the env var is unset (real portal path runs).
|
||||
"""
|
||||
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
if name == "free":
|
||||
return UsageModel(available=True, status="free", plan_name=None)
|
||||
|
||||
if name in ("healthy", "mid"):
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status="healthy",
|
||||
plan_name="Plus",
|
||||
renews_at="2026-07-01",
|
||||
subscription_remaining_usd=14.0,
|
||||
total_spendable_usd=14.0,
|
||||
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
|
||||
)
|
||||
|
||||
if name in ("topup", "top-up"):
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status="healthy",
|
||||
plan_name="Plus",
|
||||
renews_at="2026-07-01",
|
||||
subscription_remaining_usd=14.0,
|
||||
topup_remaining_usd=12.0,
|
||||
total_spendable_usd=26.0,
|
||||
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
|
||||
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
|
||||
)
|
||||
|
||||
if name == "low":
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status="low",
|
||||
plan_name="Plus",
|
||||
renews_at="2026-07-01",
|
||||
subscription_remaining_usd=3.4,
|
||||
total_spendable_usd=3.4,
|
||||
plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
|
||||
)
|
||||
|
||||
if name == "depleted":
|
||||
return UsageModel(
|
||||
available=True,
|
||||
status="depleted",
|
||||
plan_name="Plus",
|
||||
renews_at="2026-07-01",
|
||||
subscription_remaining_usd=0.0,
|
||||
total_spendable_usd=0.0,
|
||||
plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
|
||||
)
|
||||
|
||||
return None
|
||||
|
|
@ -15,6 +15,7 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
|
@ -64,15 +65,47 @@ def format_money(value: Optional[Decimal]) -> str:
|
|||
# =============================================================================
|
||||
|
||||
|
||||
# resolvedVia → the human answer to "why THIS card?". Keys are the server's card
|
||||
# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label
|
||||
# so the display degrades cleanly on servers that don't send resolvedVia yet.
|
||||
_CARD_PROVENANCE_LABELS = {
|
||||
"subPin": "the card on your subscription",
|
||||
"customerDefault": "your default card saved on the portal",
|
||||
"autoRefill": "your auto-reload card",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CardInfo:
|
||||
brand: str
|
||||
last4: str
|
||||
# NAS card-on-file field (post card-resolver): which ladder rung found the
|
||||
# card. Defaults off so pre-resolver payloads parse unchanged.
|
||||
resolved_via: Optional[str] = None
|
||||
|
||||
@property
|
||||
def masked(self) -> str:
|
||||
# A Link payment method has no card number (last4 = "") — render the
|
||||
# brand alone, not "Link ····".
|
||||
if not self.last4:
|
||||
return self.brand
|
||||
return f"{self.brand} ····{self.last4}"
|
||||
|
||||
@property
|
||||
def provenance(self) -> Optional[str]:
|
||||
"""Human label for why this card was picked, or None (unknown rung /
|
||||
server too old to say)."""
|
||||
if self.resolved_via is None:
|
||||
return None
|
||||
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
"""The one-line card display: ``Visa ····4242 — the card on your
|
||||
subscription`` (or just the masked card when provenance is unknown)."""
|
||||
label = self.provenance
|
||||
return f"{self.masked} — {label}" if label else self.masked
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyCap:
|
||||
|
|
@ -81,11 +114,20 @@ class MonthlyCap:
|
|||
is_default_ceiling: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoReloadCard:
|
||||
kind: str # "canonical" | "distinct" | "none"
|
||||
payment_method_id: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
last4: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AutoReload:
|
||||
enabled: bool = False
|
||||
threshold_usd: Optional[Decimal] = None
|
||||
reload_to_usd: Optional[Decimal] = None
|
||||
card: Optional[AutoReloadCard] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -100,7 +142,8 @@ class BillingState:
|
|||
org_id: Optional[str] = None
|
||||
org_slug: Optional[str] = None
|
||||
org_name: Optional[str] = None
|
||||
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
|
||||
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
|
||||
can_change_plan_raw: Optional[bool] = None
|
||||
balance_usd: Optional[Decimal] = None
|
||||
cli_billing_enabled: bool = False
|
||||
charge_presets: tuple[Decimal, ...] = ()
|
||||
|
|
@ -115,17 +158,33 @@ class BillingState:
|
|||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""True for OWNER/ADMIN — the roles that can manage billing."""
|
||||
"""Deprecated/display only — a legacy OWNER/ADMIN check.
|
||||
|
||||
NOT a capability check; use :attr:`can_change_plan` for gating billing
|
||||
plan-change actions.
|
||||
"""
|
||||
return (self.role or "").upper() in ("OWNER", "ADMIN")
|
||||
|
||||
@property
|
||||
def can_change_plan(self) -> bool:
|
||||
"""Server capability when supplied; otherwise the legacy role fallback."""
|
||||
if self.can_change_plan_raw is not None:
|
||||
return self.can_change_plan_raw
|
||||
return self.is_admin
|
||||
|
||||
@property
|
||||
def can_charge(self) -> bool:
|
||||
"""True when the UI should offer charge/auto-reload actions.
|
||||
|
||||
Admin role AND the per-org kill-switch on. (The server still enforces;
|
||||
this is just for graying out actions the user can't take.)
|
||||
Uses the server-granted plan-change capability (``can_change_plan``,
|
||||
which itself falls back to the legacy OWNER/ADMIN role check when the
|
||||
server omits ``canChangePlan``) AND the per-org kill-switch. This lets
|
||||
the server grant charge capability to non-OWNER/ADMIN roles (e.g.
|
||||
FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the
|
||||
deprecated 3-role admin check. (The server still enforces; this is
|
||||
just for graying out actions the user can't take.)
|
||||
"""
|
||||
return self.is_admin and self.cli_billing_enabled
|
||||
return self.can_change_plan and self.cli_billing_enabled
|
||||
|
||||
|
||||
def _parse_card(raw: Any) -> Optional[CardInfo]:
|
||||
|
|
@ -133,9 +192,13 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
|
|||
return None
|
||||
brand = raw.get("brand")
|
||||
last4 = raw.get("last4")
|
||||
if isinstance(brand, str) and isinstance(last4, str):
|
||||
return CardInfo(brand=brand, last4=last4)
|
||||
return None
|
||||
if not (isinstance(brand, str) and isinstance(last4, str)):
|
||||
return None
|
||||
# Post-resolver fields — all optional so both payload generations parse.
|
||||
resolved_via = raw.get("resolvedVia")
|
||||
if not isinstance(resolved_via, str):
|
||||
resolved_via = None
|
||||
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
|
||||
|
||||
|
||||
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
|
||||
|
|
@ -155,6 +218,27 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
|
|||
enabled=bool(raw.get("enabled")),
|
||||
threshold_usd=parse_money(raw.get("thresholdUsd")),
|
||||
reload_to_usd=parse_money(raw.get("reloadToUsd")),
|
||||
card=_parse_auto_reload_card(raw.get("card")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
kind = raw.get("kind")
|
||||
if kind not in ("canonical", "distinct", "none"):
|
||||
return None
|
||||
if kind in ("canonical", "none"):
|
||||
return AutoReloadCard(kind=kind)
|
||||
|
||||
payment_method_id = raw.get("paymentMethodId")
|
||||
brand = raw.get("brand")
|
||||
last4 = raw.get("last4")
|
||||
return AutoReloadCard(
|
||||
kind=kind,
|
||||
payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None,
|
||||
brand=brand if isinstance(brand, str) else None,
|
||||
last4=last4 if isinstance(last4, str) else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -179,6 +263,11 @@ def billing_state_from_payload(
|
|||
org_slug=org.get("slug"),
|
||||
org_name=org.get("name"),
|
||||
role=org.get("role"),
|
||||
can_change_plan_raw=(
|
||||
payload.get("canChangePlan")
|
||||
if isinstance(payload.get("canChangePlan"), bool)
|
||||
else None
|
||||
),
|
||||
balance_usd=parse_money(payload.get("balanceUsd")),
|
||||
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
|
||||
charge_presets=tuple(presets),
|
||||
|
|
@ -202,7 +291,15 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState:
|
|||
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
|
||||
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
|
||||
a clear message rather than crashing.
|
||||
|
||||
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
|
||||
card-on-file / admin / scope states are testable offline (mirrors
|
||||
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
|
||||
"""
|
||||
fixture = _dev_fixture_billing_state()
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
|
||||
try:
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingAuthError,
|
||||
|
|
@ -243,6 +340,72 @@ def _fallback_portal_url(base: str) -> str:
|
|||
return f"{base.rstrip('/')}/billing?topup=open"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _dev_fixture_billing_state() -> Optional[BillingState]:
|
||||
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
|
||||
|
||||
Recognized names::
|
||||
|
||||
nocard logged in · billing on · admin · NO card on file
|
||||
card card on file · auto-reload off
|
||||
card-autoreload card on file · auto-reload on
|
||||
notadmin logged in · MEMBER role (billing actions disabled)
|
||||
billing-off logged in · admin · per-org kill-switch OFF
|
||||
logged-out not logged in
|
||||
|
||||
Returns ``None`` when the env var is unset (the real portal path runs).
|
||||
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
|
||||
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
|
||||
"""
|
||||
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
|
||||
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
|
||||
portal = "https://portal.nousresearch.com/billing?topup=open"
|
||||
common: dict[str, Any] = dict(
|
||||
org_id="org_acme",
|
||||
org_slug="acme",
|
||||
org_name="Acme Inc",
|
||||
role="OWNER",
|
||||
balance_usd=Decimal("3.40"),
|
||||
cli_billing_enabled=True,
|
||||
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
|
||||
min_usd=Decimal("5"),
|
||||
max_usd=Decimal("500"),
|
||||
portal_url=portal,
|
||||
)
|
||||
card = CardInfo(brand="Visa", last4="4242")
|
||||
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
|
||||
|
||||
if name in ("logged-out", "logged_out", "loggedout"):
|
||||
return BillingState(logged_in=False)
|
||||
if name == "nocard":
|
||||
return BillingState(logged_in=True, card=None, **common)
|
||||
if name == "card":
|
||||
return BillingState(logged_in=True, card=card, **common)
|
||||
if name in ("card-sub", "card_sub"):
|
||||
# Post-resolver: the card came from the subscription (provenance label).
|
||||
_sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin")
|
||||
return BillingState(logged_in=True, card=_sub_card, **common)
|
||||
if name in ("card-autoreload", "card_autoreload", "autoreload"):
|
||||
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
|
||||
if name in ("notadmin", "not-admin", "member"):
|
||||
opts = {**common, "role": "MEMBER"}
|
||||
return BillingState(logged_in=True, card=card, **opts)
|
||||
if name in ("billing-off", "billing_off", "off"):
|
||||
opts = {**common, "cli_billing_enabled": False}
|
||||
return BillingState(logged_in=True, card=None, **opts)
|
||||
|
||||
# Unknown name → logged-out so the misconfiguration is visible.
|
||||
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Idempotency
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ def evaluate_credits_notices(
|
|||
if show_depleted and "credits.depleted" not in active:
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✕ Credit access paused · run /credits to top up",
|
||||
text="✕ Credit access paused · run /topup to top up",
|
||||
level="error",
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key="credits.depleted",
|
||||
|
|
|
|||
421
agent/subscription_view.py
Normal file
421
agent/subscription_view.py
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
"""Surface-agnostic core for the ``/subscription`` TUI screen.
|
||||
|
||||
Companion to :mod:`agent.billing_view` — same fail-open philosophy: when not
|
||||
logged in or the portal is unreachable, return a struct with ``logged_in=False``
|
||||
and let the surface degrade gracefully (never crash). Money is decimal end-to-end
|
||||
(server emits decimal strings); we only format for display.
|
||||
|
||||
The TUI ``SubscriptionOverlay`` drives the plan change in-terminal (V3): it
|
||||
previews the effect, then schedules a downgrade / cancellation / resume
|
||||
(chargeless) or applies an upgrade (charges the card on the subscription). The
|
||||
portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the
|
||||
fallback for an upgrade that needs 3DS / was declined.
|
||||
|
||||
WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A).
|
||||
Until it ships, the fail-open contract handles 404s — the builder returns
|
||||
``logged_in=False`` and the surface degrades gracefully.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.billing_view import parse_money
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parsed sub-structures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurrentSubscription:
|
||||
"""The user's active subscription. ``None`` (not this object) = no plan.
|
||||
|
||||
When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` /
|
||||
``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a
|
||||
fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade
|
||||
fields are optional.
|
||||
"""
|
||||
|
||||
tier_id: Optional[str] = None
|
||||
tier_name: Optional[str] = None
|
||||
monthly_credits: Optional[Decimal] = None
|
||||
credits_remaining: Optional[Decimal] = None
|
||||
cycle_ends_at: Optional[str] = None # ISO
|
||||
pending_downgrade_tier_name: Optional[str] = None
|
||||
pending_downgrade_at: Optional[str] = None # ISO
|
||||
cancel_at_period_end: bool = False
|
||||
cancellation_effective_at: Optional[str] = None # ISO
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionTier:
|
||||
"""A selectable plan in the catalog — one row of the in-terminal tier picker.
|
||||
|
||||
Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan
|
||||
(shown but not selectable); ``is_enabled=False`` is a grandfathered tier the
|
||||
user is on but that can no longer be selected. ``tier_order`` sorts the picker
|
||||
and drives the upgrade-vs-downgrade direction hint.
|
||||
"""
|
||||
|
||||
tier_id: str
|
||||
name: str
|
||||
tier_order: int = 0
|
||||
dollars_per_month: Optional[Decimal] = None
|
||||
monthly_credits: Optional[Decimal] = None
|
||||
is_current: bool = False
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionChangePreview:
|
||||
"""Parsed ``POST /api/billing/subscription/preview`` — what a change would do.
|
||||
|
||||
``effect`` is the disposition the commit would take:
|
||||
- ``charge_now`` → an upgrade; ``amount_due_now_cents`` is the prorated charge.
|
||||
- ``scheduled`` → a downgrade / same-price change at ``effective_at`` (period end).
|
||||
- ``no_op`` → already on the target tier.
|
||||
- ``blocked`` → the commit would be refused; ``reason`` says why.
|
||||
"""
|
||||
|
||||
effect: str
|
||||
reason: Optional[str] = None
|
||||
current_tier_id: Optional[str] = None
|
||||
current_tier_name: Optional[str] = None
|
||||
target_tier_id: Optional[str] = None
|
||||
target_tier_name: Optional[str] = None
|
||||
monthly_credits_delta: Optional[Decimal] = None
|
||||
amount_due_now_cents: Optional[int] = None
|
||||
effective_at: Optional[str] = None # ISO
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubscriptionState:
|
||||
"""Parsed ``GET /api/billing/subscription`` — the overview screen's data.
|
||||
|
||||
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
|
||||
portal is unreachable.
|
||||
"""
|
||||
|
||||
logged_in: bool
|
||||
org_name: Optional[str] = None
|
||||
org_id: Optional[str] = None # org.id from the NAS response
|
||||
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
|
||||
can_change_plan_raw: Optional[bool] = None
|
||||
context: str = "personal" # "personal" | "team"
|
||||
current: Optional[CurrentSubscription] = None
|
||||
tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker)
|
||||
portal_url: Optional[str] = None
|
||||
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
"""Deprecated/display only — a legacy OWNER/ADMIN check.
|
||||
|
||||
NOT a capability check; use :attr:`can_change_plan` for gating billing
|
||||
plan-change actions.
|
||||
"""
|
||||
return (self.role or "").upper() in ("OWNER", "ADMIN")
|
||||
|
||||
@property
|
||||
def can_change_plan(self) -> bool:
|
||||
"""Server capability when supplied; otherwise the legacy role fallback."""
|
||||
if self.can_change_plan_raw is not None:
|
||||
return self.can_change_plan_raw
|
||||
return self.is_admin
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Payload parsing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
|
||||
# "No plan" is wire-represented as current:null (free personal OR team) —
|
||||
# the old all-null-object shape is gone. A present current is a real plan,
|
||||
# so guard on a real tier id and return None otherwise.
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
tier_id = raw.get("tierId") or raw.get("id")
|
||||
if not tier_id:
|
||||
return None
|
||||
return CurrentSubscription(
|
||||
tier_id=tier_id,
|
||||
tier_name=raw.get("tierName") or raw.get("name"),
|
||||
monthly_credits=parse_money(raw.get("monthlyCredits")),
|
||||
credits_remaining=parse_money(raw.get("creditsRemaining")),
|
||||
cycle_ends_at=raw.get("cycleEndsAt"),
|
||||
pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"),
|
||||
pending_downgrade_at=raw.get("pendingDowngradeAt"),
|
||||
cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")),
|
||||
cancellation_effective_at=raw.get("cancellationEffectiveAt") or None,
|
||||
)
|
||||
|
||||
|
||||
def _coalesce(*vals: Any) -> Any:
|
||||
"""First non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``).
|
||||
|
||||
NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain
|
||||
``x or default`` would drop those, so coalesce on ``None`` specifically.
|
||||
"""
|
||||
for v in vals:
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
|
||||
"""Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
tier_id = raw.get("tierId") or raw.get("id")
|
||||
if not tier_id:
|
||||
return None
|
||||
return SubscriptionTier(
|
||||
tier_id=tier_id,
|
||||
name=raw.get("name") or "",
|
||||
tier_order=int(_coalesce(raw.get("tierOrder"), 0)),
|
||||
dollars_per_month=parse_money(raw.get("dollarsPerMonthDisplay")),
|
||||
monthly_credits=parse_money(raw.get("monthlyCredits")),
|
||||
is_current=bool(raw.get("isCurrent")),
|
||||
is_enabled=bool(_coalesce(raw.get("isEnabled"), True)),
|
||||
)
|
||||
|
||||
|
||||
def subscription_change_preview_from_payload(
|
||||
payload: dict[str, Any],
|
||||
) -> SubscriptionChangePreview:
|
||||
"""Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`."""
|
||||
effect = payload.get("effect")
|
||||
cents = payload.get("amountDueNowCents")
|
||||
return SubscriptionChangePreview(
|
||||
# An unrecognized/missing effect is treated as ``blocked`` — fail safe, never
|
||||
# charge on a malformed quote.
|
||||
effect=effect if isinstance(effect, str) else "blocked",
|
||||
reason=payload.get("reason") or None,
|
||||
current_tier_id=payload.get("currentTierId"),
|
||||
current_tier_name=payload.get("currentTierName"),
|
||||
target_tier_id=payload.get("targetTierId"),
|
||||
target_tier_name=payload.get("targetTierName"),
|
||||
monthly_credits_delta=parse_money(payload.get("monthlyCreditsDelta")),
|
||||
amount_due_now_cents=int(cents) if isinstance(cents, (int, float)) else None,
|
||||
effective_at=payload.get("effectiveAt") or None,
|
||||
)
|
||||
|
||||
|
||||
def subscription_state_from_payload(
|
||||
payload: dict[str, Any], *, portal_url: Optional[str] = None
|
||||
) -> SubscriptionState:
|
||||
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
|
||||
raw_org = payload.get("org")
|
||||
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
|
||||
|
||||
raw_context = payload.get("context")
|
||||
context = raw_context if raw_context in ("personal", "team") else "personal"
|
||||
|
||||
raw_tiers = payload.get("tiers")
|
||||
tiers = (
|
||||
tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None)
|
||||
if isinstance(raw_tiers, list)
|
||||
else ()
|
||||
)
|
||||
|
||||
return SubscriptionState(
|
||||
logged_in=True,
|
||||
org_name=org.get("name"),
|
||||
org_id=org.get("id") or None,
|
||||
role=org.get("role"),
|
||||
can_change_plan_raw=(
|
||||
payload.get("canChangePlan")
|
||||
if isinstance(payload.get("canChangePlan"), bool)
|
||||
else None
|
||||
),
|
||||
context=context,
|
||||
current=_parse_current(payload.get("current")),
|
||||
tiers=tiers,
|
||||
portal_url=portal_url,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fail-open builders (the surface front doors)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
|
||||
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open.
|
||||
|
||||
Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a
|
||||
portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the
|
||||
surface can show a clear message rather than crashing.
|
||||
|
||||
Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state,
|
||||
``/subscription`` renders from that fixture instead of the real portal — so
|
||||
every plan/cancel/downgrade/team/not-admin state is testable on both
|
||||
the CLI and TUI without a live account. Throwaway scaffolding; see
|
||||
:func:`dev_fixture_subscription_state`.
|
||||
"""
|
||||
fixture = dev_fixture_subscription_state()
|
||||
if fixture is not None:
|
||||
return fixture
|
||||
|
||||
try:
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingAuthError,
|
||||
BillingError,
|
||||
_absolutize_portal_url,
|
||||
get_subscription_state,
|
||||
resolve_portal_base_url,
|
||||
)
|
||||
except Exception:
|
||||
return SubscriptionState(logged_in=False, error="billing client unavailable")
|
||||
|
||||
try:
|
||||
payload = get_subscription_state(timeout=timeout)
|
||||
except BillingAuthError:
|
||||
return SubscriptionState(logged_in=False)
|
||||
except BillingError as exc:
|
||||
logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True)
|
||||
return SubscriptionState(logged_in=False, error=str(exc))
|
||||
except Exception:
|
||||
logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True)
|
||||
return SubscriptionState(logged_in=False, error="could not load subscription state")
|
||||
|
||||
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
|
||||
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
|
||||
if not portal_url:
|
||||
try:
|
||||
portal_url = resolve_portal_base_url()
|
||||
except Exception:
|
||||
portal_url = None
|
||||
|
||||
return subscription_state_from_payload(payload, portal_url=portal_url)
|
||||
|
||||
|
||||
def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
|
||||
"""Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.
|
||||
|
||||
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
|
||||
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
|
||||
Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled
|
||||
internally. ``org_id`` pins the page to the right account in multi-org
|
||||
situations. Returns ``None`` when no portal URL is resolvable.
|
||||
"""
|
||||
from urllib.parse import urlencode, urlsplit, urlunsplit
|
||||
|
||||
if not state.portal_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
parts = urlsplit(state.portal_url)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not parts.scheme or not parts.netloc:
|
||||
return None
|
||||
|
||||
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
|
||||
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
|
||||
# =============================================================================
|
||||
|
||||
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
|
||||
|
||||
|
||||
def _dev_current(**over: Any) -> CurrentSubscription:
|
||||
base: dict[str, Any] = dict(
|
||||
tier_id="plus",
|
||||
tier_name="Plus",
|
||||
monthly_credits=Decimal("1000"),
|
||||
credits_remaining=Decimal("420"),
|
||||
cycle_ends_at="2026-07-01",
|
||||
)
|
||||
base.update(over)
|
||||
return CurrentSubscription(**base)
|
||||
|
||||
|
||||
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
|
||||
"""A sample plan catalog for fixtures (marks ``current_id`` as the active tier)."""
|
||||
specs = (
|
||||
("free", "Free", 0, "0", "0"),
|
||||
("plus", "Plus", 1, "20", "1000"),
|
||||
("super", "Super", 2, "40", "3000"),
|
||||
("ultra", "Ultra", 3, "80", "7000"),
|
||||
)
|
||||
return tuple(
|
||||
SubscriptionTier(
|
||||
tier_id=tid,
|
||||
name=name,
|
||||
tier_order=order,
|
||||
dollars_per_month=parse_money(dpm),
|
||||
monthly_credits=parse_money(mc),
|
||||
is_current=(tid == current_id),
|
||||
is_enabled=True,
|
||||
)
|
||||
for tid, name, order, dpm, mc in specs
|
||||
)
|
||||
|
||||
|
||||
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
|
||||
"""Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``.
|
||||
|
||||
Lets every CLI/TUI subscription state be exercised without a live portal:
|
||||
|
||||
free | mid | top | not-admin | downgrade | cancel | team |
|
||||
logged-out
|
||||
|
||||
Returns ``None`` when the env var is unset/empty (the real portal path runs).
|
||||
Throwaway scaffolding — mirrors ``HERMES_DEV_CREDITS_FIXTURE``.
|
||||
"""
|
||||
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
|
||||
|
||||
if name in ("logged-out", "logged_out", "loggedout"):
|
||||
return SubscriptionState(logged_in=False)
|
||||
if name == "free":
|
||||
return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common)
|
||||
if name in ("mid", "mid-tier"):
|
||||
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common)
|
||||
if name in ("top", "top-tier"):
|
||||
return SubscriptionState(
|
||||
logged_in=True,
|
||||
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
|
||||
tiers=_dev_tiers("ultra"),
|
||||
**common,
|
||||
)
|
||||
if name in ("not-admin", "member"):
|
||||
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **{**common, "role": "MEMBER"})
|
||||
if name == "downgrade":
|
||||
return SubscriptionState(
|
||||
logged_in=True,
|
||||
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
|
||||
tiers=_dev_tiers("super"),
|
||||
**common,
|
||||
)
|
||||
if name == "cancel":
|
||||
return SubscriptionState(
|
||||
logged_in=True,
|
||||
current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"),
|
||||
tiers=_dev_tiers("plus"),
|
||||
**common,
|
||||
)
|
||||
if name == "team":
|
||||
return SubscriptionState(logged_in=True, context="team", current=None, org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
|
||||
|
||||
# Unknown name → behave as logged-out so the misconfiguration is visible.
|
||||
return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}")
|
||||
|
||||
744
cli.py
744
cli.py
|
|
@ -54,6 +54,7 @@ import yaml
|
|||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin
|
||||
from hermes_cli.cli_commands_mixin import CLICommandsMixin
|
||||
from hermes_cli.cli_billing_mixin import CLIBillingMixin
|
||||
|
||||
# prompt_toolkit for fixed input area TUI
|
||||
from prompt_toolkit.history import FileHistory
|
||||
|
|
@ -3698,7 +3699,7 @@ def save_config_value(key_path: str, value: any) -> bool:
|
|||
# HermesCLI Class
|
||||
# ============================================================================
|
||||
|
||||
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
||||
"""
|
||||
Interactive CLI for the Hermes Agent.
|
||||
|
||||
|
|
@ -8760,9 +8761,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
self._manual_compress(cmd_original)
|
||||
elif canonical == "usage":
|
||||
self._handle_usage_command(cmd_original)
|
||||
elif canonical == "credits":
|
||||
self._show_credits()
|
||||
elif canonical == "billing":
|
||||
elif canonical == "subscription":
|
||||
self._show_subscription()
|
||||
elif canonical == "topup":
|
||||
self._show_billing(cmd_original)
|
||||
elif canonical == "insights":
|
||||
self._show_insights(cmd_original)
|
||||
|
|
@ -9771,7 +9772,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
which would otherwise early-return before any credits showed.
|
||||
"""
|
||||
if not self.agent:
|
||||
if not self._print_nous_credits_block():
|
||||
if self._print_nous_credits_block():
|
||||
self._print_usage_cta()
|
||||
else:
|
||||
print("(._.) No active agent -- send a message first.")
|
||||
return
|
||||
|
||||
|
|
@ -9779,7 +9782,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
calls = agent.session_api_calls
|
||||
|
||||
if calls == 0:
|
||||
if not self._print_nous_credits_block():
|
||||
if self._print_nous_credits_block():
|
||||
self._print_usage_cta()
|
||||
else:
|
||||
print("(._.) No API calls made yet in this session.")
|
||||
return
|
||||
|
||||
|
|
@ -9850,7 +9855,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
|
||||
# Nous credits magnitudes + monthly-grant gauge (agent-independent — also
|
||||
# runs at the no-agent / no-calls early-returns above). See the helper.
|
||||
self._print_nous_credits_block()
|
||||
if self._print_nous_credits_block():
|
||||
self._print_usage_cta()
|
||||
|
||||
if self.verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
|
@ -9866,730 +9872,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
# Console quietness is enforced by hermes_logging not
|
||||
# installing a console StreamHandler in non-verbose mode.
|
||||
|
||||
def _print_nous_credits_block(self) -> bool:
|
||||
"""Print the Nous credits magnitudes + monthly-grant gauge when a Nous account
|
||||
is logged in. Returns True if it printed anything.
|
||||
|
||||
Delegates to the shared ``agent.account_usage.nous_credits_lines`` helper —
|
||||
the single source for the /usage credits block across CLI, gateway, and TUI.
|
||||
It's agent-independent (a portal fetch gated on "a Nous account is logged in",
|
||||
NOT the inference-provider string), so /usage shows the block even in the TUI
|
||||
slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and
|
||||
wall-clock-bounded inside the helper; also honors HERMES_DEV_CREDITS_FIXTURE
|
||||
for offline testing — same behavior as every other surface.
|
||||
"""
|
||||
from agent.account_usage import nous_credits_lines
|
||||
|
||||
lines = nous_credits_lines()
|
||||
if not lines:
|
||||
return False
|
||||
print()
|
||||
for line in lines:
|
||||
print(f" {line}")
|
||||
return True
|
||||
|
||||
def _show_credits(self):
|
||||
"""`/credits` — focused Nous credit balance + top-up handoff.
|
||||
|
||||
Interactive CLI: balance block + identity line + a 3-button panel
|
||||
(Open top-up / Copy link / Cancel). Non-interactive contexts — the TUI
|
||||
slash-worker subprocess and any place without a live prompt_toolkit app
|
||||
(``self._app is None``) — render a text variant (balance + tappable
|
||||
top-up URL), because the modal would try to read the RPC stdin and crash
|
||||
the worker. The terminal never confirms or polls payment (billing phase
|
||||
2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
|
||||
message, never a crash.
|
||||
"""
|
||||
from agent.account_usage import build_credits_view
|
||||
|
||||
view = build_credits_view()
|
||||
|
||||
if not view.logged_in:
|
||||
print()
|
||||
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
|
||||
print(" Run `hermes portal` to log in, then /credits.")
|
||||
return
|
||||
|
||||
print()
|
||||
print(" 💳 Nous credits")
|
||||
print(f" {'─' * 41}")
|
||||
for line in view.balance_lines:
|
||||
# Drop the helper's own "📈 Nous credits" header — we print our own.
|
||||
if line.lstrip().startswith("📈"):
|
||||
continue
|
||||
print(f" {line}")
|
||||
print(f" {'─' * 41}")
|
||||
if view.identity_line:
|
||||
print(f" {view.identity_line}")
|
||||
|
||||
if not view.topup_url:
|
||||
return
|
||||
|
||||
# Non-interactive (TUI slash-worker, piped, no live app): the
|
||||
# prompt_toolkit modal can't run here — it would read the worker's
|
||||
# JSON-RPC stdin and crash the command. Render the text variant: the
|
||||
# tappable URL IS the affordance, same as the messaging surfaces.
|
||||
if not getattr(self, "_app", None):
|
||||
print()
|
||||
print(f" Top up: {view.topup_url}")
|
||||
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
|
||||
return
|
||||
|
||||
choices = [
|
||||
("open", "Open top-up in browser", "launch the portal billing page"),
|
||||
("copy", "Copy link", "copy the top-up URL to your clipboard"),
|
||||
("cancel", "Cancel", "do nothing"),
|
||||
]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="💳 Add credits?",
|
||||
detail=f"Top-up page:\n{view.topup_url}",
|
||||
choices=choices,
|
||||
)
|
||||
choice = self._normalize_slash_confirm_choice(raw, choices)
|
||||
|
||||
if choice == "open":
|
||||
opened = False
|
||||
try:
|
||||
import webbrowser
|
||||
|
||||
opened = webbrowser.open(view.topup_url)
|
||||
except Exception:
|
||||
opened = False
|
||||
if not opened:
|
||||
print(f" Open this URL to top up: {view.topup_url}")
|
||||
print()
|
||||
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
|
||||
elif choice == "copy":
|
||||
try:
|
||||
self._write_osc52_clipboard(view.topup_url)
|
||||
print(f" 📋 Copied: {view.topup_url}")
|
||||
except Exception:
|
||||
print(f" Top-up URL: {view.topup_url}")
|
||||
else:
|
||||
print(" 🟡 Cancelled. No credits added.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /billing — Phase 2b terminal billing (CLI surface, all 5 screens)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _show_billing(self, command: str = "/billing"):
|
||||
"""`/billing` — terminal billing for Nous (one interactive modal).
|
||||
|
||||
ZERO sub-commands: any argument is ignored. Bare ``/billing`` always
|
||||
opens the Overview (Screen 1), whose numbered menu is the *only* way to
|
||||
reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified
|
||||
UX spec §0.4 — ``/billing buy`` etc. are gone; we don't error on a stray
|
||||
arg, we just open the menu.)
|
||||
|
||||
Interactive CLI uses the prompt_toolkit modal; non-interactive contexts
|
||||
(TUI slash-worker / no live app) render text + the portal deep-link, never
|
||||
prompting (the URL is the affordance), same discipline as ``_show_credits``.
|
||||
All money is Decimal end-to-end; the terminal never collects card details.
|
||||
"""
|
||||
from agent.billing_view import build_billing_state
|
||||
|
||||
state = build_billing_state()
|
||||
if not state.logged_in:
|
||||
print()
|
||||
if state.error:
|
||||
_msg = f"Couldn't load billing: {state.error}"
|
||||
_cprint(f" 💳 {_d(_msg)}")
|
||||
else:
|
||||
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
|
||||
print(" Run `hermes portal` to log in, then /billing.")
|
||||
return
|
||||
|
||||
# Any sub-arg is intentionally ignored — always open the menu.
|
||||
self._billing_overview(state)
|
||||
|
||||
def _billing_portal_hint(self, state, *, reason: str = "") -> None:
|
||||
"""Print a portal deep-link line (the funnel for portal-only actions)."""
|
||||
url = getattr(state, "portal_url", None)
|
||||
if not url:
|
||||
return
|
||||
if reason:
|
||||
print(f" {reason}")
|
||||
print(f" Manage on portal: {url}")
|
||||
|
||||
def _billing_overview(self, state):
|
||||
"""Screen 1 — overview: balance, spend bar, role-gated action menu."""
|
||||
from agent.billing_view import format_money
|
||||
|
||||
print()
|
||||
_cprint(f" 💳 {_b('Usage credits')}")
|
||||
print(f" {'─' * 41}")
|
||||
|
||||
cap = state.monthly_cap
|
||||
if cap is not None and cap.limit_usd is not None:
|
||||
spent = format_money(cap.spent_this_month_usd)
|
||||
limit = format_money(cap.limit_usd)
|
||||
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
|
||||
bar, pct = self._billing_spend_bar(
|
||||
cap.spent_this_month_usd, cap.limit_usd
|
||||
)
|
||||
print(f" {spent} of {limit} used{ceiling} {bar} {pct}%")
|
||||
|
||||
print(f" Balance: {format_money(state.balance_usd)}")
|
||||
|
||||
ar = state.auto_reload
|
||||
if ar is not None:
|
||||
if ar.enabled:
|
||||
print(
|
||||
f" Auto-reload: on — below {format_money(ar.threshold_usd)} "
|
||||
f"→ reload to {format_money(ar.reload_to_usd)}"
|
||||
)
|
||||
else:
|
||||
print(" Auto-reload: off")
|
||||
|
||||
if state.org_name:
|
||||
role = (state.role or "").title()
|
||||
_org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}"
|
||||
_cprint(f" {_d(_org_line)}")
|
||||
print(f" {'─' * 41}")
|
||||
|
||||
# Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal.
|
||||
if not state.is_admin:
|
||||
_cprint(f" {_d('Billing actions require an org admin/owner.')}")
|
||||
self._billing_portal_hint(state)
|
||||
return
|
||||
if not state.cli_billing_enabled:
|
||||
_cprint(f" {_d('Terminal billing is turned off for this org.')}")
|
||||
self._billing_portal_hint(state, reason="Enable it on the portal to buy credits here.")
|
||||
return
|
||||
|
||||
# Optimistic funnel: no card on file → a charge will 403 no_payment_method.
|
||||
# Surface that up front (with the portal link) but DON'T hide Buy — /state.card
|
||||
# can't fully prove CLI-chargeability, so we advise rather than gate.
|
||||
if state.card is None:
|
||||
_cprint(
|
||||
f" {_d('No saved card for terminal charges yet — set one up on the portal first.')}"
|
||||
)
|
||||
self._billing_portal_hint(state)
|
||||
|
||||
# Non-interactive (slash-worker / no live app): no modal, no sub-command
|
||||
# advertising — just the portal funnel (the URL is the affordance).
|
||||
if not getattr(self, "_app", None):
|
||||
self._billing_portal_hint(state)
|
||||
return
|
||||
|
||||
choices = [
|
||||
("buy", "Buy credits", "purchase a one-time credit top-up"),
|
||||
("auto", "Adjust auto-reload", "configure automatic top-ups"),
|
||||
("limit", "Adjust monthly limit", "show the monthly spend cap (read-only)"),
|
||||
("portal", "Manage on portal", "open the billing page in your browser"),
|
||||
("cancel", "Cancel", "do nothing"),
|
||||
]
|
||||
# The overview summary is already printed above; the modal only needs to
|
||||
# present the action menu — repeating the title/balance reads as a dupe.
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="💳 Choose an action", detail="",
|
||||
choices=choices,
|
||||
)
|
||||
choice = self._normalize_slash_confirm_choice(raw, choices)
|
||||
if choice == "buy":
|
||||
self._billing_buy_flow(state)
|
||||
elif choice == "auto":
|
||||
self._billing_auto_reload_flow(state)
|
||||
elif choice == "limit":
|
||||
self._billing_limit_screen(state)
|
||||
elif choice == "portal":
|
||||
self._billing_open_portal(state)
|
||||
else:
|
||||
print(" 🟡 Cancelled.")
|
||||
|
||||
def _billing_spend_bar(self, spent, limit, *, cells: int = 10):
|
||||
"""Render a 10-cell `█`/`░` spend bar + integer percent from spent/limit.
|
||||
|
||||
Returns ``(bar, pct)`` where ``bar`` is like ``[████░░░░░░]`` and ``pct``
|
||||
is the spent/limit percentage clamped to 0..100. Box-drawing glyphs are
|
||||
not SGR codes, so this is leak-safe even without ``_b()``/``_d()``.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
try:
|
||||
s = Decimal(str(spent)) if spent is not None else Decimal("0")
|
||||
l = Decimal(str(limit)) if limit is not None else Decimal("0")
|
||||
except Exception:
|
||||
s, l = Decimal("0"), Decimal("0")
|
||||
if l <= 0:
|
||||
pct = 0
|
||||
else:
|
||||
pct = int((s / l) * 100)
|
||||
pct = max(0, min(100, pct))
|
||||
filled = int(round(pct / 100 * cells))
|
||||
filled = max(0, min(cells, filled))
|
||||
bar = ("█" * filled) + ("░" * (cells - filled))
|
||||
return bar, pct
|
||||
|
||||
def _billing_open_portal(self, state):
|
||||
url = getattr(state, "portal_url", None)
|
||||
if not url:
|
||||
print(" No portal URL available.")
|
||||
return
|
||||
opened = False
|
||||
try:
|
||||
import webbrowser
|
||||
|
||||
opened = webbrowser.open(url)
|
||||
except Exception:
|
||||
opened = False
|
||||
if not opened:
|
||||
print(f" Open this URL: {url}")
|
||||
print(" Complete billing changes in the browser.")
|
||||
|
||||
def _billing_require_admin(self, state) -> bool:
|
||||
"""Guard charge/auto-reload entry points; print + return False if blocked."""
|
||||
if not state.is_admin:
|
||||
print()
|
||||
_cprint(f" 💳 {_d('Billing actions require an org admin/owner.')}")
|
||||
self._billing_portal_hint(state)
|
||||
return False
|
||||
if not state.cli_billing_enabled:
|
||||
print()
|
||||
_cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}")
|
||||
self._billing_portal_hint(state, reason="Enable it on the portal first.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def _billing_buy_flow(self, state):
|
||||
"""Screen 2 (preset select) → Screen 3 (confirm + charge + poll)."""
|
||||
from agent.billing_view import format_money, validate_charge_amount
|
||||
|
||||
if not self._billing_require_admin(state):
|
||||
return
|
||||
|
||||
# Screen 3 — preset selection.
|
||||
if not getattr(self, "_app", None):
|
||||
presets = ", ".join(format_money(p) for p in state.charge_presets)
|
||||
print()
|
||||
_cprint(f" 💳 {_b('Buy usage credits')}")
|
||||
print(f" Presets: {presets}")
|
||||
print(" Run this in the interactive CLI to complete a purchase.")
|
||||
self._billing_portal_hint(state)
|
||||
return
|
||||
|
||||
preset_choices = []
|
||||
for p in state.charge_presets:
|
||||
preset_choices.append((str(p), format_money(p), "one-time credit purchase"))
|
||||
preset_choices.append(("custom", "Custom amount…", "enter your own amount"))
|
||||
preset_choices.append(("cancel", "Cancel", "do nothing"))
|
||||
|
||||
card = state.card
|
||||
detail = f"Payment: {card.masked}" if card else "No saved card on file"
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="💳 Buy usage credits", detail=detail, choices=preset_choices,
|
||||
)
|
||||
choice = self._normalize_slash_confirm_choice(raw, preset_choices)
|
||||
if not choice or choice == "cancel":
|
||||
print(" 🟡 Cancelled. No credits added.")
|
||||
return
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
if choice == "custom":
|
||||
entered = self._prompt_text_input(" Amount (USD): ")
|
||||
if entered is None:
|
||||
# None = cancelled (e.g. slash-worker can't prompt off-thread).
|
||||
print(" 🟡 Cancelled. No credits added.")
|
||||
return
|
||||
v = validate_charge_amount(
|
||||
entered or "", min_usd=state.min_usd, max_usd=state.max_usd
|
||||
)
|
||||
if not v.ok:
|
||||
print(f" 🔴 {v.error}")
|
||||
return
|
||||
amount = v.amount
|
||||
else:
|
||||
try:
|
||||
amount = Decimal(choice)
|
||||
except Exception:
|
||||
print(" 🔴 Invalid selection.")
|
||||
return
|
||||
|
||||
self._billing_confirm_and_charge(state, amount)
|
||||
|
||||
def _billing_confirm_and_charge(self, state, amount):
|
||||
"""Screen 3 — confirm total + consent, charge, then poll to settlement."""
|
||||
from agent.billing_view import format_money, new_idempotency_key
|
||||
|
||||
card = state.card
|
||||
print()
|
||||
_cprint(f" 💳 {_b('Confirm purchase')}")
|
||||
print(f" {'─' * 41}")
|
||||
print(f" Total: {format_money(amount)}")
|
||||
if card:
|
||||
print(f" Payment: {card.masked}")
|
||||
print(f" {'─' * 41}")
|
||||
_consent = (
|
||||
"By confirming, you allow Nous Research to charge your card."
|
||||
)
|
||||
_cprint(f" {_d(_consent)}")
|
||||
|
||||
confirm_choices = [
|
||||
("pay", f"Pay {format_money(amount)} now", "submit the charge"),
|
||||
("cancel", "Go back", "do not charge"),
|
||||
]
|
||||
if not getattr(self, "_app", None):
|
||||
print(" Run in the interactive CLI to confirm a purchase.")
|
||||
return
|
||||
raw = self._prompt_text_input_modal(
|
||||
title=f"💳 Pay {format_money(amount)}?",
|
||||
detail=(card.masked if card else "no saved card"),
|
||||
choices=confirm_choices,
|
||||
)
|
||||
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
|
||||
if choice != "pay":
|
||||
print(" 🟡 Cancelled. No credits added.")
|
||||
return
|
||||
|
||||
# Submit the charge with a fresh idempotency key (reused on retry).
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingError,
|
||||
BillingScopeRequired,
|
||||
post_charge,
|
||||
)
|
||||
|
||||
key = new_idempotency_key()
|
||||
try:
|
||||
result = post_charge(amount_usd=amount, idempotency_key=key)
|
||||
except BillingScopeRequired:
|
||||
self._billing_handle_scope_required(state)
|
||||
return
|
||||
except BillingError as exc:
|
||||
self._billing_render_charge_error(state, exc)
|
||||
return
|
||||
|
||||
charge_id = result.get("chargeId")
|
||||
if not charge_id:
|
||||
print(" 🔴 No charge id returned; please check the portal.")
|
||||
return
|
||||
_cprint(f" {_d('Charge submitted — confirming settlement…')}")
|
||||
self._billing_poll_charge(state, charge_id, amount)
|
||||
|
||||
def _billing_poll_charge(self, state, charge_id, amount):
|
||||
"""Poll loop: 2s interval, 5-min cap, cancellable. settled = ledger truth."""
|
||||
import time as _time
|
||||
|
||||
from agent.billing_view import format_money
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingError,
|
||||
BillingRateLimited,
|
||||
get_charge_status,
|
||||
)
|
||||
|
||||
deadline = _time.time() + 300 # 5-minute cap
|
||||
interval = 2.0
|
||||
while _time.time() < deadline:
|
||||
try:
|
||||
status = get_charge_status(charge_id)
|
||||
except BillingRateLimited as exc:
|
||||
# Retry-after, NOT a failure — back off and keep polling.
|
||||
wait = exc.retry_after or 5
|
||||
_time.sleep(min(wait, 30))
|
||||
continue
|
||||
except BillingError as exc:
|
||||
print(f" 🔴 Could not check the charge: {exc}")
|
||||
return
|
||||
|
||||
state_str = status.get("status")
|
||||
if state_str == "settled":
|
||||
amt = status.get("amountUsd")
|
||||
from agent.billing_view import parse_money
|
||||
|
||||
shown = format_money(parse_money(amt)) if amt else format_money(amount)
|
||||
print(f" ✅ {shown} in credits added.")
|
||||
return
|
||||
if state_str == "failed":
|
||||
self._billing_render_charge_failed(state, status.get("reason"))
|
||||
return
|
||||
# pending → wait and poll again
|
||||
_time.sleep(interval)
|
||||
|
||||
# Past the cap with no terminal state = timeout (not an error).
|
||||
print(" 🟡 Still processing after 5 minutes — this is a timeout, not a "
|
||||
"failure. Check /billing or the portal shortly.")
|
||||
self._billing_portal_hint(state)
|
||||
|
||||
def _billing_render_charge_failed(self, state, reason):
|
||||
"""Branch the poll `failed` reasons to the right copy + portal funnel."""
|
||||
reason = (reason or "").strip()
|
||||
if reason == "authentication_required":
|
||||
print(" 🔴 Your bank requires verification (3DS). Complete it on the "
|
||||
"portal to finish this purchase.")
|
||||
elif reason == "payment_method_expired":
|
||||
print(" 🔴 Your card has expired. Update it on the portal.")
|
||||
elif reason == "card_declined":
|
||||
print(" 🔴 Your card was declined. Try another card on the portal.")
|
||||
else:
|
||||
print(f" 🔴 The charge didn't go through ({reason or 'processing_error'}).")
|
||||
self._billing_portal_hint(state)
|
||||
|
||||
def _billing_render_charge_error(self, state, exc):
|
||||
"""Render a typed BillingError at submit time (pre-poll)."""
|
||||
from hermes_cli.nous_billing import BillingRateLimited
|
||||
|
||||
code = getattr(exc, "error", None)
|
||||
portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None)
|
||||
if code == "no_payment_method":
|
||||
print(" 💳 No saved card for terminal charges yet. Set one up on the "
|
||||
"portal (one-time credit buys don't save a reusable card).")
|
||||
elif code == "cli_billing_disabled":
|
||||
print(" 🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.")
|
||||
elif code == "monthly_cap_exceeded":
|
||||
remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd")
|
||||
if remaining is not None:
|
||||
print(f" 🔴 Monthly spend cap reached — ${remaining} headroom left.")
|
||||
else:
|
||||
print(" 🔴 Monthly spend cap reached.")
|
||||
elif isinstance(exc, BillingRateLimited):
|
||||
wait = getattr(exc, "retry_after", None)
|
||||
mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else ""
|
||||
print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.")
|
||||
else:
|
||||
print(f" 🔴 {exc}")
|
||||
if portal_url:
|
||||
print(f" Portal: {portal_url}")
|
||||
|
||||
def _billing_handle_scope_required(self, state):
|
||||
"""403 insufficient_scope → lazy step-up re-auth (plan D-A)."""
|
||||
print()
|
||||
print(" 💳 Terminal billing needs an extra permission (billing:manage).")
|
||||
_scope_msg = (
|
||||
"An org admin/owner must tick \"Allow terminal billing\" during "
|
||||
"login."
|
||||
)
|
||||
_cprint(f" {_d(_scope_msg)}")
|
||||
if not getattr(self, "_app", None):
|
||||
print(" Run `hermes portal` and approve terminal billing, then retry.")
|
||||
return
|
||||
confirm_choices = [
|
||||
("yes", "Re-authorize now", "open the portal to grant billing access"),
|
||||
("no", "Not now", "cancel"),
|
||||
]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="💳 Grant terminal billing access?",
|
||||
detail="Opens the portal device-authorization page.",
|
||||
choices=confirm_choices,
|
||||
)
|
||||
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
|
||||
if choice != "yes":
|
||||
print(" 🟡 Cancelled.")
|
||||
return
|
||||
try:
|
||||
from hermes_cli.auth import step_up_nous_billing_scope
|
||||
|
||||
granted = step_up_nous_billing_scope(open_browser=True)
|
||||
except Exception as exc:
|
||||
print(f" 🔴 Re-authorization failed: {exc}")
|
||||
return
|
||||
if granted:
|
||||
print(" ✅ Billing permission granted.")
|
||||
# Step-up only grants the billing:manage TOKEN scope; the ORG
|
||||
# kill-switch (cli_billing_enabled) is a separate gate. Re-fetch
|
||||
# /state so we don't over-promise when a charge would still hit
|
||||
# cli_billing_disabled.
|
||||
from agent.billing_view import build_billing_state
|
||||
|
||||
fresh = build_billing_state()
|
||||
if fresh.logged_in and fresh.cli_billing_enabled:
|
||||
print(" Run /billing buy again to continue.")
|
||||
else:
|
||||
print(" 🟡 Permission granted, but terminal billing is still turned "
|
||||
"off for this org. Enable it in the portal, then run /billing again.")
|
||||
self._billing_portal_hint(fresh)
|
||||
else:
|
||||
print(" 🟡 Terminal billing was not granted (an admin must tick the box).")
|
||||
|
||||
def _billing_auto_reload_flow(self, state):
|
||||
"""Screen 4 — auto-reload config: threshold + reload-to → PATCH.
|
||||
|
||||
Prefills the current values from ``state.auto_reload``. Validates both
|
||||
amounts (2dp, within bounds, ``reload_to > threshold``). When auto-reload
|
||||
is already on, offers a "Turn off" path (PATCH ``enabled:false``).
|
||||
"""
|
||||
from agent.billing_view import format_money, validate_charge_amount
|
||||
|
||||
if not self._billing_require_admin(state):
|
||||
return
|
||||
|
||||
card = state.card
|
||||
ar = state.auto_reload
|
||||
currently_on = bool(ar and ar.enabled)
|
||||
|
||||
print()
|
||||
_cprint(f" 💳 {_b('Auto-reload')}")
|
||||
print(f" {'─' * 41}")
|
||||
_cprint(f" {_d('Automatically buy more credits when your balance is low.')}")
|
||||
if card:
|
||||
print(f" Card on file: {card.masked}")
|
||||
else:
|
||||
print(" No saved card — set one up on the portal first.")
|
||||
self._billing_portal_hint(state)
|
||||
return
|
||||
if currently_on:
|
||||
print(
|
||||
f" Currently: below {format_money(ar.threshold_usd)} → "
|
||||
f"reload to {format_money(ar.reload_to_usd)}"
|
||||
)
|
||||
|
||||
if not getattr(self, "_app", None):
|
||||
print(" Run in the interactive CLI to configure auto-reload.")
|
||||
self._billing_portal_hint(state)
|
||||
return
|
||||
|
||||
# When already enabled, let the user turn it off without re-entering values.
|
||||
if currently_on:
|
||||
top_choices = [
|
||||
("edit", "Edit thresholds", "change when / how much to reload"),
|
||||
("off", "Turn off", "disable auto-reload"),
|
||||
("cancel", "Cancel", "do nothing"),
|
||||
]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="💳 Auto-reload",
|
||||
detail=(
|
||||
f"On — below {format_money(ar.threshold_usd)} → "
|
||||
f"reload to {format_money(ar.reload_to_usd)}"
|
||||
),
|
||||
choices=top_choices,
|
||||
)
|
||||
top = self._normalize_slash_confirm_choice(raw, top_choices)
|
||||
if top == "off":
|
||||
self._billing_auto_reload_disable(state)
|
||||
return
|
||||
if top != "edit":
|
||||
print(" 🟡 Cancelled.")
|
||||
return
|
||||
|
||||
# Field 1 — threshold (prefilled when editing an existing config).
|
||||
cur_thr = format_money(ar.threshold_usd) if currently_on else None
|
||||
thr_prompt = " When balance falls below (USD)"
|
||||
thr_prompt += f" [{cur_thr}]: " if cur_thr else ": "
|
||||
threshold_raw = self._prompt_text_input(thr_prompt)
|
||||
if threshold_raw is None:
|
||||
# None = cancelled (e.g. slash-worker can't prompt off-thread).
|
||||
print(" 🟡 Cancelled.")
|
||||
return
|
||||
if not (threshold_raw or "").strip() and currently_on:
|
||||
threshold_amt = ar.threshold_usd # keep current value on empty input
|
||||
else:
|
||||
tv = validate_charge_amount(
|
||||
threshold_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
|
||||
)
|
||||
if not tv.ok or tv.amount is None:
|
||||
print(f" 🔴 {tv.error}")
|
||||
return
|
||||
threshold_amt = tv.amount
|
||||
|
||||
# Field 2 — reload-to (prefilled when editing an existing config).
|
||||
cur_rel = format_money(ar.reload_to_usd) if currently_on else None
|
||||
rel_prompt = " Reload balance to (USD)"
|
||||
rel_prompt += f" [{cur_rel}]: " if cur_rel else ": "
|
||||
reload_raw = self._prompt_text_input(rel_prompt)
|
||||
if reload_raw is None:
|
||||
print(" 🟡 Cancelled.")
|
||||
return
|
||||
if not (reload_raw or "").strip() and currently_on:
|
||||
reload_amt = ar.reload_to_usd # keep current value on empty input
|
||||
else:
|
||||
rv = validate_charge_amount(
|
||||
reload_raw or "", min_usd=state.min_usd, max_usd=state.max_usd
|
||||
)
|
||||
if not rv.ok or rv.amount is None:
|
||||
print(f" 🔴 {rv.error}")
|
||||
return
|
||||
reload_amt = rv.amount
|
||||
|
||||
if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt:
|
||||
print(" 🔴 Reload-to amount must be greater than the threshold.")
|
||||
return
|
||||
|
||||
print()
|
||||
_ar_consent = (
|
||||
f"By confirming, you authorize Nous Research to charge {card.masked} "
|
||||
f"whenever your balance reaches {format_money(threshold_amt)}. "
|
||||
f"Turn off any time here or on the portal."
|
||||
)
|
||||
_cprint(f" {_d(_ar_consent)}")
|
||||
confirm_choices = [
|
||||
("agree", "Agree and turn on", "enable auto-reload"),
|
||||
("cancel", "Cancel", "do nothing"),
|
||||
]
|
||||
raw = self._prompt_text_input_modal(
|
||||
title="💳 Turn on auto-reload?",
|
||||
detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}",
|
||||
choices=confirm_choices,
|
||||
)
|
||||
choice = self._normalize_slash_confirm_choice(raw, confirm_choices)
|
||||
if choice != "agree":
|
||||
print(" 🟡 Cancelled.")
|
||||
return
|
||||
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingError,
|
||||
BillingScopeRequired,
|
||||
patch_auto_top_up,
|
||||
)
|
||||
|
||||
try:
|
||||
patch_auto_top_up(
|
||||
enabled=True, threshold=float(threshold_amt), top_up_amount=float(reload_amt)
|
||||
)
|
||||
except BillingScopeRequired:
|
||||
self._billing_handle_scope_required(state)
|
||||
return
|
||||
except BillingError as exc:
|
||||
self._billing_render_charge_error(state, exc)
|
||||
return
|
||||
print(f" ✅ Auto-reload on: below {format_money(threshold_amt)} → "
|
||||
f"reload to {format_money(reload_amt)}.")
|
||||
|
||||
def _billing_auto_reload_disable(self, state):
|
||||
"""Turn off auto-reload (PATCH ``enabled:false``).
|
||||
|
||||
The endpoint requires ``threshold``/``topUpAmount`` in the body even when
|
||||
disabling, so we echo back the current values (falling back to 0).
|
||||
"""
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingError,
|
||||
BillingScopeRequired,
|
||||
patch_auto_top_up,
|
||||
)
|
||||
|
||||
ar = state.auto_reload
|
||||
thr = float(ar.threshold_usd) if ar and ar.threshold_usd is not None else 0.0
|
||||
rel = float(ar.reload_to_usd) if ar and ar.reload_to_usd is not None else 0.0
|
||||
try:
|
||||
patch_auto_top_up(enabled=False, threshold=thr, top_up_amount=rel)
|
||||
except BillingScopeRequired:
|
||||
self._billing_handle_scope_required(state)
|
||||
return
|
||||
except BillingError as exc:
|
||||
self._billing_render_charge_error(state, exc)
|
||||
return
|
||||
print(" ✅ Auto-reload turned off.")
|
||||
|
||||
def _billing_limit_screen(self, state):
|
||||
"""Screen 5 — monthly spend limit (read-only; cap is portal-only)."""
|
||||
from agent.billing_view import format_money
|
||||
|
||||
print()
|
||||
_cprint(f" 💳 {_b('Monthly spend limit')}")
|
||||
print(f" {'─' * 41}")
|
||||
cap = state.monthly_cap
|
||||
if cap is None or cap.limit_usd is None:
|
||||
_cprint(f" {_d('No monthly cap visible (managed on the portal).')}")
|
||||
else:
|
||||
spent = format_money(cap.spent_this_month_usd)
|
||||
limit = format_money(cap.limit_usd)
|
||||
ceiling = " (default ceiling)" if cap.is_default_ceiling else ""
|
||||
print(f" {spent} of {limit} used this month{ceiling}")
|
||||
_limit_note = (
|
||||
"The monthly limit is set on the portal — the terminal shows "
|
||||
"it read-only."
|
||||
)
|
||||
_cprint(f" {_d(_limit_note)}")
|
||||
self._billing_portal_hint(state)
|
||||
|
||||
def _show_insights(self, command: str = "/insights"):
|
||||
"""Show usage insights and analytics from session history."""
|
||||
# Parse optional --days flag
|
||||
|
|
|
|||
171
docs/billing-lifecycle.md
Normal file
171
docs/billing-lifecycle.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Billing lifecycle: client-side state, errors, and recovery
|
||||
|
||||
This is the map from every `billing.*`/`subscription.*` state shape the gateway
|
||||
serves (from NAS) to what the terminal actually renders, and from every typed
|
||||
refusal/error code to its exact user-facing copy and recovery action. The
|
||||
guarantee: no NAS billing state and no typed refusal falls through to a
|
||||
generic toast — every case below is an explicit branch in
|
||||
`ui-tui/src/app/slash/commands/topup.ts`, `ui-tui/src/components/billingOverlay.tsx`,
|
||||
or `ui-tui/src/components/subscriptionOverlay.tsx`. An **unknown** code still
|
||||
degrades gracefully: it hits the `default` branch (a generic-but-real message
|
||||
pulled from the server payload, never a blank toast) rather than crashing or
|
||||
silently dropping the refusal.
|
||||
|
||||
## 1. `billing.state` shapes → render
|
||||
|
||||
Source: `ui-tui/src/components/billingOverlay.tsx` (`OverviewScreen`,
|
||||
`BuyScreen`, `AutoReloadScreen`), `ui-tui/src/app/slash/commands/topup.ts` (`/topup` run).
|
||||
|
||||
| State shape | Render |
|
||||
|---|---|
|
||||
| Logged out (`s.logged_in === false`) | Overlay never opens. `sys`: `💳 Not logged into Nous Portal — run /portal to log in, then /topup.` |
|
||||
| `billing.state` RPC fetch fails (transport/timeout) | **Fail-closed**: `.catch(ctx.guardedErr)` — overlay never opens, no state is assumed. `sys`: `error: <message or "request failed">`. Never renders "no card" or any other guessed state; user must retry `/topup`. |
|
||||
| `card: null` (no saved card), full menu (`is_admin && cli_billing_enabled`) | Overview shows `No saved card on file — "Add funds" walks you through adding one.` "Add funds" opens the **add-card path**: `Add a card on the portal` / `I've added it — check again` / `Back` (never an amount picker, which would 403 `no_payment_method`). |
|
||||
| `card` present, `resolved_via` set | `Card: {display}` (e.g. `Visa ····4242 — the card on your subscription`) using the provenance-aware `display` field. |
|
||||
| `card` present, `resolved_via` absent (older NAS) | Falls back to the generic `Card: {masked}`; Confirm screen adds `Your card saved on the portal will be charged.` |
|
||||
| `auto_reload: null` | No auto-reload line at all (`autoReloadLine` returns `null`) — the feature isn't surfaced. |
|
||||
| `auto_reload.card.kind: 'canonical'` | No distinct-card warning; card line falls back to the card on file. |
|
||||
| `auto_reload.card.kind: 'distinct'` | `⚠ Auto-refill is charging {brand} ••{last4} — not your card on file.` in the Auto-reload screen (the divergence notice). |
|
||||
| `auto_reload.card.kind: 'none'` | Same as `canonical` rendering-wise — no distinct-card warning shown. |
|
||||
| `monthly_cap` present, `limit_usd != null` | `{spent_display} of {limit_display} used this month` (+ ` (default ceiling)` iff `is_default_ceiling`). |
|
||||
| `monthly_cap` absent or `limit_usd == null` | `No monthly cap visible (managed on the portal).` |
|
||||
| Role without billing capability (`!is_admin`, menu collapses) | Note: `Billing actions need someone with billing permissions (owner, admin, or finance admin).` Menu collapses to `Manage on portal` / `Cancel`. |
|
||||
| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Terminal billing is off for this org — manage it on the portal.` Same collapsed menu. |
|
||||
|
||||
Note: `full = s.is_admin && s.cli_billing_enabled` gates the **org-level**
|
||||
switch, not the per-terminal `billing:manage` scope — that's discovered
|
||||
reactively (a charge 403s `insufficient_scope`) and routes to the resumable
|
||||
step-up screen instead of a preflight check.
|
||||
|
||||
## 2. Refusal codes (`renderBillingError`, in code order)
|
||||
|
||||
Source: `renderBillingError` in `ui-tui/src/app/slash/commands/topup.ts:37-149`.
|
||||
"Portal" row = `sys('Portal: {portal_url}')` is appended whenever `portal_url` is present, for every code (including default).
|
||||
|
||||
| `error` code | Copy | Portal URL | `retry_after` |
|
||||
|---|---|:-:|:-:|
|
||||
| `insufficient_scope` | `This needs terminal billing enabled. Start a top-up to enable it, then retry.` | if present | — |
|
||||
| `remote_spending_revoked` (CF-4) | `{An admin turned off terminal billing for this terminal. \| You turned off terminal billing for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — |
|
||||
| `session_revoked` | `Your session was logged out. Run /portal to log in again.` Also clears `billing` overlay state. | if present | — |
|
||||
| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Terminal billing is off for this account — an admin must enable it on the portal.` | if present | — |
|
||||
| `role_required` | `Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.` | if present | — |
|
||||
| `consent_required` | `This action needs a one-time card confirmation and consent step on the portal before it can proceed.` | if present | — |
|
||||
| `org_access_denied` | `This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.` | if present | — |
|
||||
| `upgrade_cap_exceeded` | `🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.` | if present | — |
|
||||
| `auto_top_up_disabled_failures` | `Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.` | if present | — |
|
||||
| `idempotency_conflict` | `🔴 That charge key was already used for a different amount. Start a fresh top-up.` | if present | — |
|
||||
| `no_payment_method` | `💳 No saved card for terminal charges yet. Set one up on the portal (one-time credit buys don't save a reusable card).` | if present | — |
|
||||
| `monthly_cap_exceeded` | `🔴 Monthly spend cap reached — ${remainingUsd} headroom left.` if `payload.remainingUsd` present, else `🔴 Monthly spend cap reached.` | if present | — |
|
||||
| `rate_limited` / `temporarily_unavailable` | `🟡 Too many charges right now{ (try again in ~N min)}. This isn't a payment failure.` | if present | **yes** — minutes computed as `max(1, round(retry_after/60))` |
|
||||
| `stripe_unavailable` | `🟡 Stripe is having trouble right now — try again shortly{ (try again in ~N min)}.` | if present | **yes** (same formula) |
|
||||
| *default (unknown/other)* | `🔴 {message \|\| error \|\| 'Billing request failed.'}` — still surfaces whatever the server said, never a blank toast. | if present | — |
|
||||
|
||||
## 3. Charge settlement outcomes (`pollCharge` / `renderChargeFailed`)
|
||||
|
||||
Source: `pollCharge` (`ui-tui/src/app/slash/commands/topup.ts:170-258`) and
|
||||
`renderChargeFailed` (`:260-290`). Poll cadence: 2s interval, 5-minute cap
|
||||
(`POLL_INTERVAL_MS=2000`, `POLL_CAP_MS=5*60*1000`), applied on **every**
|
||||
non-terminal path (pending *and* throttled), so a sustained 429/503 can't
|
||||
keep the poll alive forever.
|
||||
|
||||
| Outcome | Copy | Notes |
|
||||
|---|---|---|
|
||||
| `status: 'settled'` | `✅ ${amount_usd} added.` (or `✅ Credits added.` if no amount) | Terminal success. |
|
||||
| `status: 'failed'`, `reason: 'authentication_required'` | `🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase.` | + `Portal:` line if `portalUrl`. |
|
||||
| `status: 'failed'`, `reason: 'payment_method_expired'` | `🔴 Your card has expired. Update it on the portal.` | + `Portal:` line. |
|
||||
| `status: 'failed'`, `reason: 'card_declined'` | `🔴 Your card was declined. Try another card on the portal.` | + `Portal:` line. |
|
||||
| `status: 'failed'`, `reason: 'processing_error'` | `🔴 The charge didn't go through (processing_error).` | + `Portal:` line. |
|
||||
| `status: 'failed'`, unrecognized/missing `reason` | `🔴 The charge didn't go through ({reason \|\| 'processing_error'}).` | Same portal funnel — parity with `cli.py`'s `_billing_portal_hint`. |
|
||||
| Poll timeout (still `pending` past the 5-min cap) | `🟡 Still processing after 5 minutes — this is a timeout, not a failure. Check /topup or the portal shortly.` | + `Portal:` line if `portalUrl`. Explicitly NOT called a failure. |
|
||||
| Revocation mid-poll (`remote_spending_revoked` / `session_revoked` while polling) | Renders the matching §2 copy, **then** appends: `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` | CF-7 rule 4: a post-revoke 403 while polling is ambiguous (the charge may have already settled) — never call it "failed". |
|
||||
| 429/503 while polling (`rate_limited`/`temporarily_unavailable`/`stripe_unavailable`) | No error shown; backs off using `retry_after` (default 5s, capped at 30s) and keeps polling until the 5-min cap, then reads as timeout. | Not a payment failure. |
|
||||
| Other `!ok` status-check error | `🔴 Could not check the charge: {message \|\| error \|\| 'error'}` | |
|
||||
| Transport loss (poll RPC throws/rejects) | `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` (`UNCONFIRMED_CHARGE_MESSAGE`) | Same "unconfirmed, check balance" framing as revocation mid-poll — a dropped connection can never be read as "failed". |
|
||||
|
||||
## 4. Subscription preview / pending-change / upgrade outcomes
|
||||
|
||||
Source: `previewAndRoute`, `applyPendingAndRoute`, `upgradeResult`,
|
||||
`stepUpDenialResult` in `ui-tui/src/components/subscriptionOverlay.tsx`.
|
||||
|
||||
**Preview `effect` values** (drive the Confirm screen):
|
||||
|
||||
| `effect` | Confirm screen copy | Primary action |
|
||||
|---|---|---|
|
||||
| `charge_now` | `Upgrade to {target}. You will be charged {amount} now (prorated).` (+ monthly-credits delta, + which card if resolver confidently knows) | `Pay {amount} & upgrade now` |
|
||||
| `scheduled` | `Change to {target} — takes effect {date}. No charge now; you keep your current plan until then.` | `Schedule change to {target}` |
|
||||
| `no_op` | `You are already on {target} — nothing to change.` | none (Back only) |
|
||||
| `blocked` | `{preview.reason}` or fallback `That change cannot be made here — manage it on the portal.` | `Manage on portal` |
|
||||
| Preview RPC returns `null`/transport failure | routes straight to Result: `Could not preview that change.` | — |
|
||||
| Preview `!ok`, `insufficient_scope` | routes to `stepup` screen (`{kind:'preview', tierId}`) | — |
|
||||
| Preview `!ok`, other error | routes to Result with `errorResult(p)` (`message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'`) | — |
|
||||
|
||||
**Pending-change apply outcomes** (`applyPendingAndRoute`):
|
||||
|
||||
| `pending.kind` | Success copy |
|
||||
|---|---|
|
||||
| `cancellation` | `Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.` |
|
||||
| `tier_change` (downgrade/schedule) | `Scheduled — your plan doesn't change today. You keep your current plan until the end of the billing period, then it switches.` |
|
||||
| `upgrade` | routed through `upgradeResult` (below) |
|
||||
| any kind, mutation `insufficient_scope` | routes to stepup (`{kind:'apply'}`) |
|
||||
|
||||
**Upgrade `status` × `reason` matrix** (`upgradeResult`, checked in this
|
||||
order — `reason` is checked *before* `status`):
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| `r === null` (transport failure on the charging route) | `Couldn't confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.` — ambiguous, never a blind retry. |
|
||||
| `reason: 'authentication_required'` **or** `reason: 'subscription_payment_intent_requires_action'` | `Please verify your card in the portal to finish this upgrade.` → `recovery_url`. **Both reasons map to the same SCA copy** — the client branches on `reason`, not `status`, specifically so an SCA case that pre-#711 NAS mislabels with `status: 'payment_failed'` (no distinguishing reason yet) still routes to the correct "verify your card" copy instead of reading as a hard decline. |
|
||||
| `reason: 'card_declined'` | `Your card was declined — try a different card on the portal.` → `recovery_url`. |
|
||||
| `ok && status: 'already_on_tier'` | `You are already on {target_tier_name}.` (success) |
|
||||
| `ok && status: 'upgraded'` | `Upgraded to {target_tier_name}. Your new monthly credits land in a moment.` — starts the eventual-consistency apply-poll (below). |
|
||||
| `status: 'requires_action'` (no distinguishing reason) | `This upgrade needs extra verification (3DS). Finish it on the portal.` → `recovery_url`. |
|
||||
| `status: 'payment_failed'` (no distinguishing reason) | `Your card was declined. Update your payment method on the portal and try again.` → `recovery_url`. |
|
||||
| anything else | `errorResult(r)`: `message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'` |
|
||||
|
||||
**Eventual-consistency apply-poll** (`ResultScreen`, only after `status:
|
||||
'upgraded'`): polls `billing`/subscription state every 2s
|
||||
(`UPGRADE_CONFIRM_INTERVAL_MS`) up to 15 attempts
|
||||
(`UPGRADE_CONFIRM_ATTEMPTS`, i.e. ~30s) until `current.tier_id` flips to the
|
||||
target. While waiting the screen reads `Applying…`; if it never flips inside
|
||||
the budget it reads `Still applying` / `Your upgrade succeeded and is still
|
||||
applying — refresh in a moment.` — the upgrade is never re-reported as failed
|
||||
just because NAS hasn't caught up yet.
|
||||
|
||||
**Step-up denial copy** (`stepUpDenialResult`, subscription flow):
|
||||
|
||||
| `error` | Copy |
|
||||
|---|---|
|
||||
| `session_revoked` | `Your session expired — run /portal to log in again, then retry the change.` |
|
||||
| `remote_spending_revoked` | `{message}` or `Terminal spending was turned off for this session — reconnect from the portal, then retry.` |
|
||||
| `rate_limited` | `Too many attempts — wait a moment, then try again.` |
|
||||
| other/unknown | `{message}` or `Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.` |
|
||||
|
||||
A **repeat** scope denial during a post-grant replay never re-enters the
|
||||
step-up screen (it's already mounted there — re-patching would freeze it);
|
||||
`allowStepUp=false` instead surfaces a terminal result: `Terminal billing
|
||||
still isn't enabled for this org — enable it on the portal, then retry.`
|
||||
|
||||
## Text-mode (CLI) parity
|
||||
|
||||
`cli.py`'s `_show_billing` / `_billing_overview` and `_show_subscription` /
|
||||
`_subscription_overview` render the same state shapes (balance title, two-bar
|
||||
dollar usage, auto-reload line, card line, monthly cap) and share the
|
||||
"fail-open on logged-out/portal-hiccup, never crash" discipline. The CLI's
|
||||
`/subscription` gives a paid admin/owner in an interactive context the **full
|
||||
in-terminal change flow** (tier picker → preview → confirm → apply, parity
|
||||
with the TUI overlay); members and non-interactive contexts fall back to
|
||||
`_billing_portal_hint`'s deep-link to `subscription_manage_url`. `/topup`'s
|
||||
interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and
|
||||
non-interactive contexts fall back to the same text + portal-link rendering,
|
||||
never prompting.
|
||||
|
||||
## Forward compatibility
|
||||
|
||||
Any `error`/`status`/`reason` code not in the tables above lands on the
|
||||
`default` branch in `renderBillingError` (§2) or `errorResult`/`upgradeResult`'s
|
||||
fallthrough (§4): it still renders the server's own `message` (never blank,
|
||||
never a crash), just without bespoke copy or a typed recovery affordance.
|
||||
NAS W3 introduces card-health codes (`card_paused`, `card_expired`,
|
||||
`card_mismatch`) that are not yet typed here — until a client update adds
|
||||
explicit branches, they will arrive as unknown codes and degrade to this
|
||||
default path.
|
||||
|
|
@ -10279,8 +10279,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
if canonical == "usage":
|
||||
return await self._handle_usage_command(event)
|
||||
|
||||
if canonical == "credits":
|
||||
return await self._handle_credits_command(event)
|
||||
if canonical == "topup":
|
||||
return await self._handle_topup_command(event)
|
||||
|
||||
if canonical == "insights":
|
||||
return await self._handle_insights_command(event)
|
||||
|
|
|
|||
|
|
@ -4095,15 +4095,15 @@ class GatewaySlashCommandsMixin:
|
|||
key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many"
|
||||
return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id)
|
||||
|
||||
async def _handle_credits_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /credits -- show Nous credit balance and the top-up handoff.
|
||||
async def _handle_topup_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /topup -- show the Nous balance and hand off to the portal.
|
||||
|
||||
Renders the balance block + identity line + a tappable top-up URL that
|
||||
opens the portal billing page with the modal open. The terminal does NOT
|
||||
confirm, poll, or track payment (billing phase 2a) — checkout happens in
|
||||
the browser and the next /credits shows the new balance. The tappable URL
|
||||
is the affordance: it works on every platform (button-capable or plain
|
||||
text like SMS/email). Fetched off the event loop; fail-open.
|
||||
Renders the balance block + identity line + a tappable portal URL that
|
||||
opens the billing page. Terminal billing is managed on the portal: the
|
||||
terminal does NOT charge, confirm, or track payment here — everything
|
||||
happens in the browser and the next /topup shows the new balance. The
|
||||
tappable URL is the affordance and works on every platform (button-capable
|
||||
or plain text like SMS/email). Fetched off the event loop; fail-open.
|
||||
"""
|
||||
from agent.account_usage import build_credits_view
|
||||
|
||||
|
|
@ -4115,7 +4115,7 @@ class GatewaySlashCommandsMixin:
|
|||
if view is None or not view.logged_in:
|
||||
return t("gateway.credits.not_logged_in")
|
||||
|
||||
lines: list[str] = ["💳 **Nous credits**"]
|
||||
lines: list[str] = ["💳 **Nous balance**"]
|
||||
for line in view.balance_lines:
|
||||
if line.lstrip().startswith("📈"):
|
||||
continue # drop the helper's header; we print our own
|
||||
|
|
@ -4125,8 +4125,8 @@ class GatewaySlashCommandsMixin:
|
|||
lines.append(view.identity_line)
|
||||
if view.topup_url:
|
||||
lines.append("")
|
||||
lines.append(f"Top up: {view.topup_url}")
|
||||
lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.")
|
||||
lines.append(f"Manage billing on the portal: {view.topup_url}")
|
||||
lines.append("Top up and manage billing in the browser — your balance updates here after.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _context_breakdown_lines(self, agent, source) -> list[str]:
|
||||
|
|
|
|||
1455
hermes_cli/cli_billing_mixin.py
Normal file
1455
hermes_cli/cli_billing_mixin.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -230,9 +230,9 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
gateway_only=True),
|
||||
CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info",
|
||||
args_hint="[reset [--force]]"),
|
||||
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
|
||||
CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info",
|
||||
cli_only=True),
|
||||
CommandDef("subscription", "View your Nous plan and change it in the browser", "Info",
|
||||
cli_only=True, aliases=("upgrade",)),
|
||||
CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info"),
|
||||
CommandDef("insights", "Show usage insights and analytics", "Info",
|
||||
args_hint="[days]"),
|
||||
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
|
||||
|
|
@ -1159,12 +1159,12 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
|
|||
# surface (CLI, TUI, Telegram, Discord). Keep this list TIGHT and intentional —
|
||||
# the telegram-parity test reads it so an entry here is a deliberate
|
||||
# "Slack-via-/hermes" decision, not a silent clamp.
|
||||
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
|
||||
# - billing: the terminal-billing surface (buy/auto-reload/limit); /hermes billing.
|
||||
# - topup: the billing/balance surface; reached via /hermes topup on Slack.
|
||||
# (the rehaul folded the old /credits + /billing surfaces into /topup.)
|
||||
# - moa: high-cost slash mode, available through /hermes moa to avoid
|
||||
# displacing existing native Slack slash commands at the 50-command cap.
|
||||
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug"})
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug"})
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ class BillingError(Exception):
|
|||
portal_url: Optional[str] = None,
|
||||
retry_after: Optional[int] = None,
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
actor: Optional[str] = None,
|
||||
code: Optional[str] = None,
|
||||
recovery: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
|
@ -74,6 +77,13 @@ class BillingError(Exception):
|
|||
self.portal_url = portal_url
|
||||
self.retry_after = retry_after
|
||||
self.payload = payload or {}
|
||||
# Remote-Spending contract extras (NAS PR #481): `actor` (self|admin) on a
|
||||
# revoke, `code` (the new machine code dual-emitted alongside `error`), and
|
||||
# `recovery` (reconnect|login|enable_account_toggle). Additive — absent on
|
||||
# older NAS / unrelated errors.
|
||||
self.actor = actor
|
||||
self.code = code
|
||||
self.recovery = recovery
|
||||
|
||||
|
||||
class BillingScopeRequired(BillingError):
|
||||
|
|
@ -86,17 +96,73 @@ class BillingScopeRequired(BillingError):
|
|||
"""
|
||||
|
||||
|
||||
class BillingRateLimited(BillingError):
|
||||
class BillingAuthError(BillingError):
|
||||
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
|
||||
|
||||
|
||||
class BillingRemoteSpendingRevoked(BillingError):
|
||||
"""``403 remote_spending_revoked`` — THIS terminal's spending was revoked.
|
||||
|
||||
Distinct from ``insufficient_scope`` (never had the grant) and from
|
||||
``session_revoked`` (full logout). The terminal stays logged in; only the
|
||||
money path is cut. ``actor`` is ``"admin"`` or ``"self"`` (absent → treat as
|
||||
``"self"``); recovery is **reconnect** (re-consent device-auth). The terminal
|
||||
MUST disable charge/auto-reload immediately, without waiting for the next
|
||||
token refresh (the current token still claims the scope for ~15 min).
|
||||
"""
|
||||
|
||||
|
||||
class BillingSessionRevoked(BillingAuthError):
|
||||
"""``401 session_revoked`` — the whole session was logged out.
|
||||
|
||||
Stronger than a spend-revoke: recovery is **re-login** (full device-auth),
|
||||
not just reconnect. Subclass of :class:`BillingAuthError` so existing 401
|
||||
handling still treats it as not-logged-in, but the typed code lets the
|
||||
surface route to re-login with the right copy.
|
||||
"""
|
||||
|
||||
|
||||
class BillingTransient(BillingError):
|
||||
"""A deterministic non-charge outcome: the request definitely did NOT
|
||||
reach/complete at Stripe, so it's always safe to retry after backoff —
|
||||
never the "maybe charged" ambiguity of a real 5xx/timeout. Covers
|
||||
429 rate limiting, 503 gate-unavailable, Stripe being down, and the
|
||||
daily upgrade cap — distinct failure modes that share this one
|
||||
contract property. Catch this (not the old ad-hoc subclass hierarchy)
|
||||
wherever the intent is "any transient, definitely-not-charged billing
|
||||
failure, back off and retry/poll".
|
||||
"""
|
||||
|
||||
|
||||
class BillingRateLimited(BillingTransient):
|
||||
"""``429 rate_limited`` or ``503 temporarily_unavailable``.
|
||||
|
||||
NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell
|
||||
the user "try again in N min"; never auto-retry-spam (the limiter is
|
||||
5/org/hr + 5/token/hr and easy to dig deeper into).
|
||||
5/org/hr + 5/token/hr and easy to dig deeper into). A 503 is the gate backend
|
||||
failing closed — back off, do NOT treat as revoked.
|
||||
"""
|
||||
|
||||
|
||||
class BillingAuthError(BillingError):
|
||||
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
|
||||
class BillingStripeUnavailable(BillingTransient):
|
||||
"""``503 stripe_unavailable`` — Stripe itself is down.
|
||||
|
||||
TRANSIENT: back off and retry using Retry-After; this is NOT the same as
|
||||
being throttled by our own rate limiter, so surfaces must not render "rate
|
||||
limited" copy for it — they should read ``.error`` to tell the two apart.
|
||||
A BillingTransient sibling of BillingRateLimited (not a subclass) — surfaces
|
||||
must not render "rate limited" copy for it; read ``.error`` to distinguish it.
|
||||
"""
|
||||
|
||||
|
||||
class BillingUpgradeCapExceeded(BillingTransient):
|
||||
"""``429 upgrade_cap_exceeded`` — the org hit its 5-upgrades/day cap.
|
||||
|
||||
Distinct from the hourly ``rate_limited`` charge cap (same HTTP status,
|
||||
different meaning + no useful short-Retry-After backoff). A BillingTransient
|
||||
sibling of BillingRateLimited (not a subclass) — surfaces must read ``.error``
|
||||
to distinguish the failure mode.
|
||||
"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -148,6 +214,20 @@ _TOKEN_CACHE_TTL_SECONDS = 30.0
|
|||
_token_cache: tuple[float, str, str] | None = None # (cached_at, token, base)
|
||||
|
||||
|
||||
def invalidate_cached_token() -> None:
|
||||
"""Bust the 30s token cache so post-step-up replays use the freshly-scoped token.
|
||||
|
||||
``_request`` only self-busts the cache on a 401 (an expired/invalid
|
||||
token), not on a 403 scope denial — so after a step-up grant, the
|
||||
cache would otherwise still hold the pre-grant unscoped token and
|
||||
the immediate replay would 403 again. Callers outside this module
|
||||
(e.g. the CLI's scope step-up flow) call this instead of poking
|
||||
the private ``_token_cache`` global directly.
|
||||
"""
|
||||
global _token_cache
|
||||
_token_cache = None
|
||||
|
||||
|
||||
def _billing_not_logged_in(exc: Optional[BaseException] = None) -> "BillingAuthError":
|
||||
"""Build the canonical 'not logged in' BillingAuthError (single source)."""
|
||||
err = BillingAuthError(
|
||||
|
|
@ -234,9 +314,21 @@ def _retry_after_seconds(headers: Any) -> Optional[int]:
|
|||
def _raise_for_error(
|
||||
status: int, payload: dict[str, Any], headers: Any = None
|
||||
) -> None:
|
||||
"""Map an HTTP error response to the right typed :class:`BillingError`."""
|
||||
"""Map an HTTP error response to the right typed :class:`BillingError`.
|
||||
|
||||
Recognizes the Remote-Spending gate contract (NAS PR #481):
|
||||
403 ``remote_spending_revoked`` (this terminal's spend revoked → reconnect),
|
||||
401 ``session_revoked`` (full logout → re-login), 503 ``temporarily_unavailable``
|
||||
(gate fail-closed → back off, NOT revoked). The business-denial codes
|
||||
(``cli_billing_disabled`` + dual ``code:remote_spending_disabled``,
|
||||
``role_required``, ``idempotency_conflict``, …) flow through as a generic
|
||||
BillingError carrying ``error``/``code``/``recovery`` for the surface to map.
|
||||
"""
|
||||
error = payload.get("error") if isinstance(payload, dict) else None
|
||||
message = payload.get("message") if isinstance(payload, dict) else None
|
||||
code = payload.get("code") if isinstance(payload, dict) else None
|
||||
actor = payload.get("actor") if isinstance(payload, dict) else None
|
||||
recovery = payload.get("recovery") if isinstance(payload, dict) else None
|
||||
portal_url = _absolutize_portal_url(
|
||||
payload.get("portalUrl") if isinstance(payload, dict) else None
|
||||
)
|
||||
|
|
@ -248,14 +340,42 @@ def _raise_for_error(
|
|||
"portal_url": portal_url,
|
||||
"retry_after": retry_after,
|
||||
"payload": payload if isinstance(payload, dict) else None,
|
||||
"actor": actor,
|
||||
"code": code,
|
||||
"recovery": recovery,
|
||||
}
|
||||
|
||||
if status == 401:
|
||||
raise BillingAuthError(message or "Authentication required.", **common)
|
||||
if status == 403 and error == "insufficient_scope":
|
||||
raise BillingScopeRequired(
|
||||
message or "This action needs the billing:manage scope.", **common
|
||||
if error == "stripe_unavailable":
|
||||
raise BillingStripeUnavailable(
|
||||
message or "Stripe is temporarily unavailable — try again shortly.", **common
|
||||
)
|
||||
if error == "upgrade_cap_exceeded":
|
||||
raise BillingUpgradeCapExceeded(
|
||||
message or "Daily plan-change limit reached — try again tomorrow.", **common
|
||||
)
|
||||
|
||||
if status == 401:
|
||||
# session_revoked is a full logout (→ re-login), stronger than a 401
|
||||
# expired-token. Both stay BillingAuthError-compatible for legacy callers.
|
||||
if error == "session_revoked":
|
||||
raise BillingSessionRevoked(
|
||||
message or "Your session was logged out — log in again.", **common
|
||||
)
|
||||
raise BillingAuthError(message or "Authentication required.", **common)
|
||||
if status == 403:
|
||||
# This terminal's spending was revoked (NOT the same as never having the
|
||||
# scope). Disable spend UI immediately; recovery is reconnect.
|
||||
if error == "remote_spending_revoked":
|
||||
raise BillingRemoteSpendingRevoked(
|
||||
message or "Remote Spending was revoked for this terminal.", **common
|
||||
)
|
||||
if error == "insufficient_scope":
|
||||
raise BillingScopeRequired(
|
||||
message or "This action needs the billing:manage scope.", **common
|
||||
)
|
||||
# Business 403s (cli_billing_disabled / role_required / no_payment_method /
|
||||
# monthly_cap_exceeded / …) → generic BillingError with code/recovery.
|
||||
raise BillingError(message or error or "Billing request denied.", **common)
|
||||
if status in (429, 503):
|
||||
raise BillingRateLimited(
|
||||
message or "Rate limited — try again shortly.", **common
|
||||
|
|
@ -296,7 +416,23 @@ def _request(
|
|||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw.strip() else {}
|
||||
if not raw.strip():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
# A 2xx with a non-JSON body means the endpoint isn't actually
|
||||
# serving the billing API here — e.g. a reverse-proxy / SPA
|
||||
# fallback HTML page when the route isn't deployed on this
|
||||
# deployment. Surface it as a typed, non-auth error so callers
|
||||
# degrade gracefully ("unavailable") instead of crashing with a
|
||||
# raw JSONDecodeError that reads as "not logged in".
|
||||
raise BillingError(
|
||||
"Billing endpoint returned a non-JSON response "
|
||||
"(it may not be available on this deployment).",
|
||||
error="endpoint_unavailable",
|
||||
status=getattr(resp, "status", None),
|
||||
) from exc
|
||||
except urllib.error.HTTPError as exc:
|
||||
# A 401 on a cached token → drop the cache and retry once with a fresh
|
||||
# (refresh-aware) resolve before surfacing the auth error.
|
||||
|
|
@ -404,3 +540,133 @@ def get_charge_status(
|
|||
# guard against a stray slash that would change the path shape.
|
||||
safe_id = urllib.parse.quote(charge_id.strip(), safe="")
|
||||
return _request("GET", f"/api/billing/charge/{safe_id}", timeout=timeout)
|
||||
|
||||
|
||||
def get_subscription_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
|
||||
"""``GET /api/billing/subscription`` — current plan, tiers, usage (no scope).
|
||||
|
||||
Returns the raw JSON dict from NAS (WS1 Phase A). Read-only — no
|
||||
``billing:manage`` scope required. Raises :class:`BillingAuthError`
|
||||
on 401 and :class:`BillingError` on other non-2xx.
|
||||
"""
|
||||
return _request("GET", "/api/billing/subscription", timeout=timeout)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Subscription change (V3) — preview + the pending-change resource + upgrade
|
||||
# =============================================================================
|
||||
#
|
||||
# Mutating the plan splits into a chargeless lane and the single money route:
|
||||
# - preview → a quote (no mutation, no charge) of what a change would do.
|
||||
# - PUT/DELETE pending-change → schedule / clear a downgrade or cancellation
|
||||
# (chargeless; takes effect at period end).
|
||||
# - POST upgrade → the ONE route that charges (prorate + charge the card on the
|
||||
# subscription + flip the plan, in one Stripe op).
|
||||
# All require the ``billing:manage`` scope (a 403 insufficient_scope raises
|
||||
# :class:`BillingScopeRequired`, driving the device step-up) — including preview,
|
||||
# which issues live Stripe calls and reveals charge amounts.
|
||||
|
||||
|
||||
def post_subscription_preview(
|
||||
*, subscription_type_id: str, timeout: float = DEFAULT_TIMEOUT
|
||||
) -> dict[str, Any]:
|
||||
"""``POST /api/billing/subscription/preview`` — a chargeless effect quote.
|
||||
|
||||
Quotes a change to ``subscription_type_id`` without mutating anything:
|
||||
``effect`` is ``charge_now`` (an upgrade → ``amountDueNowCents`` is the prorated
|
||||
upfront charge), ``scheduled`` (a downgrade → ``effectiveAt`` is period end),
|
||||
``no_op`` (already on the tier), or ``blocked`` (``reason`` says why the commit
|
||||
would be refused). Also returns the current + target tier and the monthly-credit
|
||||
delta. ``amountDueNowCents`` is ``None`` when not a charge or when the proration
|
||||
quote is unavailable. Requires ``billing:manage`` (live Stripe calls + amounts).
|
||||
"""
|
||||
return _request(
|
||||
"POST",
|
||||
"/api/billing/subscription/preview",
|
||||
body={"subscriptionTypeId": subscription_type_id},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def put_subscription_pending_change(
|
||||
*,
|
||||
subscription_type_id: str | None = None,
|
||||
cancel: bool = False,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
"""``PUT /api/billing/subscription/pending-change`` — set the end-of-period intent.
|
||||
|
||||
A subscription has at most one pending disposition. Pass ``cancel=True`` to
|
||||
schedule a cancellation, or a ``subscription_type_id`` to schedule a downgrade /
|
||||
same-price change. UPGRADES are rejected here (they charge immediately — use
|
||||
:func:`post_subscription_upgrade`). Chargeless; requires ``billing:manage``.
|
||||
Returns ``{rail, changeType, targetTierName, message}`` for a tier change, or
|
||||
``{rail, cancelAtPeriodEnd, message}`` for a cancellation.
|
||||
"""
|
||||
if cancel:
|
||||
body: dict[str, Any] = {"type": "cancellation"}
|
||||
else:
|
||||
if not (
|
||||
isinstance(subscription_type_id, str) and subscription_type_id.strip()
|
||||
):
|
||||
raise BillingError(
|
||||
"A subscription tier is required to schedule a plan change.",
|
||||
error="invalid_subscription_type",
|
||||
)
|
||||
body = {
|
||||
"type": "tier_change",
|
||||
"subscriptionTypeId": subscription_type_id.strip(),
|
||||
}
|
||||
return _request(
|
||||
"PUT",
|
||||
"/api/billing/subscription/pending-change",
|
||||
body=body,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def delete_subscription_pending_change(
|
||||
*, timeout: float = DEFAULT_TIMEOUT
|
||||
) -> dict[str, Any]:
|
||||
"""``DELETE /api/billing/subscription/pending-change`` — clear it (resume / undo).
|
||||
|
||||
Removes a scheduled downgrade OR cancellation in one call, restoring the live
|
||||
active tier and recurring renewal. Chargeless, but it re-enables recurring
|
||||
spend, so it requires ``billing:manage`` and is honored by the org kill-switch.
|
||||
Returns ``{rail, cancelAtPeriodEnd: false, message}``.
|
||||
"""
|
||||
return _request(
|
||||
"DELETE",
|
||||
"/api/billing/subscription/pending-change",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def post_subscription_upgrade(
|
||||
*,
|
||||
subscription_type_id: str,
|
||||
idempotency_key: str,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
"""``POST /api/billing/subscription/upgrade`` — immediate paid upgrade.
|
||||
|
||||
The SINGLE money route: one Stripe op prorates, charges the card already on the
|
||||
subscription, and flips the plan. ``Idempotency-Key`` is MANDATORY (a missing
|
||||
header is a server 400, not a default) — reuse the same key on retry so a replay
|
||||
cannot double-charge. Returns ``{status:"upgraded"|"already_on_tier", ...}`` on
|
||||
success, or ``{status:"requires_action"|"payment_failed", reason, recoveryUrl}``
|
||||
when the charge needs 3DS / was declined and must be finished in the portal at
|
||||
``recoveryUrl``. Requires ``billing:manage``.
|
||||
"""
|
||||
if not (isinstance(idempotency_key, str) and idempotency_key.strip()):
|
||||
raise BillingError(
|
||||
"Idempotency-Key is required for an upgrade.",
|
||||
error="idempotency_key_required",
|
||||
)
|
||||
return _request(
|
||||
"POST",
|
||||
"/api/billing/subscription/upgrade",
|
||||
body={"subscriptionTypeId": subscription_type_id},
|
||||
extra_headers={"Idempotency-Key": idempotency_key.strip()},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
|
|
|||
145
tests/agent/test_billing_usage.py
Normal file
145
tests/agent/test_billing_usage.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""Tests for the shared dollar usage model (agent/billing_usage.py).
|
||||
|
||||
Behavior contracts: status classification, bar math, fail-open, and the
|
||||
dollars-only / topup-split invariants the billing UX requires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.billing_usage import LOW_BALANCE_THRESHOLD_USD, UsageBar, usage_model_from_account
|
||||
|
||||
|
||||
# ── Lightweight stand-ins for the NousPortalAccountInfo shape ────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Access:
|
||||
subscription_credits_remaining: Optional[float] = None
|
||||
purchased_credits_remaining: Optional[float] = None
|
||||
total_usable_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Sub:
|
||||
plan: Optional[str] = None
|
||||
monthly_credits: Optional[float] = None
|
||||
current_period_end: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Account:
|
||||
logged_in: bool = True
|
||||
paid_service_access: Optional[bool] = None
|
||||
paid_service_access_info: Optional[_Access] = None
|
||||
subscription: Optional[_Sub] = None
|
||||
|
||||
|
||||
def _acct(**over):
|
||||
return _Account(**over)
|
||||
|
||||
|
||||
class _Boom:
|
||||
@property
|
||||
def logged_in(self):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("account", [None, _acct(logged_in=False), _Boom()])
|
||||
def test_fails_open_to_unavailable(account):
|
||||
assert usage_model_from_account(account).available is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"account,expected",
|
||||
[
|
||||
# no plan, no balance -> free
|
||||
(_acct(paid_service_access_info=_Access()), "free"),
|
||||
# paid access explicitly lost -> depleted
|
||||
(_acct(paid_service_access=False, subscription=_Sub(plan="Plus", monthly_credits=20.0),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=0.0, total_usable_credits=0.0)), "depleted"),
|
||||
# above threshold -> healthy
|
||||
(_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0)), "healthy"),
|
||||
# under $5 spendable -> low
|
||||
(_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=3.4, total_usable_credits=3.4)), "low"),
|
||||
# exactly $5 -> healthy (the threshold boundary is exclusive)
|
||||
(_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=5.0, total_usable_credits=5.0)), "healthy"),
|
||||
# top-up only, no plan -> usable (healthy), not free
|
||||
(_acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=30.0, total_usable_credits=30.0)), "healthy"),
|
||||
],
|
||||
)
|
||||
def test_status_classification(account, expected):
|
||||
m = usage_model_from_account(account)
|
||||
assert m.available is True
|
||||
assert m.status == expected
|
||||
|
||||
|
||||
def test_threshold_constant_is_five():
|
||||
assert LOW_BALANCE_THRESHOLD_USD == 5.0
|
||||
|
||||
|
||||
def test_healthy_carries_plan_name_and_renewal():
|
||||
m = usage_model_from_account(
|
||||
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0, current_period_end="2026-07-01"),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0))
|
||||
)
|
||||
assert m.plan_name == "Plus" and m.renews_at == "2026-07-01"
|
||||
|
||||
|
||||
def test_plan_bar_spent_and_pct():
|
||||
m = usage_model_from_account(
|
||||
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0))
|
||||
)
|
||||
bar = m.plan_bar
|
||||
assert bar is not None and bar.kind == "plan"
|
||||
assert (bar.remaining_usd, bar.total_usd, bar.pct_used) == (14.0, 20.0, 30)
|
||||
assert bar.spent_usd == pytest.approx(6.0)
|
||||
|
||||
|
||||
def test_plan_bar_clamps_over_cap_to_zero_spent():
|
||||
# Rollover/debt: remaining > cap clamps to the cap and reads as zero spent.
|
||||
m = usage_model_from_account(
|
||||
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=25.0, total_usable_credits=25.0))
|
||||
)
|
||||
assert m.plan_bar.remaining_usd == 20.0 and m.plan_bar.spent_usd == 0.0
|
||||
|
||||
|
||||
def test_topup_bar_is_full_with_no_denominator():
|
||||
m = usage_model_from_account(
|
||||
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=14.0, purchased_credits_remaining=12.0, total_usable_credits=26.0))
|
||||
)
|
||||
tb = m.topup_bar
|
||||
assert tb is not None and tb.kind == "topup"
|
||||
assert tb.remaining_usd == 12.0 and tb.fill_fraction == 1.0 and tb.pct_used is None
|
||||
assert m.total_spendable_usd == 26.0 and m.has_topup is True
|
||||
|
||||
|
||||
def test_no_plan_bar_without_monthly_cap():
|
||||
m = usage_model_from_account(
|
||||
_acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=8.0, total_usable_credits=8.0))
|
||||
)
|
||||
assert m.plan_bar is None and m.topup_bar is not None
|
||||
|
||||
|
||||
def test_non_finite_values_are_ignored():
|
||||
m = usage_model_from_account(
|
||||
_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=float("nan")),
|
||||
paid_service_access_info=_Access(subscription_credits_remaining=float("inf")))
|
||||
)
|
||||
assert m.plan_bar is None
|
||||
|
||||
|
||||
def test_usage_bar_fill_fraction_clamped():
|
||||
assert UsageBar(kind="plan", remaining_usd=30.0, total_usd=20.0).fill_fraction == 1.0
|
||||
assert UsageBar(kind="plan", remaining_usd=-5.0, total_usd=20.0).fill_fraction == 0.0
|
||||
assert UsageBar(kind="plan", remaining_usd=0.0, total_usd=0.0).fill_fraction == 0.0
|
||||
|
|
@ -21,6 +21,7 @@ import pytest
|
|||
import agent.billing_view as bv
|
||||
from agent.billing_view import (
|
||||
AutoReload,
|
||||
AutoReloadCard,
|
||||
BillingState,
|
||||
CardInfo,
|
||||
MonthlyCap,
|
||||
|
|
@ -37,6 +38,9 @@ from hermes_cli.nous_billing import (
|
|||
BillingError,
|
||||
BillingRateLimited,
|
||||
BillingScopeRequired,
|
||||
BillingStripeUnavailable,
|
||||
BillingTransient,
|
||||
BillingUpgradeCapExceeded,
|
||||
_raise_for_error,
|
||||
resolve_portal_base_url,
|
||||
)
|
||||
|
|
@ -130,6 +134,71 @@ def test_state_member_tier_parse():
|
|||
assert s.can_charge is False # not admin
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role,can_change_plan_raw,is_admin,can_change_plan",
|
||||
[
|
||||
("OWNER", None, True, True),
|
||||
("ADMIN", None, True, True),
|
||||
("FINANCE_ADMIN", True, False, True),
|
||||
("SECURITY_ADMIN", None, False, False),
|
||||
("MEMBER", None, False, False),
|
||||
],
|
||||
)
|
||||
def test_state_five_roles(
|
||||
role, can_change_plan_raw, is_admin, can_change_plan
|
||||
):
|
||||
payload = _member_payload()
|
||||
payload["org"]["role"] = role
|
||||
if can_change_plan_raw is not None:
|
||||
payload["canChangePlan"] = can_change_plan_raw
|
||||
|
||||
state = billing_state_from_payload(payload)
|
||||
|
||||
assert state.is_admin is is_admin
|
||||
assert state.can_change_plan_raw is can_change_plan_raw
|
||||
assert state.can_change_plan is can_change_plan
|
||||
if role == "SECURITY_ADMIN":
|
||||
assert state.card is None
|
||||
assert state.monthly_cap is None
|
||||
assert state.auto_reload is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role,server_capability",
|
||||
[("MEMBER", True), ("OWNER", False)],
|
||||
)
|
||||
def test_state_can_change_plan_prefers_server_capability(role, server_capability):
|
||||
payload = _member_payload()
|
||||
payload["org"]["role"] = role
|
||||
payload["canChangePlan"] = server_capability
|
||||
|
||||
state = billing_state_from_payload(payload)
|
||||
|
||||
assert state.can_change_plan is server_capability
|
||||
|
||||
|
||||
def test_state_can_change_plan_falls_back_to_legacy_role_check():
|
||||
owner = _member_payload()
|
||||
owner["org"]["role"] = "OWNER"
|
||||
member = _member_payload()
|
||||
|
||||
assert billing_state_from_payload(owner).can_change_plan is True
|
||||
assert billing_state_from_payload(member).can_change_plan is False
|
||||
|
||||
|
||||
def test_can_charge_finance_admin_with_server_capability():
|
||||
"""Server capability can grant FINANCE_ADMIN charge access."""
|
||||
payload = _member_payload()
|
||||
payload["org"]["role"] = "FINANCE_ADMIN"
|
||||
payload["canChangePlan"] = True
|
||||
|
||||
state = billing_state_from_payload(payload)
|
||||
|
||||
assert state.is_admin is False
|
||||
assert state.can_change_plan is True
|
||||
assert state.can_charge is True
|
||||
|
||||
|
||||
def test_state_owner_tier_parse():
|
||||
s = billing_state_from_payload(_owner_payload())
|
||||
assert s.is_admin is True
|
||||
|
|
@ -146,6 +215,54 @@ def test_state_owner_tier_parse():
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_card,expected",
|
||||
[
|
||||
(
|
||||
{"kind": "canonical", "paymentMethodId": "ignored", "brand": "ignored"},
|
||||
AutoReloadCard(kind="canonical"),
|
||||
),
|
||||
(
|
||||
{
|
||||
"kind": "distinct",
|
||||
"paymentMethodId": "pm_auto",
|
||||
"brand": None,
|
||||
"last4": None,
|
||||
},
|
||||
AutoReloadCard(
|
||||
kind="distinct",
|
||||
payment_method_id="pm_auto",
|
||||
brand=None,
|
||||
last4=None,
|
||||
),
|
||||
),
|
||||
(
|
||||
{"kind": "none", "last4": "ignored"},
|
||||
AutoReloadCard(kind="none"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_state_parses_auto_reload_card_variants(raw_card, expected):
|
||||
payload = _owner_payload()
|
||||
payload["autoReload"]["card"] = raw_card
|
||||
|
||||
state = billing_state_from_payload(payload)
|
||||
|
||||
assert state.auto_reload is not None
|
||||
assert state.auto_reload.card == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw_card", [None, "canonical", {}, {"kind": "future"}])
|
||||
def test_state_ignores_unrecognized_auto_reload_card(raw_card):
|
||||
payload = _owner_payload()
|
||||
payload["autoReload"]["card"] = raw_card
|
||||
|
||||
state = billing_state_from_payload(payload)
|
||||
|
||||
assert state.auto_reload is not None
|
||||
assert state.auto_reload.card is None
|
||||
|
||||
|
||||
def test_state_can_charge_false_when_killswitch_off():
|
||||
p = _owner_payload()
|
||||
p["cliBillingEnabled"] = False
|
||||
|
|
@ -205,6 +322,29 @@ def test_rate_limited_maps_with_retry_after(status):
|
|||
assert isinstance(ei.value, BillingRateLimited)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,error,expected_type",
|
||||
[
|
||||
(503, "stripe_unavailable", BillingStripeUnavailable),
|
||||
(429, "upgrade_cap_exceeded", BillingUpgradeCapExceeded),
|
||||
],
|
||||
)
|
||||
def test_specific_billing_throttle_errors_remain_distinguishable(
|
||||
status, error, expected_type
|
||||
):
|
||||
with pytest.raises(expected_type) as ei:
|
||||
_raise_for_error(
|
||||
status,
|
||||
{"error": error},
|
||||
_Headers({"Retry-After": "90"}),
|
||||
)
|
||||
|
||||
assert ei.value.error == error
|
||||
assert ei.value.retry_after == 90
|
||||
assert isinstance(ei.value, BillingTransient)
|
||||
assert not isinstance(ei.value, BillingRateLimited)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
|
|
@ -375,3 +515,50 @@ def test_validate_amount_rejections(raw, err_substr):
|
|||
v = validate_charge_amount(raw, min_usd=Decimal("10"), max_usd=Decimal("10000"))
|
||||
assert not v.ok
|
||||
assert err_substr.lower() in (v.error or "").lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HERMES_DEV_BILLING_FIXTURE — offline card/scope state scaffolding (T0)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_billing_fixture_unset_returns_none(monkeypatch):
|
||||
"""No env var → fixture is inert (the real portal path runs)."""
|
||||
monkeypatch.delenv("HERMES_DEV_BILLING_FIXTURE", raising=False)
|
||||
assert bv._dev_fixture_billing_state() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,has_card,is_admin,billing_on",
|
||||
[
|
||||
("nocard", False, True, True),
|
||||
("card", True, True, True),
|
||||
("card-autoreload", True, True, True),
|
||||
("notadmin", True, False, True),
|
||||
("billing-off", False, True, False),
|
||||
],
|
||||
)
|
||||
def test_billing_fixture_card_and_gate_invariants(monkeypatch, name, has_card, is_admin, billing_on):
|
||||
"""Each fixture state honors the card/admin/kill-switch contract the gate reads."""
|
||||
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", name)
|
||||
s = build_billing_state()
|
||||
assert s.logged_in is True
|
||||
assert (s.card is not None) is has_card
|
||||
assert s.is_admin is is_admin
|
||||
assert s.cli_billing_enabled is billing_on
|
||||
|
||||
|
||||
def test_billing_fixture_autoreload_state(monkeypatch):
|
||||
"""card-autoreload pairs a card with an enabled auto-reload (drives that screen)."""
|
||||
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "card-autoreload")
|
||||
s = build_billing_state()
|
||||
assert s.card is not None
|
||||
assert s.auto_reload is not None and s.auto_reload.enabled is True
|
||||
|
||||
|
||||
def test_billing_fixture_logged_out_and_unknown(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "logged-out")
|
||||
assert build_billing_state().logged_in is False
|
||||
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "bogus-state")
|
||||
s = build_billing_state()
|
||||
assert s.logged_in is False and "bogus-state" in (s.error or "")
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ class TestNoticeCopy:
|
|||
s = CreditsState(paid_access=False)
|
||||
to_show, _ = evaluate_credits_notices(s, latch)
|
||||
depleted_notice = next(n for n in to_show if n.key == "credits.depleted")
|
||||
assert "/credits" in depleted_notice.text
|
||||
assert "/topup" in depleted_notice.text
|
||||
|
||||
|
||||
# ── Scenario 8: severity order in a single call ──────────────────────────────
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ def test_view_fetch_failure_is_logged_out(monkeypatch):
|
|||
assert view.logged_in is False
|
||||
|
||||
|
||||
# ── gateway _handle_credits_command ─────────────────────────────────────────
|
||||
# ── gateway _handle_topup_command (the messaging billing surface) ────────────
|
||||
|
||||
|
||||
class _FakeEvent:
|
||||
|
|
@ -139,7 +139,7 @@ class _FakeEvent:
|
|||
|
||||
|
||||
def _make_gateway_stub():
|
||||
"""Minimal object exposing the mixin's _handle_credits_command."""
|
||||
"""Minimal object exposing the mixin's _handle_topup_command."""
|
||||
from gateway.slash_commands import GatewaySlashCommandsMixin
|
||||
|
||||
class _Stub(GatewaySlashCommandsMixin):
|
||||
|
|
@ -149,7 +149,7 @@ def _make_gateway_stub():
|
|||
return _Stub()
|
||||
|
||||
|
||||
def test_gateway_credits_renders_block_and_url(monkeypatch):
|
||||
def test_gateway_topup_renders_block_and_url(monkeypatch):
|
||||
view = CreditsView(
|
||||
logged_in=True,
|
||||
balance_lines=("📈 Nous credits", "Total usable: $52.50"),
|
||||
|
|
@ -160,101 +160,55 @@ def test_gateway_credits_renders_block_and_url(monkeypatch):
|
|||
monkeypatch.setattr(account_usage, "build_credits_view", lambda *a, **kw: view)
|
||||
|
||||
stub = _make_gateway_stub()
|
||||
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
|
||||
out = asyncio.run(stub._handle_topup_command(_FakeEvent()))
|
||||
|
||||
assert "💳" in out
|
||||
assert "Total usable: $52.50" in out
|
||||
assert "Topping up as alice@example.test / org Acme" in out
|
||||
assert "https://portal.example.test/orgs/acme/billing?topup=open" in out
|
||||
assert "credits will appear in /credits shortly" in out
|
||||
assert "Manage billing on the portal" in out
|
||||
# The helper's own 📈 header line is dropped (we render our own 💳 header).
|
||||
assert "📈 Nous credits" not in out
|
||||
|
||||
|
||||
def test_gateway_credits_not_logged_in(monkeypatch):
|
||||
def test_gateway_topup_not_logged_in(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
account_usage, "build_credits_view", lambda *a, **kw: CreditsView(logged_in=False)
|
||||
)
|
||||
stub = _make_gateway_stub()
|
||||
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
|
||||
out = asyncio.run(stub._handle_topup_command(_FakeEvent()))
|
||||
assert "Not logged into Nous Portal" in out
|
||||
|
||||
|
||||
def test_gateway_credits_fetch_exception_is_not_logged_in(monkeypatch):
|
||||
def test_gateway_topup_fetch_exception_is_not_logged_in(monkeypatch):
|
||||
def _boom(*a, **kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(account_usage, "build_credits_view", _boom)
|
||||
stub = _make_gateway_stub()
|
||||
out = asyncio.run(stub._handle_credits_command(_FakeEvent()))
|
||||
out = asyncio.run(stub._handle_topup_command(_FakeEvent()))
|
||||
assert "Not logged into Nous Portal" in out
|
||||
|
||||
|
||||
# ── command registry ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_credits_command_registered():
|
||||
def test_credits_command_fully_removed():
|
||||
"""`/credits` and the old `/billing` are gone entirely — not commands, not
|
||||
aliases. Billing lives only on /topup, with NO aliases, on every platform."""
|
||||
from hermes_cli.commands import resolve_command, COMMAND_REGISTRY
|
||||
|
||||
cmd = resolve_command("credits")
|
||||
assert cmd is not None and cmd.name == "credits"
|
||||
# Available on every surface (not cli_only / gateway_only).
|
||||
entry = next(c for c in COMMAND_REGISTRY if c.name == "credits")
|
||||
# Both old names resolve to nothing.
|
||||
assert resolve_command("credits") is None
|
||||
assert resolve_command("billing") is None
|
||||
# No standalone command for either remains in the registry.
|
||||
assert not any(c.name in ("credits", "billing") for c in COMMAND_REGISTRY)
|
||||
# And no command carries either as an alias.
|
||||
for c in COMMAND_REGISTRY:
|
||||
assert "credits" not in (c.aliases or ())
|
||||
assert "billing" not in (c.aliases or ())
|
||||
# /topup is the billing surface, on every surface, and carries no aliases.
|
||||
entry = next(c for c in COMMAND_REGISTRY if c.name == "topup")
|
||||
assert entry.cli_only is False
|
||||
assert entry.gateway_only is False
|
||||
|
||||
|
||||
# ── CLI _show_credits non-interactive (TUI slash-worker) path ───────────────
|
||||
|
||||
|
||||
def test_cli_show_credits_non_interactive_renders_text_not_modal(monkeypatch, capsys):
|
||||
"""In the TUI slash-worker (no self._app), /credits must render the text
|
||||
variant — never invoke the prompt_toolkit modal, which would read the
|
||||
worker's JSON-RPC stdin and crash the command (only the depleted banner
|
||||
would survive). Regression for that exact failure.
|
||||
"""
|
||||
import agent.account_usage as account_usage
|
||||
from cli import HermesCLI
|
||||
|
||||
monkeypatch.setattr(
|
||||
account_usage,
|
||||
"build_credits_view",
|
||||
lambda *a, **k: CreditsView(
|
||||
logged_in=True,
|
||||
balance_lines=("📈 Nous credits", "Total usable: $0.00"),
|
||||
identity_line="Topping up as a@b.c / org Acme",
|
||||
topup_url="https://prev.test/orgs/acme/billing?topup=open",
|
||||
depleted=True,
|
||||
),
|
||||
)
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._app = None # non-interactive, like the slash worker
|
||||
|
||||
# Must NOT call the modal in this context.
|
||||
def _boom_modal(*a, **k):
|
||||
raise AssertionError("modal must not run without a live app")
|
||||
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
|
||||
|
||||
cli._show_credits()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "💳 Nous credits" in out
|
||||
assert "Total usable: $0.00" in out
|
||||
assert "Topping up as a@b.c / org Acme" in out
|
||||
assert "https://prev.test/orgs/acme/billing?topup=open" in out
|
||||
assert "credits will appear in /credits shortly" in out
|
||||
|
||||
|
||||
def test_cli_show_credits_logged_out(monkeypatch, capsys):
|
||||
import agent.account_usage as account_usage
|
||||
from cli import HermesCLI
|
||||
|
||||
monkeypatch.setattr(
|
||||
account_usage, "build_credits_view", lambda *a, **k: CreditsView(logged_in=False)
|
||||
)
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
cli._app = None
|
||||
cli._show_credits()
|
||||
assert "Not logged into Nous Portal" in capsys.readouterr().out
|
||||
assert not entry.aliases
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ def test_topup_line_is_org_pinned_when_slug_present():
|
|||
blob = "\n".join(_all_lines(snap))
|
||||
# The /usage top-up link auto-opens the modal and is org-pinned.
|
||||
assert "https://portal.example.test/orgs/acme/billing?topup=open" in blob
|
||||
assert "/credits" in blob
|
||||
assert "/topup" in blob
|
||||
|
||||
|
||||
def test_topup_line_falls_back_to_legacy_when_slug_null():
|
||||
|
|
|
|||
290
tests/agent/test_subscription_view.py
Normal file
290
tests/agent/test_subscription_view.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"""Tests for agent.subscription_view — the surface-agnostic /subscription core.
|
||||
|
||||
Behavior contracts (not change-detectors): the manage-URL builder's shape, the
|
||||
payload parser's field mapping + fail-open posture, and the dev-fixture states
|
||||
that drive the CLI/TUI without a live portal.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.subscription_view import (
|
||||
SubscriptionState,
|
||||
build_subscription_state,
|
||||
dev_fixture_subscription_state,
|
||||
subscription_change_preview_from_payload,
|
||||
subscription_manage_url,
|
||||
subscription_state_from_payload,
|
||||
)
|
||||
|
||||
|
||||
# ── subscription_manage_url ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_manage_url_attaches_org_and_path_to_portal_origin():
|
||||
s = SubscriptionState(
|
||||
logged_in=True,
|
||||
org_id="org_x",
|
||||
portal_url="https://portal.nousresearch.com/billing/whatever",
|
||||
)
|
||||
# Path is replaced with /manage-subscription; org_id is pinned; origin kept.
|
||||
assert (
|
||||
subscription_manage_url(s)
|
||||
== "https://portal.nousresearch.com/manage-subscription?org_id=org_x"
|
||||
)
|
||||
|
||||
|
||||
def test_manage_url_omits_org_when_absent():
|
||||
s = SubscriptionState(logged_in=True, org_id=None, portal_url="https://p.example.com/")
|
||||
url = subscription_manage_url(s)
|
||||
assert url == "https://p.example.com/manage-subscription"
|
||||
assert "org_id" not in url
|
||||
|
||||
|
||||
def test_manage_url_none_without_portal():
|
||||
assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url=None)) is None
|
||||
|
||||
|
||||
def test_manage_url_none_for_garbage_portal():
|
||||
# No scheme/netloc → can't build a deep-link; fail closed (None), not crash.
|
||||
assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url="not a url")) is None
|
||||
|
||||
|
||||
# ── payload parser ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parser_maps_camelCase_payload_fields():
|
||||
payload = {
|
||||
"org": {"name": "Acme", "id": "org_1", "role": "ADMIN"},
|
||||
"context": "personal",
|
||||
"current": {
|
||||
"tierId": "plus",
|
||||
"tierName": "Plus",
|
||||
"monthlyCredits": "1000",
|
||||
"creditsRemaining": "420",
|
||||
"cycleEndsAt": "2026-07-01",
|
||||
"cancelAtPeriodEnd": True,
|
||||
"cancellationEffectiveAt": "2026-07-01",
|
||||
},
|
||||
}
|
||||
s = subscription_state_from_payload(payload, portal_url="https://p/billing")
|
||||
|
||||
assert s.logged_in is True
|
||||
assert s.org_name == "Acme" and s.org_id == "org_1"
|
||||
assert s.is_admin is True and s.can_change_plan is True
|
||||
assert s.current is not None
|
||||
assert s.current.tier_name == "Plus"
|
||||
assert s.current.cancel_at_period_end is True
|
||||
assert s.current.monthly_credits == Decimal("1000")
|
||||
|
||||
|
||||
def test_parser_no_plan_is_none_not_all_null_object():
|
||||
# "No plan" is current:null on the wire; a current-shaped dict with no
|
||||
# tierId must parse to None (not an all-null CurrentSubscription).
|
||||
s = subscription_state_from_payload({"current": {"tierId": None}}, portal_url=None)
|
||||
assert s.current is None
|
||||
|
||||
|
||||
def test_parser_member_role_cannot_change_plan():
|
||||
s = subscription_state_from_payload({"org": {"role": "MEMBER"}}, portal_url=None)
|
||||
assert s.is_admin is False
|
||||
assert s.can_change_plan is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role,can_change_plan_raw,is_admin,can_change_plan",
|
||||
[
|
||||
("OWNER", None, True, True),
|
||||
("ADMIN", None, True, True),
|
||||
("FINANCE_ADMIN", True, False, True),
|
||||
("SECURITY_ADMIN", None, False, False),
|
||||
("MEMBER", None, False, False),
|
||||
],
|
||||
)
|
||||
def test_parser_five_roles(
|
||||
role, can_change_plan_raw, is_admin, can_change_plan
|
||||
):
|
||||
payload = {"org": {"role": role}}
|
||||
if can_change_plan_raw is not None:
|
||||
payload["canChangePlan"] = can_change_plan_raw
|
||||
|
||||
state = subscription_state_from_payload(payload, portal_url=None)
|
||||
|
||||
assert state.is_admin is is_admin
|
||||
assert state.can_change_plan_raw is can_change_plan_raw
|
||||
assert state.can_change_plan is can_change_plan
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role,server_capability",
|
||||
[("MEMBER", True), ("OWNER", False)],
|
||||
)
|
||||
def test_parser_can_change_plan_prefers_server_capability(role, server_capability):
|
||||
state = subscription_state_from_payload(
|
||||
{"org": {"role": role}, "canChangePlan": server_capability},
|
||||
portal_url=None,
|
||||
)
|
||||
|
||||
assert state.can_change_plan is server_capability
|
||||
|
||||
|
||||
def test_parser_can_change_plan_falls_back_to_legacy_role_check():
|
||||
owner = subscription_state_from_payload(
|
||||
{"org": {"role": "OWNER"}}, portal_url=None
|
||||
)
|
||||
member = subscription_state_from_payload(
|
||||
{"org": {"role": "MEMBER"}}, portal_url=None
|
||||
)
|
||||
|
||||
assert owner.can_change_plan is True
|
||||
assert member.can_change_plan is False
|
||||
|
||||
|
||||
def test_parser_defaults_unknown_context_to_personal():
|
||||
s = subscription_state_from_payload({"context": "wat"}, portal_url=None)
|
||||
assert s.context == "personal"
|
||||
|
||||
|
||||
# ── tier catalog parsing (the picker) ────────────────────────────────
|
||||
|
||||
|
||||
def test_parser_maps_tiers_catalog():
|
||||
payload = {
|
||||
"tiers": [
|
||||
{
|
||||
"tierId": "free",
|
||||
"name": "Free",
|
||||
"tierOrder": 0,
|
||||
"dollarsPerMonthDisplay": "0",
|
||||
"monthlyCredits": "0",
|
||||
"isCurrent": False,
|
||||
"isEnabled": True,
|
||||
},
|
||||
{
|
||||
"tierId": "plus",
|
||||
"name": "Plus",
|
||||
"tierOrder": 1,
|
||||
"dollarsPerMonthDisplay": "20",
|
||||
"monthlyCredits": "1000",
|
||||
"isCurrent": True,
|
||||
"isEnabled": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
s = subscription_state_from_payload(payload, portal_url=None)
|
||||
assert len(s.tiers) == 2
|
||||
free, plus = s.tiers
|
||||
# The free tier's 0s must survive (coalesce-on-None, not falsy `or`).
|
||||
assert free.tier_id == "free" and free.tier_order == 0
|
||||
assert free.dollars_per_month == Decimal("0")
|
||||
assert plus.is_current is True
|
||||
assert plus.dollars_per_month == Decimal("20") and plus.monthly_credits == Decimal("1000")
|
||||
|
||||
|
||||
def test_parser_tiers_absent_is_empty_tuple():
|
||||
assert subscription_state_from_payload({}, portal_url=None).tiers == ()
|
||||
|
||||
|
||||
# ── preview parser (POST /preview) ───────────────────────────────────
|
||||
|
||||
|
||||
def test_preview_parser_charge_now():
|
||||
p = subscription_change_preview_from_payload(
|
||||
{
|
||||
"effect": "charge_now",
|
||||
"reason": None,
|
||||
"currentTierId": "plus",
|
||||
"currentTierName": "Plus",
|
||||
"targetTierId": "ultra",
|
||||
"targetTierName": "Ultra",
|
||||
"monthlyCreditsDelta": "6000",
|
||||
"amountDueNowCents": 1234,
|
||||
"effectiveAt": None,
|
||||
}
|
||||
)
|
||||
assert p.effect == "charge_now"
|
||||
assert p.amount_due_now_cents == 1234
|
||||
assert p.target_tier_name == "Ultra"
|
||||
assert p.monthly_credits_delta == Decimal("6000")
|
||||
|
||||
|
||||
def test_preview_parser_scheduled_has_effective_at_and_no_charge():
|
||||
p = subscription_change_preview_from_payload(
|
||||
{"effect": "scheduled", "amountDueNowCents": None, "effectiveAt": "2026-08-01"}
|
||||
)
|
||||
assert p.effect == "scheduled"
|
||||
assert p.amount_due_now_cents is None
|
||||
assert p.effective_at == "2026-08-01"
|
||||
|
||||
|
||||
def test_preview_parser_blocked_carries_reason():
|
||||
p = subscription_change_preview_from_payload(
|
||||
{"effect": "blocked", "reason": "Retract the cancellation before upgrading."}
|
||||
)
|
||||
assert p.effect == "blocked"
|
||||
assert p.reason and "Retract" in p.reason
|
||||
|
||||
|
||||
def test_preview_parser_missing_effect_fails_safe_to_blocked():
|
||||
# A malformed quote must never read as a charge — default to blocked.
|
||||
p = subscription_change_preview_from_payload({})
|
||||
assert p.effect == "blocked"
|
||||
assert p.amount_due_now_cents is None
|
||||
|
||||
|
||||
# ── dev fixtures (env-driven, no live portal) ────────────────────────
|
||||
|
||||
|
||||
def test_no_fixture_when_env_unset(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", raising=False)
|
||||
assert dev_fixture_subscription_state() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,checker",
|
||||
[
|
||||
("free", lambda s: s.logged_in and s.current is None),
|
||||
("mid", lambda s: s.current and s.current.tier_id == "plus"),
|
||||
("top", lambda s: s.current and s.current.tier_id == "ultra"),
|
||||
("not-admin", lambda s: s.role == "MEMBER" and not s.can_change_plan),
|
||||
("downgrade", lambda s: s.current and s.current.pending_downgrade_tier_name == "Plus"),
|
||||
("cancel", lambda s: s.current and s.current.cancel_at_period_end),
|
||||
("team", lambda s: s.context == "team" and s.current is None),
|
||||
("logged-out", lambda s: not s.logged_in),
|
||||
],
|
||||
)
|
||||
def test_dev_fixture_states(monkeypatch, name, checker):
|
||||
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", name)
|
||||
s = dev_fixture_subscription_state()
|
||||
assert s is not None
|
||||
assert checker(s)
|
||||
|
||||
|
||||
def test_dev_fixture_exposes_tier_catalog(monkeypatch):
|
||||
# A picker needs a catalog: the mid fixture lists tiers with the active one flagged.
|
||||
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid")
|
||||
s = dev_fixture_subscription_state()
|
||||
assert s is not None and len(s.tiers) >= 2
|
||||
current = [t for t in s.tiers if t.is_current]
|
||||
assert len(current) == 1 and current[0].tier_id == "plus"
|
||||
|
||||
|
||||
def test_dev_fixture_unknown_name_fails_safe(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "bogus")
|
||||
s = dev_fixture_subscription_state()
|
||||
assert s is not None
|
||||
assert s.logged_in is False
|
||||
assert s.error and "bogus" in s.error
|
||||
|
||||
|
||||
def test_build_subscription_state_uses_fixture(monkeypatch):
|
||||
# build_subscription_state must short-circuit to the fixture (no portal call).
|
||||
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid")
|
||||
s = build_subscription_state()
|
||||
assert s.logged_in is True
|
||||
assert s.current is not None and s.current.tier_id == "plus"
|
||||
# The manage URL is buildable from the fixture's portal_url + org_id.
|
||||
url = subscription_manage_url(s)
|
||||
assert url is not None
|
||||
assert url.endswith("/manage-subscription?org_id=org_acme")
|
||||
|
|
@ -52,11 +52,9 @@ def test_billing_overview_non_interactive_renders_text_not_modal(cli, monkeypatc
|
|||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
||||
cli._show_billing("/billing")
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage credits" in out
|
||||
assert "$142.50" in out
|
||||
assert "$180 of $1000 used (default ceiling)" in out
|
||||
# New design: a spend bar with a percentage on the overview.
|
||||
assert "%" in out and ("█" in out or "░" in out)
|
||||
# Balance now leads in the title; dollars, never "credits".
|
||||
assert "Top up · balance $142.50" in out
|
||||
assert "credits" not in out.lower()
|
||||
# ZERO sub-commands: no /billing buy|auto-reload|limit advertising.
|
||||
assert "/billing buy" not in out
|
||||
assert "Actions:" not in out
|
||||
|
|
@ -115,8 +113,10 @@ def test_billing_sub_arg_ignored_opens_overview(cli, monkeypatch, capsys):
|
|||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
||||
cli._show_billing("/billing buy") # arg is ignored
|
||||
out = capsys.readouterr().out
|
||||
assert "Usage credits" in out # overview, NOT the buy screen
|
||||
assert "Buy usage credits" not in out
|
||||
assert "Top up · balance" in out # overview, NOT the buy screen
|
||||
# The buy screen's preset list isn't shown. (The overview's no-card hint may
|
||||
# legitimately mention the "Add funds" menu item, so key on presets instead.)
|
||||
assert "Presets:" not in out
|
||||
|
||||
|
||||
def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys):
|
||||
|
|
@ -131,6 +131,106 @@ def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys):
|
|||
# Reached via the menu in real use; non-interactively it defers to the portal.
|
||||
cli._billing_buy_flow(state)
|
||||
out = capsys.readouterr().out
|
||||
assert "Buy usage credits" in out
|
||||
assert "Add funds" in out
|
||||
assert "$25" in out and "$50" in out and "$100" in out
|
||||
assert "interactive CLI" in out # defers; no charge attempted non-interactively
|
||||
|
||||
|
||||
# ── Card visibility + the add-card path (inline w/ NAS card-resolver) ──
|
||||
|
||||
|
||||
def _scripted(*responses):
|
||||
it = iter(responses)
|
||||
|
||||
def _modal(self, **kw):
|
||||
return next(it)
|
||||
|
||||
return _modal
|
||||
|
||||
|
||||
def test_overview_shows_card_with_provenance(cli, monkeypatch, capsys):
|
||||
state = BillingState(
|
||||
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
|
||||
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
||||
card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin"),
|
||||
portal_url="https://portal/billing",
|
||||
)
|
||||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
||||
cli._show_billing("/topup")
|
||||
out = capsys.readouterr().out
|
||||
assert "Card: Visa ····4242 — the card on your subscription" in out
|
||||
|
||||
|
||||
def test_overview_shows_no_card_hint(cli, monkeypatch, capsys):
|
||||
state = BillingState(
|
||||
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
|
||||
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
||||
card=None, portal_url="https://portal/billing",
|
||||
)
|
||||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
||||
cli._show_billing("/topup")
|
||||
out = capsys.readouterr().out
|
||||
assert "No saved card on file" in out
|
||||
assert "Add funds" in out # the hint names the path
|
||||
|
||||
|
||||
def test_link_card_renders_brand_alone(cli, monkeypatch, capsys):
|
||||
# A Link payment method has no card number (last4 = "") — never "Link ····".
|
||||
state = BillingState(
|
||||
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
|
||||
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
||||
card=CardInfo(brand="Link", last4=""), portal_url="https://portal/billing",
|
||||
)
|
||||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
||||
cli._show_billing("/topup")
|
||||
out = capsys.readouterr().out
|
||||
assert "Card: Link" in out
|
||||
assert "Link ····" not in out
|
||||
|
||||
|
||||
def test_buy_flow_no_card_guides_then_continues_after_recheck(cli, monkeypatch, capsys):
|
||||
# No card → the guided add-card path; "check again" re-fetches state and,
|
||||
# once the card exists, continues straight into the preset menu.
|
||||
cli._app = object()
|
||||
common = dict(
|
||||
logged_in=True, role="OWNER", cli_billing_enabled=True,
|
||||
charge_presets=(Decimal("25"), Decimal("50")),
|
||||
min_usd=Decimal("5"), max_usd=Decimal("500"),
|
||||
portal_url="https://portal/billing",
|
||||
)
|
||||
nocard = BillingState(card=None, **common)
|
||||
withcard = BillingState(card=CardInfo(brand="Visa", last4="4242", resolved_via="customerDefault"), **common)
|
||||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: withcard)
|
||||
# add-card modal → "recheck"; preset modal → "cancel" (we only test the routing)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("recheck", "cancel"), raising=False)
|
||||
|
||||
cli._billing_buy_flow(nocard)
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Add a card first" in out
|
||||
assert "Card found: Visa ····4242 — your default card saved on the portal" in out
|
||||
assert "Cancelled. No funds added." in out # reached the preset menu, then bailed
|
||||
|
||||
|
||||
def test_buy_flow_no_card_back_abandons(cli, monkeypatch, capsys):
|
||||
cli._app = object()
|
||||
nocard = BillingState(
|
||||
logged_in=True, role="OWNER", cli_billing_enabled=True,
|
||||
charge_presets=(Decimal("25"),), card=None,
|
||||
portal_url="https://portal/billing",
|
||||
)
|
||||
calls = {"n": 0}
|
||||
|
||||
def _no_fetch(*a, **kw):
|
||||
calls["n"] += 1
|
||||
return nocard
|
||||
|
||||
monkeypatch.setattr(bv, "build_billing_state", _no_fetch)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
|
||||
|
||||
cli._billing_buy_flow(nocard)
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Add a card first" in out
|
||||
assert "Cancelled. No funds added." in out
|
||||
assert calls["n"] == 0 # backed out before any re-check
|
||||
|
|
|
|||
161
tests/hermes_cli/test_nous_billing_request.py
Normal file
161
tests/hermes_cli/test_nous_billing_request.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Tests for the hermes_cli.nous_billing HTTP client's response handling.
|
||||
|
||||
Focus: a 2xx response with a NON-JSON body (e.g. a reverse-proxy / SPA fallback
|
||||
HTML page when a route isn't actually serving the billing API) must surface as a
|
||||
typed BillingError, NOT a raw json.JSONDecodeError that escapes the typed-error
|
||||
contract and reads downstream as "not logged in".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import nous_billing as nb
|
||||
|
||||
|
||||
class _FakeResp(io.BytesIO):
|
||||
"""Minimal urlopen() context-manager stand-in with a .status attribute."""
|
||||
|
||||
def __init__(self, body: bytes, status: int = 200):
|
||||
super().__init__(body)
|
||||
self.status = status
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
self.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _stub(monkeypatch, body: bytes, status: int = 200):
|
||||
# Bypass auth/token resolution entirely — we only exercise response parsing.
|
||||
monkeypatch.setattr(nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example"))
|
||||
monkeypatch.setattr(nb, "_token_cache", None, raising=False)
|
||||
monkeypatch.setattr(nb.urllib.request, "urlopen", lambda req, timeout=None: _FakeResp(body, status))
|
||||
yield
|
||||
|
||||
|
||||
def test_non_json_2xx_body_raises_typed_billing_error(monkeypatch):
|
||||
# A 200 that returns an HTML page (route not actually mounted) must NOT crash
|
||||
# with json.JSONDecodeError — it becomes a typed, non-auth BillingError.
|
||||
html = b"<!DOCTYPE html><html><head><title>Not Found</title></head></html>"
|
||||
with _stub(monkeypatch, html, status=200):
|
||||
with pytest.raises(nb.BillingError) as ei:
|
||||
nb.get_subscription_state()
|
||||
exc = ei.value
|
||||
# Not the auth subclass — this is "endpoint unavailable", not "logged out".
|
||||
assert not isinstance(exc, nb.BillingAuthError)
|
||||
assert getattr(exc, "error", None) == "endpoint_unavailable"
|
||||
|
||||
|
||||
def test_empty_2xx_body_returns_empty_dict(monkeypatch):
|
||||
with _stub(monkeypatch, b"", status=200):
|
||||
assert nb.get_billing_state() == {}
|
||||
|
||||
|
||||
def test_valid_json_2xx_body_parses(monkeypatch):
|
||||
payload = {"org": {"name": "Acme"}, "balanceUsd": "10"}
|
||||
with _stub(monkeypatch, json.dumps(payload).encode(), status=200):
|
||||
assert nb.get_billing_state() == payload
|
||||
|
||||
|
||||
def test_transient_siblings_not_parent_child():
|
||||
assert issubclass(nb.BillingRateLimited, nb.BillingTransient)
|
||||
assert issubclass(nb.BillingStripeUnavailable, nb.BillingTransient)
|
||||
assert issubclass(nb.BillingUpgradeCapExceeded, nb.BillingTransient)
|
||||
assert not issubclass(nb.BillingStripeUnavailable, nb.BillingRateLimited)
|
||||
assert not issubclass(nb.BillingUpgradeCapExceeded, nb.BillingRateLimited)
|
||||
assert not issubclass(nb.BillingRateLimited, nb.BillingStripeUnavailable)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subscription change (V3): the request the client actually puts on the wire.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _capture(monkeypatch, body: bytes = b"{}", status: int = 200):
|
||||
"""Stub urlopen, recording the urllib.request.Request the client built."""
|
||||
seen: dict[str, object] = {}
|
||||
monkeypatch.setattr(
|
||||
nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example")
|
||||
)
|
||||
|
||||
def _fake_urlopen(req, timeout=None):
|
||||
seen["method"] = req.get_method()
|
||||
seen["url"] = req.full_url
|
||||
seen["data"] = json.loads(req.data.decode()) if req.data else None
|
||||
seen["headers"] = {k.lower(): v for k, v in req.header_items()}
|
||||
return _FakeResp(body, status)
|
||||
|
||||
monkeypatch.setattr(nb.urllib.request, "urlopen", _fake_urlopen)
|
||||
yield seen
|
||||
|
||||
|
||||
def test_post_subscription_preview_request(monkeypatch):
|
||||
with _capture(monkeypatch) as seen:
|
||||
nb.post_subscription_preview(subscription_type_id="nous-chat-plan-40")
|
||||
assert seen["method"] == "POST"
|
||||
assert seen["url"] == "https://portal.example/api/billing/subscription/preview"
|
||||
assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"}
|
||||
|
||||
|
||||
def test_put_pending_change_tier_change_request(monkeypatch):
|
||||
with _capture(monkeypatch) as seen:
|
||||
nb.put_subscription_pending_change(subscription_type_id="nous-chat-plan-10")
|
||||
assert seen["method"] == "PUT"
|
||||
assert (
|
||||
seen["url"] == "https://portal.example/api/billing/subscription/pending-change"
|
||||
)
|
||||
assert seen["data"] == {
|
||||
"type": "tier_change",
|
||||
"subscriptionTypeId": "nous-chat-plan-10",
|
||||
}
|
||||
|
||||
|
||||
def test_put_pending_change_cancellation_request(monkeypatch):
|
||||
with _capture(monkeypatch) as seen:
|
||||
nb.put_subscription_pending_change(cancel=True)
|
||||
assert seen["method"] == "PUT"
|
||||
assert seen["data"] == {"type": "cancellation"}
|
||||
|
||||
|
||||
def test_put_pending_change_without_tier_or_cancel_raises():
|
||||
# No urlopen stub: a bad call must fail BEFORE any network I/O.
|
||||
with pytest.raises(nb.BillingError) as ei:
|
||||
nb.put_subscription_pending_change()
|
||||
assert getattr(ei.value, "error", None) == "invalid_subscription_type"
|
||||
|
||||
|
||||
def test_delete_pending_change_request(monkeypatch):
|
||||
with _capture(monkeypatch) as seen:
|
||||
nb.delete_subscription_pending_change()
|
||||
assert seen["method"] == "DELETE"
|
||||
assert (
|
||||
seen["url"] == "https://portal.example/api/billing/subscription/pending-change"
|
||||
)
|
||||
assert seen["data"] is None
|
||||
|
||||
|
||||
def test_post_subscription_upgrade_sends_idempotency_key(monkeypatch):
|
||||
with _capture(monkeypatch) as seen:
|
||||
nb.post_subscription_upgrade(
|
||||
subscription_type_id="nous-chat-plan-40", idempotency_key="abc-123"
|
||||
)
|
||||
assert seen["method"] == "POST"
|
||||
assert seen["url"] == "https://portal.example/api/billing/subscription/upgrade"
|
||||
assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"}
|
||||
assert seen["headers"].get("idempotency-key") == "abc-123"
|
||||
|
||||
|
||||
def test_post_subscription_upgrade_blank_key_raises():
|
||||
with pytest.raises(nb.BillingError) as ei:
|
||||
nb.post_subscription_upgrade(
|
||||
subscription_type_id="nous-chat-plan-40", idempotency_key=" "
|
||||
)
|
||||
assert getattr(ei.value, "error", None) == "idempotency_key_required"
|
||||
120
tests/hermes_cli/test_remote_spending_gate_contract.py
Normal file
120
tests/hermes_cli/test_remote_spending_gate_contract.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Tests for the Remote-Spending gate denial contract (NAS PR #481).
|
||||
|
||||
Behavior contracts: the HTTP→exception mapping in
|
||||
``hermes_cli.nous_billing._raise_for_error`` and the
|
||||
``tui_gateway.server._serialize_billing_error`` envelope the TUI branches on.
|
||||
These assert the wire contract (CF-4) — error code, actor, recovery, retry —
|
||||
not specific copy.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingError,
|
||||
BillingRateLimited,
|
||||
BillingRemoteSpendingRevoked,
|
||||
BillingScopeRequired,
|
||||
BillingSessionRevoked,
|
||||
_raise_for_error,
|
||||
)
|
||||
|
||||
|
||||
def _raise(status, payload, headers=None):
|
||||
"""Run _raise_for_error and return the exception it raises."""
|
||||
with pytest.raises(BillingError) as ei:
|
||||
_raise_for_error(status, payload, headers)
|
||||
return ei.value
|
||||
|
||||
|
||||
# ── exception mapping (hermes_cli.nous_billing) ──────────────────────
|
||||
|
||||
|
||||
def test_403_remote_spending_revoked_maps_to_typed_exc_with_actor():
|
||||
exc = _raise(403, {"error": "remote_spending_revoked", "recovery": "reconnect", "actor": "admin"})
|
||||
assert isinstance(exc, BillingRemoteSpendingRevoked)
|
||||
assert exc.actor == "admin"
|
||||
assert exc.recovery == "reconnect"
|
||||
|
||||
|
||||
def test_403_revoked_absent_actor_is_none_not_crash():
|
||||
exc = _raise(403, {"error": "remote_spending_revoked"})
|
||||
assert isinstance(exc, BillingRemoteSpendingRevoked)
|
||||
assert exc.actor is None # surface treats absent as "self"
|
||||
|
||||
|
||||
def test_401_session_revoked_is_distinct_from_plain_401():
|
||||
revoked = _raise(401, {"error": "session_revoked", "recovery": "login"})
|
||||
assert isinstance(revoked, BillingSessionRevoked)
|
||||
assert revoked.recovery == "login"
|
||||
|
||||
plain = _raise(401, {"error": "invalid_token"})
|
||||
assert not isinstance(plain, BillingSessionRevoked)
|
||||
|
||||
|
||||
def test_403_insufficient_scope_still_maps_to_scope_required():
|
||||
exc = _raise(403, {"error": "insufficient_scope"})
|
||||
assert isinstance(exc, BillingScopeRequired)
|
||||
# NOT mistaken for a revoke.
|
||||
assert not isinstance(exc, BillingRemoteSpendingRevoked)
|
||||
|
||||
|
||||
def test_503_is_rate_limited_not_revoked_and_carries_retry_after():
|
||||
exc = _raise(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"})
|
||||
assert isinstance(exc, BillingRateLimited)
|
||||
assert not isinstance(exc, BillingRemoteSpendingRevoked)
|
||||
assert exc.retry_after == 30
|
||||
|
||||
|
||||
def test_403_business_denial_carries_code_and_recovery():
|
||||
exc = _raise(403, {
|
||||
"error": "cli_billing_disabled",
|
||||
"code": "remote_spending_disabled",
|
||||
"recovery": "enable_account_toggle",
|
||||
"portalUrl": "/billing",
|
||||
})
|
||||
# Generic BillingError (not a typed revoke) — the surface maps on code.
|
||||
assert type(exc) is BillingError
|
||||
assert exc.error == "cli_billing_disabled"
|
||||
assert exc.code == "remote_spending_disabled"
|
||||
assert exc.recovery == "enable_account_toggle"
|
||||
|
||||
|
||||
def test_409_idempotency_conflict_passes_through():
|
||||
exc = _raise(409, {"error": "idempotency_conflict", "message": "same key, different amount"})
|
||||
assert exc.error == "idempotency_conflict"
|
||||
|
||||
|
||||
# ── envelope serialization (tui_gateway.server) ──────────────────────
|
||||
|
||||
|
||||
def _serialize(status, payload, headers=None):
|
||||
import tui_gateway.server as srv
|
||||
|
||||
return srv._serialize_billing_error(_raise(status, payload, headers))
|
||||
|
||||
|
||||
def test_envelope_threads_actor_code_recovery():
|
||||
env = _serialize(403, {"error": "remote_spending_revoked", "actor": "admin", "recovery": "reconnect"})
|
||||
assert env["error"] == "remote_spending_revoked"
|
||||
assert env["actor"] == "admin"
|
||||
assert env["recovery"] == "reconnect"
|
||||
assert env["ok"] is False
|
||||
|
||||
|
||||
def test_envelope_session_revoked_kind():
|
||||
env = _serialize(401, {"error": "session_revoked", "recovery": "login"})
|
||||
assert env["error"] == "session_revoked"
|
||||
assert env["recovery"] == "login"
|
||||
|
||||
|
||||
def test_envelope_503_preserves_server_code_with_retry():
|
||||
env = _serialize(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"})
|
||||
assert env["error"] == "temporarily_unavailable"
|
||||
assert env["retry_after"] == 30
|
||||
|
||||
|
||||
def test_envelope_business_code_survives():
|
||||
env = _serialize(403, {"error": "cli_billing_disabled", "code": "remote_spending_disabled", "recovery": "enable_account_toggle"})
|
||||
assert env["error"] == "cli_billing_disabled"
|
||||
assert env["code"] == "remote_spending_disabled"
|
||||
assert env["recovery"] == "enable_account_toggle"
|
||||
357
tests/hermes_cli/test_subscription_cli.py
Normal file
357
tests/hermes_cli/test_subscription_cli.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"""Tests for the /subscription CLI change flow (cli.py::_show_subscription).
|
||||
|
||||
Parity with the TUI overlay: the classic CLI now previews + applies a plan change
|
||||
in-terminal (picker → preview → confirm → apply), grants terminal billing inline on
|
||||
insufficient_scope, and leads a scheduled downgrade/cancel with a prominent banner.
|
||||
Interactive screens are driven by mocking `_prompt_text_input_modal`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
import agent.billing_usage as bu
|
||||
import agent.subscription_view as sv
|
||||
import hermes_cli.nous_billing as nb
|
||||
from agent.subscription_view import CurrentSubscription, SubscriptionState, SubscriptionTier
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli():
|
||||
obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed)
|
||||
obj._app = None # non-interactive by default; tests flip it on
|
||||
return obj
|
||||
|
||||
|
||||
_TIERS = (
|
||||
SubscriptionTier(tier_id="free", name="Free", tier_order=0, dollars_per_month=Decimal("0"), monthly_credits=Decimal("0"), is_current=False, is_enabled=True),
|
||||
SubscriptionTier(tier_id="plus", name="Plus", tier_order=1, dollars_per_month=Decimal("20"), monthly_credits=Decimal("22"), is_current=False, is_enabled=True),
|
||||
SubscriptionTier(tier_id="ultra", name="Ultra", tier_order=3, dollars_per_month=Decimal("200"), monthly_credits=Decimal("220"), is_current=True, is_enabled=True),
|
||||
)
|
||||
|
||||
|
||||
def _sub_state(**current_over) -> SubscriptionState:
|
||||
current_fields = dict(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("220"), cycle_ends_at="2026-07-28")
|
||||
current_fields.update(current_over)
|
||||
current = CurrentSubscription(**current_fields)
|
||||
return SubscriptionState(
|
||||
logged_in=True,
|
||||
org_name="Acme",
|
||||
org_id="org_1",
|
||||
role="OWNER",
|
||||
context="personal",
|
||||
current=current,
|
||||
tiers=_TIERS,
|
||||
portal_url="https://portal.example/billing",
|
||||
)
|
||||
|
||||
|
||||
def _scripted_modal(*responses):
|
||||
it = iter(responses)
|
||||
|
||||
def _modal(self, **kw):
|
||||
return next(it)
|
||||
|
||||
return _modal
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_usage_model(monkeypatch):
|
||||
# The overview's usage model needs a live portal; None → the plan-field fallback.
|
||||
monkeypatch.setattr(bu, "build_usage_model", lambda *a, **kw: None, raising=False)
|
||||
|
||||
|
||||
def test_overview_leads_with_scheduled_downgrade_banner(cli, monkeypatch, capsys):
|
||||
st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28")
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Scheduled change" in out
|
||||
assert "──▶" in out
|
||||
assert "Plus" in out
|
||||
# the status line itself echoes the transition
|
||||
assert "Plan: Ultra → Plus" in out
|
||||
|
||||
|
||||
def test_change_flow_schedules_a_downgrade(cli, monkeypatch, capsys):
|
||||
cli._app = object() # interactive
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
|
||||
# change menu → "change"; picker → "plus" (only selectable); confirm → "yes"
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes"), raising=False)
|
||||
monkeypatch.setattr(
|
||||
nb, "post_subscription_preview",
|
||||
lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28T00:00:00Z", "monthlyCreditsDelta": "-198"},
|
||||
)
|
||||
seen = {}
|
||||
monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: seen.update(kw) or {"message": "Scheduled."})
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert seen.get("subscription_type_id") == "plus"
|
||||
assert "doesn't change today" in out
|
||||
|
||||
|
||||
def test_change_flow_upgrade_charges_now(cli, monkeypatch, capsys):
|
||||
cli._app = object()
|
||||
# Current = Plus so Ultra is a selectable upgrade.
|
||||
st = _sub_state(tier_id="plus", tier_name="Plus")
|
||||
tiers = tuple(SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) for t in _TIERS)
|
||||
object.__setattr__(st, "tiers", tiers)
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
|
||||
seen = {}
|
||||
monkeypatch.setattr(nb, "post_subscription_upgrade", lambda **kw: seen.update(kw) or {"status": "upgraded", "targetTierName": "Ultra"})
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert seen.get("subscription_type_id") == "ultra"
|
||||
assert seen.get("idempotency_key") # minted
|
||||
assert "$46.30" in out
|
||||
assert "Upgraded to Ultra" in out
|
||||
|
||||
|
||||
def test_change_menu_cancel_schedules_cancellation(cli, monkeypatch, capsys):
|
||||
cli._app = object()
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("cancel_sub", "yes"), raising=False)
|
||||
seen = {}
|
||||
monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: seen.update(kw) or {"message": "Cancelled."})
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert seen.get("cancel") is True
|
||||
assert "cancels" in out.lower()
|
||||
|
||||
|
||||
def test_pending_change_menu_offers_undo(cli, monkeypatch, capsys):
|
||||
cli._app = object()
|
||||
st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28")
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("keep"), raising=False)
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(nb, "delete_subscription_pending_change", lambda **kw: called.update(n=called["n"] + 1) or {"message": "Resumed."})
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert called["n"] == 1
|
||||
assert "Undone" in out
|
||||
|
||||
|
||||
def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsys):
|
||||
cli._app = object()
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
|
||||
# menu → change; picker → plus; confirm → yes; step-up → yes
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"})
|
||||
calls = {"n": 0}
|
||||
|
||||
def _put(**kw):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise nb.BillingScopeRequired("terminal billing required")
|
||||
return {"message": "Scheduled."}
|
||||
|
||||
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
|
||||
import hermes_cli.auth as auth
|
||||
|
||||
monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True, raising=False)
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
# applied once (scope-denied), granted, replayed → applied again
|
||||
assert calls["n"] == 2
|
||||
assert "Terminal billing enabled" in out
|
||||
|
||||
|
||||
def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys):
|
||||
cli._app = object()
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"})
|
||||
calls = {"n": 0}
|
||||
|
||||
def _put(**kw):
|
||||
calls["n"] += 1
|
||||
raise nb.BillingScopeRequired("terminal billing required")
|
||||
|
||||
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
|
||||
import hermes_cli.auth as auth
|
||||
|
||||
monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: False, raising=False)
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert calls["n"] == 1 # applied once, grant denied, no replay
|
||||
assert "Couldn't enable terminal billing" in out
|
||||
|
||||
|
||||
def test_unknown_preview_effect_fails_safe(cli, monkeypatch, capsys):
|
||||
# An unrecognized effect string must NOT schedule a real change (fail safe).
|
||||
cli._app = object()
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "weird_unknown", "targetTierName": "Plus"})
|
||||
put = {"n": 0}
|
||||
monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: put.update(n=put["n"] + 1) or {})
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert put["n"] == 0 # no mutation on an unknown effect
|
||||
assert "portal" in out.lower()
|
||||
|
||||
|
||||
def test_bounded_stepup_does_not_loop_on_repeat_denial(cli, monkeypatch, capsys):
|
||||
# Grant "succeeds" but the scope stays denied → replay ONCE (allow_stepup=False),
|
||||
# then stop — no re-prompt / re-open-browser loop.
|
||||
cli._app = object()
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state())
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"})
|
||||
calls = {"n": 0}
|
||||
|
||||
def _put(**kw):
|
||||
calls["n"] += 1
|
||||
raise nb.BillingScopeRequired("still no scope")
|
||||
|
||||
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
|
||||
import hermes_cli.auth as auth
|
||||
|
||||
monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True, raising=False)
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert calls["n"] == 2 # applied, granted, replayed once — no third attempt
|
||||
assert "still isn't enabled" in out
|
||||
|
||||
|
||||
def test_upgrade_transport_failure_is_ambiguous_not_flat_failure(cli, monkeypatch, capsys):
|
||||
# BUG B: a charge-route failure must warn "may or may not have been charged"
|
||||
# (steer to a re-check), never a flat failure that invites a blind retry (which
|
||||
# would mint a fresh idempotency key the server can't dedup → a real 2nd charge).
|
||||
cli._app = object()
|
||||
st = _sub_state(tier_id="plus", tier_name="Plus")
|
||||
tiers = tuple(
|
||||
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
|
||||
for t in _TIERS
|
||||
)
|
||||
object.__setattr__(st, "tiers", tiers)
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
|
||||
|
||||
def _boom(**kw):
|
||||
raise nb.BillingError("Could not reach Nous Portal", error="endpoint_unavailable")
|
||||
|
||||
monkeypatch.setattr(nb, "post_subscription_upgrade", _boom)
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "may or may not have been charged" in out
|
||||
assert "Re-run /subscription" in out
|
||||
assert "could not be completed" not in out # not the old flat failure
|
||||
|
||||
|
||||
def test_upgrade_rate_limit_is_deterministic_not_ambiguous(cli, monkeypatch, capsys):
|
||||
# R2: a typed PRE-charge rejection (429 rate-limit) must NOT be mislabeled
|
||||
# "may or may not have been charged" — it never reached Stripe. It gets the
|
||||
# normal error copy, not the ambiguous one.
|
||||
cli._app = object()
|
||||
st = _sub_state(tier_id="plus", tier_name="Plus")
|
||||
tiers = tuple(
|
||||
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
|
||||
for t in _TIERS
|
||||
)
|
||||
object.__setattr__(st, "tiers", tiers)
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
|
||||
|
||||
def _rl(**kw):
|
||||
raise nb.BillingRateLimited("Slow down — too many requests.", error="rate_limited", status=429, retry_after=30)
|
||||
|
||||
monkeypatch.setattr(nb, "post_subscription_upgrade", _rl)
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "may or may not have been charged" not in out # NOT the ambiguous copy
|
||||
assert "Slow down" in out # the real, deterministic error
|
||||
|
||||
|
||||
def test_upgrade_transport_failure_still_ambiguous_after_narrowing(cli, monkeypatch, capsys):
|
||||
# Regression floor for R2: a genuine transport failure (network_error, no status)
|
||||
# must STILL be ambiguous after the narrowing.
|
||||
cli._app = object()
|
||||
st = _sub_state(tier_id="plus", tier_name="Plus")
|
||||
tiers = tuple(
|
||||
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
|
||||
for t in _TIERS
|
||||
)
|
||||
object.__setattr__(st, "tiers", tiers)
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
|
||||
|
||||
def _net(**kw):
|
||||
raise nb.BillingError("Could not reach Nous Portal: timeout", error="network_error")
|
||||
|
||||
monkeypatch.setattr(nb, "post_subscription_upgrade", _net)
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "may or may not have been charged" in out
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_card_lookup(monkeypatch):
|
||||
# The charge_now confirm best-effort-fetches billing state to NAME the card
|
||||
# being charged; keep unit tests offline (generic line) unless overridden.
|
||||
import agent.billing_view as bv
|
||||
|
||||
def _offline(*a, **kw):
|
||||
raise RuntimeError("offline")
|
||||
|
||||
monkeypatch.setattr(bv, "build_billing_state", _offline, raising=False)
|
||||
|
||||
|
||||
def test_upgrade_confirm_names_the_subscription_card(cli, monkeypatch, capsys):
|
||||
# Post-card-resolver NAS: the confirm names the exact card when it resolved
|
||||
# via the subscription rung (what the upgrade actually charges).
|
||||
cli._app = object()
|
||||
st = _sub_state(tier_id="plus", tier_name="Plus")
|
||||
tiers = tuple(
|
||||
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
|
||||
for t in _TIERS
|
||||
)
|
||||
object.__setattr__(st, "tiers", tiers)
|
||||
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
|
||||
# picker → ultra; charge confirm → back out (we only assert the card line)
|
||||
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "cancel"), raising=False)
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630})
|
||||
import agent.billing_view as bv
|
||||
from agent.billing_view import BillingState as _BS
|
||||
from agent.billing_view import CardInfo as _CI
|
||||
|
||||
_state = _BS(logged_in=True, card=_CI(brand="Visa", last4="4242", resolved_via="subPin"))
|
||||
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: _state, raising=False)
|
||||
|
||||
cli._show_subscription()
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Visa ····4242 — the card on your subscription — will be charged." in out
|
||||
|
|
@ -9908,6 +9908,242 @@ def test_start_agent_build_passes_session_model_override(monkeypatch):
|
|||
server._sessions.clear()
|
||||
|
||||
|
||||
# ── billing/subscription state + error serialization ─────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"card,expected",
|
||||
[
|
||||
("canonical", {"kind": "canonical"}),
|
||||
(
|
||||
"distinct",
|
||||
{
|
||||
"kind": "distinct",
|
||||
"payment_method_id": "pm_auto",
|
||||
"brand": None,
|
||||
"last4": None,
|
||||
},
|
||||
),
|
||||
("none", {"kind": "none"}),
|
||||
],
|
||||
)
|
||||
def test_billing_state_serializes_auto_reload_card_union(monkeypatch, card, expected):
|
||||
from agent.billing_view import AutoReload, AutoReloadCard, BillingState
|
||||
|
||||
monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False})
|
||||
auto_reload_card = AutoReloadCard(
|
||||
kind=card,
|
||||
payment_method_id="pm_auto" if card == "distinct" else None,
|
||||
)
|
||||
state = BillingState(
|
||||
logged_in=True,
|
||||
auto_reload=AutoReload(enabled=True, card=auto_reload_card),
|
||||
)
|
||||
|
||||
result = server._serialize_billing_state(state)
|
||||
|
||||
assert result["auto_reload"]["card"] == expected
|
||||
|
||||
|
||||
def test_billing_state_serializes_server_plan_capability(monkeypatch):
|
||||
from agent.billing_view import BillingState
|
||||
|
||||
monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False})
|
||||
state = BillingState(
|
||||
logged_in=True,
|
||||
role="MEMBER",
|
||||
can_change_plan_raw=True,
|
||||
)
|
||||
|
||||
result = server._serialize_billing_state(state)
|
||||
|
||||
assert result["is_admin"] is False
|
||||
assert result["can_change_plan"] is True
|
||||
|
||||
|
||||
class _BillingHeaders:
|
||||
def __init__(self, values):
|
||||
self._values = values
|
||||
|
||||
def get(self, key):
|
||||
return self._values.get(key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,error,retry_after",
|
||||
[
|
||||
(503, "stripe_unavailable", 75),
|
||||
(429, "upgrade_cap_exceeded", None),
|
||||
(429, "rate_limited", None),
|
||||
],
|
||||
)
|
||||
def test_billing_error_serialization_preserves_server_code(
|
||||
status, error, retry_after
|
||||
):
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
headers = _BillingHeaders({"Retry-After": str(retry_after)}) if retry_after else None
|
||||
with pytest.raises(nb.BillingTransient) as ei:
|
||||
nb._raise_for_error(status, {"error": error}, headers)
|
||||
|
||||
result = server._serialize_billing_error(ei.value)
|
||||
|
||||
assert result["error"] == error
|
||||
assert ei.value.error == error
|
||||
assert result["retry_after"] == retry_after
|
||||
|
||||
|
||||
def test_billing_rate_limit_without_error_defaults_wire_code():
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
exc = nb.BillingRateLimited("slow down", status=429, retry_after=10)
|
||||
|
||||
result = server._serialize_billing_error(exc)
|
||||
|
||||
assert result["error"] == "rate_limited"
|
||||
|
||||
|
||||
# ── subscription change RPCs (V3): preview + pending-change + upgrade ──
|
||||
|
||||
|
||||
def _sub_rpc(method, params):
|
||||
# These RPCs are in _LONG_HANDLERS (pool-routed → dispatch returns None and the
|
||||
# worker writes via the transport), so drive the inline handler directly.
|
||||
return server.handle_request({"id": "1", "method": method, "params": params})["result"]
|
||||
|
||||
|
||||
def test_subscription_preview_serializes_quote(monkeypatch):
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
monkeypatch.setattr(
|
||||
nb,
|
||||
"post_subscription_preview",
|
||||
lambda subscription_type_id: {
|
||||
"effect": "charge_now",
|
||||
"reason": None,
|
||||
"currentTierId": "plus",
|
||||
"currentTierName": "Plus",
|
||||
"targetTierId": "ultra",
|
||||
"targetTierName": "Ultra",
|
||||
"monthlyCreditsDelta": "6000",
|
||||
"amountDueNowCents": 1234,
|
||||
"effectiveAt": None,
|
||||
},
|
||||
)
|
||||
res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"})
|
||||
assert res["ok"] is True
|
||||
assert res["effect"] == "charge_now"
|
||||
assert res["amount_due_now_cents"] == 1234
|
||||
assert res["target_tier_name"] == "Ultra"
|
||||
assert res["monthly_credits_delta"] == "6000"
|
||||
|
||||
|
||||
def test_subscription_preview_requires_tier():
|
||||
res = _sub_rpc("subscription.preview", {})
|
||||
assert res["ok"] is False
|
||||
assert res["error"] == "invalid_request"
|
||||
|
||||
|
||||
def test_subscription_preview_scope_error_maps_to_step_up(monkeypatch):
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
def _raise(subscription_type_id):
|
||||
raise nb.BillingScopeRequired("billing:manage required")
|
||||
|
||||
monkeypatch.setattr(nb, "post_subscription_preview", _raise)
|
||||
res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"})
|
||||
assert res["ok"] is False
|
||||
assert res["error"] == "insufficient_scope"
|
||||
|
||||
|
||||
def test_subscription_change_cancellation(monkeypatch):
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
seen = {}
|
||||
|
||||
def _put(*, subscription_type_id=None, cancel=False):
|
||||
seen["tier"] = subscription_type_id
|
||||
seen["cancel"] = cancel
|
||||
return {"rail": "stripe", "cancelAtPeriodEnd": True, "message": "Scheduled to cancel."}
|
||||
|
||||
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
|
||||
res = _sub_rpc("subscription.change", {"cancel": True})
|
||||
assert res["ok"] is True
|
||||
assert seen == {"tier": None, "cancel": True}
|
||||
assert res["message"] == "Scheduled to cancel."
|
||||
|
||||
|
||||
def test_subscription_change_tier_downgrade(monkeypatch):
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
seen = {}
|
||||
|
||||
def _put(*, subscription_type_id=None, cancel=False):
|
||||
seen["tier"] = subscription_type_id
|
||||
seen["cancel"] = cancel
|
||||
return {"rail": "stripe", "changeType": "downgrade", "targetTierName": "Plus", "message": "Scheduled."}
|
||||
|
||||
monkeypatch.setattr(nb, "put_subscription_pending_change", _put)
|
||||
res = _sub_rpc("subscription.change", {"subscription_type_id": "plus"})
|
||||
assert res["ok"] is True
|
||||
assert seen == {"tier": "plus", "cancel": False}
|
||||
|
||||
|
||||
def test_subscription_change_requires_tier_or_cancel():
|
||||
res = _sub_rpc("subscription.change", {})
|
||||
assert res["ok"] is False
|
||||
assert res["error"] == "invalid_request"
|
||||
|
||||
|
||||
def test_subscription_resume(monkeypatch):
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
monkeypatch.setattr(
|
||||
nb,
|
||||
"delete_subscription_pending_change",
|
||||
lambda: {"rail": "stripe", "cancelAtPeriodEnd": False, "message": "Resumed."},
|
||||
)
|
||||
res = _sub_rpc("subscription.resume", {})
|
||||
assert res["ok"] is True
|
||||
assert res["message"] == "Resumed."
|
||||
|
||||
|
||||
def test_subscription_upgrade_echoes_status_and_idempotency(monkeypatch):
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
seen = {}
|
||||
|
||||
def _upgrade(*, subscription_type_id, idempotency_key):
|
||||
seen["key"] = idempotency_key
|
||||
return {"status": "upgraded", "targetTierId": "ultra", "targetTierName": "Ultra"}
|
||||
|
||||
monkeypatch.setattr(nb, "post_subscription_upgrade", _upgrade)
|
||||
res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra", "idempotency_key": "k-1"})
|
||||
assert res["ok"] is True
|
||||
assert res["status"] == "upgraded"
|
||||
assert res["target_tier_name"] == "Ultra"
|
||||
assert res["idempotency_key"] == "k-1"
|
||||
assert seen["key"] == "k-1"
|
||||
|
||||
|
||||
def test_subscription_upgrade_requires_action_surfaces_recovery(monkeypatch):
|
||||
import hermes_cli.nous_billing as nb
|
||||
|
||||
monkeypatch.setattr(
|
||||
nb,
|
||||
"post_subscription_upgrade",
|
||||
lambda *, subscription_type_id, idempotency_key: {
|
||||
"status": "requires_action",
|
||||
"reason": "authentication_required",
|
||||
"recoveryUrl": "https://portal.example/subscription?org_id=o",
|
||||
},
|
||||
)
|
||||
res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra"})
|
||||
# The RPC succeeds; the CHARGE needs 3DS → status + recovery_url for the portal.
|
||||
assert res["ok"] is True
|
||||
assert res["status"] == "requires_action"
|
||||
assert res["recovery_url"].startswith("https://portal.example")
|
||||
assert res["idempotency_key"] # minted when the caller omits one
|
||||
# ── _get_usage active_subagents (TUI status-bar ⛓ indicator) ──────────────
|
||||
# Mirrors the classic CLI status bar: _get_usage embeds a live count of
|
||||
# background/async subagents from tools.async_delegation.active_count() so the
|
||||
|
|
|
|||
|
|
@ -177,6 +177,20 @@ _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
|
|||
# response writes are safe.
|
||||
_LONG_HANDLERS = frozenset(
|
||||
{
|
||||
# Billing/usage reads each do a blocking portal HTTP fetch (state + usage
|
||||
# is two serial round-trips); keep them off the main stdin loop so a slow
|
||||
# portal can't stall approval.respond / session.interrupt / other RPCs.
|
||||
"billing.state",
|
||||
"subscription.state",
|
||||
# Subscription change (V3): preview + the pending-change mutations + upgrade
|
||||
# each do a blocking portal round-trip (preview + upgrade also hit Stripe,
|
||||
# which can take seconds) — keep them off the main stdin loop.
|
||||
"subscription.preview",
|
||||
"subscription.change",
|
||||
"subscription.resume",
|
||||
"subscription.upgrade",
|
||||
"usage.bars",
|
||||
"session.usage",
|
||||
"billing.step_up",
|
||||
"browser.manage",
|
||||
"cli.exec",
|
||||
|
|
@ -7882,37 +7896,6 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 5031, f"pet.hatch failed: {exc}")
|
||||
|
||||
|
||||
@method("credits.view")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Structured Nous credit view for the TUI /credits command.
|
||||
|
||||
Account-independent (a portal fetch gated on "a Nous account is logged in"),
|
||||
so it works with no live agent / on a resumed session — same as the /usage
|
||||
credits block. Returns the surface-agnostic CreditsView fields so the TUI can
|
||||
render a clickable top-up <Link>. Fail-open: a portal hiccup or logged-out
|
||||
account yields {logged_in: false}, never an error the user has to parse.
|
||||
"""
|
||||
try:
|
||||
from agent.account_usage import build_credits_view
|
||||
|
||||
view = build_credits_view()
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"logged_in": bool(view.logged_in),
|
||||
"balance_lines": [
|
||||
line for line in view.balance_lines if not line.lstrip().startswith("📈")
|
||||
],
|
||||
"identity_line": view.identity_line,
|
||||
"topup_url": view.topup_url,
|
||||
"depleted": bool(view.depleted),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
# Fail-open: TUI treats this as "not logged in" and shows the prompt.
|
||||
return _ok(rid, {"logged_in": False, "balance_lines": [], "identity_line": None, "topup_url": None, "depleted": False})
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 2b terminal billing RPC methods
|
||||
# ===========================================================================
|
||||
|
|
@ -7922,21 +7905,27 @@ def _(rid, params: dict) -> dict:
|
|||
# Ink side can branch on the typed billing error code (insufficient_scope,
|
||||
# rate_limited, no_payment_method, …) to render the right affordance instead of
|
||||
# landing in a generic catch. The data-building lives in the shared core
|
||||
# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /credits.
|
||||
# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /topup.
|
||||
|
||||
|
||||
def _serialize_billing_error(exc) -> dict:
|
||||
"""Map a BillingError into the result.error envelope the TUI branches on."""
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingRateLimited,
|
||||
BillingRemoteSpendingRevoked,
|
||||
BillingScopeRequired,
|
||||
BillingSessionRevoked,
|
||||
BillingTransient,
|
||||
)
|
||||
|
||||
kind = "error"
|
||||
if isinstance(exc, BillingScopeRequired):
|
||||
if isinstance(exc, BillingRemoteSpendingRevoked):
|
||||
kind = "remote_spending_revoked"
|
||||
elif isinstance(exc, BillingSessionRevoked):
|
||||
kind = "session_revoked"
|
||||
elif isinstance(exc, BillingScopeRequired):
|
||||
kind = "insufficient_scope"
|
||||
elif isinstance(exc, BillingRateLimited):
|
||||
kind = "rate_limited"
|
||||
elif isinstance(exc, BillingTransient):
|
||||
kind = str(exc.error) if getattr(exc, "error", None) else "rate_limited"
|
||||
elif getattr(exc, "error", None):
|
||||
kind = str(exc.error)
|
||||
return {
|
||||
|
|
@ -7946,6 +7935,11 @@ def _serialize_billing_error(exc) -> dict:
|
|||
"portal_url": getattr(exc, "portal_url", None),
|
||||
"retry_after": getattr(exc, "retry_after", None),
|
||||
"payload": getattr(exc, "payload", {}) or {},
|
||||
# Remote-Spending contract extras (threaded so the TUI can render
|
||||
# actor-aware copy + route recovery without re-parsing the message).
|
||||
"actor": getattr(exc, "actor", None),
|
||||
"code": getattr(exc, "code", None),
|
||||
"recovery": getattr(exc, "recovery", None),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -7958,7 +7952,18 @@ def _serialize_billing_state(state) -> dict:
|
|||
|
||||
card = None
|
||||
if state.card is not None:
|
||||
card = {"brand": state.card.brand, "last4": state.card.last4, "masked": state.card.masked}
|
||||
card = {
|
||||
"brand": state.card.brand,
|
||||
"last4": state.card.last4,
|
||||
"masked": state.card.masked,
|
||||
# Post-card-resolver fields (None/False on older NAS payloads):
|
||||
# display = "Visa ····4242 — the card on your subscription";
|
||||
# resolved_via = the raw resolution rung, for rung-gated surfaces
|
||||
# (the /subscription confirm only shows the card when the rung
|
||||
# matches what a subscription charge would use).
|
||||
"display": state.card.display,
|
||||
"resolved_via": state.card.resolved_via,
|
||||
}
|
||||
monthly_cap = None
|
||||
if state.monthly_cap is not None:
|
||||
mc = state.monthly_cap
|
||||
|
|
@ -7972,12 +7977,24 @@ def _serialize_billing_state(state) -> dict:
|
|||
auto_reload = None
|
||||
if state.auto_reload is not None:
|
||||
ar = state.auto_reload
|
||||
card_out = None
|
||||
if ar.card is not None:
|
||||
if ar.card.kind == "distinct":
|
||||
card_out = {
|
||||
"kind": "distinct",
|
||||
"payment_method_id": ar.card.payment_method_id,
|
||||
"brand": ar.card.brand,
|
||||
"last4": ar.card.last4,
|
||||
}
|
||||
else:
|
||||
card_out = {"kind": ar.card.kind}
|
||||
auto_reload = {
|
||||
"enabled": ar.enabled,
|
||||
"threshold_usd": _s(ar.threshold_usd),
|
||||
"threshold_display": format_money(ar.threshold_usd),
|
||||
"reload_to_usd": _s(ar.reload_to_usd),
|
||||
"reload_to_display": format_money(ar.reload_to_usd),
|
||||
"card": card_out,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
|
|
@ -7986,6 +8003,7 @@ def _serialize_billing_state(state) -> dict:
|
|||
"org_slug": state.org_slug,
|
||||
"role": state.role,
|
||||
"is_admin": state.is_admin,
|
||||
"can_change_plan": state.can_change_plan,
|
||||
"can_charge": state.can_charge,
|
||||
"balance_usd": _s(state.balance_usd),
|
||||
"balance_display": format_money(state.balance_usd),
|
||||
|
|
@ -7999,14 +8017,35 @@ def _serialize_billing_state(state) -> dict:
|
|||
"auto_reload": auto_reload,
|
||||
"portal_url": state.portal_url,
|
||||
"error": state.error,
|
||||
# Shared dollar usage model (two-bar view) embedded so /topup renders the
|
||||
# same plan + top-up bars as /usage and /subscription from its single
|
||||
# fetch. Built from the separate account-info path; fail-open when logged
|
||||
# out or the portal is down.
|
||||
"usage": _usage_payload(state),
|
||||
}
|
||||
|
||||
|
||||
def _usage_payload(state) -> dict:
|
||||
"""Best-effort shared usage model for the /topup + /subscription overlay bars.
|
||||
|
||||
Only fetched when logged in; fail-open to {available:false} so the overview
|
||||
still renders if the account-info path is down.
|
||||
"""
|
||||
if not getattr(state, "logged_in", False):
|
||||
return {"available": False}
|
||||
try:
|
||||
from agent.billing_usage import build_usage_model
|
||||
|
||||
return _serialize_usage_model(build_usage_model())
|
||||
except Exception:
|
||||
return {"available": False}
|
||||
|
||||
|
||||
@method("billing.state")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""GET /api/billing/state → serialized BillingState (Screen 1 + 5).
|
||||
|
||||
Fail-open like credits.view: a logged-out / unreachable portal yields
|
||||
Fail-open like the other billing RPCs: a logged-out / unreachable portal yields
|
||||
{ok:true, logged_in:false}. No scope required for this endpoint.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -8018,6 +8057,269 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"})
|
||||
|
||||
|
||||
def _serialize_usage_bar(bar) -> Optional[dict]:
|
||||
"""Serialize a UsageBar (dollar magnitudes → display strings + fractions)."""
|
||||
if bar is None:
|
||||
return None
|
||||
from agent.billing_usage import _fmt_usd
|
||||
|
||||
return {
|
||||
"kind": bar.kind,
|
||||
"remaining_display": _fmt_usd(bar.remaining_usd),
|
||||
"total_display": _fmt_usd(bar.total_usd),
|
||||
"spent_display": _fmt_usd(bar.spent_usd),
|
||||
"pct_used": bar.pct_used,
|
||||
"fill_fraction": bar.fill_fraction,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_usage_model(model) -> dict:
|
||||
"""Serialize a UsageModel for the wire — the shared two-bar dollar view.
|
||||
|
||||
Dollars-only (no 'credits'); fail-open shape mirrors the other billing RPCs
|
||||
({ok, available:false} when logged out / unreachable).
|
||||
"""
|
||||
from agent.billing_usage import _fmt_usd, format_renews
|
||||
|
||||
if model is None or not getattr(model, "available", False):
|
||||
return {"ok": True, "available": False}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"available": True,
|
||||
"status": model.status,
|
||||
"plan_name": model.plan_name,
|
||||
"renews_at": model.renews_at,
|
||||
"renews_display": getattr(model, "renews_display", None) or format_renews(model.renews_at),
|
||||
"subscription_remaining_display": (
|
||||
None if model.subscription_remaining_usd is None else _fmt_usd(model.subscription_remaining_usd)
|
||||
),
|
||||
"topup_remaining_display": (
|
||||
None if model.topup_remaining_usd is None else _fmt_usd(model.topup_remaining_usd)
|
||||
),
|
||||
"total_spendable_display": (
|
||||
None if model.total_spendable_usd is None else _fmt_usd(model.total_spendable_usd)
|
||||
),
|
||||
"has_topup": model.has_topup,
|
||||
"plan_bar": _serialize_usage_bar(model.plan_bar),
|
||||
"topup_bar": _serialize_usage_bar(model.topup_bar),
|
||||
}
|
||||
|
||||
|
||||
@method("usage.bars")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Shared dollar usage model (two-bar view) for /usage + /subscription.
|
||||
|
||||
Fail-open: logged-out / unreachable portal → {ok:true, available:false}.
|
||||
No scope required (read-only).
|
||||
"""
|
||||
try:
|
||||
from agent.billing_usage import build_usage_model
|
||||
|
||||
return _ok(rid, _serialize_usage_model(build_usage_model()))
|
||||
except Exception:
|
||||
return _ok(rid, {"ok": True, "available": False})
|
||||
|
||||
|
||||
def _serialize_subscription_state(state) -> dict:
|
||||
"""Serialize a SubscriptionState for the wire (Decimals → strings)."""
|
||||
from agent.billing_usage import format_renews
|
||||
from agent.billing_view import format_money
|
||||
|
||||
def _s(value):
|
||||
return None if value is None else str(value)
|
||||
|
||||
current = None
|
||||
if state.current is not None:
|
||||
c = state.current
|
||||
current = {
|
||||
"tier_id": c.tier_id,
|
||||
"tier_name": c.tier_name,
|
||||
"monthly_credits": _s(c.monthly_credits),
|
||||
"credits_remaining": _s(c.credits_remaining),
|
||||
"cycle_ends_at": c.cycle_ends_at,
|
||||
"pending_downgrade_tier_name": c.pending_downgrade_tier_name,
|
||||
"pending_downgrade_at": c.pending_downgrade_at,
|
||||
"pending_downgrade_display": format_renews(c.pending_downgrade_at),
|
||||
"cancel_at_period_end": c.cancel_at_period_end,
|
||||
"cancellation_effective_at": c.cancellation_effective_at,
|
||||
"cancellation_effective_display": format_renews(c.cancellation_effective_at),
|
||||
}
|
||||
# Selectable catalog for the in-terminal tier picker; price is pre-formatted
|
||||
# ($X / $X.YY) so the TUI renders it directly.
|
||||
tiers = [
|
||||
{
|
||||
"tier_id": t.tier_id,
|
||||
"name": t.name,
|
||||
"tier_order": t.tier_order,
|
||||
"dollars_per_month_display": format_money(t.dollars_per_month),
|
||||
"monthly_credits": _s(t.monthly_credits),
|
||||
"is_current": t.is_current,
|
||||
"is_enabled": t.is_enabled,
|
||||
}
|
||||
for t in state.tiers
|
||||
]
|
||||
return {
|
||||
"ok": True,
|
||||
"logged_in": state.logged_in,
|
||||
"is_admin": state.is_admin,
|
||||
"can_change_plan": state.can_change_plan,
|
||||
"org_name": state.org_name,
|
||||
"org_id": state.org_id,
|
||||
"role": state.role,
|
||||
"context": state.context,
|
||||
"current": current,
|
||||
"tiers": tiers,
|
||||
"portal_url": state.portal_url,
|
||||
"error": state.error,
|
||||
# Shared dollar usage model (two-bar view) embedded so /subscription
|
||||
# renders the same bars as /usage from its single fetch. Built from the
|
||||
# separate account-info path (the only source with top-up dollars);
|
||||
# fail-open → {available:false}. Computed lazily so a logged-out state
|
||||
# adds no cost.
|
||||
"usage": _usage_payload(state),
|
||||
}
|
||||
|
||||
|
||||
@method("subscription.state")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""GET /api/billing/subscription → serialized SubscriptionState.
|
||||
|
||||
Fail-open like billing.state: logged-out / unreachable portal →
|
||||
{ok:true, logged_in:false}. No scope required (read-only).
|
||||
"""
|
||||
try:
|
||||
from agent.subscription_view import build_subscription_state
|
||||
|
||||
state = build_subscription_state()
|
||||
return _ok(rid, _serialize_subscription_state(state))
|
||||
except Exception:
|
||||
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"})
|
||||
|
||||
|
||||
def _serialize_subscription_preview(p) -> dict:
|
||||
"""Serialize a SubscriptionChangePreview for the wire (Decimal → string)."""
|
||||
return {
|
||||
"ok": True,
|
||||
"effect": p.effect,
|
||||
"reason": p.reason,
|
||||
"current_tier_id": p.current_tier_id,
|
||||
"current_tier_name": p.current_tier_name,
|
||||
"target_tier_id": p.target_tier_id,
|
||||
"target_tier_name": p.target_tier_name,
|
||||
"monthly_credits_delta": (
|
||||
None if p.monthly_credits_delta is None else str(p.monthly_credits_delta)
|
||||
),
|
||||
"amount_due_now_cents": p.amount_due_now_cents,
|
||||
"effective_at": p.effective_at,
|
||||
}
|
||||
|
||||
|
||||
@method("subscription.preview")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""POST /api/billing/subscription/preview → serialized quote or typed error.
|
||||
|
||||
params: {subscription_type_id: str}. Chargeless effect quote. Requires
|
||||
billing:manage (live Stripe calls + amounts), so a 403 → insufficient_scope
|
||||
drives the device step-up exactly like the mutations.
|
||||
"""
|
||||
from agent.subscription_view import subscription_change_preview_from_payload
|
||||
from hermes_cli.nous_billing import BillingError, post_subscription_preview
|
||||
|
||||
tier_id = params.get("subscription_type_id")
|
||||
if not tier_id:
|
||||
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"})
|
||||
try:
|
||||
preview = subscription_change_preview_from_payload(
|
||||
post_subscription_preview(subscription_type_id=tier_id)
|
||||
)
|
||||
return _ok(rid, _serialize_subscription_preview(preview))
|
||||
except BillingError as exc:
|
||||
return _ok(rid, _serialize_billing_error(exc))
|
||||
except Exception as exc:
|
||||
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
||||
|
||||
|
||||
@method("subscription.change")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""PUT /api/billing/subscription/pending-change → {ok, message} or typed error.
|
||||
|
||||
params: {subscription_type_id?: str, cancel?: bool}. Schedules a downgrade /
|
||||
same-price change OR a cancellation at period end (chargeless). Requires
|
||||
billing:manage.
|
||||
"""
|
||||
from hermes_cli.nous_billing import BillingError, put_subscription_pending_change
|
||||
|
||||
cancel = bool(params.get("cancel"))
|
||||
tier_id = params.get("subscription_type_id")
|
||||
if not cancel and not tier_id:
|
||||
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id or cancel is required"})
|
||||
try:
|
||||
result = put_subscription_pending_change(subscription_type_id=tier_id, cancel=cancel)
|
||||
return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result})
|
||||
except BillingError as exc:
|
||||
return _ok(rid, _serialize_billing_error(exc))
|
||||
except Exception as exc:
|
||||
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
||||
|
||||
|
||||
@method("subscription.resume")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""DELETE /api/billing/subscription/pending-change → {ok, message} or typed error.
|
||||
|
||||
Clears a scheduled downgrade or cancellation (resume / undo). Chargeless, but it
|
||||
re-enables recurring spend → requires billing:manage and honors the kill-switch.
|
||||
"""
|
||||
from hermes_cli.nous_billing import BillingError, delete_subscription_pending_change
|
||||
|
||||
try:
|
||||
result = delete_subscription_pending_change()
|
||||
return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result})
|
||||
except BillingError as exc:
|
||||
return _ok(rid, _serialize_billing_error(exc))
|
||||
except Exception as exc:
|
||||
return _ok(rid, {"ok": False, "error": "error", "message": str(exc)})
|
||||
|
||||
|
||||
@method("subscription.upgrade")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""POST /api/billing/subscription/upgrade → {ok, status, ...} or typed error.
|
||||
|
||||
params: {subscription_type_id: str, idempotency_key?: str}. The single money
|
||||
route: prorate + charge the card on the subscription + flip the plan. SCA /
|
||||
decline come back as status requires_action / payment_failed with a recovery_url
|
||||
to finish in the portal. The idempotency key is minted if absent and echoed so
|
||||
the TUI reuses it on retry of the SAME upgrade. Requires billing:manage.
|
||||
"""
|
||||
from agent.billing_view import new_idempotency_key
|
||||
from hermes_cli.nous_billing import BillingError, post_subscription_upgrade
|
||||
|
||||
tier_id = params.get("subscription_type_id")
|
||||
if not tier_id:
|
||||
return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"})
|
||||
key = params.get("idempotency_key") or new_idempotency_key()
|
||||
try:
|
||||
result = post_subscription_upgrade(subscription_type_id=tier_id, idempotency_key=key)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": True,
|
||||
"status": result.get("status"),
|
||||
"target_tier_name": result.get("targetTierName"),
|
||||
"recovery_url": result.get("recoveryUrl"),
|
||||
"reason": result.get("reason"),
|
||||
"idempotency_key": key,
|
||||
},
|
||||
)
|
||||
except BillingError as exc:
|
||||
env = _serialize_billing_error(exc)
|
||||
env["idempotency_key"] = key # so the TUI can reuse on retry
|
||||
return _ok(rid, env)
|
||||
except Exception as exc:
|
||||
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key})
|
||||
|
||||
|
||||
|
||||
@method("billing.charge")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""POST /api/billing/charge → {ok, chargeId} or a typed error envelope.
|
||||
|
|
@ -8112,6 +8414,7 @@ def _(rid, params: dict) -> dict:
|
|||
sid = params.get("session_id") or ""
|
||||
try:
|
||||
from hermes_cli.auth import step_up_nous_billing_scope
|
||||
from hermes_cli.nous_billing import BillingError
|
||||
|
||||
def _on_verification(url: str, code: str) -> None:
|
||||
_emit(
|
||||
|
|
@ -8124,6 +8427,13 @@ def _(rid, params: dict) -> dict:
|
|||
open_browser=False, on_verification=_on_verification
|
||||
)
|
||||
return _ok(rid, {"ok": True, "granted": bool(granted)})
|
||||
except BillingError as exc:
|
||||
# Route typed billing errors (e.g. session_revoked when the token expires
|
||||
# mid-device-flow) through the shared spine like the other write handlers,
|
||||
# so the TUI maps them to the right copy instead of a generic failure.
|
||||
env = _serialize_billing_error(exc)
|
||||
env["granted"] = False
|
||||
return _ok(rid, env)
|
||||
except Exception as exc:
|
||||
return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "granted": False})
|
||||
|
||||
|
|
|
|||
242
ui-tui/scripts/billing-fixtures.tsx
Normal file
242
ui-tui/scripts/billing-fixtures.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* Billing/Subscription TUI fixture harness — renders any single overlay STATE
|
||||
* live in the terminal so it can be screenshotted (tmux) and UX-reviewed.
|
||||
*
|
||||
* This is a DEV/REVIEW tool, not shipped behaviour. It bypasses the gateway and
|
||||
* mounts the real Ink overlay components directly with a hand-built state object,
|
||||
* exactly the way the vitest render tests do — so what you see is pixel-identical
|
||||
* to what `/subscription` and `/topup` draw at runtime.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/billing-fixtures.tsx <fixture-name>
|
||||
* npx tsx scripts/billing-fixtures.tsx --list
|
||||
*
|
||||
* Drive a specific screen of a fixture with SCREEN=<screen>, e.g.:
|
||||
* SCREEN=confirm npx tsx scripts/billing-fixtures.tsx sub-free
|
||||
* SCREEN=handoff npx tsx scripts/billing-fixtures.tsx sub-mid
|
||||
*
|
||||
* The selection cursor can be moved with ↑/↓ once it's live (the components own
|
||||
* their own useInput); Esc/Enter behave as in production. Ctrl-C to exit.
|
||||
*/
|
||||
import { render } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
|
||||
import type { BillingOverlayState, SubscriptionOverlayState, SubscriptionScreen } from '../src/app/interfaces.js'
|
||||
import { BillingOverlay } from '../src/components/billingOverlay.js'
|
||||
import { SubscriptionOverlay } from '../src/components/subscriptionOverlay.js'
|
||||
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from '../src/gatewayTypes.js'
|
||||
import { DEFAULT_THEME } from '../src/theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const tier = (o: Partial<SubscriptionTierOption> = {}): SubscriptionTierOption => ({
|
||||
tier_id: 'free',
|
||||
name: 'Free',
|
||||
tier_order: 0,
|
||||
dollars_per_month_display: '$0',
|
||||
monthly_credits: '0',
|
||||
is_current: false,
|
||||
is_enabled: true,
|
||||
...o
|
||||
})
|
||||
|
||||
const TIERS = {
|
||||
free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }),
|
||||
plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }),
|
||||
super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }),
|
||||
ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' })
|
||||
}
|
||||
|
||||
const tierList = (currentId?: string): SubscriptionTierOption[] =>
|
||||
Object.values(TIERS).map(x => ({ ...x, is_current: x.tier_id === currentId }))
|
||||
|
||||
const subState = (o: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
|
||||
ok: true,
|
||||
logged_in: true,
|
||||
is_admin: true,
|
||||
can_change_plan: true,
|
||||
org_name: 'Acme Inc',
|
||||
org_id: 'org_acme',
|
||||
role: 'OWNER',
|
||||
context: 'personal',
|
||||
current: null,
|
||||
tiers: tierList(),
|
||||
portal_url: 'https://portal.nousresearch.com/billing',
|
||||
...o
|
||||
})
|
||||
|
||||
const cur = (o: Record<string, unknown> = {}) => ({
|
||||
tier_id: 'plus',
|
||||
tier_name: 'Plus',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '420',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null,
|
||||
cancel_at_period_end: false,
|
||||
cancellation_effective_at: null,
|
||||
...o
|
||||
})
|
||||
|
||||
const subCtx: SubscriptionOverlayState['ctx'] = {
|
||||
openManageLink: () => Promise.resolve(true),
|
||||
refreshState: () => Promise.resolve(null),
|
||||
sys: () => {}
|
||||
}
|
||||
|
||||
const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pendingTargetTierId: string | null = null): SubscriptionOverlayState => ({
|
||||
ctx: subCtx,
|
||||
screen,
|
||||
state: s,
|
||||
pendingTargetTierId
|
||||
})
|
||||
|
||||
// ── billing/topup fixtures ───────────────────────────────────────────
|
||||
|
||||
const billState = (o: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
|
||||
ok: true,
|
||||
logged_in: true,
|
||||
is_admin: true,
|
||||
cli_billing_enabled: true,
|
||||
can_charge: true,
|
||||
card: { brand: 'Visa', last4: '4242', masked: 'Visa •••• 4242' },
|
||||
balance_display: '$12.00',
|
||||
balance_usd: '12.00',
|
||||
min_usd: '5',
|
||||
max_usd: '500',
|
||||
monthly_cap: {
|
||||
is_default_ceiling: false,
|
||||
limit_display: '$20',
|
||||
limit_usd: '20',
|
||||
spent_display: '$8.00',
|
||||
spent_this_month_usd: '8'
|
||||
},
|
||||
auto_reload: { enabled: false, reload_to_display: '$25', reload_to_usd: '25', threshold_display: '$5', threshold_usd: '5' },
|
||||
org_name: 'Acme Inc',
|
||||
role: 'OWNER',
|
||||
portal_url: 'https://portal.nousresearch.com/billing',
|
||||
charge_presets: ['10', '25', '50', '100'],
|
||||
charge_presets_display: ['$10', '$25', '$50', '$100'],
|
||||
...o
|
||||
})
|
||||
|
||||
const billCtx = {
|
||||
applyAutoReload: () => Promise.resolve(true),
|
||||
charge: () => Promise.resolve('submitted' as const),
|
||||
openPortal: () => {},
|
||||
requestRemoteSpending: () => Promise.resolve(true),
|
||||
sys: () => {},
|
||||
validate: (raw: string) => ({ amount: raw })
|
||||
}
|
||||
|
||||
const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({
|
||||
ctx: billCtx,
|
||||
pendingCharge: screen === 'confirm' || screen === 'stepup' ? { amount: '100' } : null,
|
||||
screen,
|
||||
state: s
|
||||
})
|
||||
|
||||
// ── fixture registry ─────────────────────────────────────────────────
|
||||
|
||||
type Fixture = { desc: string; node: React.ReactElement }
|
||||
|
||||
const subEl = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pending: string | null = null) =>
|
||||
React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay: sub(s, screen, pending), t })
|
||||
|
||||
const billEl = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview') =>
|
||||
React.createElement(BillingOverlay, { onClose: () => {}, onPatch: () => {}, overlay: bill(s, screen), t })
|
||||
|
||||
const FIXTURES: Record<string, Fixture> = {
|
||||
// /subscription — overview states
|
||||
'sub-free': {
|
||||
desc: 'Free / no sub — upgradeable (primary conversion state)',
|
||||
node: subEl(subState({ current: null }))
|
||||
},
|
||||
'sub-mid': {
|
||||
desc: 'Subscriber mid-tier (Plus) — usage bar + up/downgrade targets',
|
||||
node: subEl(subState({ current: cur(), tiers: tierList('plus') }))
|
||||
},
|
||||
'sub-top': {
|
||||
desc: 'Subscriber top-tier (Ultra) — "on the top plan"',
|
||||
node: subEl(subState({ current: cur({ tier_id: 'ultra', tier_name: 'Ultra', monthly_credits: '7000', credits_remaining: '5000' }), tiers: tierList('ultra') }))
|
||||
},
|
||||
'sub-not-admin': {
|
||||
desc: 'Member (not admin/owner) — read-only, no tier picker',
|
||||
node: subEl(subState({ is_admin: false, can_change_plan: false, role: 'MEMBER', current: cur(), tiers: tierList('plus') }))
|
||||
},
|
||||
'sub-downgrade': {
|
||||
desc: 'Downgrade scheduled — pending-switch banner',
|
||||
node: subEl(subState({ current: cur({ pending_downgrade_tier_name: 'Plus', pending_downgrade_at: '2026-07-15' }), tiers: tierList('super') }))
|
||||
},
|
||||
'sub-cancel': {
|
||||
desc: 'Cancellation scheduled — stays active until effective date',
|
||||
node: subEl(subState({ current: cur({ cancel_at_period_end: true, cancellation_effective_at: '2026-07-01' }), tiers: tierList('plus') }))
|
||||
},
|
||||
'sub-team': {
|
||||
desc: 'Team org context — shared credits, redirect to /topup',
|
||||
node: subEl(subState({ context: 'team', current: null, org_name: 'Acme Engineering' }))
|
||||
},
|
||||
// /subscription — non-overview screens
|
||||
'sub-confirm': {
|
||||
desc: 'Confirm plan change (deep-link, no in-terminal charge)',
|
||||
node: subEl(subState({ current: cur(), tiers: tierList('plus') }), 'confirm', 'super')
|
||||
},
|
||||
'sub-confirm-new': {
|
||||
desc: 'Confirm first subscription (free → paid)',
|
||||
node: subEl(subState({ current: null }), 'confirm', 'plus')
|
||||
},
|
||||
'sub-handoff': {
|
||||
desc: 'Handoff transient — opening subscription page in browser',
|
||||
node: subEl(subState({ current: cur() }), 'handoff')
|
||||
},
|
||||
// /topup (renamed /billing)
|
||||
'topup-overview': {
|
||||
desc: '/topup overview — admin, card on file, full menu',
|
||||
node: billEl(billState())
|
||||
},
|
||||
'topup-no-card': {
|
||||
desc: '/topup overview — admin, NO saved card (card hint)',
|
||||
node: billEl(billState({ card: null }))
|
||||
},
|
||||
'topup-not-admin': {
|
||||
desc: '/topup overview — member, read-only',
|
||||
node: billEl(billState({ is_admin: false }))
|
||||
},
|
||||
'topup-disabled': {
|
||||
desc: '/topup overview — terminal billing OFF for org',
|
||||
node: billEl(billState({ cli_billing_enabled: false }))
|
||||
},
|
||||
'topup-buy': {
|
||||
desc: '/topup buy screen — presets',
|
||||
node: billEl(billState(), 'buy')
|
||||
},
|
||||
'topup-stepup': {
|
||||
desc: '/topup step-up — "Allow Remote Spending" (resumable, holds $100 buy)',
|
||||
node: billEl(billState(), 'stepup')
|
||||
}
|
||||
}
|
||||
|
||||
// ── driver ───────────────────────────────────────────────────────────
|
||||
|
||||
const arg = process.argv[2]
|
||||
|
||||
if (!arg || arg === '--list' || arg === '-l') {
|
||||
const names = Object.keys(FIXTURES)
|
||||
process.stdout.write('Billing/Subscription TUI fixtures:\n\n')
|
||||
for (const name of names) {
|
||||
process.stdout.write(` ${name.padEnd(18)} ${FIXTURES[name]!.desc}\n`)
|
||||
}
|
||||
process.stdout.write(`\n ${names.length} fixtures. Run: npx tsx scripts/billing-fixtures.tsx <name>\n`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const fixture = FIXTURES[arg]
|
||||
|
||||
if (!fixture) {
|
||||
process.stderr.write(`Unknown fixture: ${arg}\nRun with --list to see all.\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
render(fixture.node)
|
||||
185
ui-tui/src/__tests__/billingStepUp.test.tsx
Normal file
185
ui-tui/src/__tests__/billingStepUp.test.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Stub useInput so the overlay doesn't enter raw mode under renderSync.
|
||||
vi.mock('@hermes/ink', async importOriginal => {
|
||||
const mod = await importOriginal()
|
||||
|
||||
return { ...mod, useInput: () => {} }
|
||||
})
|
||||
|
||||
import type { BillingOverlayState } from '../app/interfaces.js'
|
||||
import { BillingOverlay } from '../components/billingOverlay.js'
|
||||
import type { BillingStateResponse } from '../gatewayTypes.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
function render(overlay: BillingOverlayState): string {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(
|
||||
React.createElement(BillingOverlay, {
|
||||
onClose: () => {},
|
||||
onPatch: () => {},
|
||||
overlay,
|
||||
t
|
||||
}),
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
return stripAnsi(output)
|
||||
}
|
||||
|
||||
const billState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse =>
|
||||
({
|
||||
auto_reload: null,
|
||||
balance_display: '$12.00',
|
||||
balance_usd: '12',
|
||||
can_charge: true,
|
||||
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
|
||||
charge_presets: ['25', '50'],
|
||||
charge_presets_display: ['$25', '$50'],
|
||||
cli_billing_enabled: true,
|
||||
is_admin: true,
|
||||
logged_in: true,
|
||||
max_usd: '1000',
|
||||
min_usd: '10',
|
||||
monthly_cap: null,
|
||||
ok: true,
|
||||
org_name: 'Acme',
|
||||
portal_url: 'https://portal/billing',
|
||||
role: 'OWNER',
|
||||
...overrides
|
||||
}) as BillingStateResponse
|
||||
|
||||
const ctx = {
|
||||
applyAutoReload: vi.fn(() => Promise.resolve(true)),
|
||||
charge: vi.fn(() => Promise.resolve('submitted' as const)),
|
||||
openPortal: vi.fn(),
|
||||
refreshState: vi.fn(() => Promise.resolve(null)),
|
||||
requestRemoteSpending: vi.fn(() => Promise.resolve(true)),
|
||||
sys: vi.fn(),
|
||||
validate: vi.fn((raw: string) => ({ amount: raw }))
|
||||
}
|
||||
|
||||
const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => ({
|
||||
ctx,
|
||||
pendingCharge: { amount: '100' },
|
||||
screen,
|
||||
state: billState()
|
||||
})
|
||||
|
||||
describe('BillingOverlay — step-up screen (Enable terminal billing)', () => {
|
||||
it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => {
|
||||
const out = render(overlay('stepup'))
|
||||
expect(out).toContain('One-time setup')
|
||||
expect(out).toContain('Enable terminal billing')
|
||||
expect(out).toContain('$100') // resumes the held purchase
|
||||
expect(out).toContain('Not now')
|
||||
expect(out).not.toContain('billing:manage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BillingOverlay — overview (reordered, dollars)', () => {
|
||||
it('leads with balance in the title, Add funds first, no "credits"', () => {
|
||||
const out = render(overlay('overview'))
|
||||
expect(out).toContain('Top up · balance $12.00') // balance in the title
|
||||
expect(out).toContain('Add funds') // buy action, renamed
|
||||
expect(out).toContain('Auto-reload')
|
||||
expect(out).toContain('Manage on portal')
|
||||
expect(out.toLowerCase()).not.toContain('credits') // dollars only
|
||||
// No standalone "Enable terminal billing" item — discovered at pay time.
|
||||
expect(out).not.toContain('Enable terminal billing')
|
||||
})
|
||||
|
||||
it('renders the two-bar dollar usage when a usage model is present', () => {
|
||||
const withUsage: BillingOverlayState = {
|
||||
...overlay('overview'),
|
||||
state: {
|
||||
...billState(),
|
||||
usage: {
|
||||
available: true,
|
||||
status: 'healthy',
|
||||
plan_name: 'Plus',
|
||||
has_topup: true,
|
||||
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
|
||||
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = render(withUsage)
|
||||
expect(out).toContain('$14.00 left of $20.00')
|
||||
expect(out).toContain('30% used')
|
||||
expect(out).toContain('never expires')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BillingOverlay — auto-reload card divergence', () => {
|
||||
const autoReload = (card: NonNullable<BillingStateResponse['auto_reload']>['card']) => ({
|
||||
card,
|
||||
enabled: true,
|
||||
reload_to_display: '$100',
|
||||
reload_to_usd: '100',
|
||||
threshold_display: '$20',
|
||||
threshold_usd: '20'
|
||||
})
|
||||
|
||||
it('warns when auto-reload charges a distinct card and offers the portal hand-off', () => {
|
||||
const out = render({
|
||||
...overlay('autoreload'),
|
||||
state: billState({
|
||||
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: 'Visa', last4: '9999' })
|
||||
})
|
||||
})
|
||||
|
||||
expect(out).toContain('Auto-refill is charging Visa ••9999 — not your card on file')
|
||||
expect(out).toContain('authorize Nous Research to charge Visa ••9999')
|
||||
expect(out).toContain('Use your card on file — manage on portal')
|
||||
})
|
||||
|
||||
it('uses generic distinct-card copy when metadata is unresolved', () => {
|
||||
const out = render({
|
||||
...overlay('autoreload'),
|
||||
state: billState({
|
||||
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: null, last4: null })
|
||||
})
|
||||
})
|
||||
|
||||
expect(out).toContain('Auto-refill is charging a different card — not your card on file')
|
||||
})
|
||||
|
||||
it.each(['canonical', 'none'] as const)('does not warn for a %s auto-reload card', kind => {
|
||||
const out = render({
|
||||
...overlay('autoreload'),
|
||||
state: billState({ auto_reload: autoReload({ kind }) })
|
||||
})
|
||||
|
||||
expect(out).not.toContain('not your card on file')
|
||||
expect(out).not.toContain('Use your card on file — manage on portal')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { creditsCommands } from '../app/slash/commands/credits.js'
|
||||
import type { CreditsViewResponse } from '../gatewayTypes.js'
|
||||
|
||||
// The command opens the top-up URL through this helper on confirm. Mock it so
|
||||
// the test never shells out to a real browser/`xdg-open` and we can assert the
|
||||
// success/failure messaging deterministically.
|
||||
vi.mock('../lib/openExternalUrl.js', () => ({
|
||||
openExternalUrl: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
import { openExternalUrl } from '../lib/openExternalUrl.js'
|
||||
|
||||
const openExternalUrlMock = vi.mocked(openExternalUrl)
|
||||
|
||||
const creditsCommand = creditsCommands.find(cmd => cmd.name === 'credits')!
|
||||
|
||||
const buildView = (overrides: Partial<CreditsViewResponse> = {}): CreditsViewResponse => ({
|
||||
balance_lines: ['Grant: $9.50 left', 'Top-up: $25.00'],
|
||||
depleted: false,
|
||||
identity_line: 'Signed in as ada@example.com',
|
||||
logged_in: true,
|
||||
topup_url: 'https://portal.nousresearch.com/billing/topup',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Mirror createSlashHandler's real `guarded` wrapper: skip the handler when the
|
||||
// command is stale OR the response is falsy. Tests stay non-stale, so this is a
|
||||
// straightforward "run the handler when we got a response" shim.
|
||||
const guarded =
|
||||
<T>(fn: (r: T) => void) =>
|
||||
(r: null | T) => {
|
||||
if (r) {
|
||||
fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
const buildCtx = (rpcResult: CreditsViewResponse) => {
|
||||
const sys = vi.fn()
|
||||
const rpc = vi.fn(() => Promise.resolve(rpcResult))
|
||||
const guardedErr = vi.fn()
|
||||
|
||||
const ctx = {
|
||||
gateway: { rpc },
|
||||
guarded,
|
||||
guardedErr,
|
||||
sid: 'sid-abc',
|
||||
stale: () => false,
|
||||
transcript: { page: vi.fn(), panel: vi.fn(), sys }
|
||||
}
|
||||
|
||||
// Run the command, then await the rpc promise so the .then() handler has
|
||||
// flushed before assertions — deterministic, no polling/timeouts.
|
||||
const run = async () => {
|
||||
creditsCommand.run('', ctx as any, 'credits')
|
||||
await rpc.mock.results[0]?.value
|
||||
// Allow the chained .then() microtask to settle.
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
return { ctx, rpc, run, sys }
|
||||
}
|
||||
|
||||
describe('/credits slash command', () => {
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
openExternalUrlMock.mockClear()
|
||||
openExternalUrlMock.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('renders the balance (including top-up URL) and arms the confirm overlay', async () => {
|
||||
const view = buildView()
|
||||
const { rpc, run, sys } = buildCtx(view)
|
||||
|
||||
await run()
|
||||
|
||||
expect(rpc).toHaveBeenCalledWith('credits.view', { session_id: 'sid-abc' })
|
||||
|
||||
// (a) sys received the balance text including the topup_url
|
||||
const printed = sys.mock.calls.map(call => call[0]).join('\n')
|
||||
expect(printed).toContain('💳 Nous credits')
|
||||
expect(printed).toContain('Grant: $9.50 left')
|
||||
expect(printed).toContain('Signed in as ada@example.com')
|
||||
expect(printed).toContain(view.topup_url)
|
||||
|
||||
// (b) confirm overlay set with the expected label + detail
|
||||
const confirm = getOverlayState().confirm
|
||||
expect(confirm).toBeTruthy()
|
||||
expect(confirm?.confirmLabel).toBe('Open top-up in browser')
|
||||
expect(confirm?.cancelLabel).toBe('Cancel')
|
||||
expect(confirm?.title).toBe('Add credits?')
|
||||
expect(confirm?.detail).toBe(view.topup_url)
|
||||
|
||||
// onConfirm opens the URL and reports success back to the transcript
|
||||
confirm?.onConfirm()
|
||||
expect(openExternalUrlMock).toHaveBeenCalledWith(view.topup_url)
|
||||
expect(sys).toHaveBeenCalledWith('Complete your top-up in the browser — credits will appear in /credits shortly.')
|
||||
})
|
||||
|
||||
it('falls back to printing the URL when the browser open is rejected', async () => {
|
||||
openExternalUrlMock.mockReturnValue(false)
|
||||
const view = buildView()
|
||||
const { run, sys } = buildCtx(view)
|
||||
|
||||
await run()
|
||||
|
||||
const confirm = getOverlayState().confirm
|
||||
expect(confirm).toBeTruthy()
|
||||
confirm?.onConfirm()
|
||||
expect(sys).toHaveBeenCalledWith(`Open this URL to top up: ${view.topup_url}`)
|
||||
})
|
||||
|
||||
it('does not arm the confirm overlay when there is no top-up URL', async () => {
|
||||
const view = buildView({ topup_url: null })
|
||||
const { run, sys } = buildCtx(view)
|
||||
|
||||
await run()
|
||||
|
||||
const printed = sys.mock.calls.map(call => call[0]).join('\n')
|
||||
expect(printed).toContain('💳 Nous credits')
|
||||
expect(getOverlayState().confirm).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the not-logged-in message and does NOT arm the confirm overlay', async () => {
|
||||
const view = buildView({
|
||||
balance_lines: [],
|
||||
identity_line: null,
|
||||
logged_in: false,
|
||||
topup_url: null
|
||||
})
|
||||
|
||||
const { run, sys } = buildCtx(view)
|
||||
|
||||
await run()
|
||||
|
||||
expect(sys).toHaveBeenCalledWith('💳 Not logged into Nous Portal — run /portal to log in.')
|
||||
expect(getOverlayState().confirm).toBeNull()
|
||||
expect(openExternalUrlMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
102
ui-tui/src/__tests__/subscriptionCommand.test.ts
Normal file
102
ui-tui/src/__tests__/subscriptionCommand.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { subscriptionCommands } from '../app/slash/commands/subscription.js'
|
||||
import { findSlashCommand } from '../app/slash/registry.js'
|
||||
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
|
||||
|
||||
vi.mock('../lib/openExternalUrl.js', () => ({
|
||||
openExternalUrl: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
const subscriptionCommand = subscriptionCommands.find(cmd => cmd.name === 'subscription')!
|
||||
|
||||
const loggedInState = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
|
||||
ok: true,
|
||||
logged_in: true,
|
||||
is_admin: true,
|
||||
can_change_plan: true,
|
||||
org_name: 'Acme',
|
||||
role: 'OWNER',
|
||||
current: null,
|
||||
portal_url: 'https://portal.nousresearch.com/billing',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const guarded =
|
||||
<T>(fn: (r: T) => void) =>
|
||||
(r: null | T) => {
|
||||
if (r) {
|
||||
fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
|
||||
const buildCtx = (results: Record<string, unknown>) => {
|
||||
const sys = vi.fn()
|
||||
const calls: Array<{ method: string; params: unknown }> = []
|
||||
|
||||
const rpc = vi.fn((method: string, params: unknown) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
return Promise.resolve(results[method])
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
gateway: { rpc },
|
||||
guarded,
|
||||
guardedErr: vi.fn(),
|
||||
sid: 'sid-1',
|
||||
stale: () => false,
|
||||
transcript: { page: vi.fn(), panel: vi.fn(), sys }
|
||||
}
|
||||
|
||||
const run = async (arg: string) => {
|
||||
subscriptionCommand.run(arg, ctx as any, 'subscription')
|
||||
await rpc.mock.results[0]?.value
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
return { calls, ctx, rpc, run, sys }
|
||||
}
|
||||
|
||||
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
|
||||
|
||||
describe('/subscription slash command', () => {
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
})
|
||||
|
||||
it('fetches subscription.state and opens the overlay', async () => {
|
||||
const { run } = buildCtx({
|
||||
'subscription.state': loggedInState()
|
||||
})
|
||||
|
||||
await run('')
|
||||
|
||||
const overlay = getOverlayState().subscription
|
||||
|
||||
expect(overlay).not.toBeNull()
|
||||
expect(overlay?.screen).toBe('overview')
|
||||
})
|
||||
|
||||
it('shows portal-login sys line when not logged in', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'subscription.state': loggedInState({ logged_in: false })
|
||||
})
|
||||
|
||||
await run('')
|
||||
|
||||
expect(printed(sys)).toContain('Not logged into Nous Portal')
|
||||
expect(getOverlayState().subscription).toBeNull()
|
||||
})
|
||||
|
||||
it('/upgrade alias resolves to the same command', () => {
|
||||
expect(findSlashCommand('upgrade')).toBe(subscriptionCommand)
|
||||
})
|
||||
|
||||
it('/subscription resolves to the same command', () => {
|
||||
expect(findSlashCommand('subscription')).toBe(subscriptionCommand)
|
||||
})
|
||||
})
|
||||
481
ui-tui/src/__tests__/subscriptionOverlay.test.tsx
Normal file
481
ui-tui/src/__tests__/subscriptionOverlay.test.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const inputHarness = vi.hoisted(() => ({
|
||||
handler: undefined as undefined | ((input: string, key: Record<string, boolean>) => void)
|
||||
}))
|
||||
|
||||
// Stub useInput so the overlay doesn't try to enter raw mode under renderSync
|
||||
// (PassThrough stdin doesn't support it). Box/Text pass through to real Ink.
|
||||
vi.mock('@hermes/ink', async importOriginal => {
|
||||
const mod = await importOriginal()
|
||||
|
||||
return {
|
||||
...mod,
|
||||
useInput: (handler: (input: string, key: Record<string, boolean>) => void) => {
|
||||
inputHarness.handler = handler
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import type { SubscriptionOverlayState } from '../app/interfaces.js'
|
||||
import { SubscriptionOverlay } from '../components/subscriptionOverlay.js'
|
||||
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
/** Mount a SubscriptionOverlay via renderSync + PassThrough. */
|
||||
function mount(overlay: SubscriptionOverlayState, onPatch: (next: Partial<SubscriptionOverlayState>) => void = () => {}) {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
inputHarness.handler = undefined
|
||||
const element = React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch, overlay, t })
|
||||
const instance = renderSync(
|
||||
element,
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
},
|
||||
output: () => stripAnsi(output),
|
||||
rerender: () => instance.rerender(element)
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */
|
||||
function render(overlay: SubscriptionOverlayState): string {
|
||||
const mounted = mount(overlay)
|
||||
const output = mounted.output()
|
||||
mounted.cleanup()
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const TIERS = [
|
||||
{ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0', is_current: false, is_enabled: true },
|
||||
{ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true, is_enabled: true },
|
||||
{ tier_id: 'ultra', name: 'Ultra', tier_order: 2, dollars_per_month_display: '$40', monthly_credits: '3000', is_current: false, is_enabled: true }
|
||||
]
|
||||
|
||||
const state = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
|
||||
ok: true,
|
||||
logged_in: true,
|
||||
is_admin: true,
|
||||
can_change_plan: true,
|
||||
org_name: 'Acme',
|
||||
org_id: 'org_acme',
|
||||
role: 'OWNER',
|
||||
current: null,
|
||||
tiers: [],
|
||||
portal_url: 'https://portal.nousresearch.com/billing',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
fetchCard: vi.fn(() => Promise.resolve(null)),
|
||||
openManageLink: vi.fn(() => Promise.resolve(true)),
|
||||
openPortal: vi.fn(),
|
||||
preview: vi.fn(() => Promise.resolve(null)),
|
||||
refreshState: vi.fn(() => Promise.resolve(null)),
|
||||
requestRemoteSpending: vi.fn(() => Promise.resolve({ granted: true })),
|
||||
resume: vi.fn(() => Promise.resolve(null)),
|
||||
scheduleCancellation: vi.fn(() => Promise.resolve(null)),
|
||||
scheduleChange: vi.fn(() => Promise.resolve(null)),
|
||||
sys: vi.fn(),
|
||||
upgrade: vi.fn(() => Promise.resolve(null))
|
||||
}
|
||||
|
||||
const overlay = (s: SubscriptionStateResponse): SubscriptionOverlayState => ({ ctx, screen: 'overview', state: s })
|
||||
|
||||
// Overview: the entry screen across every account state (plan + usage + the
|
||||
// actions that enter the in-terminal change flow).
|
||||
describe('SubscriptionOverlay — overview', () => {
|
||||
it('free: upsell + "Start a subscription", no tier list, no "credits"', () => {
|
||||
const out = render(overlay(state({ current: null, usage: { available: true, status: 'free', plan_name: null } })))
|
||||
|
||||
expect(out).toContain('Plan: Free · free models only')
|
||||
expect(out).toContain('Paid models need a subscription')
|
||||
expect(out).toContain('Start a subscription')
|
||||
expect(out).not.toContain('$20/mo')
|
||||
expect(out.toLowerCase()).not.toContain('credits')
|
||||
})
|
||||
|
||||
it('subscriber: status line + plan bar + top-up bar, no "credits"', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
state({
|
||||
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '700', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
|
||||
usage: {
|
||||
available: true,
|
||||
status: 'healthy',
|
||||
plan_name: 'Pro',
|
||||
renews_display: 'Jul 1, 2026',
|
||||
total_spendable_display: '$26.00',
|
||||
has_topup: true,
|
||||
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
|
||||
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(out).toContain('Plan: Pro')
|
||||
expect(out).toContain('$14.00 left of $20.00')
|
||||
expect(out).toContain('30% used')
|
||||
expect(out).toContain('top-up')
|
||||
expect(out).toContain('never expires')
|
||||
expect(out.toLowerCase()).not.toContain('credits')
|
||||
})
|
||||
|
||||
it('low balance: shows alert nudge', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
state({
|
||||
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '170', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
|
||||
usage: { available: true, status: 'low', plan_name: 'Pro', total_spendable_display: '$3.40', plan_bar: { kind: 'plan', remaining_display: '$3.40', total_display: '$20.00', spent_display: '$16.60', pct_used: 83, fill_fraction: 0.17 } }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(out).toContain('Plan: Pro · $3.40 left')
|
||||
expect(out).toContain('Low balance')
|
||||
})
|
||||
|
||||
it('not-admin: shows read-only note', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
state({
|
||||
is_admin: false,
|
||||
can_change_plan: false,
|
||||
role: 'MEMBER',
|
||||
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null },
|
||||
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(out).toContain('view only')
|
||||
expect(out).toContain('Manage on portal')
|
||||
})
|
||||
|
||||
it('downgrade-pending: leads with a Pro ──▶ Free banner + status echo', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
state({
|
||||
current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: 'Free', pending_downgrade_at: '2026-07-15', pending_downgrade_display: 'Jul 15, 2026' },
|
||||
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(out).toContain('Scheduled change')
|
||||
expect(out).toContain('──▶')
|
||||
expect(out).toContain('Free')
|
||||
expect(out).toContain('Jul 15, 2026')
|
||||
// the status line itself echoes the transition
|
||||
expect(out).toContain('Plan: Pro → Free')
|
||||
})
|
||||
|
||||
it('team context: redirects to /topup, no tier picker', () => {
|
||||
const out = render(overlay(state({ context: 'team', current: null })))
|
||||
|
||||
expect(out).toContain('shared balance')
|
||||
expect(out).toContain('/topup')
|
||||
})
|
||||
})
|
||||
|
||||
// In-terminal change flow (V3): picker → confirm → result. useInput is mocked
|
||||
// (no key simulation), so these assert each screen's rendered content.
|
||||
|
||||
const subscriber = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse =>
|
||||
state({
|
||||
current: {
|
||||
tier_id: 'plus',
|
||||
tier_name: 'Plus',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '500',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
},
|
||||
tiers: TIERS,
|
||||
usage: { available: true, status: 'healthy', plan_name: 'Plus' },
|
||||
...overrides
|
||||
})
|
||||
|
||||
const at = (
|
||||
screen: SubscriptionOverlayState['screen'],
|
||||
s: SubscriptionStateResponse,
|
||||
extra: Partial<SubscriptionOverlayState> = {}
|
||||
): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra })
|
||||
|
||||
describe('SubscriptionOverlay — overview actions', () => {
|
||||
it('admin subscriber: offers Change plan + Cancel subscription', () => {
|
||||
const out = render(overlay(subscriber()))
|
||||
|
||||
expect(out).toContain('Change plan')
|
||||
expect(out).toContain('Cancel subscription')
|
||||
})
|
||||
|
||||
it('pending change: offers undo instead of cancel', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
subscriber({
|
||||
current: {
|
||||
tier_id: 'plus',
|
||||
tier_name: 'Plus',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '500',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
cancel_at_period_end: true,
|
||||
cancellation_effective_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// undo is promoted to the first action; the banner shows the pending cancel
|
||||
expect(out).toContain('Keep Plus (undo this change)')
|
||||
expect(out).toContain('cancels')
|
||||
expect(out).not.toContain('Cancel subscription')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — step-up', () => {
|
||||
it('prompts to enable terminal billing (never leaks the raw scope)', () => {
|
||||
const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } }))
|
||||
|
||||
expect(out).toContain('Terminal billing')
|
||||
expect(out).toContain('Enable terminal billing')
|
||||
expect(out).not.toContain('billing:manage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — picker', () => {
|
||||
it('lists other paid tiers with direction hints; hides current + free', () => {
|
||||
const out = render(at('picker', subscriber()))
|
||||
|
||||
expect(out).toContain('Ultra')
|
||||
expect(out).toContain('$40/mo')
|
||||
expect(out).toContain('upgrade') // ultra (order 2) > plus (order 1)
|
||||
expect(out).not.toContain('Plus · $20/mo') // current tier is not selectable
|
||||
expect(out).not.toContain('$0/mo') // free tier excluded — use Cancel instead
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — confirm', () => {
|
||||
it('charge_now: shows the prorated charge + upgrade copy', () => {
|
||||
const out = render(
|
||||
at('confirm', subscriber(), {
|
||||
pending: {
|
||||
kind: 'upgrade',
|
||||
targetTierId: 'ultra',
|
||||
preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234, monthly_credits_delta: '2000' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(out).toContain('Pay $12.34 & upgrade now')
|
||||
expect(out).toContain('Upgrade to Ultra')
|
||||
})
|
||||
|
||||
it('scheduled: shows effective date + no charge now', () => {
|
||||
const out = render(
|
||||
at('confirm', subscriber(), {
|
||||
pending: {
|
||||
kind: 'tier_change',
|
||||
targetTierId: 'plus',
|
||||
preview: { ok: true, effect: 'scheduled', target_tier_name: 'Plus', effective_at: '2026-08-01T00:00:00Z', amount_due_now_cents: null }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(out).toContain('Schedule change to Plus')
|
||||
expect(out).toContain('2026-08-01')
|
||||
expect(out).toContain('No charge now')
|
||||
})
|
||||
|
||||
it('cancellation: shows cancel-at-period-end copy', () => {
|
||||
const out = render(at('confirm', subscriber(), { pending: { kind: 'cancellation', targetTierId: null, preview: null } }))
|
||||
|
||||
expect(out).toContain('Confirm cancellation')
|
||||
expect(out).toContain('will not renew')
|
||||
})
|
||||
|
||||
it('blocked: shows the reason + Manage on portal', () => {
|
||||
const out = render(
|
||||
at('confirm', subscriber(), {
|
||||
pending: { kind: 'tier_change', targetTierId: 'ultra', preview: { ok: true, effect: 'blocked', reason: 'Retract the cancellation before upgrading.' } }
|
||||
})
|
||||
)
|
||||
|
||||
expect(out).toContain('Retract the cancellation')
|
||||
expect(out).toContain('Manage on portal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — result', () => {
|
||||
it('ok: shows Done + the re-run hint', () => {
|
||||
const out = render(at('result', subscriber(), { result: { ok: true, message: 'Upgraded to Ultra.' } }))
|
||||
|
||||
expect(out).toContain('Done')
|
||||
expect(out).toContain('Upgraded to Ultra.')
|
||||
expect(out).toContain('Re-run /subscription')
|
||||
})
|
||||
|
||||
it('error with recovery: shows the message + Open the portal', () => {
|
||||
const out = render(
|
||||
at('result', subscriber(), {
|
||||
result: { ok: false, message: 'This upgrade needs extra verification (3DS).', recoveryUrl: 'https://portal.example/x' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(out).toContain('Could not complete')
|
||||
expect(out).toContain('3DS')
|
||||
expect(out).toContain('Open the portal to finish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — upgrade response mapping', () => {
|
||||
const applyUpgrade = async (response: unknown) => {
|
||||
const onPatch = vi.fn()
|
||||
const upgrade = vi.fn(() => Promise.resolve(response))
|
||||
const mounted = mount(
|
||||
at('confirm', subscriber(), {
|
||||
ctx: { ...ctx, upgrade } as SubscriptionOverlayState['ctx'],
|
||||
pending: {
|
||||
idempotencyKey: 'upgrade-key',
|
||||
kind: 'upgrade',
|
||||
preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234 },
|
||||
targetTierId: 'ultra'
|
||||
}
|
||||
}),
|
||||
onPatch
|
||||
)
|
||||
|
||||
inputHarness.handler?.('', { return: true })
|
||||
await vi.waitFor(() => expect(onPatch).toHaveBeenCalled())
|
||||
mounted.cleanup()
|
||||
|
||||
return onPatch.mock.calls.at(-1)?.[0] as Partial<SubscriptionOverlayState>
|
||||
}
|
||||
|
||||
it.each([
|
||||
['authentication_required', 'upgraded'],
|
||||
['subscription_payment_intent_requires_action', 'payment_failed']
|
||||
])('reason %s routes to card verification regardless of status %s', async (reason, status) => {
|
||||
const patch = await applyUpgrade({
|
||||
ok: status === 'upgraded',
|
||||
reason,
|
||||
recovery_url: 'https://portal.example/verify',
|
||||
status,
|
||||
target_tier_name: 'Ultra'
|
||||
})
|
||||
|
||||
expect(patch.screen).toBe('result')
|
||||
expect(patch.result?.ok).toBe(false)
|
||||
expect(patch.result?.message).toContain('verify your card in the portal')
|
||||
expect(patch.result?.recoveryUrl).toBe('https://portal.example/verify')
|
||||
})
|
||||
|
||||
it('card_declined reason routes to a different-card recovery', async () => {
|
||||
const patch = await applyUpgrade({
|
||||
ok: false,
|
||||
reason: 'card_declined',
|
||||
recovery_url: 'https://portal.example/card',
|
||||
status: 'requires_action'
|
||||
})
|
||||
|
||||
expect(patch.result?.message).toContain('try a different card on the portal')
|
||||
expect(patch.result?.recoveryUrl).toBe('https://portal.example/card')
|
||||
})
|
||||
|
||||
it('already_on_tier remains an immediate success', async () => {
|
||||
const patch = await applyUpgrade({ ok: true, status: 'already_on_tier', target_tier_name: 'Ultra' })
|
||||
|
||||
expect(patch.result).toMatchObject({ message: 'You are already on Ultra.', ok: true })
|
||||
expect(patch.result).not.toHaveProperty('pendingTierId')
|
||||
})
|
||||
|
||||
it('upgraded marks the result as applying to the target tier', async () => {
|
||||
const patch = await applyUpgrade({ ok: true, status: 'upgraded', target_tier_name: 'Ultra' })
|
||||
|
||||
expect(patch.result).toMatchObject({ ok: true, pendingTierId: 'ultra' })
|
||||
})
|
||||
|
||||
it('shows Applying, then Done once refreshed state reaches the upgraded tier', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const refreshState = vi.fn(() => Promise.resolve(subscriber({
|
||||
current: {
|
||||
tier_id: 'ultra',
|
||||
tier_name: 'Ultra',
|
||||
monthly_credits: '3000',
|
||||
credits_remaining: '3000',
|
||||
cycle_ends_at: '2026-08-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
}
|
||||
})))
|
||||
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
|
||||
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))
|
||||
|
||||
expect(mounted.output()).toContain('Applying…')
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
mounted.rerender()
|
||||
expect(refreshState).toHaveBeenCalledTimes(1)
|
||||
expect(mounted.output()).toContain('Done')
|
||||
expect(mounted.output()).toContain('Upgraded to Ultra.')
|
||||
mounted.cleanup()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a successful upgrade soft-pending after the bounded confirmation window', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const refreshState = vi.fn(() => Promise.resolve(subscriber()))
|
||||
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
|
||||
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
mounted.rerender()
|
||||
expect(refreshState).toHaveBeenCalledTimes(15)
|
||||
expect(mounted.output()).toContain('Still applying')
|
||||
expect(mounted.output()).toContain('refresh in a moment')
|
||||
expect(mounted.output()).not.toContain('Could not complete')
|
||||
mounted.cleanup()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { billingCommands } from '../app/slash/commands/billing.js'
|
||||
import { topupCommands } from '../app/slash/commands/topup.js'
|
||||
import type { BillingStateResponse } from '../gatewayTypes.js'
|
||||
|
||||
vi.mock('../lib/openExternalUrl.js', () => ({
|
||||
openExternalUrl: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
const billingCommand = billingCommands.find(cmd => cmd.name === 'billing')!
|
||||
const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')!
|
||||
|
||||
const ownerState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
|
||||
auto_reload: {
|
||||
card: { kind: 'canonical' },
|
||||
enabled: false,
|
||||
reload_to_display: '—',
|
||||
reload_to_usd: null,
|
||||
|
|
@ -72,7 +73,7 @@ const buildCtx = (results: Record<string, unknown>) => {
|
|||
}
|
||||
|
||||
const run = async (arg: string) => {
|
||||
billingCommand.run(arg, ctx as any, 'billing')
|
||||
topupCommand.run(arg, ctx as any, 'topup')
|
||||
await rpc.mock.results[0]?.value
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
|
@ -231,19 +232,157 @@ describe('/billing slash command (overlay-driven)', () => {
|
|||
expect(out).toContain('Portal: /billing?topup=open')
|
||||
})
|
||||
|
||||
it('ctx.charge insufficient_scope → arms step-up confirm', async () => {
|
||||
it('ctx.charge consent_required → one-time portal confirmation copy + portal funnel', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'consent_required',
|
||||
portal_url: '/billing/consent',
|
||||
idempotency_key: 'k'
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
await getOverlayState().billing!.ctx.charge('100')
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('one-time card confirmation')
|
||||
expect(out).toContain('Portal: /billing/consent')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['org_access_denied', "This token isn't bound to an org you can manage"],
|
||||
['upgrade_cap_exceeded', 'Daily plan-change limit reached'],
|
||||
['auto_top_up_disabled_failures', 'Auto-reload was turned off after repeated charge failures']
|
||||
])('ctx.charge %s → typed recovery copy', async (error, copy) => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: false, error, idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
await getOverlayState().billing!.ctx.charge('100')
|
||||
expect(printed(sys)).toContain(copy)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, 'Stripe is having trouble right now — try again shortly.'],
|
||||
[120, 'Stripe is having trouble right now — try again shortly (try again in ~2 min).']
|
||||
])('ctx.charge stripe_unavailable (retry_after=%s) → transient Stripe copy', async (retryAfter, copy) => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'stripe_unavailable',
|
||||
idempotency_key: 'k',
|
||||
...(retryAfter == null ? {} : { retry_after: retryAfter })
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
await getOverlayState().billing!.ctx.charge('100')
|
||||
const out = printed(sys)
|
||||
expect(out).toContain(copy)
|
||||
expect(out).not.toContain('Too many charges')
|
||||
})
|
||||
|
||||
it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => {
|
||||
const { run } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
const outcome = await getOverlayState().billing!.ctx.charge('100')
|
||||
// No separate confirm overlay is armed anymore — the overlay's stepup
|
||||
// screen owns the UX; the ctx just reports the outcome.
|
||||
expect(outcome).toBe('needs_remote_spending')
|
||||
expect(getOverlayState().confirm).toBeNull()
|
||||
})
|
||||
|
||||
it.each([[true], [false]])('ctx.requestRemoteSpending → billing.step_up resolves %s', async granted => {
|
||||
const { run, calls } = buildCtx({ 'billing.state': ownerState(), 'billing.step_up': { ok: true, granted } })
|
||||
|
||||
await run('')
|
||||
expect(await getOverlayState().billing!.ctx.requestRemoteSpending()).toBe(granted)
|
||||
expect(calls.find(c => c.method === 'billing.step_up')).toBeTruthy()
|
||||
})
|
||||
|
||||
// ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ──
|
||||
|
||||
it.each([
|
||||
['admin', 'An admin turned off terminal billing for this terminal'],
|
||||
['self', 'You turned off terminal billing for this terminal']
|
||||
])('ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy', async (actor, copy) => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: false, error: 'remote_spending_revoked', actor, recovery: 'reconnect', idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
// The charge failed with insufficient_scope → a NEW confirm (step-up) is armed.
|
||||
const stepUp = getOverlayState().confirm
|
||||
expect(stepUp?.title).toBe('Grant terminal billing access?')
|
||||
expect(printed(sys)).toContain(copy)
|
||||
expect(getOverlayState().billing).toBeNull()
|
||||
})
|
||||
|
||||
it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(printed(sys)).toContain('Your session was logged out')
|
||||
expect(getOverlayState().billing).toBeNull()
|
||||
})
|
||||
|
||||
it('ctx.charge → poll transport loss reports an unconfirmed outcome', async () => {
|
||||
const { ctx, rpc, run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
rpc.mockImplementation((method: string) => {
|
||||
if (method === 'billing.charge_status') {
|
||||
return Promise.reject(new Error('socket closed'))
|
||||
}
|
||||
|
||||
return Promise.resolve(
|
||||
method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null
|
||||
)
|
||||
})
|
||||
await getOverlayState().billing!.ctx.charge('100')
|
||||
await vi.waitFor(() => expect(printed(sys)).toContain('outcome is unconfirmed'))
|
||||
expect(ctx.guardedErr).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'cli_billing_disabled',
|
||||
code: 'remote_spending_disabled',
|
||||
recovery: 'enable_account_toggle',
|
||||
portal_url: '/billing',
|
||||
idempotency_key: 'k'
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('Terminal billing is off for this account')
|
||||
// Account-wide switch is NOT a per-terminal revoke — overlay stays open.
|
||||
expect(getOverlayState().billing).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => {
|
||||
|
|
@ -263,6 +402,7 @@ describe('/billing slash command (overlay-driven)', () => {
|
|||
const { run, calls } = buildCtx({
|
||||
'billing.state': ownerState({
|
||||
auto_reload: {
|
||||
card: { kind: 'canonical' },
|
||||
enabled: true,
|
||||
reload_to_display: '$100',
|
||||
reload_to_usd: '100',
|
||||
|
|
@ -292,6 +432,25 @@ describe('/billing slash command (overlay-driven)', () => {
|
|||
expect(printed(sys)).toContain('Monthly spend cap reached.')
|
||||
})
|
||||
|
||||
it('ctx.charge → poll → processing_error has intentional failure copy', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
|
||||
'billing.charge_status': { ok: true, status: 'failed', reason: 'processing_error' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(printed(sys)).toContain("The charge didn't go through (processing_error).")
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ctx.openPortal opens the URL + echoes a transcript line', async () => {
|
||||
const { run, sys } = buildCtx({ 'billing.state': ownerState() })
|
||||
await run('')
|
||||
|
|
@ -35,12 +35,7 @@ describe('turnController.startMessage — flash-and-yield notices clear on next
|
|||
|
||||
it('leaves a sticky credits.depleted notice across a new turn', () => {
|
||||
patchUiState({
|
||||
notice: {
|
||||
key: 'credits.depleted',
|
||||
kind: 'sticky',
|
||||
level: 'error',
|
||||
text: '✕ Credit access paused · run /credits to top up'
|
||||
}
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' }
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice?.key).toBe('credits.depleted')
|
||||
|
|
|
|||
110
ui-tui/src/__tests__/usageCommand.test.ts
Normal file
110
ui-tui/src/__tests__/usageCommand.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { sessionCommands } from '../app/slash/commands/session.js'
|
||||
import type { SessionUsageResponse } from '../gatewayTypes.js'
|
||||
|
||||
const usageCommand = sessionCommands.find(cmd => cmd.name === 'usage')!
|
||||
|
||||
const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'
|
||||
|
||||
const guarded =
|
||||
<T>(fn: (r: T) => void) =>
|
||||
(r: null | T) => {
|
||||
if (r) {
|
||||
fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
|
||||
const buildCtx = (results: Record<string, unknown>) => {
|
||||
const sys = vi.fn()
|
||||
const panel = vi.fn()
|
||||
|
||||
const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method]))
|
||||
|
||||
const ctx = {
|
||||
gateway: { rpc },
|
||||
guarded,
|
||||
guardedErr: vi.fn(),
|
||||
sid: 'sid-1',
|
||||
stale: () => false,
|
||||
transcript: { page: vi.fn(), panel, sys }
|
||||
}
|
||||
|
||||
const run = async (arg: string) => {
|
||||
usageCommand.run(arg, ctx as any, 'usage')
|
||||
await rpc.mock.results[0]?.value
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
return { ctx, panel, run, sys }
|
||||
}
|
||||
|
||||
const baseUsage = (overrides: Partial<SessionUsageResponse> = {}): SessionUsageResponse =>
|
||||
({ calls: 0, input: 0, output: 0, total: 0, ...overrides }) as SessionUsageResponse
|
||||
|
||||
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
|
||||
|
||||
const balancePanel = (panel: ReturnType<typeof vi.fn>) => {
|
||||
const sections = panel.mock.calls.find(c => c[0] === 'Balance')?.[1] as { text?: string }[] | undefined
|
||||
|
||||
return (sections ?? []).map(s => s.text ?? '').join('\n')
|
||||
}
|
||||
|
||||
describe('/usage slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('always shows the CTA; "no API calls yet" only when there is no balance', async () => {
|
||||
const empty = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: [] }) })
|
||||
await empty.run('')
|
||||
expect(printed(empty.sys)).toContain('no API calls yet')
|
||||
expect(printed(empty.sys)).toContain(USAGE_CTA)
|
||||
|
||||
const withBalance = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] }) })
|
||||
await withBalance.run('')
|
||||
expect(printed(withBalance.sys)).not.toContain('no API calls yet')
|
||||
expect(printed(withBalance.sys)).toContain(USAGE_CTA)
|
||||
})
|
||||
|
||||
it('renders the dollar two-bar model (no "credits" wording) when available', async () => {
|
||||
const { panel, run } = buildCtx({
|
||||
'session.usage': baseUsage({
|
||||
usage: {
|
||||
available: true,
|
||||
status: 'healthy',
|
||||
plan_name: 'Plus',
|
||||
renews_display: 'Jul 1, 2026',
|
||||
total_spendable_display: '$26.00',
|
||||
has_topup: true,
|
||||
plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 },
|
||||
topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await run('')
|
||||
|
||||
const body = balancePanel(panel)
|
||||
expect(body).toContain('Plus')
|
||||
expect(body).toContain('$14.00 left of $20.00')
|
||||
expect(body).toContain('30% used')
|
||||
expect(body).toContain('top-up')
|
||||
expect(body).toContain('$12.00')
|
||||
expect(body.toLowerCase()).not.toContain('credits')
|
||||
})
|
||||
|
||||
it('shows the free-models upsell for a free account', async () => {
|
||||
const { panel, run } = buildCtx({
|
||||
'session.usage': baseUsage({ usage: { available: true, status: 'free', plan_name: null } })
|
||||
})
|
||||
|
||||
await run('')
|
||||
|
||||
const body = balancePanel(panel)
|
||||
expect(body).toContain('free models only')
|
||||
expect(body).toContain('/subscription')
|
||||
})
|
||||
})
|
||||
|
|
@ -3,7 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
|
|||
|
||||
import type { PasteEvent } from '../components/textInput.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { BillingStateResponse, ImageAttachResponse, SessionCloseResponse } from '../gatewayTypes.js'
|
||||
import type { BillingCardInfo, BillingMutationResponse, BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../gatewayTypes.js'
|
||||
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
|
||||
import type { RpcResult } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
|
@ -92,7 +92,13 @@ export interface GatewayProviderProps {
|
|||
// the SAME RPCs as the old slash flows (billing.charge / charge_status /
|
||||
// auto_reload / step_up). Backend is unchanged & shared with the CLI.
|
||||
|
||||
export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview'
|
||||
export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview' | 'stepup'
|
||||
|
||||
/** Outcome of a charge attempt — lets the overlay route without tearing down. */
|
||||
export type BillingChargeOutcome =
|
||||
| 'submitted' // 202 accepted; settlement is reported via transcript lines
|
||||
| 'needs_remote_spending' // insufficient_scope → route to the stepup screen
|
||||
| 'error' // any other failure (already surfaced via sys)
|
||||
|
||||
/**
|
||||
* The functions the overlay needs to talk to the gateway and emit
|
||||
|
|
@ -104,10 +110,27 @@ export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overvi
|
|||
export interface BillingOverlayCtx {
|
||||
/** Run `billing.auto_reload` (enabled/threshold/top_up) → resolve ok/false. */
|
||||
applyAutoReload: (enabled: boolean, threshold?: number, topUp?: number) => Promise<boolean>
|
||||
/** Submit `billing.charge` for `amount` and poll to settlement (non-blocking). */
|
||||
charge: (amount: string) => void
|
||||
/**
|
||||
* Submit `billing.charge` for `amount` and poll to settlement. Resolves a
|
||||
* discriminated outcome so the overlay can route to the resumable step-up on
|
||||
* `needs_remote_spending` instead of tearing down. Settlement/most errors are
|
||||
* still reported via transcript lines (the poll is non-blocking).
|
||||
*/
|
||||
charge: (amount: string, idempotencyKey?: string) => Promise<BillingChargeOutcome>
|
||||
/**
|
||||
* Run the `billing.step_up` device flow (grant Remote Spending). Resolves
|
||||
* `true` when the grant lands. The browser opens via the gateway's
|
||||
* out-of-band `billing.step_up.verification` event — the overlay just awaits.
|
||||
*/
|
||||
requestRemoteSpending: () => Promise<boolean>
|
||||
/** Open the portal in the browser + echo a transcript line. */
|
||||
openPortal: (url: string) => void
|
||||
/**
|
||||
* Re-fetch billing state (`billing.state`) — used by the add-card path's
|
||||
* "I've added it — check again" so a card saved on the portal appears without
|
||||
* re-running /topup. Resolves null on failure (caller keeps the old state).
|
||||
*/
|
||||
refreshState: () => Promise<BillingStateResponse | null>
|
||||
/** Emit a transcript system line. */
|
||||
sys: (text: string) => void
|
||||
/** Validate a custom amount against state bounds + 2dp (mirrors the server). */
|
||||
|
|
@ -117,6 +140,12 @@ export interface BillingOverlayCtx {
|
|||
/** Pending confirm built when leaving the buy/autoreload screen. */
|
||||
export interface BillingPendingCharge {
|
||||
amount: string
|
||||
/**
|
||||
* Stable idempotency key for THIS purchase, minted when the amount is chosen.
|
||||
* Reused across the step-up replay so a re-charge after the grant dedups
|
||||
* server-side (and a double-submit collapses to one charge).
|
||||
*/
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
export interface BillingOverlayState {
|
||||
|
|
@ -127,6 +156,106 @@ export interface BillingOverlayState {
|
|||
state: BillingStateResponse
|
||||
}
|
||||
|
||||
// ── Subscription overlay (in-terminal plan change, V3) ──
|
||||
|
||||
// A small state machine: overview → picker → confirm → result, with a stepup
|
||||
// screen spliced in on demand.
|
||||
// overview — plan + status, entry to the picker / resume / manage-on-portal.
|
||||
// picker — the tier catalog (up/down direction hints; current tier shown,
|
||||
// not selectable).
|
||||
// confirm — the previewed effect of the chosen change (charge $X now /
|
||||
// scheduled at date / no-op / blocked) + the apply action.
|
||||
// result — the outcome, including an SCA/decline upgrade handed off to the
|
||||
// portal.
|
||||
// stepup — reached when a mutation returns insufficient_scope: grants the
|
||||
// terminal-billing scope in place, then auto-replays the held action.
|
||||
export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' | 'stepup'
|
||||
|
||||
// The action held while the stepup screen grants terminal billing, replayed on
|
||||
// grant: re-preview a tier, re-apply the confirmed pending change, or re-resume.
|
||||
export type SubscriptionStepUpRetry =
|
||||
| { kind: 'apply' }
|
||||
| { kind: 'preview'; tierId: string }
|
||||
| { kind: 'resume' }
|
||||
|
||||
/** Outcome of a terminal-billing step-up: granted, plus the typed denial (for copy). */
|
||||
export interface StepUpResult {
|
||||
granted: boolean
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface SubscriptionOverlayCtx {
|
||||
/**
|
||||
* Best-effort card lookup (`billing.state`) for the upgrade confirm — shows
|
||||
* WHICH card the upgrade will charge. Resolves null on any failure or when
|
||||
* the server doesn't say (older NAS): the confirm keeps its generic line.
|
||||
*/
|
||||
fetchCard: () => Promise<BillingCardInfo | null>
|
||||
/** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */
|
||||
openManageLink: () => Promise<boolean>
|
||||
/** Open an arbitrary portal recovery URL (e.g. an upgrade's SCA handoff). */
|
||||
openPortal: (url: string) => void
|
||||
/** Re-fetch subscription.state. */
|
||||
refreshState: () => Promise<SubscriptionStateResponse | null>
|
||||
/** POST /preview a change to `tierId` → the chargeless effect quote (or typed error). */
|
||||
preview: (tierId: string) => Promise<SubscriptionPreviewResponse | null>
|
||||
/** PUT pending-change: schedule a downgrade / same-price change to `tierId`. */
|
||||
scheduleChange: (tierId: string) => Promise<BillingMutationResponse | null>
|
||||
/** PUT pending-change: schedule a cancellation at period end. */
|
||||
scheduleCancellation: () => Promise<BillingMutationResponse | null>
|
||||
/** DELETE pending-change: clear a scheduled downgrade / cancellation (resume). */
|
||||
resume: () => Promise<BillingMutationResponse | null>
|
||||
/** POST /upgrade: charge the card on the subscription + flip the plan now. */
|
||||
upgrade: (tierId: string, idempotencyKey?: string) => Promise<SubscriptionUpgradeResponse | null>
|
||||
/**
|
||||
* Run the `billing.step_up` device flow (grant terminal billing / "Remote
|
||||
* Spending"). Resolves `{granted}` plus the typed denial (`error`/`message`) so
|
||||
* the stepup screen shows the right recovery. The browser opens via the
|
||||
* gateway's out-of-band verification event — the stepup screen just awaits.
|
||||
*/
|
||||
requestRemoteSpending: () => Promise<StepUpResult>
|
||||
/** Emit a transcript system line. */
|
||||
sys: (text: string) => void
|
||||
}
|
||||
|
||||
/** What the confirm screen is about to apply, plus its preview quote. */
|
||||
export interface SubscriptionPendingChange {
|
||||
/** The target tier (null for a cancellation). */
|
||||
targetTierId: string | null
|
||||
/** How it will be applied — drives which ctx call confirm makes. */
|
||||
kind: 'cancellation' | 'tier_change' | 'upgrade'
|
||||
/** The preview quote shown on confirm (null = the quote call failed). */
|
||||
preview?: null | SubscriptionPreviewResponse
|
||||
/**
|
||||
* Stable idempotency key for an upgrade charge, minted when confirm opens.
|
||||
* Reused on retry so a re-submit dedups server-side.
|
||||
*/
|
||||
idempotencyKey?: string
|
||||
}
|
||||
|
||||
/** The outcome rendered on the result screen. */
|
||||
export interface SubscriptionResult {
|
||||
message: string
|
||||
ok: boolean
|
||||
/** Set on a successful upgrade; drives the ResultScreen apply-poll. */
|
||||
pendingTierId?: null | string
|
||||
/** A portal URL to finish an SCA/declined upgrade, when present. */
|
||||
recoveryUrl?: null | string
|
||||
}
|
||||
|
||||
export interface SubscriptionOverlayState {
|
||||
ctx: SubscriptionOverlayCtx
|
||||
/** Set on the 'confirm' screen: the change being confirmed + its preview. */
|
||||
pending?: null | SubscriptionPendingChange
|
||||
/** Set on the 'result' screen: the outcome to render. */
|
||||
result?: null | SubscriptionResult
|
||||
screen: SubscriptionScreen
|
||||
state: SubscriptionStateResponse
|
||||
/** Held while on the 'stepup' screen: the action to replay once the grant lands. */
|
||||
stepUpRetry?: null | SubscriptionStepUpRetry
|
||||
}
|
||||
|
||||
export interface OverlayState {
|
||||
agents: boolean
|
||||
agentsInitialHistoryIndex: number
|
||||
|
|
@ -142,6 +271,7 @@ export interface OverlayState {
|
|||
secret: null | SecretReq
|
||||
sessions: boolean
|
||||
skillsHub: boolean
|
||||
subscription: SubscriptionOverlayState | null
|
||||
sudo: null | SudoReq
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const buildOverlayState = (): OverlayState => ({
|
|||
secret: null,
|
||||
sessions: false,
|
||||
skillsHub: false,
|
||||
subscription: null,
|
||||
sudo: null
|
||||
})
|
||||
|
||||
|
|
@ -38,23 +39,25 @@ export const $isBlocked = computed(
|
|||
secret,
|
||||
sessions,
|
||||
skillsHub,
|
||||
subscription,
|
||||
sudo
|
||||
}) =>
|
||||
Boolean(
|
||||
agents ||
|
||||
approval ||
|
||||
billing ||
|
||||
clarify ||
|
||||
confirm ||
|
||||
journey ||
|
||||
modelPicker ||
|
||||
pager ||
|
||||
petPicker ||
|
||||
pluginsHub ||
|
||||
secret ||
|
||||
sessions ||
|
||||
skillsHub ||
|
||||
sudo
|
||||
approval ||
|
||||
billing ||
|
||||
clarify ||
|
||||
confirm ||
|
||||
journey ||
|
||||
modelPicker ||
|
||||
pager ||
|
||||
petPicker ||
|
||||
pluginsHub ||
|
||||
secret ||
|
||||
sessions ||
|
||||
skillsHub ||
|
||||
subscription ||
|
||||
sudo
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import type { CreditsViewResponse } from '../../../gatewayTypes.js'
|
||||
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
export const creditsCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'Show Nous credit balance and top up',
|
||||
name: 'credits',
|
||||
run: (_arg, ctx) => {
|
||||
ctx.gateway
|
||||
.rpc<CreditsViewResponse>('credits.view', { session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<CreditsViewResponse>(view => {
|
||||
if (!view.logged_in) {
|
||||
ctx.transcript.sys('💳 Not logged into Nous Portal — run /portal to log in.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const lines = ['💳 Nous credits', ...view.balance_lines]
|
||||
|
||||
if (view.identity_line) {
|
||||
lines.push('', view.identity_line)
|
||||
}
|
||||
|
||||
if (view.topup_url) {
|
||||
lines.push('', `Top up: ${view.topup_url}`)
|
||||
}
|
||||
|
||||
ctx.transcript.sys(lines.join('\n'))
|
||||
|
||||
const url = view.topup_url
|
||||
|
||||
if (url) {
|
||||
patchOverlayState({
|
||||
confirm: {
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Open top-up in browser',
|
||||
detail: url,
|
||||
onConfirm: () => {
|
||||
const ok = openExternalUrl(url)
|
||||
ctx.transcript.sys(
|
||||
ok
|
||||
? 'Complete your top-up in the browser — credits will appear in /credits shortly.'
|
||||
: `Open this URL to top up: ${url}`
|
||||
)
|
||||
},
|
||||
title: 'Add credits?'
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { usageBarsText } from '../../../components/overlayPrimitives.js'
|
||||
import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js'
|
||||
import { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js'
|
||||
import type {
|
||||
|
|
@ -19,6 +20,8 @@ import { patchOverlayState } from '../../overlayStore.js'
|
|||
import { patchUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'
|
||||
|
||||
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
|
||||
|
||||
const modelValueForConfigSet = (arg: string) => {
|
||||
|
|
@ -574,26 +577,60 @@ export const sessionCommands: SlashCommand[] = [
|
|||
return
|
||||
}
|
||||
|
||||
const sys = ctx.transcript.sys
|
||||
|
||||
if (r) {
|
||||
patchUiState({
|
||||
usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 }
|
||||
})
|
||||
}
|
||||
|
||||
// Nous credits block is agent-independent (a portal fetch), so it shows
|
||||
// even with zero API calls or on a resumed session. Render it whenever
|
||||
// present, before the token panel.
|
||||
const creditsLines = r?.credits_lines ?? []
|
||||
// Nous balance block is agent-independent (a portal fetch), so it shows
|
||||
// even with zero API calls or on a resumed session. Prefer the shared
|
||||
// dollar usage model (two-bar view, dollars-only); fall back to the
|
||||
// legacy text lines only when the model is unavailable.
|
||||
const usageModel = r?.usage
|
||||
const barLines = usageBarsText(usageModel)
|
||||
let showedBalance = false
|
||||
|
||||
if (creditsLines.length) {
|
||||
ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }])
|
||||
if (usageModel?.available && (barLines.length || usageModel.status === 'free')) {
|
||||
const sections: PanelSection[] = []
|
||||
const plan = usageModel.plan_name ?? (usageModel.status === 'free' ? 'Free' : null)
|
||||
|
||||
if (plan) {
|
||||
sections.push({ text: `Plan: ${plan}${usageModel.renews_display ? ` · renews ${usageModel.renews_display}` : ''}` })
|
||||
}
|
||||
|
||||
if (barLines.length) {
|
||||
sections.push({ text: barLines.join('\n') })
|
||||
}
|
||||
|
||||
if (usageModel.status === 'free') {
|
||||
sections.push({ text: '> Free · free models only. Run /subscription to reach paid models.' })
|
||||
} else if (usageModel.status === 'low') {
|
||||
sections.push({
|
||||
text: `! Low balance · ${usageModel.total_spendable_display ?? 'under $5'} left. Run /topup or /subscription.`
|
||||
})
|
||||
}
|
||||
|
||||
ctx.transcript.panel('Balance', sections)
|
||||
showedBalance = true
|
||||
} else {
|
||||
const creditsLines = r?.credits_lines ?? []
|
||||
|
||||
if (creditsLines.length) {
|
||||
ctx.transcript.panel('Nous balance', [{ text: creditsLines.join('\n') }])
|
||||
showedBalance = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!r?.calls) {
|
||||
if (!creditsLines.length) {
|
||||
ctx.transcript.sys('no API calls yet')
|
||||
if (!showedBalance) {
|
||||
sys('no API calls yet')
|
||||
}
|
||||
|
||||
sys(USAGE_CTA)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -618,6 +655,8 @@ export const sessionCommands: SlashCommand[] = [
|
|||
}
|
||||
|
||||
ctx.transcript.panel('Usage', sections)
|
||||
|
||||
sys(USAGE_CTA)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
169
ui-tui/src/app/slash/commands/subscription.ts
Normal file
169
ui-tui/src/app/slash/commands/subscription.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import type {
|
||||
BillingMutationResponse,
|
||||
BillingStateResponse,
|
||||
SubscriptionPreviewResponse,
|
||||
SubscriptionStateResponse,
|
||||
SubscriptionUpgradeResponse
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
|
||||
import type { SubscriptionOverlayCtx } from '../../interfaces.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import type { SlashCommand, SlashRunCtx } from '../types.js'
|
||||
|
||||
type Sys = (text: string) => void
|
||||
|
||||
/**
|
||||
* Build the manage-subscription URL locally from the loaded subscription state.
|
||||
*
|
||||
* Uses `portal_url` (the resolved portal base URL carried in the state) and
|
||||
* `org_id` to construct `{portal_base}/manage-subscription?org_id=<id>`.
|
||||
* `org_id` pins the page to the correct account in multi-org situations.
|
||||
* Falls back to bare `/manage-subscription` if org_id is absent.
|
||||
*/
|
||||
function buildManageUrl(s: SubscriptionStateResponse): string | null {
|
||||
// portal_url is already an absolute URL resolved by resolve_portal_base_url()
|
||||
// on the Python side (e.g. https://portal.nousresearch.com/billing). Strip any
|
||||
// path so we can attach /manage-subscription cleanly.
|
||||
let base: string | null = null
|
||||
|
||||
if (s.portal_url) {
|
||||
try {
|
||||
base = new URL(s.portal_url).origin
|
||||
} catch {
|
||||
// A malformed portal_url must not throw out of the Ink key handler
|
||||
// (it would crash the overlay) — treat it as "no manage URL".
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const url = new URL('/manage-subscription', base)
|
||||
|
||||
if (s.org_id) {
|
||||
url.searchParams.set('org_id', s.org_id)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ctx the overlay uses to talk to the gateway + emit transcript
|
||||
* lines. Mirrors topup.ts's buildOverlayCtx — all RPC + error-mapping logic
|
||||
* lives here (single source of truth); the overlay only renders + routes keys.
|
||||
*/
|
||||
const buildSubscriptionCtx = (
|
||||
ctx: SlashRunCtx,
|
||||
sys: Sys,
|
||||
initialState: SubscriptionStateResponse
|
||||
): SubscriptionOverlayCtx => ({
|
||||
fetchCard: () =>
|
||||
ctx.gateway
|
||||
.rpc<BillingStateResponse>('billing.state', {})
|
||||
.then(r => (r?.ok ? (r.card ?? null) : null))
|
||||
.catch(() => null),
|
||||
openManageLink: () => {
|
||||
const url = buildManageUrl(initialState)
|
||||
|
||||
if (!url) {
|
||||
sys('Could not build manage URL — is your portal configured?')
|
||||
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
const opened = openExternalUrl(url)
|
||||
|
||||
if (opened) {
|
||||
sys('Opening your subscription page in the browser — finish there, then re-run /subscription.')
|
||||
} else {
|
||||
sys('Could not open browser — visit your subscription page manually at ' + url)
|
||||
}
|
||||
|
||||
return Promise.resolve(opened)
|
||||
},
|
||||
openPortal: (url: string) => {
|
||||
if (openExternalUrl(url)) {
|
||||
sys('Opening the portal in your browser — finish there, then re-run /subscription.')
|
||||
} else {
|
||||
sys('Could not open browser — visit ' + url + ' to finish.')
|
||||
}
|
||||
},
|
||||
preview: tierId =>
|
||||
ctx.gateway
|
||||
.rpc<SubscriptionPreviewResponse>('subscription.preview', { subscription_type_id: tierId })
|
||||
.then(r => r ?? null)
|
||||
.catch(() => null),
|
||||
refreshState: () =>
|
||||
ctx.gateway
|
||||
.rpc<SubscriptionStateResponse>('subscription.state', {})
|
||||
.then(r => r ?? null)
|
||||
.catch(() => null),
|
||||
requestRemoteSpending: () =>
|
||||
ctx.gateway
|
||||
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
|
||||
// Carry the typed denial (session_revoked / remote_spending_revoked /
|
||||
// rate_limited / …) so the stepup screen shows the right recovery.
|
||||
.then(r => ({ error: r?.error, granted: !!(r && r.ok && r.granted), message: r?.message }))
|
||||
.catch(() => ({ granted: false, message: 'Could not reach the billing service — check your connection, then retry.' })),
|
||||
resume: () =>
|
||||
ctx.gateway
|
||||
.rpc<BillingMutationResponse>('subscription.resume', {})
|
||||
.then(r => r ?? null)
|
||||
.catch(() => null),
|
||||
scheduleCancellation: () =>
|
||||
ctx.gateway
|
||||
.rpc<BillingMutationResponse>('subscription.change', { cancel: true })
|
||||
.then(r => r ?? null)
|
||||
.catch(() => null),
|
||||
scheduleChange: tierId =>
|
||||
ctx.gateway
|
||||
.rpc<BillingMutationResponse>('subscription.change', { subscription_type_id: tierId })
|
||||
.then(r => r ?? null)
|
||||
.catch(() => null),
|
||||
sys,
|
||||
upgrade: (tierId, idempotencyKey) =>
|
||||
ctx.gateway
|
||||
.rpc<SubscriptionUpgradeResponse>('subscription.upgrade', {
|
||||
subscription_type_id: tierId,
|
||||
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {})
|
||||
})
|
||||
.then(r => r ?? null)
|
||||
.catch(() => null)
|
||||
})
|
||||
|
||||
export const subscriptionCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'View or change your Nous subscription plan',
|
||||
name: 'subscription',
|
||||
aliases: ['upgrade'],
|
||||
// ZERO sub-commands: bare `/subscription` fetches state and opens the
|
||||
// overlay's in-terminal change flow (only /upgrade's charge_now confirm
|
||||
// moves money, via the V3 upgrade route).
|
||||
run: (_arg, ctx) => {
|
||||
const sys: Sys = ctx.transcript.sys
|
||||
|
||||
ctx.gateway
|
||||
.rpc<SubscriptionStateResponse>('subscription.state', {})
|
||||
.then(
|
||||
ctx.guarded<SubscriptionStateResponse>(s => {
|
||||
if (!s.logged_in) {
|
||||
sys('Not logged into Nous Portal — run /portal to log in, then /subscription.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
patchOverlayState({
|
||||
subscription: {
|
||||
ctx: buildSubscriptionCtx(ctx, sys, s),
|
||||
screen: 'overview',
|
||||
state: s
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -6,13 +6,14 @@ import type {
|
|||
BillingStateResponse
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
|
||||
import type { BillingOverlayCtx } from '../../interfaces.js'
|
||||
import type { BillingChargeOutcome, BillingOverlayCtx } from '../../interfaces.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import type { SlashCommand, SlashRunCtx } from '../types.js'
|
||||
|
||||
// Poll cadence (plan §5, frozen): 2s interval, 5-minute cap.
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
const POLL_CAP_MS = 5 * 60 * 1000
|
||||
const UNCONFIRMED_CHARGE_MESSAGE = '🟡 Your last charge’s outcome is unconfirmed — check your balance/history before retrying.'
|
||||
|
||||
type Sys = (text: string) => void
|
||||
|
||||
|
|
@ -21,10 +22,13 @@ const renderBillingError = (
|
|||
sys: Sys,
|
||||
ctx: SlashRunCtx,
|
||||
env: {
|
||||
actor?: string
|
||||
code?: string
|
||||
error?: string
|
||||
message?: string
|
||||
payload?: BillingErrorPayload
|
||||
portal_url?: string | null
|
||||
recovery?: string
|
||||
retry_after?: number | null
|
||||
}
|
||||
): void => {
|
||||
|
|
@ -32,9 +36,72 @@ const renderBillingError = (
|
|||
|
||||
switch (env.error) {
|
||||
case 'insufficient_scope':
|
||||
armStepUp(sys, ctx)
|
||||
// Reached by non-charge mutations (e.g. auto-reload config) that need
|
||||
// terminal billing enabled. The resumable step-up lives on the buy/charge
|
||||
// path; point the user there rather than leaking the raw scope name.
|
||||
sys('This needs terminal billing enabled. Start a top-up to enable it, then retry.')
|
||||
|
||||
break
|
||||
case 'remote_spending_revoked': {
|
||||
// CF-4: this terminal's spend was revoked. Kill the spend UI NOW (don't
|
||||
// wait for the token refresh ~15 min away) and tell the user who did it.
|
||||
patchOverlayState({ billing: null })
|
||||
|
||||
const who =
|
||||
env.actor === 'admin'
|
||||
? 'An admin turned off terminal billing for this terminal.'
|
||||
: 'You turned off terminal billing for this terminal.'
|
||||
|
||||
sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'session_revoked':
|
||||
// Stronger than a spend-revoke: the whole session is gone → full re-login.
|
||||
patchOverlayState({ billing: null })
|
||||
sys('Your session was logged out. Run /portal to log in again.')
|
||||
|
||||
return
|
||||
|
||||
case 'cli_billing_disabled':
|
||||
|
||||
case 'remote_spending_disabled':
|
||||
// Account-wide switch is OFF (dual-emitted error/code). An admin must flip
|
||||
// it on the portal; this is NOT a per-terminal revoke.
|
||||
sys('Terminal billing is off for this account — an admin must enable it on the portal.')
|
||||
|
||||
break
|
||||
|
||||
case 'role_required':
|
||||
sys('Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.')
|
||||
|
||||
break
|
||||
|
||||
case 'consent_required':
|
||||
sys('This action needs a one-time card confirmation and consent step on the portal before it can proceed.')
|
||||
|
||||
break
|
||||
|
||||
case 'org_access_denied':
|
||||
sys("This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.")
|
||||
|
||||
break
|
||||
|
||||
case 'upgrade_cap_exceeded':
|
||||
sys('🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.')
|
||||
|
||||
break
|
||||
|
||||
case 'auto_top_up_disabled_failures':
|
||||
sys('Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.')
|
||||
|
||||
break
|
||||
|
||||
case 'idempotency_conflict':
|
||||
sys('🔴 That charge key was already used for a different amount. Start a fresh top-up.')
|
||||
|
||||
break
|
||||
|
||||
case 'no_payment_method':
|
||||
sys(
|
||||
|
|
@ -42,11 +109,6 @@ const renderBillingError = (
|
|||
"(one-time credit buys don't save a reusable card)."
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
case 'cli_billing_disabled':
|
||||
sys('🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.')
|
||||
|
||||
break
|
||||
case 'monthly_cap_exceeded': {
|
||||
// Surface the remaining headroom the server attaches (parity with the CLI).
|
||||
|
|
@ -60,13 +122,23 @@ const renderBillingError = (
|
|||
break
|
||||
}
|
||||
|
||||
case 'rate_limited': {
|
||||
case 'rate_limited':
|
||||
case 'temporarily_unavailable': {
|
||||
// 429 throttle OR 503 gate-fail-closed: NOT a payment failure, NOT a
|
||||
// revoke. Back off and tell the user to retry.
|
||||
const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : ''
|
||||
sys(`🟡 Too many charges right now${mins}. This isn't a payment failure.`)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'stripe_unavailable': {
|
||||
const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : ''
|
||||
sys(`🟡 Stripe is having trouble right now — try again shortly${mins}.`)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
sys(`🔴 ${env.message || env.error || 'Billing request failed.'}`)
|
||||
}
|
||||
|
|
@ -76,71 +148,46 @@ const renderBillingError = (
|
|||
}
|
||||
}
|
||||
|
||||
/** 403 insufficient_scope → arm a ConfirmReq that runs the lazy step-up. */
|
||||
const armStepUp = (sys: Sys, ctx: SlashRunCtx): void => {
|
||||
sys('💳 Terminal billing needs an extra permission (billing:manage).')
|
||||
patchOverlayState({
|
||||
confirm: {
|
||||
cancelLabel: 'Not now',
|
||||
confirmLabel: 'Re-authorize',
|
||||
detail: 'An org admin/owner must tick "Allow terminal billing" in the portal.',
|
||||
onConfirm: () => {
|
||||
// session_id lets the gateway route the billing.step_up.verification
|
||||
// event (the verification link) back to this session — the device flow
|
||||
// runs headless in the gateway, so the link can't be printed there.
|
||||
ctx.gateway
|
||||
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
|
||||
.then(
|
||||
ctx.guarded<BillingMutationResponse>(r => {
|
||||
if (r.ok && r.granted) {
|
||||
// Step-up only grants the billing:manage TOKEN scope — the ORG
|
||||
// kill-switch (cli_billing_enabled) is a separate gate. Re-fetch
|
||||
// /state so we don't over-promise "enabled" when a charge would
|
||||
// still hit cli_billing_disabled.
|
||||
sys('✅ Billing permission granted.')
|
||||
ctx.gateway
|
||||
.rpc<BillingStateResponse>('billing.state', {})
|
||||
.then(
|
||||
ctx.guarded<BillingStateResponse>(s => {
|
||||
if (s.cli_billing_enabled) {
|
||||
sys('Run /billing again to continue.')
|
||||
} else {
|
||||
sys(
|
||||
'🟡 Permission granted, but terminal billing is still turned off ' +
|
||||
'for this org. Enable it in the portal, then run /billing again.'
|
||||
)
|
||||
|
||||
if (s.portal_url) {
|
||||
sys(`Portal: ${s.portal_url}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(() => {
|
||||
sys('Run /billing again to continue.')
|
||||
})
|
||||
} else {
|
||||
sys('🟡 Terminal billing was not granted (an admin must tick the box).')
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(() => {
|
||||
// The device flow can outlive the RPC's 120s timeout while the user
|
||||
// is still authorizing in the browser. A reject here is NOT a hard
|
||||
// failure — the grant (if it lands) is persisted gateway-side; tell
|
||||
// the user to re-run /billing rather than reporting an error.
|
||||
sys('🟡 Still waiting on approval — finish in the browser, then run /billing again.')
|
||||
})
|
||||
},
|
||||
title: 'Grant terminal billing access?'
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Run the Remote-Spending device flow and resolve whether the grant landed.
|
||||
*
|
||||
* The browser opens via the gateway's out-of-band `billing.step_up.verification`
|
||||
* event (handled globally in createGatewayEventHandler), so this just kicks the
|
||||
* blocking `billing.step_up` RPC and awaits its result. A reject (the device
|
||||
* flow can outlive the RPC's timeout while the user is still authorizing) is
|
||||
* treated as "not yet granted" — non-fatal; the grant persists gateway-side.
|
||||
*
|
||||
* NOTE: never surface the raw `billing:manage` scope — the user-facing concept
|
||||
* is "Remote Spending".
|
||||
*/
|
||||
const requestRemoteSpending = (ctx: SlashRunCtx): Promise<boolean> =>
|
||||
ctx.gateway
|
||||
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
|
||||
.then(r => !!(r && r.ok && r.granted))
|
||||
.catch(() => false)
|
||||
|
||||
/** Poll a charge to a terminal state (settled/failed/timeout). Non-blocking. */
|
||||
const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: string | null): void => {
|
||||
const start = Date.now()
|
||||
|
||||
// The 5-min cap, honored on EVERY non-terminal path (pending AND throttled)
|
||||
// so a sustained 429/503 can't keep the poll alive forever.
|
||||
const timedOut = (): boolean => {
|
||||
if (Date.now() - start < POLL_CAP_MS) {
|
||||
return false
|
||||
}
|
||||
|
||||
sys(
|
||||
'🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + 'Check /topup or the portal shortly.'
|
||||
)
|
||||
|
||||
if (portalUrl) {
|
||||
sys(`Portal: ${portalUrl}`)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const tick = (): void => {
|
||||
if (ctx.stale()) {
|
||||
return
|
||||
|
|
@ -152,13 +199,27 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st
|
|||
ctx.guarded<BillingChargeStatusResponse>(r => {
|
||||
if (!r.ok) {
|
||||
// 429/503 while polling = retry-after, NOT a failure. Back off + continue.
|
||||
if (r.error === 'rate_limited') {
|
||||
if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable' || r.error === 'stripe_unavailable') {
|
||||
if (timedOut()) {
|
||||
return
|
||||
}
|
||||
|
||||
const wait = (r.retry_after ?? 5) * 1000
|
||||
setTimeout(tick, Math.min(wait, 30000))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// CF-7 rule 4: a post-revoke 403 (or session loss) while polling means
|
||||
// the prior charge's outcome is AMBIGUOUS — it may have settled. Do not
|
||||
// call it failed; surface the revoke + tell the user to verify balance.
|
||||
if (r.error === 'remote_spending_revoked' || r.error === 'session_revoked') {
|
||||
renderBillingError(sys, ctx, r)
|
||||
sys(UNCONFIRMED_CHARGE_MESSAGE)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sys(`🔴 Could not check the charge: ${r.message || r.error || 'error'}`)
|
||||
|
||||
return
|
||||
|
|
@ -177,23 +238,20 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st
|
|||
}
|
||||
|
||||
// pending → keep polling until the 5-min cap, then call it a timeout.
|
||||
if (Date.now() - start >= POLL_CAP_MS) {
|
||||
sys(
|
||||
'🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' +
|
||||
'Check /billing or the portal shortly.'
|
||||
)
|
||||
|
||||
if (portalUrl) {
|
||||
sys(`Portal: ${portalUrl}`)
|
||||
}
|
||||
|
||||
if (timedOut()) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(tick, POLL_INTERVAL_MS)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
.catch(e => {
|
||||
ctx.guardedErr(e)
|
||||
|
||||
if (!ctx.stale()) {
|
||||
sys(UNCONFIRMED_CHARGE_MESSAGE)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tick()
|
||||
|
|
@ -216,6 +274,11 @@ const renderChargeFailed = (sys: Sys, reason?: string | null, portalUrl?: string
|
|||
|
||||
break
|
||||
|
||||
case 'processing_error':
|
||||
sys("🔴 The charge didn't go through (processing_error).")
|
||||
|
||||
break
|
||||
|
||||
default:
|
||||
sys(`🔴 The charge didn't go through (${reason || 'processing_error'}).`)
|
||||
}
|
||||
|
|
@ -280,34 +343,60 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B
|
|||
|
||||
return false
|
||||
}),
|
||||
charge: (amount: string) => {
|
||||
charge: (amount: string, idempotencyKey?: string): Promise<BillingChargeOutcome> => {
|
||||
sys('💳 Charge submitted — confirming settlement…')
|
||||
ctx.gateway
|
||||
.rpc<BillingChargeResponse>('billing.charge', { amount_usd: amount })
|
||||
.then(
|
||||
ctx.guarded<BillingChargeResponse>(r => {
|
||||
if (r.ok && r.charge_id) {
|
||||
pollCharge(sys, ctx, r.charge_id, s.portal_url)
|
||||
} else {
|
||||
renderBillingError(sys, ctx, r)
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
|
||||
return ctx.gateway
|
||||
.rpc<BillingChargeResponse>('billing.charge', {
|
||||
amount_usd: amount,
|
||||
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {})
|
||||
})
|
||||
.then((r): BillingChargeOutcome => {
|
||||
if (!r) {
|
||||
return 'error'
|
||||
}
|
||||
|
||||
if (r.ok && r.charge_id) {
|
||||
pollCharge(sys, ctx, r.charge_id, s.portal_url)
|
||||
|
||||
return 'submitted'
|
||||
}
|
||||
|
||||
// insufficient_scope → the overlay routes to the resumable step-up
|
||||
// (no error line here; the stepup screen owns that UX).
|
||||
if (r.error === 'insufficient_scope') {
|
||||
return 'needs_remote_spending'
|
||||
}
|
||||
|
||||
renderBillingError(sys, ctx, r)
|
||||
|
||||
return 'error'
|
||||
})
|
||||
.catch((e): BillingChargeOutcome => {
|
||||
ctx.guardedErr(e)
|
||||
|
||||
return 'error'
|
||||
})
|
||||
},
|
||||
requestRemoteSpending: () => requestRemoteSpending(ctx),
|
||||
openPortal: (url: string) => {
|
||||
openExternalUrl(url)
|
||||
sys(`Opening portal: ${url}`)
|
||||
},
|
||||
refreshState: () =>
|
||||
ctx.gateway
|
||||
.rpc<BillingStateResponse>('billing.state', {})
|
||||
.then(r => (r?.ok ? r : null))
|
||||
.catch(() => null),
|
||||
sys,
|
||||
validate: (raw: string) => validateAmount(raw, s)
|
||||
})
|
||||
|
||||
export const billingCommands: SlashCommand[] = [
|
||||
export const topupCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'Manage Nous terminal billing — buy credits, auto-reload, limits',
|
||||
name: 'billing',
|
||||
// ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/billing`
|
||||
help: 'Show your balance and manage billing — add funds, auto-reload, limits',
|
||||
name: 'topup',
|
||||
// ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/topup`
|
||||
// fetches state and opens the interactive overlay (CLI/TUI parity).
|
||||
run: (_arg, ctx) => {
|
||||
const sys: Sys = ctx.transcript.sys
|
||||
|
|
@ -317,7 +406,7 @@ export const billingCommands: SlashCommand[] = [
|
|||
.then(
|
||||
ctx.guarded<BillingStateResponse>(s => {
|
||||
if (!s.logged_in) {
|
||||
sys('💳 Not logged into Nous Portal — run /portal to log in, then /billing.')
|
||||
sys('💳 Not logged into Nous Portal — run /portal to log in, then /topup.')
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -1,17 +1,17 @@
|
|||
import { billingCommands } from './commands/billing.js'
|
||||
import { coreCommands } from './commands/core.js'
|
||||
import { creditsCommands } from './commands/credits.js'
|
||||
import { debugCommands } from './commands/debug.js'
|
||||
import { opsCommands } from './commands/ops.js'
|
||||
import { sessionCommands } from './commands/session.js'
|
||||
import { setupCommands } from './commands/setup.js'
|
||||
import { subscriptionCommands } from './commands/subscription.js'
|
||||
import { topupCommands } from './commands/topup.js'
|
||||
import type { SlashCommand } from './types.js'
|
||||
|
||||
export const SLASH_COMMANDS: SlashCommand[] = [
|
||||
...coreCommands,
|
||||
...billingCommands,
|
||||
...creditsCommands,
|
||||
...topupCommands,
|
||||
...sessionCommands,
|
||||
...subscriptionCommands,
|
||||
...opsCommands,
|
||||
...setupCommands,
|
||||
...debugCommands
|
||||
|
|
|
|||
|
|
@ -195,6 +195,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
return patchOverlayState({ billing: null })
|
||||
}
|
||||
|
||||
if (overlay.subscription) {
|
||||
return patchOverlayState({ subscription: null })
|
||||
}
|
||||
|
||||
if (overlay.skillsHub) {
|
||||
return patchOverlayState({ skillsHub: false })
|
||||
}
|
||||
|
|
@ -324,7 +328,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
// answering felt like the prompt had locked the entire UI. Explicitly
|
||||
// skip the prompt-overlay early-return for scroll keys so they fall
|
||||
// through to the wheel / PageUp / Shift+arrow handlers below.
|
||||
const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm
|
||||
const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm || overlay.subscription
|
||||
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
|
||||
|
||||
if (promptOverlay && !fallThroughForScroll) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { PetPicker } from './petPicker.js'
|
|||
import { PluginsHub } from './pluginsHub.js'
|
||||
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
|
||||
import { SkillsHub } from './skillsHub.js'
|
||||
import { SubscriptionOverlay } from './subscriptionOverlay.js'
|
||||
|
||||
const COMPLETION_WINDOW = 16
|
||||
|
||||
|
|
@ -52,6 +53,21 @@ export function PromptZone({
|
|||
)
|
||||
}
|
||||
|
||||
if (overlay.subscription) {
|
||||
const current = overlay.subscription
|
||||
|
||||
const onPatch = (next: Partial<typeof current>) =>
|
||||
patchOverlayState(prev => (prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev))
|
||||
|
||||
const onClose = () => patchOverlayState({ subscription: null })
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
|
||||
<SubscriptionOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (overlay.confirm) {
|
||||
const req = overlay.confirm
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { Box, Text, useInput } from '@hermes/ink'
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import type { BillingOverlayState } from '../app/interfaces.js'
|
||||
import type { BillingStateResponse } from '../gatewayTypes.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js'
|
||||
import { TextInput } from './textInput.js'
|
||||
|
||||
const SPEND_BAR_CELLS = 10
|
||||
|
||||
interface BillingOverlayProps {
|
||||
/** Replace the overlay slot (screen transitions + pending data). */
|
||||
onPatch: (next: Partial<BillingOverlayState>) => void
|
||||
|
|
@ -18,54 +19,6 @@ interface BillingOverlayProps {
|
|||
t: Theme
|
||||
}
|
||||
|
||||
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */
|
||||
function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
|
||||
return (
|
||||
<Text>
|
||||
<Text bold={active} color={active ? t.color.label : t.color.muted} inverse={active}>
|
||||
{active ? '▸ ' : ' '}
|
||||
{index}. {label}
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */
|
||||
function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) {
|
||||
return (
|
||||
<Text>
|
||||
<Text color={active ? t.color.accent : t.color.muted}>{active ? '▸ ' : ' '}</Text>
|
||||
<Text bold={active} color={active ? (color ?? t.color.text) : t.color.muted}>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** 10-cell spend bar + percent (omit entirely when there's no usable cap). */
|
||||
function spendBar(s: BillingStateResponse): null | string {
|
||||
const cap = s.monthly_cap
|
||||
|
||||
if (!cap || cap.limit_usd == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const limit = Number(cap.limit_usd)
|
||||
const spent = Number(cap.spent_this_month_usd ?? '0')
|
||||
|
||||
if (!(limit > 0) || Number.isNaN(spent)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ratio = Math.max(0, Math.min(1, spent / limit))
|
||||
const filled = Math.round(ratio * SPEND_BAR_CELLS)
|
||||
const bar = '█'.repeat(filled) + '░'.repeat(SPEND_BAR_CELLS - filled)
|
||||
const pct = Math.round(ratio * 100)
|
||||
const ceiling = cap.is_default_ceiling ? ' (default ceiling)' : ''
|
||||
|
||||
return `${cap.spent_display} of ${cap.limit_display} used ${bar} ${pct}%${ceiling}`
|
||||
}
|
||||
|
||||
function autoReloadLine(s: BillingStateResponse): null | string {
|
||||
if (!s.auto_reload) {
|
||||
return null
|
||||
|
|
@ -76,8 +29,6 @@ function autoReloadLine(s: BillingStateResponse): null | string {
|
|||
: 'Auto-reload: off'
|
||||
}
|
||||
|
||||
const footer = (extra: string, t: Theme) => <Text color={t.color.muted}>{extra}</Text>
|
||||
|
||||
/**
|
||||
* The /billing modal. A self-contained state machine:
|
||||
* overview → buy | autoreload | limit (and buy → confirm).
|
||||
|
|
@ -96,14 +47,25 @@ export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayP
|
|||
<ConfirmScreen
|
||||
amount={overlay.pendingCharge?.amount ?? ''}
|
||||
ctx={ctx}
|
||||
idempotencyKey={overlay.pendingCharge?.idempotencyKey}
|
||||
onBack={() => onPatch({ pendingCharge: null, screen: 'buy' })}
|
||||
onClose={onClose}
|
||||
onPatch={onPatch}
|
||||
s={s}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{screen === 'autoreload' && <AutoReloadScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
|
||||
{screen === 'limit' && <LimitScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
|
||||
{screen === 'stepup' && (
|
||||
<StepUpScreen
|
||||
amount={overlay.pendingCharge?.amount ?? ''}
|
||||
ctx={ctx}
|
||||
idempotencyKey={overlay.pendingCharge?.idempotencyKey}
|
||||
onClose={onClose}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -119,27 +81,29 @@ interface ScreenProps {
|
|||
}
|
||||
|
||||
function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
||||
// Gate: full menu only for an admin with the kill-switch on. Otherwise the
|
||||
// menu collapses to Manage-on-portal / Cancel + a one-line note.
|
||||
// Full charge menu only for an admin with the org kill-switch on; otherwise it
|
||||
// collapses to Manage-on-portal / Close + a one-line note. NOTE: this is the
|
||||
// ORG-level gate (cli_billing_enabled), NOT the per-terminal billing scope —
|
||||
// that's discovered reactively at pay time (a charge 403s insufficient_scope
|
||||
// and the confirm screen routes into the resumable step-up). We deliberately
|
||||
// do NOT preflight the scope here.
|
||||
const full = s.is_admin && s.cli_billing_enabled
|
||||
|
||||
const note = !s.is_admin
|
||||
? 'Billing actions need an org admin/owner.'
|
||||
? 'Billing actions need someone with billing permissions (owner, admin, or finance admin).'
|
||||
: !s.cli_billing_enabled
|
||||
? 'Terminal billing is off for this org — enable it on the portal.'
|
||||
? 'Terminal billing is off for this org — manage it on the portal.'
|
||||
: null
|
||||
|
||||
// Optimistic funnel: admin + kill-switch on but no saved card → a charge will
|
||||
// 403 no_payment_method. Advise up front (Buy stays available — /state.card
|
||||
// can't fully prove CLI-chargeability, so we hint rather than hide).
|
||||
const cardHint = full && !s.card ? 'No saved card for terminal charges yet — set one up on the portal first.' : null
|
||||
|
||||
// Always show the full billing menu for an admin/billing-on org — a missing
|
||||
// card does NOT mean nothing can be done (the org may already have balance,
|
||||
// auto-reload, a limit). The card only matters at CHARGE time: with no card
|
||||
// on file, "Add funds" opens the guided add-card path (portal + check-again)
|
||||
// instead of an amount picker that would 403 no_payment_method.
|
||||
const items = full
|
||||
? ['Buy credits', 'Adjust auto-reload', 'Adjust monthly limit', 'Manage on portal', 'Cancel']
|
||||
? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel']
|
||||
: ['Manage on portal', 'Cancel']
|
||||
|
||||
const [sel, setSel] = useState(0)
|
||||
|
||||
const choose = (i: number) => {
|
||||
if (full) {
|
||||
if (i === 0) {
|
||||
|
|
@ -148,76 +112,59 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
onPatch({ screen: 'autoreload' })
|
||||
} else if (i === 2) {
|
||||
onPatch({ screen: 'limit' })
|
||||
} else if (i === 3) {
|
||||
if (s.portal_url) {
|
||||
} else {
|
||||
if (i === 3 && s.portal_url) {
|
||||
ctx.openPortal(s.portal_url)
|
||||
}
|
||||
|
||||
onClose()
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
} else {
|
||||
if (i === 0 && s.portal_url) {
|
||||
ctx.openPortal(s.portal_url)
|
||||
}
|
||||
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (i === 0 && s.portal_url) {
|
||||
ctx.openPortal(s.portal_url)
|
||||
}
|
||||
|
||||
onClose()
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.escape) {
|
||||
return onClose()
|
||||
}
|
||||
const rows: MenuRowSpec[] = items.map((label, i) => ({ label, run: () => choose(i) }))
|
||||
const sel = useMenu(rows, onClose)
|
||||
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(v => v - 1)
|
||||
}
|
||||
|
||||
if (key.downArrow && sel < items.length - 1) {
|
||||
setSel(v => v + 1)
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return choose(sel)
|
||||
}
|
||||
|
||||
const n = parseInt(ch, 10)
|
||||
|
||||
if (n >= 1 && n <= items.length) {
|
||||
return choose(n - 1)
|
||||
}
|
||||
})
|
||||
|
||||
const bar = spendBar(s)
|
||||
const auto = autoReloadLine(s)
|
||||
// Balance leads, in the title — the first thing seen (review feedback).
|
||||
const title = `Top up · balance ${s.balance_display}`
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Usage credits
|
||||
{title}
|
||||
</Text>
|
||||
{bar && <Text color={t.color.text}>{bar}</Text>}
|
||||
<Text color={t.color.text}>Balance: {s.balance_display}</Text>
|
||||
{auto && <Text color={t.color.muted}>{auto}</Text>}
|
||||
{s.org_name && (
|
||||
<Text color={t.color.muted}>
|
||||
Org: {s.org_name}
|
||||
{s.role ? ` · ${s.role}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
{/* The shared two-bar dollar usage (plan + top-up), same as /usage and
|
||||
/subscription. Renders nothing when no usage model is available. */}
|
||||
<UsageBars model={s.usage} t={t} />
|
||||
{auto && <Text color={t.color.muted}>{auto}</Text>}
|
||||
{/* Card presence at a glance: which card a charge would use (with why —
|
||||
"the card on your subscription"), or that none is saved. Only for the
|
||||
full menu — members/billing-off get the portal note instead. */}
|
||||
{full && (
|
||||
<Text color={t.color.muted}>
|
||||
{s.card ? `Card: ${s.card.display ?? s.card.masked}` : 'No saved card on file — “Add funds” walks you through adding one.'}
|
||||
</Text>
|
||||
)}
|
||||
{note && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.warn}>{note}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{cardHint && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.warn}>{cardHint}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{cardHint && s.portal_url && <Text color={t.color.muted}>Portal: {s.portal_url}</Text>}
|
||||
|
||||
<Text />
|
||||
{items.map((label, i) => (
|
||||
|
|
@ -235,17 +182,60 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
|
||||
const presets = s.charge_presets_display
|
||||
const rawPresets = s.charge_presets
|
||||
// rows: [...presets, 'Custom amount…', 'Cancel']
|
||||
const rows = [...presets, 'Custom amount…', 'Cancel']
|
||||
// No card on file → the buy screen becomes the ADD-CARD path: cards are added
|
||||
// on the portal (never in-terminal), and "check again" re-fetches state so the
|
||||
// flow continues right here once the card is saved. Card present → the normal
|
||||
// preset menu. (The card display is best-effort server-side, so "check again"
|
||||
// also recovers a transient miss.)
|
||||
const noCard = !s.card
|
||||
|
||||
const rows = noCard
|
||||
? ['Add a card on the portal', 'I’ve added it — check again', 'Back']
|
||||
: [...presets, 'Custom amount…', 'Cancel']
|
||||
|
||||
const customIdx = presets.length
|
||||
|
||||
const [sel, setSel] = useState(0)
|
||||
const [typing, setTyping] = useState(false)
|
||||
const [custom, setCustom] = useState('')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
const [checking, setChecking] = useState(false)
|
||||
// Synchronous guard: double-Enter on "check again" must not stack re-fetches.
|
||||
const checkingRef = useRef(false)
|
||||
|
||||
const recheck = () => {
|
||||
if (checkingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
checkingRef.current = true
|
||||
setChecking(true)
|
||||
void ctx.refreshState().then(fresh => {
|
||||
checkingRef.current = false
|
||||
setChecking(false)
|
||||
|
||||
if (!fresh) {
|
||||
return setError('Could not refresh billing state — try again in a moment.')
|
||||
}
|
||||
|
||||
setError(null)
|
||||
// Re-render with the fresh state: if the card is now on file, this same
|
||||
// screen flips into the preset menu and the purchase continues here.
|
||||
onPatch({ state: fresh })
|
||||
|
||||
if (fresh.card) {
|
||||
ctx.sys(`✓ Card found: ${fresh.card.display ?? fresh.card.masked} — pick an amount.`)
|
||||
} else {
|
||||
ctx.sys('Still no card on file — finish adding it on the portal, then check again.')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toConfirm = (amount: string) => {
|
||||
onPatch({ pendingCharge: { amount }, screen: 'confirm' })
|
||||
// Mint the idempotency key here (purchase identity = this amount). It rides
|
||||
// pendingCharge into Confirm AND the step-up replay, so a retried charge
|
||||
// dedups server-side; a fresh amount selection gets a fresh key.
|
||||
onPatch({ pendingCharge: { amount, idempotencyKey: randomUUID() }, screen: 'confirm' })
|
||||
}
|
||||
|
||||
const pickPreset = (i: number) => {
|
||||
|
|
@ -275,6 +265,25 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
|
|||
}
|
||||
|
||||
const choose = (i: number) => {
|
||||
if (noCard) {
|
||||
if (i === 0) {
|
||||
if (s.portal_url) {
|
||||
ctx.openPortal(s.portal_url)
|
||||
ctx.sys('Add a card on the billing page, then come back and pick “check again”.')
|
||||
} else {
|
||||
setError('Could not build the portal link — is your portal configured?')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (i === 1) {
|
||||
return recheck()
|
||||
}
|
||||
|
||||
return onPatch({ screen: 'overview' })
|
||||
}
|
||||
|
||||
if (i < presets.length) {
|
||||
pickPreset(i)
|
||||
} else if (i === customIdx) {
|
||||
|
|
@ -303,7 +312,7 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
|
|||
}
|
||||
|
||||
if (key.return) {
|
||||
return choose(sel)
|
||||
return choose(Math.min(sel, rows.length - 1))
|
||||
}
|
||||
|
||||
const n = parseInt(ch, 10)
|
||||
|
|
@ -313,13 +322,16 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
|
|||
}
|
||||
})
|
||||
|
||||
const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file'
|
||||
// sel can go stale when a refresh flips the row set (3 add-card rows ↔ N
|
||||
// preset rows) — clamp for render + Enter.
|
||||
const cSel = Math.min(sel, rows.length - 1)
|
||||
const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file'
|
||||
|
||||
if (typing) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Buy usage credits
|
||||
Add funds
|
||||
</Text>
|
||||
<Text color={t.color.muted}>{payLine}</Text>
|
||||
<Text />
|
||||
|
|
@ -335,15 +347,34 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
|
|||
)
|
||||
}
|
||||
|
||||
if (noCard) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Add funds
|
||||
</Text>
|
||||
<Text color={t.color.text}>No saved card on file.</Text>
|
||||
<Text color={t.color.muted}>Add a card once on the portal billing page — after that you can top up right from the terminal.</Text>
|
||||
<Text />
|
||||
{rows.map((label, i) => (
|
||||
<MenuRow active={cSel === i} index={i + 1} key={label} label={label} t={t} />
|
||||
))}
|
||||
{error && <Text color={t.color.error}>{error}</Text>}
|
||||
<Text />
|
||||
{footer(checking ? 'Checking for a card…' : `↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`, t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Buy usage credits
|
||||
Add funds
|
||||
</Text>
|
||||
<Text color={t.color.muted}>{payLine}</Text>
|
||||
<Text />
|
||||
{rows.map((label, i) => (
|
||||
<MenuRow active={sel === i} index={i + 1} key={label} label={label} t={t} />
|
||||
<MenuRow active={cSel === i} index={i + 1} key={label} label={label} t={t} />
|
||||
))}
|
||||
{error && <Text color={t.color.error}>{error}</Text>}
|
||||
<Text />
|
||||
|
|
@ -357,25 +388,50 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
|
|||
function ConfirmScreen({
|
||||
amount,
|
||||
ctx,
|
||||
idempotencyKey,
|
||||
onBack,
|
||||
onClose,
|
||||
onPatch,
|
||||
s,
|
||||
t
|
||||
}: {
|
||||
amount: string
|
||||
ctx: BillingOverlayState['ctx']
|
||||
idempotencyKey?: string
|
||||
onBack: () => void
|
||||
onClose: () => void
|
||||
onPatch: (next: Partial<BillingOverlayState>) => void
|
||||
s: BillingStateResponse
|
||||
t: Theme
|
||||
}) {
|
||||
// rows: Pay $X now / Cancel
|
||||
const [sel, setSel] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// Synchronous guard: two key events can both observe `submitting === false`
|
||||
// before React commits the state update, double-firing the charge (and the
|
||||
// gateway mints a fresh idempotency key per call → two charges).
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
const pay = () => {
|
||||
ctx.charge(amount)
|
||||
// Settlement is reported via transcript lines; close the overlay now.
|
||||
onClose()
|
||||
if (submittingRef.current || submitting) {
|
||||
return
|
||||
}
|
||||
|
||||
submittingRef.current = true
|
||||
setSubmitting(true)
|
||||
void ctx.charge(amount, idempotencyKey).then(outcome => {
|
||||
if (outcome === 'needs_remote_spending') {
|
||||
// Resumable step-up: keep the modal MOUNTED, switch to the stepup
|
||||
// screen (which holds pendingCharge.amount for the post-grant replay).
|
||||
onPatch({ screen: 'stepup' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// submitted (settlement reported via transcript) or error (already
|
||||
// surfaced) → close the overlay. The transcript carries the outcome.
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
const back = () => onBack()
|
||||
|
|
@ -408,7 +464,7 @@ function ConfirmScreen({
|
|||
}
|
||||
})
|
||||
|
||||
const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file'
|
||||
const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file'
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
|
@ -417,6 +473,9 @@ function ConfirmScreen({
|
|||
</Text>
|
||||
<Text color={t.color.text}>Total: ${amount}</Text>
|
||||
<Text color={t.color.muted}>{payLine}</Text>
|
||||
{/* Provenance-less payloads (older NAS) keep the generic line; when the
|
||||
resolver says WHY this card, payLine already carries it. */}
|
||||
{s.card && !s.card.resolved_via && <Text color={t.color.muted}>Your card saved on the portal will be charged.</Text>}
|
||||
<Text color={t.color.muted}>By confirming, you allow Nous Research to charge your card.</Text>
|
||||
<Text />
|
||||
<ActionRow active={sel === 0} color={t.color.ok} label={`Pay $${amount} now`} t={t} />
|
||||
|
|
@ -427,11 +486,201 @@ function ConfirmScreen({
|
|||
)
|
||||
}
|
||||
|
||||
// ── Screen: Step-up (resumable "Enable terminal billing") ────────────
|
||||
// Reached ONLY when a charge returns insufficient_scope — there is no preflight
|
||||
// or scope check anywhere; the buy path discovers it reactively. The modal stays
|
||||
// MOUNTED through the browser device-flow:
|
||||
// prompt (heads-up) → waiting (browser authorize) → granted (press Enter to
|
||||
// resume) → replay the held charge (pendingCharge.amount) → settle → close.
|
||||
// Never leaks the raw billing:manage scope — the user-facing concept is
|
||||
// "terminal billing".
|
||||
|
||||
function StepUpScreen({
|
||||
amount,
|
||||
ctx,
|
||||
idempotencyKey,
|
||||
onClose,
|
||||
t
|
||||
}: {
|
||||
amount: string
|
||||
ctx: BillingOverlayState['ctx']
|
||||
idempotencyKey?: string
|
||||
onClose: () => void
|
||||
t: Theme
|
||||
}) {
|
||||
const [sel, setSel] = useState(0)
|
||||
const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt')
|
||||
|
||||
const allow = () => {
|
||||
if (phase !== 'prompt') {
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('waiting')
|
||||
ctx.sys('Opening your browser to enable terminal billing…')
|
||||
|
||||
void ctx.requestRemoteSpending().then(granted => {
|
||||
if (!granted) {
|
||||
ctx.sys(
|
||||
"! Couldn't enable terminal billing — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged."
|
||||
)
|
||||
onClose()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Granted → hold here and wait for an explicit Enter to resume the held
|
||||
// purchase (the reassuring "you're back, press Enter" beat).
|
||||
setPhase('granted')
|
||||
})
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
if (phase !== 'granted') {
|
||||
return
|
||||
}
|
||||
|
||||
setPhase('resuming')
|
||||
ctx.sys('✓ Terminal billing enabled — resuming your purchase.')
|
||||
void ctx.charge(amount, idempotencyKey).then(outcome => {
|
||||
// If the replay STILL can't spend (grant raced/expired or downscoped),
|
||||
// say so — don't close on a reassuring line with no charge made.
|
||||
if (outcome === 'needs_remote_spending') {
|
||||
ctx.sys('! Terminal billing still needs approval — run /topup to try again. Your card was not charged.')
|
||||
}
|
||||
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
const decline = () => {
|
||||
ctx.sys('No charge made. Run /topup when you want to enable terminal billing.')
|
||||
onClose()
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (phase === 'waiting' || phase === 'resuming') {
|
||||
// While the device flow / replay runs, only Esc (give up) is live.
|
||||
if (key.escape) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'granted') {
|
||||
// Back from the browser — Enter resumes, Esc abandons.
|
||||
if (key.escape) {
|
||||
return onClose()
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return resume()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// phase === 'prompt'
|
||||
if (key.escape) {
|
||||
return decline()
|
||||
}
|
||||
|
||||
const lower = ch.toLowerCase()
|
||||
|
||||
if (lower === 'y') {
|
||||
return allow()
|
||||
}
|
||||
|
||||
if (lower === 'n') {
|
||||
return decline()
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSel(0)
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
setSel(1)
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return sel === 0 ? allow() : decline()
|
||||
}
|
||||
})
|
||||
|
||||
if (phase === 'waiting') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Enable terminal billing
|
||||
</Text>
|
||||
<Text color={t.color.warn}>Waiting for your browser…</Text>
|
||||
<Text color={t.color.muted}>Approve in the page that just opened.</Text>
|
||||
<Text color={t.color.muted}>Your ${amount} top-up is held here and resumes when you're done.</Text>
|
||||
<Text />
|
||||
{footer('Esc cancel', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase === 'granted') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.ok}>
|
||||
Terminal billing enabled
|
||||
</Text>
|
||||
<Text color={t.color.text}>Your ${amount} top-up is ready to finish.</Text>
|
||||
<Text />
|
||||
<ActionRow active color={t.color.ok} label="Press Enter to resume" t={t} />
|
||||
<Text />
|
||||
{footer('Enter resume · Esc cancel', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (phase === 'resuming') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Enable terminal billing
|
||||
</Text>
|
||||
<Text color={t.color.muted}>Resuming your ${amount} top-up…</Text>
|
||||
<Text />
|
||||
{footer('Esc cancel', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// phase === 'prompt' — the one heads-up, triggered only by the 403.
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.warn}>
|
||||
One-time setup
|
||||
</Text>
|
||||
<Text color={t.color.text}>To charge this terminal, enable terminal billing once.</Text>
|
||||
<Text color={t.color.muted}>
|
||||
It opens your browser to authorize, then your ${amount} top-up picks up right here.
|
||||
</Text>
|
||||
<Text />
|
||||
<ActionRow active={sel === 0} color={t.color.ok} label="Enable terminal billing" t={t} />
|
||||
<ActionRow active={sel === 1} label="Not now" t={t} />
|
||||
<Text />
|
||||
{footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Screen 4: Auto-reload (the 2-field form) ──────────────────────────
|
||||
|
||||
function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
||||
const ar = s.auto_reload
|
||||
const enabled = Boolean(ar?.enabled)
|
||||
const distinctCard = ar?.card.kind === 'distinct' ? ar.card : null
|
||||
const distinctCardName = distinctCard
|
||||
? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') || 'a different card'
|
||||
: null
|
||||
const manageCardLabel = 'Use your card on file — manage on portal'
|
||||
|
||||
// Prefill from state (strip the $ from the *_usd raw fields if present).
|
||||
const prefill = (raw?: null | string) => (raw == null ? '' : String(raw).replace(/^\$/, '').trim())
|
||||
|
|
@ -440,7 +689,15 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
const [field, setField] = useState<'reloadTo' | 'threshold'>('threshold')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
// focusRow: 0=threshold field, 1=reloadTo field, 2=Agree, 3=Turn off (if enabled), last=Cancel
|
||||
const actionRows = enabled ? ['Agree and turn on', 'Turn off', 'Cancel'] : ['Agree and turn on', 'Cancel']
|
||||
const manageCardRows = distinctCard && s.portal_url ? [manageCardLabel] : []
|
||||
const actionRows = enabled
|
||||
? ['Agree and turn on', 'Turn off', ...manageCardRows, 'Cancel']
|
||||
: ['Agree and turn on', ...manageCardRows, 'Cancel']
|
||||
const actionColors: Record<string, string> = {
|
||||
'Agree and turn on': t.color.ok,
|
||||
'Turn off': t.color.warn,
|
||||
[manageCardLabel]: t.color.accent
|
||||
}
|
||||
const FIELD_ROWS = 2
|
||||
const [row, setRow] = useState(0)
|
||||
|
||||
|
|
@ -476,7 +733,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
|
||||
const turnOn = () => {
|
||||
if (noCard) {
|
||||
ctx.sys('🔴 No saved card — set one up on the portal first.')
|
||||
ctx.sys('🔴 No saved card — manage billing on the portal.')
|
||||
|
||||
if (s.portal_url) {
|
||||
ctx.openPortal(s.portal_url)
|
||||
|
|
@ -502,7 +759,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
}
|
||||
|
||||
const turnOff = () => {
|
||||
void ctx.applyAutoReload(false).then(ok => {
|
||||
// The PATCH requires threshold/top_up_amount even when disabling (parity
|
||||
// with the CLI's _billing_auto_reload_disable) — echo the current values,
|
||||
// else the gateway rejects with invalid_request and auto-reload stays ON.
|
||||
const thr = Number(prefill(ar?.threshold_usd)) || 0
|
||||
const rel = Number(prefill(ar?.reload_to_usd)) || 0
|
||||
void ctx.applyAutoReload(false, thr, rel).then(ok => {
|
||||
if (ok) {
|
||||
ctx.sys('✅ Auto-reload turned off.')
|
||||
}
|
||||
|
|
@ -515,6 +777,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
turnOn()
|
||||
} else if (label === 'Turn off') {
|
||||
turnOff()
|
||||
} else if (label === manageCardLabel) {
|
||||
if (s.portal_url) {
|
||||
ctx.openPortal(s.portal_url)
|
||||
}
|
||||
|
||||
onClose()
|
||||
} else {
|
||||
onPatch({ screen: 'overview' })
|
||||
}
|
||||
|
|
@ -561,6 +829,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
})
|
||||
|
||||
const cardLine = s.card ? `Card on file: ${s.card.masked}` : 'No saved card on file'
|
||||
const chargeCardName = distinctCardName ?? (s.card ? s.card.masked : 'your card')
|
||||
|
||||
const fieldBox = (label: string, value: string, onChange: (v: string) => void, focused: boolean, key: string) => (
|
||||
<Box flexDirection="column" key={key}>
|
||||
|
|
@ -592,22 +861,25 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
<Text bold color={t.color.accent}>
|
||||
Auto-reload
|
||||
</Text>
|
||||
<Text color={t.color.muted}>Automatically buy more credits when your balance is low.</Text>
|
||||
<Text color={t.color.muted}>Automatically add funds when your balance is low.</Text>
|
||||
<Text color={t.color.muted}>{cardLine}</Text>
|
||||
{distinctCardName && (
|
||||
<Text color={t.color.warn}>⚠ Auto-refill is charging {distinctCardName} — not your card on file.</Text>
|
||||
)}
|
||||
<Text />
|
||||
{fieldBox('When balance falls below:', threshold, setThreshold, row === 0, 'threshold')}
|
||||
{fieldBox('Reload balance to:', reloadTo, setReloadTo, row === 1, 'reloadTo')}
|
||||
<Text />
|
||||
<Text color={t.color.muted}>
|
||||
By confirming, you authorize Nous Research to charge {s.card ? s.card.masked : 'your card'} whenever your
|
||||
balance falls below the threshold. Turn off any time here or on the portal.
|
||||
By confirming, you authorize Nous Research to charge {chargeCardName} whenever your balance falls below the
|
||||
threshold. Turn off any time here or on the portal.
|
||||
</Text>
|
||||
{error && <Text color={t.color.error}>{error}</Text>}
|
||||
<Text />
|
||||
{actionRows.map((label, i) => (
|
||||
<ActionRow
|
||||
active={!editingField && row - FIELD_ROWS === i}
|
||||
color={label === 'Turn off' ? t.color.warn : label === 'Agree and turn on' ? t.color.ok : t.color.text}
|
||||
color={actionColors[label] ?? t.color.text}
|
||||
key={label}
|
||||
label={label}
|
||||
t={t}
|
||||
|
|
@ -622,8 +894,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
// ── Screen 5: Monthly spend limit (read-only) ─────────────────────────
|
||||
|
||||
function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
||||
const rows = ['Manage on portal', 'Cancel']
|
||||
const [sel, setSel] = useState(0)
|
||||
const labels = ['Manage on portal', 'Cancel']
|
||||
|
||||
const choose = (i: number) => {
|
||||
if (i === 0 && s.portal_url) {
|
||||
|
|
@ -635,29 +906,8 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
onPatch({ screen: 'overview' })
|
||||
}
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (key.escape) {
|
||||
return onPatch({ screen: 'overview' })
|
||||
}
|
||||
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(v => v - 1)
|
||||
}
|
||||
|
||||
if (key.downArrow && sel < rows.length - 1) {
|
||||
setSel(v => v + 1)
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return choose(sel)
|
||||
}
|
||||
|
||||
const n = parseInt(ch, 10)
|
||||
|
||||
if (n >= 1 && n <= rows.length) {
|
||||
return choose(n - 1)
|
||||
}
|
||||
})
|
||||
const rows: MenuRowSpec[] = labels.map((label, i) => ({ label, run: () => choose(i) }))
|
||||
const sel = useMenu(rows, () => onPatch({ screen: 'overview' }))
|
||||
|
||||
const cap = s.monthly_cap
|
||||
|
||||
|
|
@ -674,11 +924,11 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
|
|||
<Text color={t.color.text}>{usageLine}</Text>
|
||||
<Text color={t.color.muted}>The monthly limit is set on the portal — shown here read-only.</Text>
|
||||
<Text />
|
||||
{rows.map((label, i) => (
|
||||
{labels.map((label, i) => (
|
||||
<MenuRow active={sel === i} index={i + 1} key={label} label={label} t={t} />
|
||||
))}
|
||||
<Text />
|
||||
{footer(`↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`, t)}
|
||||
{footer(`↑/↓ select · 1-${labels.length} quick pick · Enter confirm · Esc back`, t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
186
ui-tui/src/components/overlayPrimitives.tsx
Normal file
186
ui-tui/src/components/overlayPrimitives.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import type { Key } from '@hermes/ink'
|
||||
import { Text, useInput } from '@hermes/ink'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import type { UsageModelData } from '../gatewayTypes.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
export interface MenuRowSpec {
|
||||
color?: string
|
||||
label: string
|
||||
run: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* ↑/↓ + Enter + number-key selection over `rows`; Esc runs `onEscape`.
|
||||
* `onKey`, when given, runs first on every keypress — return `true` to mark
|
||||
* the key fully handled and skip the default escape/arrow/enter/number
|
||||
* handling for that keypress (e.g. a screen with a text-input sub-mode).
|
||||
*/
|
||||
export function useMenu(
|
||||
rows: MenuRowSpec[],
|
||||
onEscape: () => void,
|
||||
onKey?: (ch: string, key: Key) => boolean
|
||||
): number {
|
||||
const [sel, setSel] = useState(0)
|
||||
|
||||
useInput((ch, key) => {
|
||||
if (onKey?.(ch, key)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
return onEscape()
|
||||
}
|
||||
|
||||
if (key.upArrow && sel > 0) {
|
||||
setSel(v => v - 1)
|
||||
}
|
||||
|
||||
if (key.downArrow && sel < rows.length - 1) {
|
||||
setSel(v => v + 1)
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return rows[sel]?.run()
|
||||
}
|
||||
|
||||
const n = parseInt(ch, 10)
|
||||
|
||||
if (n >= 1 && n <= rows.length) {
|
||||
return rows[n - 1]?.run()
|
||||
}
|
||||
})
|
||||
|
||||
return Math.min(sel, Math.max(0, rows.length - 1))
|
||||
}
|
||||
|
||||
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */
|
||||
export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
|
||||
return (
|
||||
<Text>
|
||||
<Text bold={active} color={active ? t.color.label : t.color.muted} inverse={active}>
|
||||
{active ? '▸ ' : ' '}
|
||||
{index}. {label}
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */
|
||||
export function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) {
|
||||
return (
|
||||
<Text>
|
||||
<Text color={active ? t.color.accent : t.color.muted}>{active ? '▸ ' : ' '}</Text>
|
||||
<Text bold={active} color={active ? (color ?? t.color.text) : t.color.muted}>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export const BAR_CELLS = 10
|
||||
|
||||
/** ratio in [0,1] -> { bar: '█…░…', pct: 0-100 } using `cells` cells. */
|
||||
export function barCells(ratio: number, cells: number = BAR_CELLS): { bar: string; pct: number } {
|
||||
const r = Math.max(0, Math.min(1, ratio))
|
||||
|
||||
const filled = Math.round(r * cells)
|
||||
|
||||
return { bar: '█'.repeat(filled) + '░'.repeat(cells - filled), pct: Math.round(r * 100) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-bar dollar usage view (decided with the user over a crammed three-segment
|
||||
* bar: at terminal widths a single fill glyph per full-resolution bar is the
|
||||
* only legible option). The plan bar is labeled with the plan name and shows
|
||||
* the allowance detail + % used; the top-up bar shows purchased dollars (no
|
||||
* denominator, renders full = balance, rolls over). Dollars only — never
|
||||
* "credits". Each row:
|
||||
* `Plus [██████░░░░] $14.00 of $20.00 · 30% used`
|
||||
* Renders nothing for a free account (no bars to draw — caller shows upsell).
|
||||
*/
|
||||
export function UsageBars({ model, t }: { model: undefined | UsageModelData; t: Theme }) {
|
||||
if (!model || !model.available) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rows: ReactNode[] = []
|
||||
// Label the plan bar with the plan name (padded for column alignment with the
|
||||
// top-up row). Falls back to 'plan' when the name is absent.
|
||||
const planLabel = (model.plan_name || 'plan').padEnd(8).slice(0, 8)
|
||||
|
||||
if (model.plan_bar) {
|
||||
const b = model.plan_bar
|
||||
const { bar } = barCells(b.fill_fraction)
|
||||
const pct = b.pct_used == null ? '' : ` · ${b.pct_used}% used`
|
||||
|
||||
rows.push(
|
||||
<Text color={t.color.text} key="plan">
|
||||
{planLabel}
|
||||
<Text color={t.color.muted}>[</Text>
|
||||
<Text color={t.color.accent}>{bar}</Text>
|
||||
<Text color={t.color.muted}>]</Text>
|
||||
{` ${b.remaining_display} left of ${b.total_display}${pct}`}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (model.topup_bar) {
|
||||
const b = model.topup_bar
|
||||
const { bar } = barCells(1)
|
||||
|
||||
rows.push(
|
||||
<Text color={t.color.text} key="topup">
|
||||
{'top-up '}
|
||||
<Text color={t.color.muted}>[</Text>
|
||||
<Text color={t.color.ok}>{bar}</Text>
|
||||
<Text color={t.color.muted}>]</Text>
|
||||
{` ${b.remaining_display} · never expires`}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{rows}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text version of the two-bar usage view, for text-only surfaces (the
|
||||
* /usage transcript panel). Returns one string per line: a plan bar, a top-up
|
||||
* bar, and a total-spendable summary, whichever apply. Dollars only.
|
||||
*/
|
||||
export function usageBarsText(model: undefined | UsageModelData): string[] {
|
||||
if (!model || !model.available) {
|
||||
return []
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
const planLabel = (model.plan_name || 'plan').padEnd(8).slice(0, 8)
|
||||
|
||||
if (model.plan_bar) {
|
||||
const b = model.plan_bar
|
||||
const { bar } = barCells(b.fill_fraction)
|
||||
const pct = b.pct_used == null ? '' : ` · ${b.pct_used}% used`
|
||||
|
||||
lines.push(`${planLabel}[${bar}] ${b.remaining_display} left of ${b.total_display}${pct}`)
|
||||
}
|
||||
|
||||
if (model.topup_bar) {
|
||||
const b = model.topup_bar
|
||||
const { bar } = barCells(1)
|
||||
|
||||
lines.push(`top-up [${bar}] ${b.remaining_display} · never expires`)
|
||||
}
|
||||
|
||||
if (model.total_spendable_display && model.has_topup) {
|
||||
lines.push(`Total spendable: ${model.total_spendable_display}`)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
export const footer = (extra: string, t: Theme) => <Text color={t.color.muted}>{extra}</Text>
|
||||
930
ui-tui/src/components/subscriptionOverlay.tsx
Normal file
930
ui-tui/src/components/subscriptionOverlay.tsx
Normal file
|
|
@ -0,0 +1,930 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { Box, Text, useInput } from '@hermes/ink'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type {
|
||||
SubscriptionOverlayState,
|
||||
SubscriptionPendingChange,
|
||||
SubscriptionResult,
|
||||
SubscriptionStepUpRetry
|
||||
} from '../app/interfaces.js'
|
||||
import type {
|
||||
SubscriptionStateResponse,
|
||||
SubscriptionTierOption,
|
||||
SubscriptionUpgradeResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js'
|
||||
|
||||
const UPGRADE_CONFIRM_INTERVAL_MS = 2000
|
||||
const UPGRADE_CONFIRM_ATTEMPTS = 15
|
||||
|
||||
interface SubscriptionOverlayProps {
|
||||
/** Close the overlay entirely. */
|
||||
onClose: () => void
|
||||
/** Merge a partial into the overlay state (screen transitions + pending/result). */
|
||||
onPatch: (next: Partial<SubscriptionOverlayState>) => void
|
||||
overlay: SubscriptionOverlayState
|
||||
t: Theme
|
||||
}
|
||||
|
||||
/**
|
||||
* The /subscription modal — an in-terminal plan-change flow (V3). A small state
|
||||
* machine: overview → picker → confirm → result, with a stepup screen spliced in
|
||||
* when a mutation needs terminal billing. Downgrades / cancellations / resume are
|
||||
* chargeless; an upgrade charges the card on the subscription, and an SCA/decline
|
||||
* is handed off to the portal. Starting a NEW subscription still deep-links (needs
|
||||
* a fresh card). All RPCs live in subscription.ts, reached via `overlay.ctx`.
|
||||
*/
|
||||
export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) {
|
||||
const { screen, state: s } = overlay
|
||||
|
||||
// Teams have no personal subscription — dead-end to /topup, no picker.
|
||||
if (s.context === 'team') {
|
||||
return (
|
||||
<Box borderColor={t.color.accent} borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
<TeamContextScreen onClose={onClose} s={s} t={t} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor={t.color.accent} borderStyle="round" flexDirection="column" paddingX={1}>
|
||||
{screen === 'picker' && <PickerScreen onClose={onClose} onPatch={onPatch} overlay={overlay} t={t} />}
|
||||
{screen === 'confirm' && <ConfirmScreen onClose={onClose} onPatch={onPatch} overlay={overlay} t={t} />}
|
||||
{screen === 'result' && <ResultScreen onClose={onClose} overlay={overlay} t={t} />}
|
||||
{screen === 'stepup' && <StepUpScreen onClose={onClose} onPatch={onPatch} overlay={overlay} t={t} />}
|
||||
{screen === 'overview' && <OverviewScreen onClose={onClose} onPatch={onPatch} overlay={overlay} t={t} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared helpers ───────────────────────────────────────────────────
|
||||
|
||||
interface ScreenProps {
|
||||
onClose: () => void
|
||||
onPatch: (next: Partial<SubscriptionOverlayState>) => void
|
||||
overlay: SubscriptionOverlayState
|
||||
t: Theme
|
||||
}
|
||||
|
||||
/** ISO datetime → YYYY-MM-DD for display, or a soft fallback. */
|
||||
function shortDate(iso?: null | string): string {
|
||||
return iso && iso.length >= 10 ? iso.slice(0, 10) : 'the end of the billing period'
|
||||
}
|
||||
|
||||
/** Integer cents → "$X.YY", or null when no amount is quoted. */
|
||||
function centsDisplay(cents?: null | number): null | string {
|
||||
return typeof cents === 'number' ? `$${(cents / 100).toFixed(2)}` : null
|
||||
}
|
||||
|
||||
/** True when a response is the insufficient_scope denial (route to step-up). */
|
||||
function isScopeDenial(r: { error?: string; ok?: boolean } | null): boolean {
|
||||
return !!r && !r.ok && r.error === 'insufficient_scope'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a failed RPC envelope to a result. (insufficient_scope is intercepted
|
||||
* earlier and routed to the step-up screen, so it should not reach here.)
|
||||
*/
|
||||
function errorResult(r: { error?: string; message?: string; portal_url?: null | string } | null): SubscriptionResult {
|
||||
return {
|
||||
message: r?.message || r?.error || 'Something went wrong. Try again, or manage on the portal.',
|
||||
ok: false,
|
||||
recoveryUrl: r?.portal_url ?? null
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a chargeless pending-change mutation (schedule / cancel / resume). */
|
||||
function mutationResult(r: null | { message?: string; ok?: boolean }, okMessage: string): SubscriptionResult {
|
||||
return r?.ok ? { message: r.message || okMessage, ok: true } : errorResult(r)
|
||||
}
|
||||
|
||||
/** Map an upgrade response, routing SCA / decline to a portal recovery. */
|
||||
function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: null | string): SubscriptionResult {
|
||||
if (!r) {
|
||||
// null = a transport failure (WS drop / request timeout) on the CHARGING
|
||||
// route — NAS may have already prorated + charged. Report it as ambiguous and
|
||||
// steer to a safe re-check, never a blind retry (which #2's dedup can't cover
|
||||
// once the key is lost).
|
||||
return {
|
||||
message:
|
||||
'Couldn’t confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.',
|
||||
ok: false
|
||||
}
|
||||
}
|
||||
|
||||
if (r.reason === 'authentication_required' || r.reason === 'subscription_payment_intent_requires_action') {
|
||||
return {
|
||||
message: 'Please verify your card in the portal to finish this upgrade.',
|
||||
ok: false,
|
||||
recoveryUrl: r.recovery_url ?? null
|
||||
}
|
||||
}
|
||||
|
||||
if (r.reason === 'card_declined') {
|
||||
return {
|
||||
message: 'Your card was declined — try a different card on the portal.',
|
||||
ok: false,
|
||||
recoveryUrl: r.recovery_url ?? null
|
||||
}
|
||||
}
|
||||
|
||||
if (r.ok && r.status === 'already_on_tier') {
|
||||
return {
|
||||
message: `You are already on ${r.target_tier_name ?? 'this plan'}.`,
|
||||
ok: true
|
||||
}
|
||||
}
|
||||
|
||||
if (r.ok && r.status === 'upgraded') {
|
||||
return {
|
||||
message: `Upgraded to ${r.target_tier_name ?? 'your new plan'}. Your new monthly credits land in a moment.`,
|
||||
ok: true,
|
||||
pendingTierId: pendingTierId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
if (r.status === 'requires_action') {
|
||||
return {
|
||||
message: 'This upgrade needs extra verification (3DS). Finish it on the portal.',
|
||||
ok: false,
|
||||
recoveryUrl: r.recovery_url ?? null
|
||||
}
|
||||
}
|
||||
|
||||
if (r.status === 'payment_failed') {
|
||||
return {
|
||||
message: 'Your card was declined. Update your payment method on the portal and try again.',
|
||||
ok: false,
|
||||
recoveryUrl: r.recovery_url ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return errorResult(r)
|
||||
}
|
||||
|
||||
/** Map a failed terminal-billing step-up to the right recovery copy (typed). */
|
||||
function stepUpDenialResult(res: { error?: string; message?: string }): SubscriptionResult {
|
||||
if (res.error === 'session_revoked') {
|
||||
return { message: 'Your session expired — run /portal to log in again, then retry the change.', ok: false }
|
||||
}
|
||||
|
||||
if (res.error === 'remote_spending_revoked') {
|
||||
return { message: res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.', ok: false }
|
||||
}
|
||||
|
||||
if (res.error === 'rate_limited') {
|
||||
return { message: 'Too many attempts — wait a moment, then try again.', ok: false }
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
res.message || 'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.',
|
||||
ok: false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scope-aware routing (shared by the picker, confirm, overview + step-up) ──
|
||||
|
||||
// A REPEAT scope denial during a post-grant replay must NOT route back to the
|
||||
// stepup screen: we're already mounted there (in the 'resuming' phase), so an
|
||||
// onPatch({screen:'stepup'}) is a no-op that never remounts → the screen freezes.
|
||||
// Post-grant replays pass allowStepUp=false and surface this instead (mirrors the
|
||||
// CLI's allow_stepup=False cap).
|
||||
const scopeStillDeniedResult: SubscriptionResult = {
|
||||
message: 'Terminal billing still isn’t enabled for this org — enable it on the portal, then retry.',
|
||||
ok: false
|
||||
}
|
||||
|
||||
/** Preview a tier and route: confirm (ok), stepup (scope), or result (other error). */
|
||||
function previewAndRoute(
|
||||
ctx: SubscriptionOverlayState['ctx'],
|
||||
tierId: string,
|
||||
onPatch: ScreenProps['onPatch'],
|
||||
allowStepUp = true
|
||||
): Promise<void> {
|
||||
return ctx.preview(tierId).then(p => {
|
||||
if (!p) {
|
||||
return onPatch({ result: { message: 'Could not preview that change.', ok: false }, screen: 'result' })
|
||||
}
|
||||
|
||||
if (!p.ok) {
|
||||
if (isScopeDenial(p)) {
|
||||
return allowStepUp
|
||||
? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'preview', tierId } })
|
||||
: onPatch({ result: scopeStillDeniedResult, screen: 'result' })
|
||||
}
|
||||
|
||||
return onPatch({ result: errorResult(p), screen: 'result' })
|
||||
}
|
||||
|
||||
// charge_now ⇒ an upgrade (charges now); everything else schedules at period
|
||||
// end. blocked/no_op still go to confirm, which shows why + no apply.
|
||||
const kind = p.effect === 'charge_now' ? 'upgrade' : 'tier_change'
|
||||
|
||||
// Mint the upgrade idempotency key HERE so it rides `pending` into confirm AND
|
||||
// the step-up replay — a re-submit / post-grant replay dedups server-side
|
||||
// (mirrors billingOverlay's pendingCharge.idempotencyKey).
|
||||
const pending: SubscriptionPendingChange =
|
||||
kind === 'upgrade'
|
||||
? { idempotencyKey: randomUUID(), kind, preview: p, targetTierId: tierId }
|
||||
: { kind, preview: p, targetTierId: tierId }
|
||||
|
||||
onPatch({ pending, screen: 'confirm' })
|
||||
})
|
||||
}
|
||||
|
||||
/** Apply the confirmed pending change and route: result (ok/err) or stepup (scope). */
|
||||
function applyPendingAndRoute(
|
||||
ctx: SubscriptionOverlayState['ctx'],
|
||||
pending: null | SubscriptionPendingChange,
|
||||
onPatch: ScreenProps['onPatch'],
|
||||
allowStepUp = true
|
||||
): Promise<void> {
|
||||
if (!pending) {
|
||||
// Nothing to apply (defensive) — return to the overview rather than stranding.
|
||||
onPatch({ screen: 'overview' })
|
||||
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const toStepUp = () =>
|
||||
allowStepUp
|
||||
? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'apply' } })
|
||||
: onPatch({ result: scopeStillDeniedResult, screen: 'result' })
|
||||
|
||||
const finish = (result: SubscriptionResult) => onPatch({ result, screen: 'result' })
|
||||
|
||||
if (pending.kind === 'cancellation') {
|
||||
return ctx.scheduleCancellation().then(r =>
|
||||
isScopeDenial(r)
|
||||
? toStepUp()
|
||||
: finish(mutationResult(r, 'Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.'))
|
||||
)
|
||||
}
|
||||
|
||||
if (pending.kind === 'upgrade') {
|
||||
return ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r =>
|
||||
isScopeDenial(r) ? toStepUp() : finish(upgradeResult(r, pending.targetTierId))
|
||||
)
|
||||
}
|
||||
|
||||
return ctx.scheduleChange(pending.targetTierId ?? '').then(r =>
|
||||
isScopeDenial(r)
|
||||
? toStepUp()
|
||||
: finish(mutationResult(r, 'Scheduled — your plan doesn’t change today. You keep your current plan until the end of the billing period, then it switches.'))
|
||||
)
|
||||
}
|
||||
|
||||
/** Resume (undo the pending change) and route: result (ok/err) or stepup (scope). */
|
||||
function resumeAndRoute(ctx: SubscriptionOverlayState['ctx'], onPatch: ScreenProps['onPatch'], allowStepUp = true): Promise<void> {
|
||||
return ctx.resume().then(r => {
|
||||
if (isScopeDenial(r)) {
|
||||
return allowStepUp
|
||||
? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'resume' } })
|
||||
: onPatch({ result: scopeStillDeniedResult, screen: 'result' })
|
||||
}
|
||||
|
||||
return onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' })
|
||||
})
|
||||
}
|
||||
|
||||
// ── The pending scheduled change (drives the banner + status echo) ──
|
||||
|
||||
interface PendingTransition {
|
||||
to: string
|
||||
when: string
|
||||
}
|
||||
|
||||
/** The scheduled downgrade/cancel as a from→to transition, or null. */
|
||||
function pendingTransition(c: SubscriptionStateResponse['current']): null | PendingTransition {
|
||||
if (!c) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (c.cancel_at_period_end) {
|
||||
return { to: 'cancels', when: c.cancellation_effective_display ?? shortDate(c.cancellation_effective_at) }
|
||||
}
|
||||
|
||||
if (c.pending_downgrade_tier_name) {
|
||||
return { to: c.pending_downgrade_tier_name, when: c.pending_downgrade_display ?? shortDate(c.pending_downgrade_at) }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Screen: Overview (plan + usage + entry to the change flow) ────────
|
||||
|
||||
/** Status line — dollars-only, and echoes a pending "Ultra → Plus" transition. */
|
||||
function statusLine(s: SubscriptionStateResponse): string {
|
||||
const u = s.usage
|
||||
const c = s.current
|
||||
const plan = c?.tier_name ?? u?.plan_name ?? null
|
||||
const trans = pendingTransition(c)
|
||||
const flip = plan && trans ? ` → ${trans.to}` : ''
|
||||
const renewsRaw = u?.renews_display ?? null
|
||||
const renews = renewsRaw ? ` · renews ${renewsRaw}` : ''
|
||||
const viewOnly = !s.can_change_plan
|
||||
|
||||
if (!plan) {
|
||||
return 'Plan: Free · free models only'
|
||||
}
|
||||
|
||||
if (u?.status === 'low' && u.total_spendable_display) {
|
||||
return `Plan: ${plan}${flip} · ${u.total_spendable_display} left`
|
||||
}
|
||||
|
||||
const left = u?.total_spendable_display ? ` · ${u.total_spendable_display} left` : ''
|
||||
|
||||
return `Plan: ${plan}${flip}${left}${viewOnly ? ' · view only' : renews}`
|
||||
}
|
||||
|
||||
function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
|
||||
const { ctx, state: s } = overlay
|
||||
const c = s.current
|
||||
const isFree = !c?.tier_id
|
||||
const currentName = c?.tier_name ?? 'your plan'
|
||||
const trans = pendingTransition(c)
|
||||
const hasPendingChange = !!trans
|
||||
// Admin/owner on a personal paid plan can change it in-terminal; otherwise the
|
||||
// portal enforces who can act (members) / starting a new sub needs a card.
|
||||
const canChange = s.can_change_plan && !isFree
|
||||
|
||||
// Guard the async resume so a double-press cannot fire two DELETEs mid-await.
|
||||
const busyRef = useRef(false)
|
||||
|
||||
const u = s.usage
|
||||
const freeNudge = isFree ? 'Paid models need a subscription. Start one to reach them.' : null
|
||||
|
||||
const lowNudge =
|
||||
u?.status === 'low'
|
||||
? `Low balance · ${u.total_spendable_display ?? 'under $5'} left. Top up or upgrade before a mid-run cutoff.`
|
||||
: null
|
||||
|
||||
const doManage = () => {
|
||||
if (s.portal_url) {
|
||||
void ctx.openManageLink()
|
||||
} else {
|
||||
ctx.sys('🔴 No portal URL available — manage your subscription on the Nous portal.')
|
||||
}
|
||||
|
||||
return onClose()
|
||||
}
|
||||
|
||||
const doResume = () => {
|
||||
if (busyRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
void resumeAndRoute(ctx, onPatch)
|
||||
}
|
||||
|
||||
const rows: MenuRowSpec[] = []
|
||||
|
||||
if (canChange) {
|
||||
// When a change is already scheduled, undo is the most likely next intent —
|
||||
// promote it to the first, highlighted action.
|
||||
if (hasPendingChange) {
|
||||
rows.push({ color: t.color.ok, label: `Keep ${currentName} (undo this change)`, run: doResume })
|
||||
rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) })
|
||||
} else {
|
||||
rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) })
|
||||
rows.push({
|
||||
label: 'Cancel subscription',
|
||||
run: () => onPatch({ pending: { kind: 'cancellation', preview: null, targetTierId: null }, screen: 'confirm' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage })
|
||||
rows.push({ label: 'Close', run: onClose })
|
||||
|
||||
const sel = useMenu(rows, onClose)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Lead with the scheduled change so it can't read as "nothing happened". */}
|
||||
{trans && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={t.color.warn}>
|
||||
⏳ Scheduled change
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color={t.color.text}>{currentName} </Text>
|
||||
<Text color={t.color.warn}>──▶ </Text>
|
||||
<Text color={t.color.text}>{trans.to}</Text>
|
||||
<Text color={t.color.muted}> · {trans.when}</Text>
|
||||
</Box>
|
||||
<Text color={t.color.muted}>You keep {currentName} (and its credits) until then.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text bold color={t.color.accent}>
|
||||
{statusLine(s)}
|
||||
</Text>
|
||||
<UsageBars model={s.usage} t={t} />
|
||||
{freeNudge && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.warn}>
|
||||
{'> '}
|
||||
{freeNudge}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{lowNudge && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={t.color.warn}>
|
||||
{'! '}
|
||||
{lowNudge}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{s.org_name && (
|
||||
<Text color={t.color.muted}>
|
||||
Org: {s.org_name}
|
||||
{s.role ? ` · ${s.role}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text />
|
||||
{rows.map((row, i) => (
|
||||
<MenuRow active={sel === i} index={i + 1} key={row.label} label={row.label} t={t} />
|
||||
))}
|
||||
|
||||
<Text />
|
||||
{footer('↑/↓ select · Enter confirm · Esc close', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Screen: Picker (choose a tier → preview → confirm) ───────────────
|
||||
|
||||
function PickerScreen({ onPatch, overlay, t }: ScreenProps) {
|
||||
const { ctx, state: s } = overlay
|
||||
const currentOrder = s.tiers.find(tier => tier.is_current)?.tier_order ?? 0
|
||||
|
||||
// Selectable = enabled, not the current plan, and not the free/no-sub tier
|
||||
// (going to free is a cancellation, offered on the overview). Sorted by price.
|
||||
const choices: SubscriptionTierOption[] = s.tiers
|
||||
.filter(tier => tier.is_enabled && !tier.is_current && tier.tier_order > 0)
|
||||
.sort((a, b) => a.tier_order - b.tier_order)
|
||||
|
||||
// Guard the async preview so a double-press cannot fire two quotes.
|
||||
const busyRef = useRef(false)
|
||||
|
||||
const pick = (tier: SubscriptionTierOption) => {
|
||||
if (busyRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
void previewAndRoute(ctx, tier.tier_id, onPatch)
|
||||
}
|
||||
|
||||
const back = () => onPatch({ screen: 'overview' })
|
||||
|
||||
const rows: MenuRowSpec[] = choices.map(tier => {
|
||||
const direction = tier.tier_order > currentOrder ? 'upgrade' : 'downgrade'
|
||||
|
||||
return {
|
||||
label: `${tier.name} · ${tier.dollars_per_month_display}/mo · ${direction}`,
|
||||
run: () => pick(tier)
|
||||
}
|
||||
})
|
||||
|
||||
rows.push({ label: 'Back', run: back })
|
||||
|
||||
const sel = useMenu(rows, back)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Change plan
|
||||
</Text>
|
||||
<Text color={t.color.muted}>
|
||||
Current: {s.current?.tier_name ?? 'Free'}. Pick a plan to see the effect before confirming.
|
||||
</Text>
|
||||
<Text />
|
||||
{choices.length === 0 && <Text color={t.color.muted}>No other plans are available to switch to right now.</Text>}
|
||||
{rows.map((row, i) => (
|
||||
<MenuRow active={sel === i} index={i + 1} key={row.label} label={row.label} t={t} />
|
||||
))}
|
||||
<Text />
|
||||
{footer('↑/↓ select · Enter preview · Esc back', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Screen: Confirm (show the previewed effect, then apply) ──────────
|
||||
|
||||
function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
|
||||
const { ctx, state: s } = overlay
|
||||
const pending: null | SubscriptionPendingChange = overlay.pending ?? null
|
||||
const preview = pending?.preview ?? null
|
||||
const isCancellation = pending?.kind === 'cancellation'
|
||||
// Cancellation is always a scheduled (chargeless) effect; otherwise trust the
|
||||
// quote (default to blocked so a missing quote never offers an apply).
|
||||
const effect = isCancellation ? 'scheduled' : (preview?.effect ?? 'blocked')
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// Synchronous guard: two key events can both see submitting===false before
|
||||
// React commits, double-firing the mutation/charge.
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
const back = () => {
|
||||
// Don't navigate away while an apply is in flight: the screen hasn't changed
|
||||
// yet (applyPendingAndRoute patches only after the RPC resolves), so a fresh
|
||||
// re-mount would re-fire the mutation — a second charge on the upgrade path.
|
||||
if (submittingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
onPatch({ pending: null, screen: isCancellation ? 'overview' : 'picker' })
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
if (submittingRef.current || !pending) {
|
||||
return
|
||||
}
|
||||
|
||||
submittingRef.current = true
|
||||
setSubmitting(true)
|
||||
void applyPendingAndRoute(ctx, pending, onPatch)
|
||||
}
|
||||
|
||||
const manage = () => {
|
||||
void ctx.openManageLink()
|
||||
|
||||
return onClose()
|
||||
}
|
||||
|
||||
// WHICH card the upgrade will charge (brand + last4) — best-effort via
|
||||
// billing.state, shown only when the resolver rung matches what a
|
||||
// subscription charge actually uses (subPin / customerDefault, mirroring
|
||||
// Stripe's own precedence). Anything else → the generic line stands.
|
||||
const [chargeCard, setChargeCard] = useState<null | string>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCancellation || effect !== 'charge_now') {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void ctx.fetchCard().then(card => {
|
||||
if (!cancelled && card && (card.resolved_via === 'subPin' || card.resolved_via === 'customerDefault')) {
|
||||
setChargeCard(card.masked)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ctx, effect, isCancellation])
|
||||
|
||||
const amount = centsDisplay(preview?.amount_due_now_cents)
|
||||
const targetName = isCancellation ? null : (preview?.target_tier_name ?? 'the selected plan')
|
||||
|
||||
let primary: MenuRowSpec | null = null
|
||||
|
||||
if (isCancellation) {
|
||||
primary = { color: t.color.warn, label: 'Cancel subscription', run: apply }
|
||||
} else if (effect === 'charge_now') {
|
||||
primary = { color: t.color.ok, label: amount ? `Pay ${amount} & upgrade now` : 'Upgrade now (prorated charge)', run: apply }
|
||||
} else if (effect === 'scheduled') {
|
||||
primary = { color: t.color.ok, label: `Schedule change to ${targetName}`, run: apply }
|
||||
} else if (effect === 'blocked') {
|
||||
primary = { label: 'Manage on portal', run: manage }
|
||||
}
|
||||
|
||||
const rows: MenuRowSpec[] = primary ? [primary, { label: 'Back', run: back }] : [{ label: 'Back', run: back }]
|
||||
const sel = useMenu(rows, back)
|
||||
// Chip contrasts an immediate charge vs a period-end schedule at a glance.
|
||||
const chip = effect === 'charge_now' ? { color: t.color.ok, label: 'charged now' } : effect === 'scheduled' ? { color: t.color.warn, label: 'scheduled · not today' } : null
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={t.color.accent}>
|
||||
{isCancellation ? 'Confirm cancellation' : 'Confirm plan change'}
|
||||
</Text>
|
||||
{chip && <Text color={chip.color}> · {chip.label}</Text>}
|
||||
</Box>
|
||||
{submitting && <Text color={t.color.muted}>Working…</Text>}
|
||||
|
||||
{isCancellation && (
|
||||
<>
|
||||
<Text color={t.color.text}>
|
||||
Cancel {s.current?.tier_name ?? 'your plan'} — it stays active until {shortDate(s.current?.cycle_ends_at)}, then
|
||||
will not renew.
|
||||
</Text>
|
||||
<Text color={t.color.muted}>You keep your remaining credits for this period. You can resume before it ends.</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{effect === 'charge_now' && !isCancellation && (
|
||||
<>
|
||||
<Text color={t.color.text}>
|
||||
Upgrade to {targetName}.{' '}
|
||||
{amount ? `You will be charged ${amount} now (prorated).` : 'You will be charged the prorated amount now.'}
|
||||
</Text>
|
||||
{preview?.monthly_credits_delta && (
|
||||
<Text color={t.color.muted}>Monthly credits change: {preview.monthly_credits_delta}.</Text>
|
||||
)}
|
||||
<Text color={t.color.muted}>
|
||||
{chargeCard ? `${chargeCard} — the card on your subscription — will be charged.` : 'The card on your subscription will be charged.'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{effect === 'scheduled' && !isCancellation && (
|
||||
<>
|
||||
<Text color={t.color.text}>
|
||||
Change to {targetName} — takes effect {shortDate(preview?.effective_at)}. No charge now; you keep your current
|
||||
plan until then.
|
||||
</Text>
|
||||
{preview?.monthly_credits_delta && (
|
||||
<Text color={t.color.muted}>Monthly credits change: {preview.monthly_credits_delta}.</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{effect === 'no_op' && !isCancellation && (
|
||||
<Text color={t.color.muted}>You are already on {targetName} — nothing to change.</Text>
|
||||
)}
|
||||
|
||||
{effect === 'blocked' && !isCancellation && (
|
||||
<Text color={t.color.warn}>{preview?.reason ?? 'That change cannot be made here — manage it on the portal.'}</Text>
|
||||
)}
|
||||
|
||||
<Text />
|
||||
{rows.map((row, i) => (
|
||||
<ActionRow active={sel === i} color={row.color} key={row.label} label={row.label} t={t} />
|
||||
))}
|
||||
<Text />
|
||||
{footer('↑/↓ select · Enter confirm · Esc back', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Screen: Result (outcome + optional portal recovery) ──────────────
|
||||
|
||||
function ResultScreen({ onClose, overlay, t }: Omit<ScreenProps, 'onPatch'>) {
|
||||
const { ctx } = overlay
|
||||
const result = overlay.result ?? null
|
||||
const recoveryUrl = result?.recoveryUrl ?? null
|
||||
const pendingTierId = result?.pendingTierId ?? null
|
||||
const [applyState, setApplyState] = useState<'applying' | 'confirmed' | 'timed_out'>(
|
||||
pendingTierId ? 'applying' : 'confirmed'
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTierId) {
|
||||
return
|
||||
}
|
||||
|
||||
let attempts = 0
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const scheduleOrFinish = () => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (attempts >= UPGRADE_CONFIRM_ATTEMPTS) {
|
||||
setApplyState('timed_out')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, UPGRADE_CONFIRM_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
attempts += 1
|
||||
void ctx
|
||||
.refreshState()
|
||||
.then(fresh => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fresh?.current?.tier_id === pendingTierId) {
|
||||
setApplyState('confirmed')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scheduleOrFinish()
|
||||
})
|
||||
.catch(scheduleOrFinish)
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, UPGRADE_CONFIRM_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [ctx, pendingTierId])
|
||||
|
||||
const applying = result?.ok && applyState === 'applying'
|
||||
const timedOut = result?.ok && applyState === 'timed_out'
|
||||
const message = timedOut
|
||||
? 'Your upgrade succeeded and is still applying — refresh in a moment.'
|
||||
: (result?.message ?? '')
|
||||
|
||||
const openRecovery = () => {
|
||||
if (recoveryUrl) {
|
||||
ctx.openPortal(recoveryUrl)
|
||||
}
|
||||
|
||||
return onClose()
|
||||
}
|
||||
|
||||
const rows: MenuRowSpec[] = recoveryUrl
|
||||
? [{ color: t.color.accent, label: 'Open the portal to finish', run: openRecovery }, { label: 'Close', run: onClose }]
|
||||
: [{ label: 'Close', run: onClose }]
|
||||
|
||||
const sel = useMenu(rows, onClose)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={result?.ok ? t.color.ok : t.color.warn}>
|
||||
{applying ? 'Applying…' : timedOut ? 'Still applying' : result?.ok ? 'Done' : 'Could not complete'}
|
||||
</Text>
|
||||
<Text color={t.color.text}>{message}</Text>
|
||||
{result?.ok && !applying && !timedOut && <Text color={t.color.muted}>Re-run /subscription anytime to review it.</Text>}
|
||||
<Text />
|
||||
{rows.map((row, i) => (
|
||||
<ActionRow active={sel === i} color={row.color} key={row.label} label={row.label} t={t} />
|
||||
))}
|
||||
<Text />
|
||||
{footer('↑/↓ select · Enter · Esc close', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Screen: Step-up (grant terminal billing inline, then replay) ──────
|
||||
|
||||
function StepUpScreen({ onPatch, overlay, t }: ScreenProps) {
|
||||
const { ctx } = overlay
|
||||
const retry: null | SubscriptionStepUpRetry = overlay.stepUpRetry ?? null
|
||||
const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt')
|
||||
const startedRef = useRef(false)
|
||||
// Set when the user cancels while the browser grant is still in flight. The
|
||||
// grant's late `.then` MUST NOT fire the held change after a cancel — otherwise
|
||||
// a cancel-then-approve charges the card the user just declined.
|
||||
const abortedRef = useRef(false)
|
||||
// Guards the post-grant replay from double-firing (double-Enter on the default
|
||||
// 'Continue' row) — mirrors billingOverlay.resume()'s phase flip.
|
||||
const resumingRef = useRef(false)
|
||||
|
||||
const enable = () => {
|
||||
if (startedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
startedRef.current = true
|
||||
setPhase('waiting')
|
||||
void ctx.requestRemoteSpending().then(res => {
|
||||
if (abortedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (res.granted) {
|
||||
// HOLD — do not auto-fire the held change. Require an explicit Continue so
|
||||
// a cancelled/late grant can never charge (mirrors billingOverlay's
|
||||
// 'granted' phase). The user already consented at confirm; this reconfirms.
|
||||
return setPhase('granted')
|
||||
}
|
||||
|
||||
// Typed denial (session_revoked / remote_spending_revoked / rate_limited /
|
||||
// admin-approval) → the right recovery copy, not a flat "admin must allow".
|
||||
onPatch({ result: stepUpDenialResult(res), screen: 'result', stepUpRetry: null })
|
||||
})
|
||||
}
|
||||
|
||||
const resume = () => {
|
||||
// Fire the held replay at most once. Without this, a double-Enter on the
|
||||
// default 'Continue' row sends two mutations (the upgrade dedups on the shared
|
||||
// idempotency key, but schedule/cancel/resume replays carry none).
|
||||
if (resumingRef.current || phase !== 'granted') {
|
||||
return
|
||||
}
|
||||
|
||||
resumingRef.current = true
|
||||
setPhase('resuming')
|
||||
onPatch({ stepUpRetry: null })
|
||||
|
||||
if (!retry) {
|
||||
return onPatch({ screen: 'overview' })
|
||||
}
|
||||
|
||||
// allowStepUp=false: a repeat scope denial surfaces a result, never a frozen
|
||||
// re-entry into this (already-mounted) stepup screen.
|
||||
if (retry.kind === 'preview') {
|
||||
return void previewAndRoute(ctx, retry.tierId, onPatch, false)
|
||||
}
|
||||
|
||||
if (retry.kind === 'resume') {
|
||||
return void resumeAndRoute(ctx, onPatch, false)
|
||||
}
|
||||
|
||||
return void applyPendingAndRoute(ctx, overlay.pending ?? null, onPatch, false)
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
// Once a replay is firing, block abandon — the mutation/charge is in flight and
|
||||
// re-mounting confirm would let a second submit through.
|
||||
if (resumingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Abandon. If a grant is in flight, mark it aborted so its .then no-ops (no
|
||||
// un-consented charge); if already granted, just leave without replaying.
|
||||
abortedRef.current = true
|
||||
onPatch({ screen: retry?.kind === 'apply' ? 'confirm' : 'overview', stepUpRetry: null })
|
||||
}
|
||||
|
||||
const rows: MenuRowSpec[] =
|
||||
phase === 'granted'
|
||||
? [{ color: t.color.ok, label: retry?.kind === 'apply' ? 'Continue the change' : 'Continue', run: resume }, { label: 'Cancel', run: back }]
|
||||
: phase === 'prompt'
|
||||
? [{ color: t.color.ok, label: 'Enable terminal billing', run: enable }, { label: 'Cancel', run: back }]
|
||||
: []
|
||||
|
||||
const sel = useMenu(rows, back)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Terminal billing
|
||||
</Text>
|
||||
{phase === 'prompt' && (
|
||||
<>
|
||||
<Text color={t.color.text}>Changing your plan needs terminal billing enabled for this org. Enable it here, then continue.</Text>
|
||||
<Text color={t.color.muted}>Someone with billing permissions (owner, admin, or finance admin) approves it once in the browser.</Text>
|
||||
</>
|
||||
)}
|
||||
{phase === 'waiting' && (
|
||||
<Text color={t.color.muted}>Opening your browser to approve… finish there, then come back — nothing is charged until you continue.</Text>
|
||||
)}
|
||||
{phase === 'granted' && <Text color={t.color.ok}>Terminal billing enabled. Continue to finish your change.</Text>}
|
||||
{phase === 'resuming' && <Text color={t.color.muted}>Applying your change…</Text>}
|
||||
<Text />
|
||||
{rows.map((row, i) => (
|
||||
<ActionRow active={sel === i} color={row.color} key={row.label} label={row.label} t={t} />
|
||||
))}
|
||||
<Text />
|
||||
{footer(phase === 'waiting' ? 'Waiting for approval… · Esc to cancel' : phase === 'resuming' ? 'Working…' : '↑/↓ select · Enter · Esc back', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Screen: Team context (no tier picker — teams use shared credits) ──
|
||||
|
||||
interface TeamContextScreenProps {
|
||||
onClose: () => void
|
||||
s: SubscriptionStateResponse
|
||||
t: Theme
|
||||
}
|
||||
|
||||
function TeamContextScreen({ onClose, s, t }: TeamContextScreenProps) {
|
||||
useInput((_ch, key) => {
|
||||
if (key.escape || key.return) {
|
||||
return onClose()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.accent}>
|
||||
Team subscription
|
||||
</Text>
|
||||
{s.org_name && (
|
||||
<Text color={t.color.muted}>
|
||||
Org: {s.org_name}
|
||||
{s.role ? ` · ${s.role}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
<Text />
|
||||
<Text color={t.color.text}>
|
||||
This terminal is connected to {s.org_name ?? 'a team org'}. Teams run on a shared balance · use /topup to add
|
||||
funds.
|
||||
</Text>
|
||||
<Text color={t.color.muted}>Personal subscriptions live on your personal account.</Text>
|
||||
|
||||
<Text />
|
||||
{footer('Enter/Esc close', t)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -43,22 +43,16 @@ export interface SlashExecResponse {
|
|||
warning?: string
|
||||
}
|
||||
|
||||
// ── Credits / top-up ─────────────────────────────────────────────────
|
||||
|
||||
export interface CreditsViewResponse {
|
||||
balance_lines: string[]
|
||||
depleted: boolean
|
||||
identity_line: string | null
|
||||
logged_in: boolean
|
||||
topup_url: string | null
|
||||
}
|
||||
|
||||
// ── Terminal billing (Phase 2b) ──────────────────────────────────────
|
||||
|
||||
export interface BillingCardInfo {
|
||||
brand: string
|
||||
last4: string
|
||||
masked: string
|
||||
/** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */
|
||||
display?: string
|
||||
/** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */
|
||||
resolved_via?: null | string
|
||||
}
|
||||
|
||||
export interface BillingMonthlyCap {
|
||||
|
|
@ -70,6 +64,15 @@ export interface BillingMonthlyCap {
|
|||
}
|
||||
|
||||
export interface BillingAutoReload {
|
||||
card:
|
||||
| { kind: 'canonical' }
|
||||
| {
|
||||
kind: 'distinct'
|
||||
payment_method_id: string
|
||||
brand: string | null
|
||||
last4: string | null
|
||||
}
|
||||
| { kind: 'none' }
|
||||
enabled: boolean
|
||||
reload_to_display: string
|
||||
reload_to_usd: string | null
|
||||
|
|
@ -96,6 +99,9 @@ export interface BillingStateResponse {
|
|||
org_name: string | null
|
||||
portal_url: string | null
|
||||
role: string | null
|
||||
// Shared dollar usage model (two-bar view), embedded by the gateway so /topup
|
||||
// renders the same bars as /usage and /subscription from this single fetch.
|
||||
usage?: UsageModelData
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -109,13 +115,16 @@ export interface BillingErrorPayload {
|
|||
}
|
||||
|
||||
export interface BillingChargeResponse {
|
||||
actor?: string
|
||||
charge_id?: string
|
||||
code?: string
|
||||
error?: string
|
||||
idempotency_key?: string
|
||||
message?: string
|
||||
ok: boolean
|
||||
payload?: BillingErrorPayload
|
||||
portal_url?: string | null
|
||||
recovery?: string
|
||||
retry_after?: number | null
|
||||
}
|
||||
|
||||
|
|
@ -133,15 +142,104 @@ export interface BillingChargeStatusResponse {
|
|||
}
|
||||
|
||||
export interface BillingMutationResponse {
|
||||
actor?: string
|
||||
code?: string
|
||||
error?: string
|
||||
granted?: boolean
|
||||
message?: string
|
||||
ok: boolean
|
||||
payload?: BillingErrorPayload
|
||||
portal_url?: string | null
|
||||
recovery?: string
|
||||
retry_after?: number | null
|
||||
}
|
||||
|
||||
export interface SubscriptionTierOption {
|
||||
tier_id: string
|
||||
name: string
|
||||
tier_order: number // sorts the picker + upgrade/downgrade hint
|
||||
dollars_per_month_display: string // pre-formatted ($X / $X.YY)
|
||||
monthly_credits: string | null
|
||||
is_current: boolean // the active plan: shown, not selectable
|
||||
is_enabled: boolean // false = grandfathered current tier
|
||||
}
|
||||
|
||||
export interface SubscriptionStateResponse {
|
||||
ok: boolean
|
||||
logged_in: boolean
|
||||
is_admin: boolean
|
||||
can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS
|
||||
org_name: string | null
|
||||
org_id: string | null // org.id from the NAS response
|
||||
role: string | null
|
||||
context: 'personal' | 'team' // personal account vs team/org terminal
|
||||
current: {
|
||||
tier_id: string | null // null = free (no active sub)
|
||||
tier_name: string | null
|
||||
monthly_credits: string | null
|
||||
credits_remaining: string | null
|
||||
cycle_ends_at: string | null // ISO
|
||||
pending_downgrade_tier_name: string | null
|
||||
pending_downgrade_at: string | null
|
||||
pending_downgrade_display: string | null // formatted pending_downgrade_at
|
||||
cancel_at_period_end: boolean // subscription scheduled to cancel at period end
|
||||
cancellation_effective_at: string | null // ISO when cancellation takes effect
|
||||
cancellation_effective_display: string | null // formatted cancellation_effective_at
|
||||
} | null
|
||||
tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker
|
||||
portal_url: string | null
|
||||
error?: string | null
|
||||
// Shared dollar usage model (two-bar view), embedded by the gateway so the
|
||||
// overlay renders the same bars as /usage from this single fetch.
|
||||
usage?: UsageModelData
|
||||
}
|
||||
|
||||
// A chargeless quote (POST /subscription/preview) of what a change would do.
|
||||
// `effect` drives the confirm copy; a failed preview reuses the typed-error
|
||||
// envelope fields (same as the mutations) so a 403 still triggers the step-up.
|
||||
export interface SubscriptionPreviewResponse {
|
||||
ok: boolean
|
||||
effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked'
|
||||
reason?: string | null
|
||||
current_tier_id?: string | null
|
||||
current_tier_name?: string | null
|
||||
target_tier_id?: string | null
|
||||
target_tier_name?: string | null
|
||||
monthly_credits_delta?: string | null
|
||||
amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade
|
||||
effective_at?: string | null // ISO, when a scheduled change lands
|
||||
// typed-error envelope (present when ok=false)
|
||||
error?: string
|
||||
message?: string
|
||||
portal_url?: string | null
|
||||
retry_after?: number | null
|
||||
payload?: BillingErrorPayload
|
||||
actor?: string
|
||||
code?: string
|
||||
recovery?: string
|
||||
}
|
||||
|
||||
// The single money route (POST /subscription/upgrade). `status` distinguishes a
|
||||
// completed upgrade from an SCA/decline that must finish in the portal at
|
||||
// `recovery_url`. `idempotency_key` is echoed so a retry reuses it.
|
||||
export interface SubscriptionUpgradeResponse {
|
||||
ok: boolean
|
||||
status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed'
|
||||
target_tier_name?: string | null
|
||||
recovery_url?: string | null
|
||||
reason?: string | null
|
||||
idempotency_key?: string
|
||||
// typed-error envelope (present when ok=false)
|
||||
error?: string
|
||||
message?: string
|
||||
portal_url?: string | null
|
||||
retry_after?: number | null
|
||||
payload?: BillingErrorPayload
|
||||
actor?: string
|
||||
code?: string
|
||||
recovery?: string
|
||||
}
|
||||
|
||||
export type CommandDispatchResponse =
|
||||
| { output?: string; type: 'exec' | 'plugin' }
|
||||
| { target: string; type: 'alias' }
|
||||
|
|
@ -330,6 +428,34 @@ export interface SessionUsageResponse {
|
|||
model?: string
|
||||
output?: number
|
||||
total?: number
|
||||
// Shared dollar usage model (two-bar view) so /usage renders the same bars
|
||||
// as /subscription. Dollars only — never "credits".
|
||||
usage?: UsageModelData
|
||||
}
|
||||
|
||||
/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */
|
||||
export interface UsageBarData {
|
||||
kind: 'plan' | 'topup'
|
||||
remaining_display: string
|
||||
total_display: string
|
||||
spent_display: string
|
||||
pct_used: null | number
|
||||
fill_fraction: number
|
||||
}
|
||||
|
||||
/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */
|
||||
export interface UsageModelData {
|
||||
available: boolean
|
||||
status?: string
|
||||
plan_name?: null | string
|
||||
renews_at?: null | string
|
||||
renews_display?: null | string
|
||||
subscription_remaining_display?: null | string
|
||||
topup_remaining_display?: null | string
|
||||
total_spendable_display?: null | string
|
||||
has_topup?: boolean
|
||||
plan_bar?: null | UsageBarData
|
||||
topup_bar?: null | UsageBarData
|
||||
}
|
||||
|
||||
export interface SessionStatusResponse {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue