From d296749056fa18f2c741dd46e5e60debe4016ed5 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:38:02 +0530 Subject: [PATCH] feat(desktop): billing settings tab (#61054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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=. - 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 · · you keep Ultra until then), the status line echoes the transition (Plan: Ultra → Plus), 'Keep (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 · · 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. * feat(desktop): add desktop-local billing wire types * feat(desktop): billing gateway API client and refusal taxonomy * feat(desktop): register billing settings tab with skeleton view * feat(desktop): wire billing tab to live gateway reads with fail-open states * feat(desktop): buy-credits charge flow with settlement poller * fix(desktop): keep About last in settings nav, billing above it * feat(desktop): auto-refill editing and billing step-up verification flow * fix(desktop): clamp overdrawn subscription credits and pin USD symbol formatting * fix(desktop): move billing next to notifications in settings nav * feat(desktop): usage-bar state colors and dev fixture simulator * feat(desktop): wide usage bars with top-up bar and refresh affordance * fix(desktop): disable buy controls without a card, neutral tracks for bar-less usage rows * polish(desktop): usage-grid alignment, tabular numerals, legible tracks and danger states * polish(desktop): dithered empty and depleted usage-bar tracks per app bar idiom * 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. * refactor(shared): move terminal-billing wire types to @hermes/shared The billing/subscription wire shapes (plus UsageBarData/UsageModelData, which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into apps/shared/src/billing-types.ts so the desktop app can share the same gateway contract. gatewayTypes.ts re-exports every moved name from the new @hermes/shared/billing subpath, so no ui-tui consumer changes. The subpath export keeps DOM-less ui-tui from pulling the barrel (whose WebSocket helpers need the DOM lib). ui-tui also now declares its @hermes/shared dependency explicitly instead of relying on workspace hoisting. * test(cli): pin nous_billing wire-layer status-to-exception mapping The HTTP layer's error handling had zero coverage through _request: only 2xx parsing and request shaping were tested, and the mapping cases in test_remote_spending_gate_contract.py hit _raise_for_error directly. Adds 19 tests driving _request via a monkeypatched urlopen: the 401-refresh-retry path (success, terminal plain/session_revoked, idempotency-key preservation, base re-resolution), 403 variants through the wire, 429/503 retry-after, non-JSON error bodies, 404/502 fallbacks, and URLError normalization. Two behaviors are pinned as findings rather than fixed: a JSON-body retryAfter hint is ignored unless the Retry-After header is present, and a bare socket.timeout propagates uncaught (real urllib wraps timeouts in URLError before this layer). * fix(tui_gateway): delete dead credits.view RPC The handler assigns into an undefined `usage` variable, so any call would raise NameError (the except swallows the first hit, then the return re-raises it uncaught). Nothing can reach it: the TUI command registry removed /credits (pinned by test_credits_command_fully_removed) and no client sends the RPC. The live credit view is agent/account_usage.py::build_credits_view via the remote gateway's /topup command, which is untouched. * fix(cli): normalize read-phase timeouts to the typed billing error urlopen wraps connect-phase timeouts in URLError (already mapped to network_error), but a timeout during resp.read() raises a bare TimeoutError that escaped the typed-BillingError contract and reached callers as an unhandled exception. Catch it narrowly and normalize. The boundary test now asserts normalization instead of documenting the leak. * fix(shared): stop typing mutation success payloads as error payloads BillingMutationResponse.payload was declared BillingErrorPayload, but on ok:true the gateway passes through the raw NAS success body (rail, changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing broke, but the shared contract now feeds the desktop app too — widen the field deliberately and document both shapes. * feat(shared): typed billing refusal and charge-failure unions - BillingRefusalCode covers every code the gateway serializes today, with a (string & {}) arm so unknown future codes (the NAS W3 card-health family) stay assignable — consumers keep their unknown-code fallback. - ChargeFailureReason models the four NAS terminal reasons plus the raw subscription_payment_intent_requires_action code NAS leaks pre-#711. - billing.state now carries the server-derived can_change_plan the gateway emits; capability comments updated (canChangePlan is capability-based, not an OWNER/ADMIN role gate). * 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. * refactor(desktop): consume @hermes/shared billing types, full refusal copy, divergence notice - billing/types.ts becomes a re-export shim over @hermes/shared/billing (keeps the desktop-only bounds field via a local BillingAutoReload extension); needs_repair is gone with the shared type. - resolveRefusal gains specific copy for consent_required, org_access_denied, upgrade_cap_exceeded, stripe_unavailable (transient, honors retry_after) and processing_error; BillingErrorKind now IS the shared BillingRefusalCode. Default fallback unchanged. - Auto-refill row surfaces the distinct-card divergence: caption naming the charging card (or 'a different card' when brand/last4 are null) and a Reconcile portal deep-link instead of the inline edit form. - Fixtures/tests updated for the required auto_reload.card union; new auto-refill-divergent dev fixture. * fix(desktop): auto-refill-divergent fixture must be enabled to exercise the divergence row * refactor(billing): explicit BillingTransient trait, drop broken credits.view, public token-cache invalidation - BillingRateLimited / BillingStripeUnavailable / BillingUpgradeCapExceeded become siblings under a new BillingTransient trait (deterministic non-charge outcome, safe to retry) instead of the false is-a chain that made a Stripe outage 'a kind of rate limiting'. Catch sites that meant 'any deterministic pre-charge transient' now say so explicitly; the gateway serializer dispatches on the trait and emits the preserved raw code. - Delete the credits.view RPC handler left broken by the /topup rename (its body referenced an undefined variable; no caller remains). - invalidate_cached_token() replaces the CLI's reach into the private _token_cache global after a billing step-up. * refactor(cli): extract CLIBillingMixin; charge gates follow the server capability - Move the ~1,400-line billing/subscription handler family out of cli.py into hermes_cli/cli_billing_mixin.py, following the existing HermesCLI mixin pattern (lazy cli imports, verbatim bodies). - can_charge and the CLI billing-action gates now route through can_change_plan (server capability with legacy role fallback) instead of the deprecated 3-role is_admin — a FINANCE_ADMIN the server authorizes can now add funds, matching the plan-change path. - Render the spend bar from the UsageBar model's fill_fraction instead of the deleted _billing_spend_bar re-derivation; fix a stale docstring. * refactor(tui): promote useMenu to overlay primitives, type pendingTierId end-to-end - useMenu (arrow/number/Enter/Esc menu hook) moves to overlayPrimitives with an onKey escape hatch; billingOverlay's Overview and Limit screens drop their verbatim copies. BuyScreen keeps its bespoke handler (typing mode + stale-selection clamp don't fit the shared contract cleanly). - SubscriptionResult carries pendingTierId directly; the shadow SubscriptionResultWithPending interface and the ResultScreen cast are gone, so the apply-poll field is type-tracked through finish(). * docs(billing): correct the CLI-parity row — the CLI has the full in-terminal change flow * refactor(shared): move terminal-billing wire types to @hermes/shared The billing/subscription wire shapes (plus UsageBarData/UsageModelData, which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into apps/shared/src/billing-types.ts so the desktop app can share the same gateway contract. gatewayTypes.ts re-exports every moved name from the new @hermes/shared/billing subpath, so no ui-tui consumer changes. The subpath export keeps DOM-less ui-tui from pulling the barrel (whose WebSocket helpers need the DOM lib). ui-tui also now declares its @hermes/shared dependency explicitly instead of relying on workspace hoisting. * test(cli): pin nous_billing wire-layer status-to-exception mapping The HTTP layer's error handling had zero coverage through _request: only 2xx parsing and request shaping were tested, and the mapping cases in test_remote_spending_gate_contract.py hit _raise_for_error directly. Adds 19 tests driving _request via a monkeypatched urlopen: the 401-refresh-retry path (success, terminal plain/session_revoked, idempotency-key preservation, base re-resolution), 403 variants through the wire, 429/503 retry-after, non-JSON error bodies, 404/502 fallbacks, and URLError normalization. Two behaviors are pinned as findings rather than fixed: a JSON-body retryAfter hint is ignored unless the Retry-After header is present, and a bare socket.timeout propagates uncaught (real urllib wraps timeouts in URLError before this layer). * fix(cli): normalize read-phase timeouts to the typed billing error urlopen wraps connect-phase timeouts in URLError (already mapped to network_error), but a timeout during resp.read() raises a bare TimeoutError that escaped the typed-BillingError contract and reached callers as an unhandled exception. Catch it narrowly and normalize. The boundary test now asserts normalization instead of documenting the leak. * fix(shared): stop typing mutation success payloads as error payloads BillingMutationResponse.payload was declared BillingErrorPayload, but on ok:true the gateway passes through the raw NAS success body (rail, changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing broke, but the shared contract now feeds the desktop app too — widen the field deliberately and document both shapes. * feat(shared): typed billing refusal and charge-failure unions - BillingRefusalCode covers every code the gateway serializes today, with a (string & {}) arm so unknown future codes (the NAS W3 card-health family) stay assignable — consumers keep their unknown-code fallback. - ChargeFailureReason models the four NAS terminal reasons plus the raw subscription_payment_intent_requires_action code NAS leaks pre-#711. - billing.state now carries the server-derived can_change_plan the gateway emits; capability comments updated (canChangePlan is capability-based, not an OWNER/ADMIN role gate). * feat(shared): closed Known* halves for the refusal and charge-failure unions - KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets, so classification tables, copy maps and tests can be Record-exhaustive and break at compile time when a code is added but not mapped. The wire types keep the (string & {}) open arm for unknown future codes. - Add network_error (client-originated transport code the gateway already serializes) to the known set. - Export the union types from the root barrel alongside the other billing names. * feat(shared): canonical billing refusal policy and charge-settlement driver - billing-policy.ts: one exhaustive Record classifying every known code (recovery kind, mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code fallback. Surfaces keep their own copy; the behavior classification now has a single home that breaks the build when a new code goes unmapped. - charge-settlement.ts: the settlement poll state machine (2s cadence, 5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a pure dependency-injected driver returning a discriminated outcome. - The TUI's pollCharge becomes a thin renderer over the shared driver — byte-identical output, and the desktop poller can now share the same machine instead of a drifting copy. * fix(desktop): real auto-reload bounds, shared refusal policy and settlement driver - Delete the phantom BillingAutoReload.bounds plumbing: nothing ever populated it, so the auto-reload amount validation it fed was silently dead. The editor and validators now enforce the gateway's real top-level min_usd/max_usd (new test pins the $10 minimum actually rejecting), and types.ts collapses to a plain re-export shim over @hermes/shared/billing. - Delete the test-only BillingRpcResponse envelope family; BillingResult is the one response model. - Refusal copy speaks desktop: reconnect/sign-in route to Settings → Gateway instead of the TUI's /portal command; the dead processing_error refusal case is gone (it is a charge-failure reason, already rendered by the poller). - Adopt @hermes/shared billing-policy + charge-settlement: the poll loop is the shared driver, revocation-ambiguity comes from the policy table (insufficient_scope mid-poll now counts, per the ruling), and all policy-retry codes back off during polling instead of failing hard. errors.test.ts is Record-exhaustive over KnownBillingRefusalCode again. * refactor(shared): move terminal-billing wire types to @hermes/shared The billing/subscription wire shapes (plus UsageBarData/UsageModelData, which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into apps/shared/src/billing-types.ts so the desktop app can share the same gateway contract. gatewayTypes.ts re-exports every moved name from the new @hermes/shared/billing subpath, so no ui-tui consumer changes. The subpath export keeps DOM-less ui-tui from pulling the barrel (whose WebSocket helpers need the DOM lib). ui-tui also now declares its @hermes/shared dependency explicitly instead of relying on workspace hoisting. * test(cli): pin nous_billing wire-layer status-to-exception mapping The HTTP layer's error handling had zero coverage through _request: only 2xx parsing and request shaping were tested, and the mapping cases in test_remote_spending_gate_contract.py hit _raise_for_error directly. Adds 19 tests driving _request via a monkeypatched urlopen: the 401-refresh-retry path (success, terminal plain/session_revoked, idempotency-key preservation, base re-resolution), 403 variants through the wire, 429/503 retry-after, non-JSON error bodies, 404/502 fallbacks, and URLError normalization. Two behaviors are pinned as findings rather than fixed: a JSON-body retryAfter hint is ignored unless the Retry-After header is present, and a bare socket.timeout propagates uncaught (real urllib wraps timeouts in URLError before this layer). * fix(cli): normalize read-phase timeouts to the typed billing error urlopen wraps connect-phase timeouts in URLError (already mapped to network_error), but a timeout during resp.read() raises a bare TimeoutError that escaped the typed-BillingError contract and reached callers as an unhandled exception. Catch it narrowly and normalize. The boundary test now asserts normalization instead of documenting the leak. * fix(shared): stop typing mutation success payloads as error payloads BillingMutationResponse.payload was declared BillingErrorPayload, but on ok:true the gateway passes through the raw NAS success body (rail, changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing broke, but the shared contract now feeds the desktop app too — widen the field deliberately and document both shapes. * feat(shared): typed billing refusal and charge-failure unions - BillingRefusalCode covers every code the gateway serializes today, with a (string & {}) arm so unknown future codes (the NAS W3 card-health family) stay assignable — consumers keep their unknown-code fallback. - ChargeFailureReason models the four NAS terminal reasons plus the raw subscription_payment_intent_requires_action code NAS leaks pre-#711. - billing.state now carries the server-derived can_change_plan the gateway emits; capability comments updated (canChangePlan is capability-based, not an OWNER/ADMIN role gate). * feat(shared): closed Known* halves for the refusal and charge-failure unions - KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets, so classification tables, copy maps and tests can be Record-exhaustive and break at compile time when a code is added but not mapped. The wire types keep the (string & {}) open arm for unknown future codes. - Add network_error (client-originated transport code the gateway already serializes) to the known set. - Export the union types from the root barrel alongside the other billing names. * feat(shared): canonical billing refusal policy and charge-settlement driver - billing-policy.ts: one exhaustive Record classifying every known code (recovery kind, mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code fallback. Surfaces keep their own copy; the behavior classification now has a single home that breaks the build when a new code goes unmapped. - charge-settlement.ts: the settlement poll state machine (2s cadence, 5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a pure dependency-injected driver returning a discriminated outcome. - The TUI's pollCharge becomes a thin renderer over the shared driver — byte-identical output, and the desktop poller can now share the same machine instead of a drifting copy. * refactor(shared): move terminal-billing wire types to @hermes/shared The billing/subscription wire shapes (plus UsageBarData/UsageModelData, which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into apps/shared/src/billing-types.ts so the desktop app can share the same gateway contract. gatewayTypes.ts re-exports every moved name from the new @hermes/shared/billing subpath, so no ui-tui consumer changes. The subpath export keeps DOM-less ui-tui from pulling the barrel (whose WebSocket helpers need the DOM lib). ui-tui also now declares its @hermes/shared dependency explicitly instead of relying on workspace hoisting. * test(cli): pin nous_billing wire-layer status-to-exception mapping The HTTP layer's error handling had zero coverage through _request: only 2xx parsing and request shaping were tested, and the mapping cases in test_remote_spending_gate_contract.py hit _raise_for_error directly. Adds 19 tests driving _request via a monkeypatched urlopen: the 401-refresh-retry path (success, terminal plain/session_revoked, idempotency-key preservation, base re-resolution), 403 variants through the wire, 429/503 retry-after, non-JSON error bodies, 404/502 fallbacks, and URLError normalization. Two behaviors are pinned as findings rather than fixed: a JSON-body retryAfter hint is ignored unless the Retry-After header is present, and a bare socket.timeout propagates uncaught (real urllib wraps timeouts in URLError before this layer). * fix(cli): normalize read-phase timeouts to the typed billing error urlopen wraps connect-phase timeouts in URLError (already mapped to network_error), but a timeout during resp.read() raises a bare TimeoutError that escaped the typed-BillingError contract and reached callers as an unhandled exception. Catch it narrowly and normalize. The boundary test now asserts normalization instead of documenting the leak. * fix(shared): stop typing mutation success payloads as error payloads BillingMutationResponse.payload was declared BillingErrorPayload, but on ok:true the gateway passes through the raw NAS success body (rail, changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing broke, but the shared contract now feeds the desktop app too — widen the field deliberately and document both shapes. * feat(shared): typed billing refusal and charge-failure unions - BillingRefusalCode covers every code the gateway serializes today, with a (string & {}) arm so unknown future codes (the NAS W3 card-health family) stay assignable — consumers keep their unknown-code fallback. - ChargeFailureReason models the four NAS terminal reasons plus the raw subscription_payment_intent_requires_action code NAS leaks pre-#711. - billing.state now carries the server-derived can_change_plan the gateway emits; capability comments updated (canChangePlan is capability-based, not an OWNER/ADMIN role gate). * feat(shared): closed Known* halves for the refusal and charge-failure unions - KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets, so classification tables, copy maps and tests can be Record-exhaustive and break at compile time when a code is added but not mapped. The wire types keep the (string & {}) open arm for unknown future codes. - Add network_error (client-originated transport code the gateway already serializes) to the known set. - Export the union types from the root barrel alongside the other billing names. * feat(shared): canonical billing refusal policy and charge-settlement driver - billing-policy.ts: one exhaustive Record classifying every known code (recovery kind, mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code fallback. Surfaces keep their own copy; the behavior classification now has a single home that breaks the build when a new code goes unmapped. - charge-settlement.ts: the settlement poll state machine (2s cadence, 5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a pure dependency-injected driver returning a discriminated outcome. - The TUI's pollCharge becomes a thin renderer over the shared driver — byte-identical output, and the desktop poller can now share the same machine instead of a drifting copy. * chore: retrigger CI with the current base SHA (stale base pin flagged a false CI-sensitive change) * refactor(shared): move terminal-billing wire types to @hermes/shared The billing/subscription wire shapes (plus UsageBarData/UsageModelData, which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into apps/shared/src/billing-types.ts so the desktop app can share the same gateway contract. gatewayTypes.ts re-exports every moved name from the new @hermes/shared/billing subpath, so no ui-tui consumer changes. The subpath export keeps DOM-less ui-tui from pulling the barrel (whose WebSocket helpers need the DOM lib). ui-tui also now declares its @hermes/shared dependency explicitly instead of relying on workspace hoisting. * test(cli): pin nous_billing wire-layer status-to-exception mapping The HTTP layer's error handling had zero coverage through _request: only 2xx parsing and request shaping were tested, and the mapping cases in test_remote_spending_gate_contract.py hit _raise_for_error directly. Adds 19 tests driving _request via a monkeypatched urlopen: the 401-refresh-retry path (success, terminal plain/session_revoked, idempotency-key preservation, base re-resolution), 403 variants through the wire, 429/503 retry-after, non-JSON error bodies, 404/502 fallbacks, and URLError normalization. Two behaviors are pinned as findings rather than fixed: a JSON-body retryAfter hint is ignored unless the Retry-After header is present, and a bare socket.timeout propagates uncaught (real urllib wraps timeouts in URLError before this layer). * fix(cli): normalize read-phase timeouts to the typed billing error urlopen wraps connect-phase timeouts in URLError (already mapped to network_error), but a timeout during resp.read() raises a bare TimeoutError that escaped the typed-BillingError contract and reached callers as an unhandled exception. Catch it narrowly and normalize. The boundary test now asserts normalization instead of documenting the leak. * fix(shared): stop typing mutation success payloads as error payloads BillingMutationResponse.payload was declared BillingErrorPayload, but on ok:true the gateway passes through the raw NAS success body (rail, changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing broke, but the shared contract now feeds the desktop app too — widen the field deliberately and document both shapes. * feat(shared): typed billing refusal and charge-failure unions - BillingRefusalCode covers every code the gateway serializes today, with a (string & {}) arm so unknown future codes (the NAS W3 card-health family) stay assignable — consumers keep their unknown-code fallback. - ChargeFailureReason models the four NAS terminal reasons plus the raw subscription_payment_intent_requires_action code NAS leaks pre-#711. - billing.state now carries the server-derived can_change_plan the gateway emits; capability comments updated (canChangePlan is capability-based, not an OWNER/ADMIN role gate). * feat(shared): closed Known* halves for the refusal and charge-failure unions - KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets, so classification tables, copy maps and tests can be Record-exhaustive and break at compile time when a code is added but not mapped. The wire types keep the (string & {}) open arm for unknown future codes. - Add network_error (client-originated transport code the gateway already serializes) to the known set. - Export the union types from the root barrel alongside the other billing names. * feat(shared): canonical billing refusal policy and charge-settlement driver - billing-policy.ts: one exhaustive Record classifying every known code (recovery kind, mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code fallback. Surfaces keep their own copy; the behavior classification now has a single home that breaks the build when a new code goes unmapped. - charge-settlement.ts: the settlement poll state machine (2s cadence, 5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a pure dependency-injected driver returning a discriminated outcome. - The TUI's pollCharge becomes a thin renderer over the shared driver — byte-identical output, and the desktop poller can now share the same machine instead of a drifting copy. * refactor(shared): move terminal-billing wire types to @hermes/shared The billing/subscription wire shapes (plus UsageBarData/UsageModelData, which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into apps/shared/src/billing-types.ts so the desktop app can share the same gateway contract. gatewayTypes.ts re-exports every moved name from the new @hermes/shared/billing subpath, so no ui-tui consumer changes. The subpath export keeps DOM-less ui-tui from pulling the barrel (whose WebSocket helpers need the DOM lib). ui-tui also now declares its @hermes/shared dependency explicitly instead of relying on workspace hoisting. * test(cli): pin nous_billing wire-layer status-to-exception mapping The HTTP layer's error handling had zero coverage through _request: only 2xx parsing and request shaping were tested, and the mapping cases in test_remote_spending_gate_contract.py hit _raise_for_error directly. Adds 19 tests driving _request via a monkeypatched urlopen: the 401-refresh-retry path (success, terminal plain/session_revoked, idempotency-key preservation, base re-resolution), 403 variants through the wire, 429/503 retry-after, non-JSON error bodies, 404/502 fallbacks, and URLError normalization. Two behaviors are pinned as findings rather than fixed: a JSON-body retryAfter hint is ignored unless the Retry-After header is present, and a bare socket.timeout propagates uncaught (real urllib wraps timeouts in URLError before this layer). * fix(cli): normalize read-phase timeouts to the typed billing error urlopen wraps connect-phase timeouts in URLError (already mapped to network_error), but a timeout during resp.read() raises a bare TimeoutError that escaped the typed-BillingError contract and reached callers as an unhandled exception. Catch it narrowly and normalize. The boundary test now asserts normalization instead of documenting the leak. * fix(shared): stop typing mutation success payloads as error payloads BillingMutationResponse.payload was declared BillingErrorPayload, but on ok:true the gateway passes through the raw NAS success body (rail, changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing broke, but the shared contract now feeds the desktop app too — widen the field deliberately and document both shapes. * feat(shared): typed billing refusal and charge-failure unions - BillingRefusalCode covers every code the gateway serializes today, with a (string & {}) arm so unknown future codes (the NAS W3 card-health family) stay assignable — consumers keep their unknown-code fallback. - ChargeFailureReason models the four NAS terminal reasons plus the raw subscription_payment_intent_requires_action code NAS leaks pre-#711. - billing.state now carries the server-derived can_change_plan the gateway emits; capability comments updated (canChangePlan is capability-based, not an OWNER/ADMIN role gate). * feat(shared): closed Known* halves for the refusal and charge-failure unions - KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets, so classification tables, copy maps and tests can be Record-exhaustive and break at compile time when a code is added but not mapped. The wire types keep the (string & {}) open arm for unknown future codes. - Add network_error (client-originated transport code the gateway already serializes) to the known set. - Export the union types from the root barrel alongside the other billing names. * feat(shared): canonical billing refusal policy and charge-settlement driver - billing-policy.ts: one exhaustive Record classifying every known code (recovery kind, mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code fallback. Surfaces keep their own copy; the behavior classification now has a single home that breaks the build when a new code goes unmapped. - charge-settlement.ts: the settlement poll state machine (2s cadence, 5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a pure dependency-injected driver returning a discriminated outcome. - The TUI's pollCharge becomes a thin renderer over the shared driver — byte-identical output, and the desktop poller can now share the same machine instead of a drifting copy. --- .../src/app/settings/billing/api.test.ts | 172 ++++ apps/desktop/src/app/settings/billing/api.ts | 168 +++ .../src/app/settings/billing/dev-fixtures.ts | 315 ++++++ .../src/app/settings/billing/errors.test.ts | 85 ++ .../src/app/settings/billing/errors.ts | 163 +++ .../settings/billing/fixtures.test-util.ts | 35 + .../src/app/settings/billing/index.test.tsx | 380 +++++++ .../src/app/settings/billing/index.tsx | 961 ++++++++++++++++++ .../src/app/settings/billing/types.test.ts | 120 +++ .../desktop/src/app/settings/billing/types.ts | 33 + .../billing/use-billing-state.test.ts | 302 ++++++ .../app/settings/billing/use-billing-state.ts | 584 +++++++++++ .../billing/use-charge-poller.test.ts | 232 +++++ .../app/settings/billing/use-charge-poller.ts | 298 ++++++ .../app/settings/billing/use-step-up.test.tsx | 132 +++ .../src/app/settings/billing/use-step-up.ts | 143 +++ apps/desktop/src/app/settings/index.tsx | 12 + apps/desktop/src/app/settings/types.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/tsconfig.json | 1 + apps/desktop/vite.config.ts | 1 + 25 files changed, 4143 insertions(+) create mode 100644 apps/desktop/src/app/settings/billing/api.test.ts create mode 100644 apps/desktop/src/app/settings/billing/api.ts create mode 100644 apps/desktop/src/app/settings/billing/dev-fixtures.ts create mode 100644 apps/desktop/src/app/settings/billing/errors.test.ts create mode 100644 apps/desktop/src/app/settings/billing/errors.ts create mode 100644 apps/desktop/src/app/settings/billing/fixtures.test-util.ts create mode 100644 apps/desktop/src/app/settings/billing/index.test.tsx create mode 100644 apps/desktop/src/app/settings/billing/index.tsx create mode 100644 apps/desktop/src/app/settings/billing/types.test.ts create mode 100644 apps/desktop/src/app/settings/billing/types.ts create mode 100644 apps/desktop/src/app/settings/billing/use-billing-state.test.ts create mode 100644 apps/desktop/src/app/settings/billing/use-billing-state.ts create mode 100644 apps/desktop/src/app/settings/billing/use-charge-poller.test.ts create mode 100644 apps/desktop/src/app/settings/billing/use-charge-poller.ts create mode 100644 apps/desktop/src/app/settings/billing/use-step-up.test.tsx create mode 100644 apps/desktop/src/app/settings/billing/use-step-up.ts diff --git a/apps/desktop/src/app/settings/billing/api.test.ts b/apps/desktop/src/app/settings/billing/api.test.ts new file mode 100644 index 000000000000..02f359c35a58 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/api.test.ts @@ -0,0 +1,172 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { BillingChargeResponse, BillingStateResponse } from './types' + +const requestGatewayMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({ + useGatewayRequest: () => ({ requestGateway: requestGatewayMock }) +})) + +import { createBillingApi, useBillingApi } from './api' + +describe('createBillingApi', () => { + beforeEach(() => { + requestGatewayMock.mockReset() + vi.restoreAllMocks() + }) + + it('passes successful RPC results through as data', async () => { + const state = { + auto_reload: null, + balance_display: '$10.00', + balance_usd: '10', + can_charge: true, + card: null, + charge_presets: ['10'], + charge_presets_display: ['$10'], + cli_billing_enabled: true, + is_admin: true, + logged_in: true, + max_usd: '100', + min_usd: '10', + monthly_cap: null, + ok: true, + org_name: 'Nous', + portal_url: 'https://portal.nousresearch.com/billing', + role: 'OWNER' + } satisfies BillingStateResponse + + requestGatewayMock.mockResolvedValueOnce(state) + + const { result } = renderHook(() => useBillingApi()) + const response = await result.current.fetchBillingState() + + expect(response).toEqual({ data: state, ok: true }) + expect(requestGatewayMock).toHaveBeenCalledWith('billing.state', {}) + }) + + it('normalizes object-shaped refusal envelopes', async () => { + requestGatewayMock.mockResolvedValueOnce({ + error: { + kind: 'no_payment_method', + message: 'No saved card.', + portal_url: 'https://portal.nousresearch.com/billing', + retry_after: 30 + }, + ok: false + }) + + const api = createBillingApi(requestGatewayMock) + const response = await api.chargeStatus('ch_123') + + expect(response).toMatchObject({ + ok: false, + refusal: { + kind: 'no_payment_method', + message: 'No saved card.', + portalUrl: 'https://portal.nousresearch.com/billing', + retryAfter: 30 + } + }) + expect(requestGatewayMock).toHaveBeenCalledWith('billing.charge_status', { charge_id: 'ch_123' }) + }) + + it('normalizes current string-shaped refusal envelopes', async () => { + requestGatewayMock.mockResolvedValueOnce({ + error: 'monthly_cap_exceeded', + message: 'Monthly spend cap reached.', + ok: false, + payload: { remainingUsd: '4.50' }, + portal_url: 'https://portal.nousresearch.com/billing' + }) + + const api = createBillingApi(requestGatewayMock) + const response = await api.updateAutoReload({ enabled: true, reload_to_usd: '100', threshold_usd: '25' }) + + expect(response).toMatchObject({ + ok: false, + refusal: { + kind: 'monthly_cap_exceeded', + message: 'Monthly spend cap reached.', + payload: { remainingUsd: '4.50' }, + portalUrl: 'https://portal.nousresearch.com/billing' + } + }) + expect(requestGatewayMock).toHaveBeenCalledWith('billing.auto_reload', { + enabled: true, + threshold: '25', + top_up_amount: '100' + }) + }) + + it('maps thrown gateway failures to transport refusals', async () => { + requestGatewayMock.mockRejectedValueOnce(new Error('connection closed')) + + const api = createBillingApi(requestGatewayMock) + const response = await api.fetchSubscriptionState() + + expect(response).toEqual({ + ok: false, + refusal: { + kind: 'transport', + message: 'connection closed', + raw: expect.any(Error) + } + }) + }) + + it('maps thrown timeout failures to timeout refusals', async () => { + requestGatewayMock.mockRejectedValueOnce(new Error('request timed out after 5000ms')) + + const api = createBillingApi(requestGatewayMock) + const response = await api.stepUp() + + expect(response).toEqual({ + ok: false, + refusal: { + kind: 'timeout', + message: 'request timed out after 5000ms', + raw: expect.any(Error) + } + }) + }) + + it('sends a step-up session id when provided', async () => { + requestGatewayMock.mockResolvedValueOnce({ granted: true, ok: true }) + + const api = createBillingApi(requestGatewayMock) + await api.stepUp('session-123') + + expect(requestGatewayMock).toHaveBeenCalledWith('billing.step_up', { session_id: 'session-123' }) + }) + + it('sends a minted charge idempotency key and reuses it on explicit retry', async () => { + vi.spyOn(crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111') + + const submitted = { + charge_id: 'ch_123', + idempotency_key: '11111111-1111-4111-8111-111111111111', + ok: true + } satisfies BillingChargeResponse + + requestGatewayMock.mockResolvedValue(submitted) + + const api = createBillingApi(requestGatewayMock) + const first = await api.charge('25') + const second = await api.charge('25', first.idempotencyKey) + + expect(first).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true }) + expect(second).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true }) + expect(crypto.randomUUID).toHaveBeenCalledTimes(1) + expect(requestGatewayMock).toHaveBeenNthCalledWith(1, 'billing.charge', { + amount_usd: '25', + idempotency_key: '11111111-1111-4111-8111-111111111111' + }) + expect(requestGatewayMock).toHaveBeenNthCalledWith(2, 'billing.charge', { + amount_usd: '25', + idempotency_key: '11111111-1111-4111-8111-111111111111' + }) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/api.ts b/apps/desktop/src/app/settings/billing/api.ts new file mode 100644 index 000000000000..6177db8da1b6 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/api.ts @@ -0,0 +1,168 @@ +import { useMemo } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' + +import type { + BillingChargeResponse, + BillingChargeStatusResponse, + BillingErrorPayload, + BillingMutationResponse, + BillingRefusalCode, + BillingStateResponse, + SubscriptionStateResponse +} from './types' + +export type BillingErrorKind = BillingRefusalCode + +export interface BillingRefusal { + actor?: string + code?: string + kind: BillingErrorKind | 'timeout' | 'transport' + message: string + payload?: BillingErrorPayload + portalUrl?: string + raw?: unknown + recovery?: string + retryAfter?: number +} + +export type BillingResult = { data: T; ok: true } | { ok: false; refusal: BillingRefusal } + +export type BillingChargeResult = BillingResult & { idempotencyKey: string } + +export interface UpdateAutoReloadInput { + enabled: boolean + reload_to_usd?: string + threshold_usd?: string +} + +export type BillingRequestGateway = ( + method: string, + params?: Record, + timeoutMs?: number, + signal?: AbortSignal +) => Promise + +export interface BillingApi { + charge: (amountUsd: string, idempotencyKey?: string) => Promise + chargeStatus: (chargeId: string) => Promise> + fetchBillingState: () => Promise> + fetchSubscriptionState: () => Promise> + stepUp: (sessionId?: string) => Promise> + updateAutoReload: (input: UpdateAutoReloadInput) => Promise> +} + +interface RefusalRecord { + actor?: unknown + code?: unknown + error?: unknown + kind?: unknown + message?: unknown + payload?: unknown + portal_url?: unknown + recovery?: unknown + retry_after?: unknown +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null + +const asOptionalString = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined + +const asOptionalNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined) + +const asPayload = (value: unknown): BillingErrorPayload | undefined => + isRecord(value) ? (value as BillingErrorPayload) : undefined + +const getMessage = (value: unknown): string => { + if (value instanceof Error && value.message) { + return value.message + } + + if (typeof value === 'string' && value.length > 0) { + return value + } + + return String(value || 'Billing request failed.') +} + +const normalizeRefusal = (raw: Record): BillingRefusal => { + const rawError = raw.error + const error = isRecord(rawError) ? (rawError as RefusalRecord) : undefined + const kind = asOptionalString(error?.kind) ?? asOptionalString(error?.error) ?? asOptionalString(rawError) ?? 'error' + const message = asOptionalString(error?.message) ?? asOptionalString(raw.message) ?? kind + + return { + actor: asOptionalString(error?.actor) ?? asOptionalString(raw.actor), + code: asOptionalString(error?.code) ?? asOptionalString(raw.code), + kind, + message, + payload: asPayload(error?.payload) ?? asPayload(raw.payload), + portalUrl: asOptionalString(error?.portal_url) ?? asOptionalString(raw.portal_url), + raw, + recovery: asOptionalString(error?.recovery) ?? asOptionalString(raw.recovery), + retryAfter: asOptionalNumber(error?.retry_after) ?? asOptionalNumber(raw.retry_after) + } +} + +const normalizeThrown = (error: unknown): BillingRefusal => { + const message = getMessage(error) + const name = error instanceof Error ? error.name : '' + + return { + kind: name === 'TimeoutError' || /timed?\s*out|timeout/i.test(message) ? 'timeout' : 'transport', + message, + raw: error + } +} + +const normalizeRpcResult = (response: T): BillingResult => { + if (isRecord(response) && response.ok === false) { + return { ok: false, refusal: normalizeRefusal(response) } + } + + return { data: response, ok: true } +} + +const callBilling = async ( + requestGateway: BillingRequestGateway, + method: string, + params: Record = {} +): Promise> => { + try { + return normalizeRpcResult(await requestGateway(method, params)) + } catch (error) { + return { ok: false, refusal: normalizeThrown(error) } + } +} + +export const createBillingApi = (requestGateway: BillingRequestGateway): BillingApi => ({ + charge: async (amountUsd, idempotencyKey = crypto.randomUUID()) => { + const result = await callBilling(requestGateway, 'billing.charge', { + amount_usd: amountUsd, + idempotency_key: idempotencyKey + }) + + return { ...result, idempotencyKey } + }, + chargeStatus: chargeId => + callBilling(requestGateway, 'billing.charge_status', { charge_id: chargeId }), + fetchBillingState: () => callBilling(requestGateway, 'billing.state'), + fetchSubscriptionState: () => callBilling(requestGateway, 'subscription.state'), + stepUp: sessionId => + callBilling(requestGateway, 'billing.step_up', { + ...(sessionId !== undefined ? { session_id: sessionId } : {}) + }), + updateAutoReload: input => + callBilling(requestGateway, 'billing.auto_reload', { + enabled: input.enabled, + ...(input.threshold_usd !== undefined ? { threshold: input.threshold_usd } : {}), + ...(input.reload_to_usd !== undefined ? { top_up_amount: input.reload_to_usd } : {}) + }) +}) + +export function useBillingApi(): BillingApi { + const { requestGateway } = useGatewayRequest() + + return useMemo(() => createBillingApi(requestGateway), [requestGateway]) +} diff --git a/apps/desktop/src/app/settings/billing/dev-fixtures.ts b/apps/desktop/src/app/settings/billing/dev-fixtures.ts new file mode 100644 index 000000000000..8f69cf687a1e --- /dev/null +++ b/apps/desktop/src/app/settings/billing/dev-fixtures.ts @@ -0,0 +1,315 @@ +import type { BillingResult } from './api' +import type { BillingStateResponse, SubscriptionStateResponse } from './types' + +const current = ( + overrides: Partial> = {} +): NonNullable => ({ + cancel_at_period_end: false, + cancellation_effective_at: null, + cancellation_effective_display: null, + credits_remaining: '120', + cycle_ends_at: '2026-07-11T08:14:55.000Z', + monthly_credits: '220', + pending_downgrade_at: null, + pending_downgrade_display: null, + pending_downgrade_tier_name: null, + tier_id: 'ultra', + tier_name: 'Ultra', + ...overrides +}) + +export const todayBillingState = { + auto_reload: { + card: { kind: 'canonical' }, + enabled: true, + reload_to_display: '$10', + reload_to_usd: '10', + threshold_display: '$5', + threshold_usd: '5' + }, + balance_display: '$996.47', + balance_usd: '996.47', + can_charge: false, + card: { + brand: 'visa', + last4: '3206', + masked: 'visa ....3206' + }, + charge_presets: ['100', '250', '500'], + charge_presets_display: ['$100', '$250', '$500'], + cli_billing_enabled: false, + is_admin: true, + logged_in: true, + max_usd: '1000', + min_usd: '10', + monthly_cap: { + is_default_ceiling: true, + limit_display: '$100', + limit_usd: '100', + spent_display: '$10', + spent_this_month_usd: '10' + }, + ok: true, + org_name: 'sid-5', + portal_url: 'https://portal.nousresearch.com/billing', + role: 'OWNER', + usage: { + available: true, + has_topup: true, + plan_name: 'Ultra', + renews_at: '2026-07-11T08:14:55.000Z', + renews_display: 'Jul 11', + status: 'active', + subscription_remaining_display: '$120', + topup_remaining_display: '$876.47', + total_spendable_display: '$996.47' + } +} satisfies BillingStateResponse + +export const todaySubscriptionState = { + can_change_plan: true, + context: 'team', + current: current(), + is_admin: true, + logged_in: true, + ok: true, + org_id: 'sid-5', + org_name: 'sid-5', + portal_url: 'https://portal.nousresearch.com/billing', + role: 'OWNER', + tiers: [ + { + dollars_per_month_display: '$200', + is_current: true, + is_enabled: true, + monthly_credits: '220', + name: 'Ultra', + tier_id: 'ultra', + tier_order: 3 + } + ], + usage: todayBillingState.usage +} satisfies SubscriptionStateResponse + +export const postTrainBillingState = { + ...todayBillingState, + auto_reload: { + card: { kind: 'canonical' }, + enabled: false, + reload_to_display: '$100', + reload_to_usd: '100', + threshold_display: '$25', + threshold_usd: '25' + }, + balance_display: '$142.50', + balance_usd: '142.50', + can_charge: true, + card: { + brand: 'visa', + display: 'Visa ....4242 - the card on your subscription', + last4: '4242', + masked: 'visa ....4242', + resolved_via: 'subPin' + }, + charge_presets: ['25', '50', '100'], + charge_presets_display: ['$25', '$50', '$100'], + cli_billing_enabled: true, + monthly_cap: { + is_default_ceiling: false, + limit_display: '$1,000', + limit_usd: '1000', + spent_display: '$180', + spent_this_month_usd: '180' + }, + org_name: 'Acme Research', + usage: { + available: true, + has_topup: true, + plan_bar: { + fill_fraction: 0.4, + kind: 'plan', + pct_used: 60, + remaining_display: '$40', + spent_display: '$60', + total_display: '$100' + }, + plan_name: 'Pro', + renews_at: '2026-07-31T00:00:00Z', + renews_display: 'Jul 31', + status: 'active', + subscription_remaining_display: '$40', + topup_bar: { + fill_fraction: 0.75, + kind: 'topup', + pct_used: 25, + remaining_display: '$75', + spent_display: '$25', + total_display: '$100' + }, + topup_remaining_display: '$75', + total_spendable_display: '$115' + } +} satisfies BillingStateResponse + +export const postTrainSubscriptionState = { + ...todaySubscriptionState, + current: current({ + credits_remaining: '40', + cycle_ends_at: '2026-07-31T00:00:00Z', + monthly_credits: '100', + tier_id: 'pro', + tier_name: 'Pro' + }), + org_id: 'org_123', + org_name: 'Acme Research', + tiers: [ + { + dollars_per_month_display: '$20', + is_current: true, + is_enabled: true, + monthly_credits: '100', + name: 'Pro', + tier_id: 'pro', + tier_order: 2 + } + ], + usage: postTrainBillingState.usage +} satisfies SubscriptionStateResponse + +export const loggedOutBillingState = { + ...todayBillingState, + auto_reload: null, + balance_display: '$0.00', + balance_usd: null, + can_charge: false, + card: null, + charge_presets: [], + charge_presets_display: [], + logged_in: false, + monthly_cap: null, + org_name: null, + portal_url: 'https://portal.nousresearch.com/login', + role: null, + usage: undefined +} satisfies BillingStateResponse + +export const loggedOutSubscriptionState = { + ...todaySubscriptionState, + can_change_plan: false, + current: null, + is_admin: false, + logged_in: false, + org_id: null, + org_name: null, + portal_url: 'https://portal.nousresearch.com/login', + role: null, + tiers: [], + usage: undefined +} satisfies SubscriptionStateResponse + +const okBilling = (data: BillingStateResponse): BillingResult => ({ data, ok: true }) + +const okSubscription = (data: SubscriptionStateResponse): BillingResult => ({ + data, + ok: true +}) + +function withUsage( + name: string, + { + autoReload = postTrainBillingState.auto_reload, + canCharge = true, + card = postTrainBillingState.card, + cliBillingEnabled = true, + monthlyCapSpent = '89', + remaining, + subscriptionCurrent = current({ credits_remaining: remaining, monthly_credits: '220' }) + }: { + autoReload?: BillingStateResponse['auto_reload'] + canCharge?: boolean + card?: BillingStateResponse['card'] + cliBillingEnabled?: boolean + monthlyCapSpent?: string + remaining: string + subscriptionCurrent?: SubscriptionStateResponse['current'] + } +) { + const billing = { + ...postTrainBillingState, + auto_reload: autoReload, + balance_display: '$142.50', + balance_usd: '142.50', + can_charge: canCharge, + card, + cli_billing_enabled: cliBillingEnabled, + monthly_cap: { + is_default_ceiling: false, + limit_display: '$100', + limit_usd: '100', + spent_display: `$${monthlyCapSpent}`, + spent_this_month_usd: monthlyCapSpent + }, + org_name: `${name} Fixture`, + usage: { + ...postTrainBillingState.usage, + plan_name: 'Ultra', + subscription_remaining_display: `$${remaining}`, + total_spendable_display: '$142.50' + } + } satisfies BillingStateResponse + + const subscription = { + ...todaySubscriptionState, + current: subscriptionCurrent, + org_name: `${name} Fixture`, + usage: billing.usage + } satisfies SubscriptionStateResponse + + return { billing: okBilling(billing), subscription: okSubscription(subscription) } +} + +export const billingDevFixtures = { + healthy: withUsage('Healthy', { monthlyCapSpent: '89', remaining: '132' }), + 'auto-refill-divergent': withUsage('Auto Refill Divergent', { + autoReload: { + ...postTrainBillingState.auto_reload, + card: { kind: 'distinct', payment_method_id: 'pm_divergent_1', brand: 'mastercard', last4: '4444' }, + enabled: true + }, + remaining: '132' + }), + low: withUsage('Low', { remaining: '19.8' }), + boundary: withUsage('Boundary', { remaining: '22' }), + 'empty-overdrawn': withUsage('Empty Overdrawn', { remaining: '-0.79' }), + 'cap-near': withUsage('Cap Near', { monthlyCapSpent: '92', remaining: '132' }), + 'cap-hit': withUsage('Cap Hit', { monthlyCapSpent: '100', remaining: '132' }), + 'no-card': withUsage('No Card', { card: null, remaining: '132' }), + 'no-subscription': withUsage('No Subscription', { remaining: '132', subscriptionCurrent: null }), + 'logged-out': { + billing: okBilling(loggedOutBillingState), + subscription: okSubscription(loggedOutSubscriptionState) + }, + refusal: { + billing: { + ok: false, + refusal: { + kind: 'temporarily_unavailable', + message: 'Billing is temporarily unavailable.', + retryAfter: 90 + } + }, + subscription: okSubscription(todaySubscriptionState) + }, + 'billing-off': { + billing: okBilling(todayBillingState), + subscription: okSubscription(todaySubscriptionState) + } +} satisfies Record< + string, + { + billing: BillingResult + subscription: BillingResult + } +> + +export type BillingDevFixtureName = keyof typeof billingDevFixtures diff --git a/apps/desktop/src/app/settings/billing/errors.test.ts b/apps/desktop/src/app/settings/billing/errors.test.ts new file mode 100644 index 000000000000..f478f8ca5266 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/errors.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' + +import type { KnownBillingRefusalCode } from '@hermes/shared/billing' + +import type { BillingRefusal } from './api' +import { resolveRefusal } from './errors' + +const expectedActions: Record< + KnownBillingRefusalCode | 'timeout' | 'transport', + 'none' | 'portal' | 'retry' | 'step_up' +> = { + auto_top_up_disabled_failures: 'none', + cli_billing_disabled: 'portal', + consent_required: 'portal', + endpoint_unavailable: 'retry', + idempotency_conflict: 'none', + idempotency_key_required: 'none', + insufficient_scope: 'step_up', + internal_error: 'none', + invalid_charge_id: 'none', + invalid_request: 'none', + monthly_cap_exceeded: 'portal', + network_error: 'none', + no_payment_method: 'portal', + org_access_denied: 'none', + preview_rejected: 'none', + rate_limited: 'retry', + remote_spending_disabled: 'portal', + remote_spending_revoked: 'portal', + role_required: 'portal', + session_revoked: 'portal', + stripe_unavailable: 'retry', + temporarily_unavailable: 'retry', + timeout: 'retry', + transport: 'retry', + upgrade_cap_exceeded: 'none', + validation_failed: 'none' +} + +describe('resolveRefusal', () => { + it('maps every known refusal kind to copy and the expected action', () => { + for (const [kind, actionType] of Object.entries(expectedActions)) { + const resolved = resolveRefusal({ + kind: kind as BillingRefusal['kind'], + message: 'Server message.', + portalUrl: 'https://portal.nousresearch.com/billing', + retryAfter: 90 + }) + + expect(resolved.title, kind).not.toHaveLength(0) + expect(resolved.message, kind).not.toHaveLength(0) + expect(resolved.action.type, kind).toBe(actionType) + } + }) + + it('includes monthly cap headroom when the server sends it', () => { + const resolved = resolveRefusal({ + kind: 'monthly_cap_exceeded', + message: 'Monthly spend cap reached.', + payload: { remainingUsd: '4.50' } + }) + + expect(resolved.message).toContain('$4.50 headroom left') + }) + + it('includes Stripe retry timing when the server sends it', () => { + const resolved = resolveRefusal({ + kind: 'stripe_unavailable', + message: 'Stripe is unavailable.', + retryAfter: 120 + }) + + expect(resolved.message).toContain('try again in ~2 min') + }) + + it('falls back sanely for unknown refusal kinds', () => { + const resolved = resolveRefusal({ kind: 'new_billing_code', message: 'Something changed upstream.' }) + + expect(resolved).toEqual({ + action: { type: 'none' }, + message: 'Something changed upstream.', + title: 'Billing request failed' + }) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/errors.ts b/apps/desktop/src/app/settings/billing/errors.ts new file mode 100644 index 000000000000..90207d3d37cb --- /dev/null +++ b/apps/desktop/src/app/settings/billing/errors.ts @@ -0,0 +1,163 @@ +import type { BillingRefusal } from './api' + +export interface BillingRefusalPresentation { + action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' } + message: string + title: string +} + +const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url }) + +const retryMessage = (refusal: BillingRefusal): string => { + const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : '' + + return `🟡 Too many charges right now${mins}. This isn't a payment failure.` +} + +const stripeRetryMessage = (refusal: BillingRefusal): string => { + const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : '' + + return `Stripe is having trouble — try again shortly${mins}` +} + +export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => { + switch (refusal.kind) { + case 'consent_required': + return { + action: portalAction(refusal.portalUrl), + message: 'Confirm this card for terminal charges in the portal', + title: 'Card confirmation needed' + } + + case 'insufficient_scope': + return { + action: { type: 'step_up' }, + message: 'This needs terminal billing enabled. Start a top-up to enable it, then retry.', + title: 'Terminal billing needs approval' + } + case 'remote_spending_revoked': { + const who = + refusal.actor === 'admin' + ? 'An admin turned off terminal billing for this terminal.' + : 'You turned off terminal billing for this terminal.' + + return { + action: portalAction(refusal.portalUrl), + message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`, + title: 'Terminal billing was turned off' + } + } + + case 'session_revoked': + return { + action: portalAction(refusal.portalUrl), + message: 'Your session was logged out. Sign in again from Settings → Gateway.', + title: 'Session logged out' + } + + case 'cli_billing_disabled': + + case 'remote_spending_disabled': + return { + action: portalAction(refusal.portalUrl), + message: 'Terminal billing is off for this account — an admin must enable it on the portal.', + title: 'Terminal billing is off' + } + + case 'role_required': + return { + action: portalAction(refusal.portalUrl), + message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.', + title: 'Admin role required' + } + + case 'idempotency_conflict': + return { + action: { type: 'none' }, + message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.', + title: 'Start a fresh top-up' + } + + case 'no_payment_method': + return { + action: portalAction(refusal.portalUrl), + message: + '💳 No saved card for terminal charges yet. Set one up on the portal ' + + "(one-time credit buys don't save a reusable card).", + title: 'No saved card' + } + + case 'org_access_denied': + return { + action: { type: 'none' }, + message: "This token isn't bound to an org you can manage", + title: 'Org access denied' + } + + case 'monthly_cap_exceeded': { + const remaining = refusal.payload?.remainingUsd + + return { + action: portalAction(refusal.portalUrl), + message: + remaining != null + ? `🔴 Monthly spend cap reached — $${remaining} headroom left.` + : '🔴 Monthly spend cap reached.', + title: 'Monthly spend cap reached' + } + } + + case 'rate_limited': + + case 'temporarily_unavailable': + return { + action: { type: 'retry' }, + message: retryMessage(refusal), + title: 'Too many charges right now' + } + + case 'stripe_unavailable': + return { + action: { type: 'retry' }, + message: stripeRetryMessage(refusal), + title: 'Stripe is having trouble' + } + + case 'upgrade_cap_exceeded': + return { + action: { type: 'none' }, + message: 'Daily plan-change limit reached — try again tomorrow', + title: 'Daily plan-change limit reached' + } + + case 'endpoint_unavailable': + return { + action: { type: 'retry' }, + message: + refusal.message || + 'Billing endpoint returned a non-JSON response (it may not be available on this deployment).', + title: 'Billing endpoint unavailable' + } + + case 'timeout': + return { + action: { type: 'retry' }, + message: refusal.message || 'Billing request timed out.', + title: 'Billing request timed out' + } + + case 'transport': + return { + action: { type: 'retry' }, + message: refusal.message || 'Billing request failed before reaching the gateway.', + title: 'Billing connection failed' + } + + default: + return { + action: { type: 'none' }, + message: refusal.message || 'Billing request failed.', + title: 'Billing request failed' + } + } +} diff --git a/apps/desktop/src/app/settings/billing/fixtures.test-util.ts b/apps/desktop/src/app/settings/billing/fixtures.test-util.ts new file mode 100644 index 000000000000..fb72e21b1a68 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/fixtures.test-util.ts @@ -0,0 +1,35 @@ +import type { BillingResult } from './api' +import type { BillingStateResponse, SubscriptionStateResponse } from './types' + +export { + billingDevFixtures, + loggedOutBillingState, + loggedOutSubscriptionState, + postTrainBillingState, + postTrainSubscriptionState, + todayBillingState, + todaySubscriptionState +} from './dev-fixtures' + +export const okBilling = (data: BillingStateResponse): BillingResult => ({ data, ok: true }) + +export const okSubscription = (data: SubscriptionStateResponse): BillingResult => ({ + data, + ok: true +}) + +export const endpointUnavailableBilling = { + ok: false, + refusal: { + kind: 'endpoint_unavailable', + message: 'Billing endpoint returned a non-JSON response.' + } +} satisfies BillingResult + +export const endpointUnavailableSubscription = { + ok: false, + refusal: { + kind: 'endpoint_unavailable', + message: 'Subscription endpoint is not available.' + } +} satisfies BillingResult diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx new file mode 100644 index 000000000000..27b702d99d34 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -0,0 +1,380 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + billingDevFixtures, + loggedOutBillingState, + loggedOutSubscriptionState, + okBilling, + okSubscription, + postTrainBillingState, + postTrainSubscriptionState, + todayBillingState, + todaySubscriptionState +} from './fixtures.test-util' +import { formatUsageUpdatedAgo } from './use-billing-state' + +import { BillingSettings } from './index' + +const apiMocks = vi.hoisted(() => ({ + charge: vi.fn(), + chargeStatus: vi.fn(), + fetchBillingState: vi.fn(), + fetchSubscriptionState: vi.fn(), + openExternal: vi.fn(), + stepUp: vi.fn(), + updateAutoReload: vi.fn() +})) + +vi.mock('./api', () => ({ + useBillingApi: () => ({ + charge: apiMocks.charge, + chargeStatus: apiMocks.chargeStatus, + fetchBillingState: apiMocks.fetchBillingState, + fetchSubscriptionState: apiMocks.fetchSubscriptionState, + stepUp: apiMocks.stepUp, + updateAutoReload: apiMocks.updateAutoReload + }) +})) + +function renderBilling() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + + ) + + return client +} + +beforeEach(() => { + apiMocks.fetchBillingState.mockResolvedValue(okBilling(todayBillingState)) + apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(todaySubscriptionState)) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + openExternal: apiMocks.openExternal + } + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('BillingSettings', () => { + it('renders the deployed-today payload with buy controls hidden and usage rows visible', async () => { + renderBilling() + + expect(await screen.findByText('$996.47')).toBeTruthy() + expect(screen.getByText('Ultra · $200/mo')).toBeTruthy() + expect(screen.getByText('Visa •••• 3206')).toBeTruthy() + expect( + screen.getByText('Terminal billing is off for this account — an admin must enable it on the portal.') + ).toBeTruthy() + expect(screen.queryByRole('button', { name: '$100' })).toBeNull() + expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy() + expect(screen.getByText('$120 of $220 left')).toBeTruthy() + expect(screen.getByText('$876.47')).toBeTruthy() + expect(screen.getByText('$10 of $100 used').classList.contains('tabular-nums')).toBe(true) + expect(screen.getByText('Default ceiling')).toBeTruthy() + }) + + it('renders the post-train payload with enabled buy controls and card provenance', async () => { + apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState)) + apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState)) + + renderBilling() + + expect(await screen.findByText('$142.50')).toBeTruthy() + expect(screen.getByText('Visa •••• 4242 - subscription card')).toBeTruthy() + expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(false) + expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(false) + expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(false) + expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' })).toBeTruthy() + expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(false) + }) + + it('disables buy controls when no card is on file', async () => { + const fixture = billingDevFixtures['no-card'] + + apiMocks.fetchBillingState.mockResolvedValue(fixture.billing) + apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription) + + renderBilling() + + expect(await screen.findByText('No card on file')).toBeTruthy() + expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: /^Buy$/ })) + + expect(apiMocks.charge).not.toHaveBeenCalled() + }) + + it('saves enabled auto-refill edits and refreshes billing state', async () => { + const client = renderBilling() + const invalidate = vi.spyOn(client, 'invalidateQueries') + + apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true }) + + fireEvent.click(await screen.findByRole('button', { name: 'Manage' })) + fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), { + target: { value: '15' } + }) + fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), { + target: { value: '20' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => + expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ + enabled: true, + reload_to_usd: '20', + threshold_usd: '15' + }) + ) + await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] })) + expect(await screen.findByText('Auto-refill updated.')).toBeTruthy() + }) + + it('rejects auto-refill amounts outside the billing bounds', async () => { + renderBilling() + + fireEvent.click(await screen.findByRole('button', { name: 'Manage' })) + fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), { + target: { value: '7.50' } + }) + + expect(screen.getByText('Threshold: minimum is $10.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(apiMocks.updateAutoReload).not.toHaveBeenCalled() + }) + + it('requires inline confirmation before disabling auto-refill', async () => { + renderBilling() + + apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true }) + + fireEvent.click(await screen.findByRole('button', { name: 'Manage' })) + fireEvent.click(screen.getByRole('button', { name: 'Disable' })) + + expect(screen.getByText('Turn off auto-refill?')).toBeTruthy() + expect(apiMocks.updateAutoReload).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Turn off' })) + + await waitFor(() => expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: false })) + }) + + it('renders auto-refill mutation refusals and step-up affordance', async () => { + renderBilling() + + apiMocks.updateAutoReload.mockResolvedValue({ + ok: false, + refusal: { + kind: 'insufficient_scope', + message: 'billing:manage required' + } + }) + + fireEvent.click(await screen.findByRole('button', { name: 'Manage' })) + fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), { + target: { value: '15' } + }) + fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), { + target: { value: '20' } + }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + expect(await screen.findByText('Terminal billing needs approval:')).toBeTruthy() + expect( + screen.getByText('This needs terminal billing enabled. Start a top-up to enable it, then retry.') + ).toBeTruthy() + expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy() + }) + + it('keeps disabled auto-refill portal-only with no enable control', async () => { + apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState)) + apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState)) + + renderBilling() + + expect((await screen.findAllByText('Off')).length).toBeGreaterThan(0) + expect(screen.getByText('Turn on auto-refill from the portal')).toBeTruthy() + expect(screen.queryByRole('button', { name: /enable/i })).toBeNull() + expect(screen.queryByRole('button', { name: 'Manage' })).toBeNull() + }) + + it('disables buy controls while polling and renders the settled outcome', async () => { + let settleStatus: (value: unknown) => void = () => {} + + const statusPromise = new Promise(resolve => { + settleStatus = resolve + }) + + apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState)) + apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState)) + apiMocks.charge.mockResolvedValue({ + data: { + charge_id: 'ch_123', + ok: true + }, + idempotencyKey: 'key-1', + ok: true + }) + apiMocks.chargeStatus.mockReturnValue(statusPromise) + + renderBilling() + + fireEvent.click(await screen.findByRole('button', { name: /^Buy$/ })) + + expect(await screen.findByText('Processing… checking settlement')).toBeTruthy() + expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true) + + settleStatus({ + data: { + amount_usd: '25', + ok: true, + status: 'settled' + }, + ok: true + }) + + await waitFor(() => expect(screen.getByText('$25 added. Balance is refreshing.')).toBeTruthy()) + }) + + it('renders logged-out as a connect card without normal account rows', async () => { + apiMocks.fetchBillingState.mockResolvedValue(okBilling(loggedOutBillingState)) + apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(loggedOutSubscriptionState)) + + renderBilling() + + expect(await screen.findByText('Connect your Nous account')).toBeTruthy() + expect(screen.getByText('Run /portal in the TUI or open the Nous portal to connect your account.')).toBeTruthy() + expect(screen.queryByText('Payment method')).toBeNull() + expect(screen.queryByText('Usage')).toBeNull() + }) + + it('renders danger value text for overdrawn subscription credits', async () => { + const fixture = billingDevFixtures['empty-overdrawn'] + + apiMocks.fetchBillingState.mockResolvedValue(fixture.billing) + apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription) + + renderBilling() + + expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true) + const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' }) + + expect(subscriptionTrack.classList.contains('dither')).toBe(true) + expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true) + expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true) + }) + + it('renders an empty neutral usage track when a row has no bar data', async () => { + const fixture = billingDevFixtures['no-subscription'] + + apiMocks.fetchBillingState.mockResolvedValue( + okBilling({ + ...todayBillingState, + monthly_cap: { + ...todayBillingState.monthly_cap, + spent_display: '$0', + spent_this_month_usd: '0' + } + }) + ) + apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription) + + renderBilling() + + await screen.findByText('Subscription credits') + const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits usage' }) + + expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0') + expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false) + expect(subscriptionTrack.classList.contains('dither')).toBe(true) + + const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' }) + + expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0') + expect(monthlyCapTrack.classList.contains('dither')).toBe(true) + expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true) + }) + + it('refreshes both billing queries from the usage refresh button', async () => { + renderBilling() + + await screen.findByText('$120 of $220 left') + expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1) + expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })) + + await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2)) + expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2) + }) + + it('disables the usage refresh button while either query is fetching', async () => { + let settleBilling: (value: unknown) => void = () => {} + + let settleSubscription: (value: unknown) => void = () => {} + + apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce( + new Promise(resolve => { + settleBilling = resolve + }) + ) + apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce( + new Promise(resolve => { + settleSubscription = resolve + }) + ) + + renderBilling() + + const refresh = await screen.findByRole('button', { name: 'Refresh' }) + + fireEvent.click(refresh) + + await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true)) + + settleBilling(okBilling(todayBillingState)) + settleSubscription(okSubscription(todaySubscriptionState)) + + await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false)) + }) +}) + +describe('formatUsageUpdatedAgo', () => { + it('formats sub-second and current timestamps as just now', () => { + expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now') + expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now') + }) + + it('formats seconds below a minute', () => { + expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago') + }) + + it('rounds elapsed time to whole minutes from 61 seconds', () => { + expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago') + }) + + it('formats one hour and later as hours', () => { + expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago') + }) +}) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx new file mode 100644 index 000000000000..94ffda3b7c64 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -0,0 +1,961 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Tip } from '@/components/ui/tooltip' +import { BarChart3, ExternalLink, RefreshCw } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { ListRow, Pill, SectionHeading, SettingsContent } from '../primitives' + +import type { BillingRefusal } from './api' +import { useBillingApi } from './api' +import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures' +import { resolveRefusal } from './errors' +import type { BillingAutoReload, BillingStateResponse } from './types' +import { + type BillingAccountRowView, + type BillingNoticeView, + type BillingUsageRowView, + deriveBillingView, + EMPTY_BILLING_VALUE, + formatUsageUpdatedAgo, + useBillingState, + useSubscriptionState +} from './use-billing-state' +import { useChargeFlow } from './use-charge-poller' +import { useStepUpFlow } from './use-step-up' + +const FEATURE_BILLING_INVOICES = false + +const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV + ? (Object.keys(billingDevFixtures) as BillingDevFixtureName[]) + : [] + +type BillingFixtureSelection = 'live' | BillingDevFixtureName + +function openExternal(url?: string) { + if (!url) { + return + } + + void window.hermesDesktop?.openExternal?.(url) +} + +function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} + +function NoticeCard({ notice }: { notice: BillingNoticeView }) { + return ( +
+
{notice.title}
+
+ {notice.message} +
+ {notice.action && ( + + )} +
+ ) +} + +function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) { + return ( +
+ {row.value && ( + + {row.value} + + )} + {row.pill && {row.pill.label}} + {row.secondaryPill && {row.secondaryPill}} + {row.chips?.map(chip => ( + + ))} + {row.action && ( + + )} +
+ ) +} + +function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) { + if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) { + return + } + + if (row.id === 'auto_reload' && billing?.auto_reload) { + return + } + + return ( + } + below={ + row.caption ? ( +
+ {row.caption} +
+ ) : undefined + } + description={row.description} + key={row.id} + title={row.title} + /> + ) +} + +function AutoReloadRow({ + autoReload, + bounds, + row +}: { + autoReload: BillingAutoReload + bounds: Pick + row: BillingAccountRowView +}) { + const api = useBillingApi() + const queryClient = useQueryClient() + const [confirmDisable, setConfirmDisable] = useState(false) + const [editing, setEditing] = useState(false) + const [message, setMessage] = useState(null) + const [refusal, setRefusal] = useState(null) + + const [reloadTo, setReloadTo] = useState( + initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display) + ) + + const [saving, setSaving] = useState(false) + + const [threshold, setThreshold] = useState( + initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display) + ) + + const validation = validateAutoReloadInputs(threshold, reloadTo, bounds) + const busy = saving + const maxBound = bounds.max_usd ?? undefined + const minBound = bounds.min_usd ?? undefined + + const resetFeedback = () => { + setConfirmDisable(false) + setMessage(null) + setRefusal(null) + } + + const save = async () => { + if (!validation.values || busy) { + return + } + + resetFeedback() + setSaving(true) + + const result = await api.updateAutoReload({ + enabled: true, + reload_to_usd: validation.values.reloadTo, + threshold_usd: validation.values.threshold + }) + + setSaving(false) + + if (!result.ok) { + setRefusal(result.refusal) + + return + } + + await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }) + setMessage({ kind: 'success', text: 'Auto-refill updated.' }) + setEditing(false) + } + + const disable = async () => { + if (busy) { + return + } + + resetFeedback() + setSaving(true) + + const result = await api.updateAutoReload({ enabled: false }) + + setSaving(false) + + if (!result.ok) { + setRefusal(result.refusal) + + return + } + + await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }) + setMessage({ kind: 'success', text: 'Auto-refill turned off.' }) + setEditing(false) + } + + const below = editing ? ( +
+
+ + +
+ {validation.error && ( +
{validation.error}
+ )} +
+ + + +
+ {confirmDisable && ( +
+ Turn off auto-refill? + + +
+ )} + + {message && {message.text}} +
+ ) : ( + <> + {row.caption ? ( +
+ {row.caption} +
+ ) : null} + + {message && {message.text}} + + ) + + return ( + { + resetFeedback() + setEditing(true) + } + } + row={row} + /> + } + below={below} + description={row.description} + key={row.id} + title={row.title} + /> + ) +} + +function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) { + const presets = useMemo( + () => + billing.charge_presets.map((amount, index) => ({ + amount, + label: billing.charge_presets_display[index] || formatMoney(amount) + })), + [billing.charge_presets, billing.charge_presets_display] + ) + + const initialAmount = presets[0]?.amount ?? billing.min_usd ?? '' + const [amount, setAmount] = useState(initialAmount) + const flow = useChargeFlow() + const busy = flow.phase === 'charging' || flow.phase === 'polling' + const controlsDisabled = busy || !billing.card + const clampedAmount = clampAmount(amount, billing) + const canBuy = !controlsDisabled && clampedAmount !== '' + + const startBuy = () => { + if (!canBuy) { + return + } + + setAmount(clampedAmount) + void flow.start(clampedAmount) + } + + return ( + + {presets.map(preset => ( + + ))} + setAmount(clampedAmount)} + onChange={event => { + flow.reset() + setAmount(event.target.value) + }} + placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'} + step="0.01" + type="number" + value={amount} + /> + + + } + below={ + { + if (!clampedAmount) { + return + } + + void flow.start(clampedAmount) + }} + outcome={flow.outcome} + /> + } + description={row.description} + key={row.id} + title={row.title} + /> + ) +} + +function BuyCreditsOutcome({ + amount, + busy, + onPortal, + onRetry, + outcome +}: { + amount: string + busy: boolean + onPortal: (url?: string) => void + onRetry: () => void + outcome: ReturnType['outcome'] +}) { + const stepUp = useStepUpFlow() + + if (busy) { + return ( +
+ Processing… checking settlement +
+ ) + } + + if (!outcome) { + return null + } + + if (outcome.kind === 'success') { + return ( +
+ {formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing. +
+ ) + } + + if (outcome.kind === 'ambiguous') { + return ( +
+ + {outcome.title}: {outcome.message} + + {outcome.portalUrl && ( + + )} +
+ ) + } + + const portalUrl = outcome.action?.type === 'portal' ? outcome.action.url : undefined + + return ( +
+ + {outcome.title}: {outcome.message} + + {outcome.action?.type === 'retry' && ( + + )} + {outcome.action?.type === 'step_up' && } + {portalUrl && ( + + )} +
+ ) +} + +function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) { + const stepUp = useStepUpFlow() + + if (!refusal) { + return null + } + + const resolved = resolveRefusal(refusal) + const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined + + return ( +
+ + {resolved.title}: {resolved.message} + + {resolved.action.type === 'step_up' && } + {portalUrl && ( + + )} +
+ ) +} + +function StepUpInlineAction({ flow }: { flow: ReturnType }) { + if (flow.verification) { + return ( + + {flow.verification.code} + + + ) + } + + if (flow.message) { + return ( + + + {flow.message.title}: {flow.message.text} + + + + ) + } + + if (flow.phase === 'waiting') { + return Waiting for verification link… + } + + return ( + + ) +} + +function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) { + return ( +
+ {children} +
+ ) +} + +function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) { + const resolvedBar = bar ?? { + label: `${fallbackLabel} usage`, + state: 'neutral', + tone: 'topup', + value: 0 + } + + const width = Math.round(resolvedBar.value * 100) + const isEmpty = resolvedBar.value === 0 + const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0 + + return ( +
+ {showDangerNub &&
} +
0 ? 4 : undefined, + width: `${width}%` + }} + /> +
+ ) +} + +function UsageRow({ row }: { row: BillingUsageRowView }) { + return ( +
+
+
+
+ {row.title} +
+
+ {row.caption} +
+
+
+ +
+
+ {row.value} +
+
+
+ ) +} + +function UsageRefreshRow({ + fixtureName, + isFetching, + onRefresh, + updatedAt +}: { + fixtureName?: BillingFixtureSelection + isFetching: boolean + onRefresh: () => void + updatedAt: number +}) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + const interval = window.setInterval(() => setNow(Date.now()), 30_000) + + return () => window.clearInterval(interval) + }, []) + + if (fixtureName && fixtureName !== 'live') { + return ( +
+ fixture: {fixtureName} +
+ ) + } + + return ( +
+ Updated {formatUsageUpdatedAgo(updatedAt, now)} + + + +
+ ) +} + +function BillingFixtureSelect({ + onValueChange, + value +}: { + onValueChange: (value: BillingFixtureSelection) => void + value: BillingFixtureSelection +}) { + return ( + + ) +} + +function BillingHeader({ + fixtureName, + onFixtureChange +}: { + fixtureName?: BillingFixtureSelection + onFixtureChange?: (value: BillingFixtureSelection) => void +}) { + return ( +
+
+ + Billing +
+ {import.meta.env.DEV && fixtureName && onFixtureChange ? ( + + ) : null} +
+ ) +} + +function BillingSettingsContent({ + fixtureName, + onFixtureChange +}: { + fixtureName?: BillingFixtureSelection + onFixtureChange?: (value: BillingFixtureSelection) => void +}) { + const fixture = + import.meta.env.DEV && fixtureName && fixtureName !== 'live' ? billingDevFixtures[fixtureName] : undefined + + const billingState = useBillingState(!fixture) + const subscriptionState = useSubscriptionState(!fixture) + const billingResult = fixture?.billing ?? billingState.data + const subscriptionResult = fixture?.subscription ?? subscriptionState.data + const view = deriveBillingView(billingResult, subscriptionResult) + const billing = billingResult?.ok ? billingResult.data : undefined + const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt) + const usageIsFetching = billingState.isFetching || subscriptionState.isFetching + + const refreshUsage = () => { + void Promise.all([billingState.refetch(), subscriptionState.refetch()]) + } + + return ( + + + +
+
+ {view.summary.map(item => ( + + ))} +
+
+ + {view.notice && } + + {view.accountRows.length > 0 && ( + <> + + {view.accountRows.map(row => ( + + ))} + + )} + + {view.usageRows.length > 0 && ( + <> + +
+ {view.usageRows.map(row => ( + + ))} + +
+ + )} + + { + // no endpoint yet — NAS capability-board gap + FEATURE_BILLING_INVOICES ? : null + } +
+ ) +} + +function BillingSettingsWithDevFixtures() { + const [fixtureName, setFixtureName] = useState('live') + + return +} + +export function BillingSettings() { + if (import.meta.env.DEV) { + return + } + + return +} + +function clampAmount(raw: string, billing: Pick): string { + const amount = parseAmount(raw) + + if (amount == null) { + return '' + } + + const min = parseAmount(billing.min_usd) + const max = parseAmount(billing.max_usd) + const clampedMin = min == null ? amount : Math.max(min, amount) + const clamped = max == null ? clampedMin : Math.min(max, clampedMin) + + return formatAmountForRequest(clamped) +} + +function parseAmount(value?: null | number | string): null | number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + + if (typeof value !== 'string') { + return null + } + + const parsed = Number(value.replace(/[$,\s]/g, '')) + + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + +function formatAmountForRequest(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') +} + +function oldestUpdatedAt(...timestamps: number[]): number { + const populated = timestamps.filter(timestamp => timestamp > 0) + + return populated.length > 0 ? Math.min(...populated) : Date.now() +} + +function initialAutoReloadAmount(...candidates: Array): string { + for (const candidate of candidates) { + const amount = parseAmount(candidate) + + if (amount != null) { + return formatAmountForRequest(amount) + } + } + + return '' +} + +function validateAutoReloadInputs( + thresholdRaw: string, + reloadToRaw: string, + bounds: Pick +): { error?: string; values?: { reloadTo: string; threshold: string } } { + const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds) + + if (threshold.error || threshold.amount == null) { + return { error: threshold.error } + } + + const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds) + + if (reloadTo.error || reloadTo.amount == null) { + return { error: reloadTo.error } + } + + if (reloadTo.amount <= threshold.amount) { + return { error: 'Reload-to amount must be greater than the threshold.' } + } + + return { + values: { + reloadTo: formatAmountForRequest(reloadTo.amount), + threshold: formatAmountForRequest(threshold.amount) + } + } +} + +function validateBillingAmount( + label: string, + raw: string, + bounds: Pick +): { amount?: number; error?: string } { + const cleaned = raw.trim().replace(/^\$/, '').trim() + + if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) { + return { error: `${label}: enter a dollar amount with at most 2 decimal places.` } + } + + const amount = Number(cleaned) + + if (!(amount > 0)) { + return { error: `${label}: amount must be greater than $0.` } + } + + const min = parseAmount(bounds.min_usd) + + if (min != null && amount < min) { + return { error: `${label}: minimum is ${formatMoney(min)}.` } + } + + const max = parseAmount(bounds.max_usd) + + if (max != null && amount > max) { + return { error: `${label}: maximum is ${formatMoney(max)}.` } + } + + return { amount } +} + +function formatMoney(value?: null | number | string): string { + const amount = parseAmount(value) + + if (amount == null) { + return EMPTY_BILLING_VALUE + } + + return new Intl.NumberFormat(undefined, { + currency: 'USD', + maximumFractionDigits: amount % 1 === 0 ? 0 : 2, + minimumFractionDigits: amount % 1 === 0 ? 0 : 2, + style: 'currency' + }).format(amount) +} diff --git a/apps/desktop/src/app/settings/billing/types.test.ts b/apps/desktop/src/app/settings/billing/types.test.ts new file mode 100644 index 000000000000..a189309a4615 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/types.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' + +import type { + BillingStateResponse, + SubscriptionStateResponse +} from './types' + +const fullBillingState = { + auto_reload: { + card: { kind: 'canonical' }, + enabled: true, + reload_to_display: '$100', + reload_to_usd: '100', + threshold_display: '$25', + threshold_usd: '25' + }, + balance_display: '$142.50', + balance_usd: '142.50', + can_charge: true, + card: { + brand: 'visa', + display: 'Visa ....4242 - the card on your subscription', + last4: '4242', + masked: 'visa ....4242', + resolved_via: 'subPin' + }, + charge_presets: ['25', '50', '100'], + charge_presets_display: ['$25', '$50', '$100'], + cli_billing_enabled: true, + is_admin: true, + logged_in: true, + max_usd: '10000', + min_usd: '10', + monthly_cap: { + is_default_ceiling: false, + limit_display: '$1,000', + limit_usd: '1000', + spent_display: '$180', + spent_this_month_usd: '180' + }, + ok: true, + org_name: 'Acme Research', + portal_url: 'https://portal.nousresearch.com/billing', + role: 'OWNER', + usage: { + available: true, + has_topup: true, + plan_bar: { + fill_fraction: 0.4, + kind: 'plan', + pct_used: 60, + remaining_display: '$40', + spent_display: '$60', + total_display: '$100' + }, + plan_name: 'Pro', + renews_at: '2026-07-31T00:00:00Z', + renews_display: 'Jul 31', + status: 'active', + subscription_remaining_display: '$40', + topup_bar: { + fill_fraction: 0.75, + kind: 'topup', + pct_used: 25, + remaining_display: '$75', + spent_display: '$25', + total_display: '$100' + }, + topup_remaining_display: '$75', + total_spendable_display: '$115' + } +} satisfies BillingStateResponse + +const deployedTodayBillingState = { + auto_reload: null, + balance_display: '$0.00', + balance_usd: null, + can_charge: false, + card: { + brand: 'mastercard', + last4: '4444', + masked: 'mastercard ....4444' + }, + charge_presets: [], + charge_presets_display: [], + cli_billing_enabled: false, + is_admin: true, + logged_in: true, + max_usd: null, + min_usd: null, + monthly_cap: null, + ok: true, + org_name: 'Fresh Deploy', + portal_url: null, + role: 'OWNER' +} satisfies BillingStateResponse + +const loggedOutSubscriptionState = { + can_change_plan: false, + context: 'personal', + current: null, + is_admin: false, + logged_in: false, + ok: true, + org_id: null, + org_name: null, + portal_url: 'https://portal.nousresearch.com/login', + role: null, + tiers: [] +} satisfies SubscriptionStateResponse + +describe('desktop billing wire types', () => { + it('pins realistic billing and subscription RPC payload shapes', () => { + expect(fullBillingState.card?.resolved_via).toBe('subPin') + expect(deployedTodayBillingState.can_charge).toBe(false) + expect(deployedTodayBillingState.cli_billing_enabled).toBe(false) + expect(deployedTodayBillingState.card?.last4).toBe('4444') + expect(loggedOutSubscriptionState.logged_in).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/types.ts b/apps/desktop/src/app/settings/billing/types.ts new file mode 100644 index 000000000000..eee9718ae3e9 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/types.ts @@ -0,0 +1,33 @@ +import type { + BillingAutoReload, + BillingCardInfo, + BillingChargeResponse, + BillingChargeStatusResponse, + BillingErrorPayload, + BillingMonthlyCap, + BillingMutationResponse, + BillingRefusalCode, + BillingStateResponse, + ChargeFailureReason, + SubscriptionStateResponse, + SubscriptionTierOption, + UsageBarData, + UsageModelData +} from '@hermes/shared/billing' + +export type { + BillingAutoReload, + BillingCardInfo, + BillingChargeResponse, + BillingChargeStatusResponse, + BillingErrorPayload, + BillingMonthlyCap, + BillingMutationResponse, + BillingRefusalCode, + BillingStateResponse, + ChargeFailureReason, + SubscriptionStateResponse, + SubscriptionTierOption, + UsageBarData, + UsageModelData +} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts new file mode 100644 index 000000000000..f7f751e3ffe5 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from 'vitest' + +import { + billingDevFixtures, + endpointUnavailableBilling, + endpointUnavailableSubscription, + loggedOutBillingState, + loggedOutSubscriptionState, + okBilling, + okSubscription, + postTrainBillingState, + postTrainSubscriptionState, + todayBillingState, + todaySubscriptionState +} from './fixtures.test-util' +import { buildManageSubscriptionUrl, deriveBillingView } from './use-billing-state' + +function usageRowFor( + fixtureName: keyof typeof billingDevFixtures, + rowId: 'monthly_cap' | 'subscription_credits' | 'topup_credits' +) { + const fixture = billingDevFixtures[fixtureName] + const view = deriveBillingView(fixture.billing, fixture.subscription) + + return view.usageRows.find(row => row.id === rowId) +} + +function subscriptionCreditsRowForRemaining(remaining: string) { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + current: { ...todaySubscriptionState.current, credits_remaining: remaining, monthly_credits: '220' } + }) + ) + + return view.usageRows.find(row => row.id === 'subscription_credits') +} + +function monthlyCapRowForSpent(spent: string) { + const view = deriveBillingView( + okBilling({ + ...todayBillingState, + monthly_cap: { + is_default_ceiling: false, + limit_display: '$100', + limit_usd: '100', + spent_display: `$${spent}`, + spent_this_month_usd: spent + } + }), + okSubscription(todaySubscriptionState) + ) + + return view.usageRows.find(row => row.id === 'monthly_cap') +} + +describe('deriveBillingView', () => { + it('derives the deployed-today shape with fail-open disabled charge controls', () => { + const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState)) + + expect(view.status).toBe('normal') + expect(view.summary).toContainEqual({ label: 'Balance', value: '$996.47' }) + expect(view.summary).toContainEqual({ label: 'Plan', value: 'Ultra · $200/mo' }) + const buyCredits = view.accountRows.find(row => row.id === 'buy_credits') + + expect(buyCredits?.description).toBe( + 'Terminal billing is off for this account — an admin must enable it on the portal.' + ) + expect(buyCredits?.chips).toBeUndefined() + expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({ + action: { label: 'Manage' }, + caption: 'Refill $10 when balance falls below $5', + pill: { label: 'Enabled', tone: 'primary' } + }) + expect(view.usageRows.map(row => row.id)).toEqual(['subscription_credits', 'topup_credits', 'monthly_cap']) + }) + + it('derives the post-train shape with card provenance, presets, and denominated usage bars', () => { + const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState)) + + expect(view.status).toBe('normal') + expect(view.accountRows.find(row => row.id === 'payment_method')?.value).toBe('Visa •••• 4242 - subscription card') + expect(view.accountRows.find(row => row.id === 'buy_credits')?.chips?.map(chip => chip.label)).toEqual([ + '$25', + '$50', + '$100' + ]) + expect(view.accountRows.find(row => row.id === 'subscription')?.action?.url).toBe( + 'https://portal.nousresearch.com/manage-subscription?org_id=org_123' + ) + expect(view.usageRows.find(row => row.id === 'subscription_credits')).toMatchObject({ + bar: { value: 0.4 }, + value: '$40 of $100 left' + }) + }) + + it('points divergent auto-refill cards at the portal for reconciliation', () => { + const view = deriveBillingView( + okBilling({ + ...todayBillingState, + auto_reload: { + ...todayBillingState.auto_reload, + card: { kind: 'distinct', payment_method_id: 'pm_1', brand: 'mastercard', last4: '4444' } + } + }), + okSubscription(todaySubscriptionState) + ) + const autoReload = view.accountRows.find(row => row.id === 'auto_reload') + + expect(autoReload?.caption).toContain('Mastercard ••4444') + expect(autoReload?.caption).toContain('reconcile') + expect(autoReload?.action).toEqual({ + label: 'Reconcile ↗', + url: 'https://portal.nousresearch.com/billing' + }) + }) + + it('degrades safely when a divergent auto-refill card has no display details', () => { + const view = deriveBillingView( + okBilling({ + ...todayBillingState, + auto_reload: { + ...todayBillingState.auto_reload, + card: { kind: 'distinct', payment_method_id: 'pm_1', brand: null, last4: null } + } + }), + okSubscription(todaySubscriptionState) + ) + const autoReload = view.accountRows.find(row => row.id === 'auto_reload') + + expect(autoReload?.caption).toContain('a different card') + expect(autoReload?.caption).not.toContain('null') + expect(autoReload?.action?.url).toBe('https://portal.nousresearch.com/billing') + }) + + it('keeps buy credit controls visible but disabled when no card is on file', () => { + const fixture = billingDevFixtures['no-card'] + const view = deriveBillingView(fixture.billing, fixture.subscription) + const buyCredits = view.accountRows.find(row => row.id === 'buy_credits') + + expect(buyCredits).toMatchObject({ + action: { disabled: true, label: 'Buy' }, + description: + '💳 No saved card for terminal charges yet. Set one up on the portal ' + + "(one-time credit buys don't save a reusable card)." + }) + expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true]) + }) + + it('derives a calm logged-out card with no account or usage rows', () => { + const view = deriveBillingView(okBilling(loggedOutBillingState), okSubscription(loggedOutSubscriptionState)) + + expect(view.status).toBe('logged_out') + expect(view.summary.map(item => item.value)).toEqual(['—', '—', '—']) + expect(view.notice).toMatchObject({ + title: 'Connect your Nous account' + }) + expect(view.accountRows).toEqual([]) + expect(view.usageRows).toEqual([]) + }) + + it('derives a refusal notice when billing.state is unavailable', () => { + const view = deriveBillingView(endpointUnavailableBilling, okSubscription(todaySubscriptionState)) + + expect(view.status).toBe('refusal') + expect(view.summary.map(item => item.value)).toEqual(['—', '—', '—']) + expect(view.notice).toMatchObject({ + title: 'Billing endpoint unavailable' + }) + expect(view.accountRows).toEqual([]) + }) + + it('keeps subscription unavailable as a row-level degradation when billing.state succeeds', () => { + const view = deriveBillingView(okBilling(todayBillingState), endpointUnavailableSubscription) + const subscription = view.accountRows.find(row => row.id === 'subscription') + + expect(view.status).toBe('normal') + expect(subscription).toMatchObject({ + caption: 'Subscription details are unavailable; opening the portal is still available.', + value: 'Ultra' + }) + }) + + it('clamps overdrawn subscription credits to $0 and names the overage', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + current: { ...todaySubscriptionState.current, credits_remaining: '-0.79', monthly_credits: '220' } + }) + ) + + const row = view.usageRows.find(r => r.id === 'subscription_credits') + expect(row?.value).toBe('$0 of $220 left · $0.79 over') + expect(row?.bar?.value).toBe(0) + }) + + it('marks subscription remaining bars as ok above 10% and danger at or below 10%', () => { + const elevenPercent = subscriptionCreditsRowForRemaining('24.2') + + expect(elevenPercent?.bar?.state).toBe('ok') + expect(elevenPercent?.bar?.value).toBeCloseTo(0.11) + expect(usageRowFor('healthy', 'subscription_credits')?.bar).toMatchObject({ + state: 'ok', + value: 0.6 + }) + + // Owner wording is "green until 10%, then red"; the exact 10% boundary is red. + expect(usageRowFor('boundary', 'subscription_credits')?.bar).toMatchObject({ + state: 'danger', + value: 0.1 + }) + + expect(usageRowFor('low', 'subscription_credits')?.bar).toMatchObject({ + state: 'danger', + value: 0.09 + }) + }) + + it('marks empty or overdrawn subscription bars as danger with a full danger track', () => { + const row = usageRowFor('empty-overdrawn', 'subscription_credits') + + expect(row?.value).toBe('$0 of $220 left · $0.79 over') + expect(row?.bar).toMatchObject({ + state: 'danger', + track: 'danger', + value: 0 + }) + }) + + it('marks monthly cap bars as neutral below 90% and danger at or above 90%', () => { + expect(usageRowFor('healthy', 'monthly_cap')?.bar).toMatchObject({ + state: 'ok', + value: 0.89 + }) + + expect(monthlyCapRowForSpent('90')?.bar).toMatchObject({ + state: 'danger', + value: 0.9 + }) + + expect(usageRowFor('cap-near', 'monthly_cap')?.bar).toMatchObject({ + state: 'danger', + value: 0.92 + }) + + expect(usageRowFor('cap-hit', 'monthly_cap')?.bar).toMatchObject({ + state: 'danger', + track: 'danger', + value: 1 + }) + }) + + it('renders top-up balance as a full ok bar when credits remain', () => { + const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState)) + + expect(view.usageRows.find(row => row.id === 'topup_credits')).toMatchObject({ + bar: { + state: 'ok', + tone: 'topup', + value: 1 + }, + value: '$75' + }) + }) + + it('renders zero top-up balance as an empty neutral bar', () => { + const view = deriveBillingView( + okBilling({ + ...todayBillingState, + balance_display: '$0', + balance_usd: '0', + usage: { + ...todayBillingState.usage, + topup_remaining_display: '$0' + } + }), + undefined + ) + + expect(view.usageRows.find(row => row.id === 'topup_credits')).toMatchObject({ + bar: { + state: 'neutral', + tone: 'topup', + value: 0 + }, + value: '$0' + }) + }) +}) + +describe('buildManageSubscriptionUrl', () => { + it('mirrors the TUI manage-subscription URL construction', () => { + expect( + buildManageSubscriptionUrl({ + org_id: 'org_123', + portal_url: 'https://portal.nousresearch.com/billing' + }) + ).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123') + }) +}) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts new file mode 100644 index 000000000000..33ee75e77fb9 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -0,0 +1,584 @@ +import { useQuery } from '@tanstack/react-query' + +import { fmtDate } from '@/lib/time' + +import type { BillingRefusal, BillingResult } from './api' +import { useBillingApi } from './api' +import { resolveRefusal } from './errors' +import type { BillingStateResponse, SubscriptionStateResponse, UsageModelData } from './types' + +export const EMPTY_BILLING_VALUE = '—' +export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing' +export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com' + +const BILLING_QUERY_OPTIONS = { + refetchOnWindowFocus: true, + retry: false, + staleTime: 30_000 +} as const + +export interface BillingSummaryItemView { + label: 'Auto-refill' | 'Balance' | 'Plan' + tone?: 'muted' | 'primary' + value: string +} + +export interface BillingNoticeView { + action?: { + label: string + url: string + } + message: string + title: string +} + +export interface BillingRowActionView { + disabled?: boolean + label: string + url?: string +} + +export interface BillingChipView { + disabled: boolean + label: string +} + +export interface BillingAccountRowView { + action?: BillingRowActionView + caption?: string + chips?: BillingChipView[] + description: string + id: 'auto_reload' | 'buy_credits' | 'payment_method' | 'subscription' + pill?: { + label: string + tone: 'muted' | 'primary' + } + secondaryPill?: string + title: string + value?: string +} + +export interface BillingUsageRowView { + bar?: { + label: string + state: 'danger' | 'neutral' | 'ok' + tone: 'cap' | 'subscription' | 'topup' + track?: 'danger' + value: number + } + caption: string + id: 'monthly_cap' | 'subscription_credits' | 'topup_credits' + title: string + value: string +} + +export interface BillingView { + accountRows: BillingAccountRowView[] + notice?: BillingNoticeView + status: 'loading' | 'logged_out' | 'normal' | 'refusal' + summary: BillingSummaryItemView[] + usageRows: BillingUsageRowView[] +} + +export function useBillingState(enabled = true) { + const api = useBillingApi() + + return useQuery({ + ...BILLING_QUERY_OPTIONS, + enabled, + queryFn: () => api.fetchBillingState(), + queryKey: ['billing', 'state'] + }) +} + +export function useSubscriptionState(enabled = true) { + const api = useBillingApi() + + return useQuery({ + ...BILLING_QUERY_OPTIONS, + enabled, + queryFn: () => api.fetchSubscriptionState(), + queryKey: ['billing', 'subscription'] + }) +} + +export function deriveBillingView( + stateResult?: BillingResult, + subscriptionResult?: BillingResult +): BillingView { + if (!stateResult) { + return { + accountRows: [], + status: 'loading', + summary: emptySummary(), + usageRows: [] + } + } + + if (!stateResult.ok) { + return { + accountRows: [], + notice: refusalNotice(stateResult.refusal), + status: 'refusal', + summary: emptySummary(), + usageRows: [] + } + } + + const billing = stateResult.data + const subscription = subscriptionResult?.ok ? subscriptionResult.data : null + + if (!billing.logged_in || subscription?.logged_in === false) { + return { + accountRows: [], + notice: { + action: { label: 'Open portal ↗', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL }, + message: 'Run /portal in the TUI or open the Nous portal to connect your account.', + title: 'Connect your Nous account' + }, + status: 'logged_out', + summary: emptySummary(), + usageRows: [] + } + } + + return { + accountRows: deriveAccountRows(billing, subscription, subscriptionResult), + status: 'normal', + summary: [ + { label: 'Balance', value: displayBalance(billing) }, + { label: 'Plan', value: displayPlan(subscription, billing.usage) }, + { + label: 'Auto-refill', + tone: billing.auto_reload?.enabled ? 'primary' : billing.auto_reload ? 'muted' : undefined, + value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE + } + ], + usageRows: deriveUsageRows(billing, subscription) + } +} + +export function buildManageSubscriptionUrl( + subscription?: null | Pick, + fallbackPortalUrl?: null | string +): string { + const portalUrls = [subscription?.portal_url, fallbackPortalUrl].filter( + (url): url is string => typeof url === 'string' && url.length > 0 + ) + + for (const portalUrl of portalUrls) { + try { + const url = new URL('/manage-subscription', new URL(portalUrl).origin) + + if (subscription?.org_id) { + url.searchParams.set('org_id', subscription.org_id) + } + + return url.toString() + } catch { + // Try the next candidate; malformed portal URLs should not break settings. + } + } + + return FALLBACK_PORTAL_BILLING_URL +} + +export function formatBillingDate(value?: null | string): string { + if (!value) { + return EMPTY_BILLING_VALUE + } + + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return EMPTY_BILLING_VALUE + } + + return fmtDate.format(date) +} + +export function formatUsageUpdatedAgo(updatedAt: number, now: number): string { + const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000)) + + if (elapsedSeconds < 1) { + return 'just now' + } + + if (elapsedSeconds < 60) { + return `${elapsedSeconds}s ago` + } + + const elapsedMinutes = Math.floor(elapsedSeconds / 60) + + if (elapsedMinutes < 60) { + return `${elapsedMinutes}m ago` + } + + return `${Math.floor(elapsedMinutes / 60)}h ago` +} + +function emptySummary(): BillingSummaryItemView[] { + return [ + { label: 'Balance', value: EMPTY_BILLING_VALUE }, + { label: 'Plan', value: EMPTY_BILLING_VALUE }, + { label: 'Auto-refill', value: EMPTY_BILLING_VALUE } + ] +} + +function refusalNotice(refusal: BillingRefusal): BillingNoticeView { + const resolved = resolveRefusal(refusal) + const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined + + return { + action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined, + message: resolved.message, + title: resolved.title + } +} + +function deriveAccountRows( + billing: BillingStateResponse, + subscription: null | SubscriptionStateResponse, + subscriptionResult?: BillingResult +): BillingAccountRowView[] { + return [ + paymentMethodRow(billing), + subscriptionRow(billing, subscription, subscriptionResult), + buyCreditsRow(billing), + autoReloadRow(billing) + ] +} + +function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView { + const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL + const card = billing.card + + if (!card) { + return { + action: { label: 'Update ↗', url: portalUrl }, + description: 'Add a payment method on the portal before buying top-up credits.', + id: 'payment_method', + title: 'Payment method', + value: 'No card on file' + } + } + + return { + action: { label: 'Update ↗', url: portalUrl }, + description: 'Manage the card used for top-ups and subscription renewals.', + id: 'payment_method', + title: 'Payment method', + value: `${capitalize(card.brand)} •••• ${card.last4}${provenanceSuffix(card.resolved_via)}` + } +} + +function subscriptionRow( + billing: BillingStateResponse, + subscription: null | SubscriptionStateResponse, + subscriptionResult?: BillingResult +): BillingAccountRowView { + const manageUrl = buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url) + const current = subscription?.current + const fallbackPlan = billing.usage?.plan_name ?? EMPTY_BILLING_VALUE + const value = current?.tier_name ?? fallbackPlan + const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at) + const unavailable = subscriptionResult && !subscriptionResult.ok + + return { + action: { label: 'Adjust plan ↗', url: manageUrl }, + caption: unavailable + ? 'Subscription details are unavailable; opening the portal is still available.' + : `Renews ${renewal}`, + description: 'Review your plan and change it from the billing portal.', + id: 'subscription', + secondaryPill: 'opens portal', + title: 'Subscription', + value + } +} + +function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView { + if (!billing.card) { + return { + action: { disabled: true, label: 'Buy' }, + chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })), + description: resolveRefusal({ + kind: 'no_payment_method', + message: '', + portalUrl: billing.portal_url ?? undefined + }).message, + id: 'buy_credits', + title: 'Buy credits' + } + } + + const disabledReason = buyCreditsDisabledReason(billing) + + if (disabledReason) { + return { + description: disabledReason, + id: 'buy_credits', + title: 'Buy credits' + } + } + + return { + action: { disabled: true, label: 'Buy' }, + chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })), + description: 'Add top-up credits for agent runs outside your plan.', + id: 'buy_credits', + title: 'Buy credits' + } +} + +function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView { + const autoReload = billing.auto_reload + + if (!autoReload) { + return { + action: { disabled: true, label: 'Manage' }, + caption: 'Manage auto-refill from the portal.', + description: 'Keep your balance topped up when it drops below your threshold.', + id: 'auto_reload', + pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' }, + title: 'Auto-refill' + } + } + + if (!autoReload.enabled) { + return { + caption: 'Turn on auto-refill from the portal', + description: 'Keep your balance topped up when it drops below your threshold.', + id: 'auto_reload', + pill: { label: 'Off', tone: 'muted' }, + title: 'Auto-refill' + } + } + + if (autoReload.card.kind === 'distinct') { + const { brand, last4 } = autoReload.card + const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card' + const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL + + return { + action: { label: 'Reconcile ↗', url: portalUrl }, + caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`, + description: 'Keep your balance topped up when it drops below your threshold.', + id: 'auto_reload', + pill: { label: 'Enabled', tone: 'primary' }, + title: 'Auto-refill' + } + } + + return { + action: { label: 'Manage' }, + caption: `Refill ${autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd)} when balance falls below ${ + autoReload.threshold_display || formatMoney(autoReload.threshold_usd) + }`, + description: 'Keep your balance topped up when it drops below your threshold.', + id: 'auto_reload', + pill: { label: 'Enabled', tone: 'primary' }, + title: 'Auto-refill' + } +} + +function deriveUsageRows( + billing: BillingStateResponse, + subscription: null | SubscriptionStateResponse +): BillingUsageRowView[] { + const rows: BillingUsageRowView[] = [] + const current = subscription?.current + const remaining = parseAmount(current?.credits_remaining) + const monthly = parseAmount(current?.monthly_credits) + const usage = subscription?.usage ?? billing.usage + + // Remaining can go slightly negative (usage settles after credits hit zero). + // A raw "-$0.79 left" reads as broken — clamp to $0 and name the overage. + const subscriptionValue = + remaining != null && monthly != null + ? remaining < 0 + ? `${formatMoney(0)} of ${formatMoney(monthly)} left · ${formatMoney(Math.abs(remaining))} over` + : `${formatMoney(remaining)} of ${formatMoney(monthly)} left` + : (usage?.subscription_remaining_display ?? usage?.plan_bar?.remaining_display ?? EMPTY_BILLING_VALUE) + + const remainingFraction = remaining != null && monthly != null && monthly > 0 ? remaining / monthly : null + + rows.push({ + bar: + remainingFraction != null + ? { + label: 'Subscription credits remaining', + state: remainingFraction <= 0.1 ? 'danger' : 'ok', + tone: 'subscription', + track: remaining != null && remaining <= 0 ? 'danger' : undefined, + value: clamp01(remainingFraction) + } + : undefined, + caption: `Resets ${formatBillingDate(current?.cycle_ends_at ?? usage?.renews_at)}`, + id: 'subscription_credits', + title: 'Subscription credits', + value: subscriptionValue + }) + + const topupValue = topupCreditsValue(billing, usage) + const topupRemaining = topupCreditsAmount(billing, usage) + + rows.push({ + bar: + topupRemaining != null + ? { + label: 'Top-up credits remaining', + state: topupRemaining > 0 ? 'ok' : 'neutral', + tone: 'topup', + value: topupRemaining > 0 ? 1 : 0 + } + : undefined, + caption: 'Does not expire', + id: 'topup_credits', + title: 'Top-up credits', + value: topupValue + }) + + const cap = billing.monthly_cap + + if (cap && cap.limit_usd != null) { + const limit = parseAmount(cap.limit_usd) + const spent = parseAmount(cap.spent_this_month_usd) ?? 0 + const usedFraction = limit != null && limit > 0 ? spent / limit : null + const value = `${cap.spent_display || formatMoney(spent)} of ${cap.limit_display || formatMoney(limit)} used` + + rows.push({ + bar: + usedFraction != null + ? { + label: 'Monthly spend cap used', + state: usedFraction >= 0.9 ? 'danger' : 'ok', + tone: 'cap', + track: usedFraction >= 1 ? 'danger' : undefined, + value: clamp01(usedFraction) + } + : undefined, + caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly terminal billing spend', + id: 'monthly_cap', + title: 'Monthly spend cap', + value + }) + } + + return rows +} + +function displayBalance(billing: BillingStateResponse): string { + return nonEmpty(billing.balance_display) ?? formatMoney(billing.balance_usd) +} + +function displayPlan(subscription: null | SubscriptionStateResponse, usage?: UsageModelData): string { + const current = subscription?.current + const tier = current?.tier_name ?? usage?.plan_name + + if (!tier) { + return EMPTY_BILLING_VALUE + } + + const currentTier = subscription?.tiers.find(t => t.is_current || t.tier_id === current?.tier_id) + const price = currentTier?.dollars_per_month_display + + return price ? `${tier} · ${price}/mo` : tier +} + +function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData): string { + return ( + usage?.topup_remaining_display ?? + usage?.topup_bar?.remaining_display ?? + nonEmpty(billing.balance_display) ?? + formatMoney(billing.balance_usd) + ) +} + +function topupCreditsAmount(billing: BillingStateResponse, usage?: UsageModelData): null | number { + return ( + parseAmount(usage?.topup_bar?.remaining_display) ?? + parseAmount(usage?.topup_remaining_display) ?? + parseAmount(billing.balance_usd) ?? + parseAmount(billing.balance_display) + ) +} + +function buyCreditsDisabledReason(billing: BillingStateResponse): null | string { + if (!billing.is_admin) { + return resolveRefusal({ kind: 'role_required', message: '' }).message + } + + if (!billing.cli_billing_enabled) { + return resolveRefusal({ kind: 'cli_billing_disabled', message: '', portalUrl: billing.portal_url ?? undefined }) + .message + } + + if (!billing.can_charge) { + return resolveRefusal({ kind: 'remote_spending_disabled', message: '', portalUrl: billing.portal_url ?? undefined }) + .message + } + + return null +} + +function provenanceSuffix(resolvedVia?: null | string): string { + if (!resolvedVia) { + return '' + } + + const labels: Record = { + autoRefill: 'auto-refill card', + customerDefault: 'customer default', + subPin: 'subscription card' + } + + return ` - ${labels[resolvedVia] ?? resolvedVia}` +} + +function capitalize(value: string): string { + return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : value +} + +function nonEmpty(value?: null | string): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + +function parseAmount(value?: null | number | string): null | number { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + + if (typeof value !== 'string') { + return null + } + + const parsed = Number(value.replace(/[$,\s]/g, '')) + + return Number.isFinite(parsed) ? parsed : null +} + +function formatMoney(value?: null | number | string): string { + const amount = parseAmount(value) + + if (amount == null) { + return EMPTY_BILLING_VALUE + } + + // Pin en-US so the symbol is always "$" — the server's *_display strings + // ("$996.47") sit next to these, and other locales render USD as "US$". + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: amount % 1 === 0 ? 0 : 2, + minimumFractionDigits: amount % 1 === 0 ? 0 : 2, + style: 'currency' + }).format(amount) +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(1, value)) +} diff --git a/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts b/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts new file mode 100644 index 000000000000..82f6df182fee --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-charge-poller.test.ts @@ -0,0 +1,232 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { createElement, type PropsWithChildren } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { BillingResult } from './api' +import type { BillingChargeStatusResponse } from './types' + +const apiMocks = vi.hoisted(() => ({ + charge: vi.fn(), + chargeStatus: vi.fn() +})) + +vi.mock('./api', () => ({ + useBillingApi: () => ({ + charge: apiMocks.charge, + chargeStatus: apiMocks.chargeStatus + }) +})) + +import { CHARGE_POLL_CAP_MS, pollChargeSettlement, useChargeFlow } from './use-charge-poller' + +const status = (overrides: Partial = {}): BillingResult => ({ + data: { + ok: true, + status: 'pending', + ...overrides + }, + ok: true +}) + +const refusal = ( + kind: string, + overrides: Partial, { ok: false }>['refusal']> = {} +): BillingResult => ({ + ok: false, + refusal: { + kind, + message: kind, + ...overrides + } +}) + +function controlledClock() { + let current = 0 + const waits: number[] = [] + + return { + now: () => current, + sleep: vi.fn(async (ms: number) => { + waits.push(ms) + current += ms + }), + waits + } +} + +function wrapper({ children }: PropsWithChildren) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + return createElement(QueryClientProvider, { client }, children) +} + +beforeEach(() => { + apiMocks.charge.mockReset() + apiMocks.chargeStatus.mockReset() +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('pollChargeSettlement', () => { + it('settles after pending polls', async () => { + const clock = controlledClock() + + const api = { + chargeStatus: vi + .fn() + .mockResolvedValueOnce(status()) + .mockResolvedValueOnce(status()) + .mockResolvedValueOnce(status({ amount_usd: '25', status: 'settled' })) + } + + const outcome = await pollChargeSettlement(api, 'ch_123', clock) + + expect(outcome).toMatchObject({ amountUsd: '25', kind: 'success' }) + expect(api.chargeStatus).toHaveBeenCalledTimes(3) + expect(clock.waits).toEqual([2000, 2000]) + }) + + it('returns a failed outcome with the charge failure reason', async () => { + const clock = controlledClock() + + const api = { + chargeStatus: vi.fn().mockResolvedValue(status({ reason: 'card_declined', status: 'failed' })) + } + + const outcome = await pollChargeSettlement(api, 'ch_123', clock) + + expect(outcome).toMatchObject({ + kind: 'failure', + message: 'Your card was declined. Try another card on the portal.', + title: 'Charge failed' + }) + expect(clock.waits).toEqual([]) + }) + + it('backs off on rate limits, honors retryAfter, and keeps polling', async () => { + const clock = controlledClock() + + const api = { + chargeStatus: vi + .fn() + .mockResolvedValueOnce(refusal('rate_limited', { retryAfter: 7 })) + .mockResolvedValueOnce(status({ amount_usd: '50', status: 'settled' })) + } + + const outcome = await pollChargeSettlement(api, 'ch_123', clock) + + expect(outcome).toMatchObject({ amountUsd: '50', kind: 'success' }) + expect(clock.waits).toEqual([7000]) + }) + + it('backs off when Stripe is unavailable and keeps polling', async () => { + const clock = controlledClock() + + const api = { + chargeStatus: vi + .fn() + .mockResolvedValueOnce(refusal('stripe_unavailable', { retryAfter: 3 })) + .mockResolvedValueOnce(status({ amount_usd: '50', status: 'settled' })) + } + + const outcome = await pollChargeSettlement(api, 'ch_123', clock) + + expect(outcome).toMatchObject({ amountUsd: '50', kind: 'success' }) + expect(clock.waits).toEqual([3000]) + }) + + it('caps pending polling at 5 minutes as an ambiguous outcome', async () => { + const clock = controlledClock() + + const api = { + chargeStatus: vi.fn().mockResolvedValue(status()) + } + + const outcome = await pollChargeSettlement(api, 'ch_123', { + ...clock, + portalUrl: 'https://portal.nousresearch.com/billing' + }) + + expect(outcome).toEqual({ + kind: 'ambiguous', + message: 'Charge may still settle. Check the portal before retrying.', + portalUrl: 'https://portal.nousresearch.com/billing', + title: 'Still processing after 5 minutes' + }) + expect(clock.waits.reduce((total, ms) => total + ms, 0)).toBe(CHARGE_POLL_CAP_MS) + }) + + it('treats auth revocation while polling as ambiguous', async () => { + const clock = controlledClock() + + const api = { + chargeStatus: vi.fn().mockResolvedValue( + refusal('session_revoked', { + message: 'Your session was logged out.', + portalUrl: 'https://portal.nousresearch.com/billing' + }) + ) + } + + const outcome = await pollChargeSettlement(api, 'ch_123', clock) + + expect(outcome).toMatchObject({ + kind: 'ambiguous', + portalUrl: 'https://portal.nousresearch.com/billing', + title: 'Charge outcome unconfirmed' + }) + }) +}) + +describe('useChargeFlow', () => { + it('turns a charge refusal into an immediate outcome without polling', async () => { + apiMocks.charge.mockResolvedValue({ + idempotencyKey: 'key-1', + ok: false, + refusal: { + kind: 'no_payment_method', + message: 'No saved card.', + portalUrl: 'https://portal.nousresearch.com/billing' + } + }) + + const { result } = renderHook(() => useChargeFlow(), { wrapper }) + + await act(async () => { + await result.current.start('25') + }) + + expect(result.current.phase).toBe('done') + expect(result.current.outcome).toMatchObject({ + kind: 'failure', + title: 'No saved card' + }) + expect(apiMocks.chargeStatus).not.toHaveBeenCalled() + }) + + it('reuses one idempotency key when retrying a failed-to-send charge', async () => { + apiMocks.charge.mockResolvedValue({ + idempotencyKey: 'key-1', + ok: false, + refusal: { + kind: 'transport', + message: 'connection closed' + } + }) + + const { result } = renderHook(() => useChargeFlow(), { wrapper }) + + await act(async () => { + await result.current.start('25') + }) + await act(async () => { + await result.current.start('25') + }) + + expect(apiMocks.charge).toHaveBeenNthCalledWith(1, '25', undefined) + expect(apiMocks.charge).toHaveBeenNthCalledWith(2, '25', 'key-1') + }) +}) diff --git a/apps/desktop/src/app/settings/billing/use-charge-poller.ts b/apps/desktop/src/app/settings/billing/use-charge-poller.ts new file mode 100644 index 000000000000..8727365a4315 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-charge-poller.ts @@ -0,0 +1,298 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useRef, useState } from 'react' + +import { refusalPolicy } from '@hermes/shared/billing-policy' +import { + driveChargeSettlement, + SETTLEMENT_POLL_CAP_MS, + SETTLEMENT_POLL_INTERVAL_MS +} from '@hermes/shared/charge-settlement' + +import type { BillingApi, BillingRefusal } from './api' +import { useBillingApi } from './api' +import { resolveRefusal } from './errors' +import type { BillingChargeStatusResponse } from './types' + +export const CHARGE_POLL_INTERVAL_MS = SETTLEMENT_POLL_INTERVAL_MS +export const CHARGE_POLL_CAP_MS = SETTLEMENT_POLL_CAP_MS + +export type ChargeFlowPhase = 'charging' | 'done' | 'idle' | 'polling' + +export type ChargeFlowOutcome = + | { + amountUsd?: string | null + kind: 'success' + message: string + } + | { + action?: { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' } + kind: 'failure' + message: string + retryFreshKey: boolean + title: string + } + | { + kind: 'ambiguous' + message: string + portalUrl?: string + title: string + } + +export interface ChargePollClock { + now?: () => number + sleep?: (ms: number) => Promise +} + +export interface ChargePollOptions extends ChargePollClock { + portalUrl?: null | string +} + +interface PendingChargeIntent { + amountUsd: string + idempotencyKey: string +} + +const defaultSleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)) + +const retryableSendKinds = new Set([ + 'endpoint_unavailable', + 'rate_limited', + 'temporarily_unavailable', + 'timeout', + 'transport' +]) + +export async function pollChargeSettlement( + api: Pick, + chargeId: string, + opts: ChargePollOptions = {} +): Promise { + const sleep = opts.sleep ?? defaultSleep + const now = opts.now ?? Date.now + const observed: { refusal?: BillingRefusal; status?: BillingChargeStatusResponse } = {} + + const settlement = await driveChargeSettlement({ + fetchStatus: async () => { + const result = await api.chargeStatus(chargeId) + + if (result.ok) { + observed.refusal = undefined + observed.status = result.data + + return result.data + } + + observed.refusal = result.refusal + observed.status = statusFromRefusal(result.refusal) + + return observed.status + }, + isCancelled: () => false, + now, + sleep + }) + + switch (settlement.kind) { + case 'settled': + return { + amountUsd: settlement.status.amount_usd, + kind: 'success', + message: settlement.status.amount_usd ? `$${settlement.status.amount_usd} added.` : 'Credits added.' + } + + case 'failed': + return { + action: { type: 'retry' }, + kind: 'failure', + message: renderChargeFailed(settlement.status.reason), + retryFreshKey: true, + title: 'Charge failed' + } + + case 'ambiguous': { + if (settlement.status && refusalPolicy(settlement.error).ambiguousMidPoll) { + const refusal = observed.refusal ?? refusalFromStatus(settlement.error, settlement.status) + const resolved = resolveRefusal(refusal) + const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : refusal.portalUrl + + return { + kind: 'ambiguous', + message: `${resolved.message} Your last charge's outcome is unconfirmed - check your balance/history before retrying.`, + portalUrl: portalUrl ?? opts.portalUrl ?? undefined, + title: 'Charge outcome unconfirmed' + } + } + + return { + kind: 'failure', + message: observed.refusal?.message || 'Could not check the charge.', + retryFreshKey: true, + title: 'Could not check charge' + } + } + + case 'refused': + return { + kind: 'failure', + message: observed.refusal?.message || settlement.status.message || 'Could not check the charge.', + retryFreshKey: true, + title: 'Could not check charge' + } + + case 'cancelled': + case 'timed_out': + return timeoutOutcome(observed.status?.ok ? (observed.status.portal_url ?? opts.portalUrl) : opts.portalUrl) + } +} + +function statusFromRefusal(refusal: BillingRefusal): BillingChargeStatusResponse { + const raw = isRecord(refusal.raw) ? refusal.raw : {} + + return { + ...raw, + error: refusal.kind, + message: refusal.message, + ok: false, + ...(refusal.payload !== undefined ? { payload: refusal.payload } : {}), + ...(refusal.portalUrl !== undefined ? { portal_url: refusal.portalUrl } : {}), + ...(refusal.retryAfter !== undefined ? { retry_after: refusal.retryAfter } : {}) + } as BillingChargeStatusResponse +} + +function refusalFromStatus(error: string, status: BillingChargeStatusResponse): BillingRefusal { + return { + kind: error, + message: status.message || error, + payload: status.payload, + portalUrl: status.portal_url ?? undefined, + retryAfter: status.retry_after ?? undefined + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function useChargeFlow() { + const api = useBillingApi() + const queryClient = useQueryClient() + const [phase, setPhase] = useState('idle') + const [outcome, setOutcome] = useState(null) + const phaseRef = useRef('idle') + const retryIntentRef = useRef(null) + + const setPhaseState = useCallback((next: ChargeFlowPhase) => { + phaseRef.current = next + setPhase(next) + }, []) + + const reset = useCallback(() => { + retryIntentRef.current = null + setOutcome(null) + setPhaseState('idle') + }, [setPhaseState]) + + const start = useCallback( + async (amountUsd: string) => { + if (phaseRef.current === 'charging' || phaseRef.current === 'polling') { + return + } + + const retryIntent = retryIntentRef.current + const idempotencyKey = retryIntent?.amountUsd === amountUsd ? retryIntent.idempotencyKey : undefined + + setOutcome(null) + setPhaseState('charging') + + const chargeResult = await api.charge(amountUsd, idempotencyKey) + + if (!chargeResult.ok) { + const resolved = resolveRefusal(chargeResult.refusal) + + const action = + resolved.action.type === 'portal' + ? ({ type: 'portal', url: resolved.action.url } as const) + : resolved.action.type === 'retry' + ? ({ type: 'retry' } as const) + : resolved.action.type === 'step_up' + ? ({ type: 'step_up' } as const) + : undefined + + retryIntentRef.current = shouldReuseIdempotencyKey(chargeResult.refusal) + ? { amountUsd, idempotencyKey: chargeResult.idempotencyKey } + : null + setOutcome({ + action, + kind: 'failure', + message: resolved.message, + retryFreshKey: false, + title: resolved.title + }) + setPhaseState('done') + + return + } + + retryIntentRef.current = null + + const chargeId = chargeResult.data.charge_id + + if (!chargeId) { + setOutcome({ + kind: 'failure', + message: 'The billing service accepted the request but did not return a charge id.', + retryFreshKey: true, + title: 'Charge could not be tracked' + }) + setPhaseState('done') + + return + } + + setPhaseState('polling') + + const pollOutcome = await pollChargeSettlement(api, chargeId, { + portalUrl: chargeResult.data.portal_url + }) + + setOutcome(pollOutcome) + setPhaseState('done') + + if (pollOutcome.kind === 'success') { + void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }) + } + }, + [api, queryClient, setPhaseState] + ) + + return { outcome, phase, reset, start } +} + +function shouldReuseIdempotencyKey(refusal: BillingRefusal): boolean { + return retryableSendKinds.has(refusal.kind) +} + +function timeoutOutcome(portalUrl?: null | string): ChargeFlowOutcome { + return { + kind: 'ambiguous', + message: 'Charge may still settle. Check the portal before retrying.', + portalUrl: portalUrl ?? undefined, + title: 'Still processing after 5 minutes' + } +} + +function renderChargeFailed(reason?: null | string): string { + switch ((reason || '').trim()) { + case 'authentication_required': + return 'Your bank requires verification (3DS). Complete it on the portal to finish this purchase.' + + case 'payment_method_expired': + return 'Your card has expired. Update it on the portal.' + + case 'card_declined': + return 'Your card was declined. Try another card on the portal.' + + default: + return `The charge didn't go through (${reason || 'processing_error'}).` + } +} diff --git a/apps/desktop/src/app/settings/billing/use-step-up.test.tsx b/apps/desktop/src/app/settings/billing/use-step-up.test.tsx new file mode 100644 index 000000000000..ee90129e5b81 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-step-up.test.tsx @@ -0,0 +1,132 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { createElement, type PropsWithChildren } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const apiMocks = vi.hoisted(() => ({ + stepUp: vi.fn() +})) + +const gatewayMock = vi.hoisted(() => { + const handlers = new Map void>>() + + const gateway = { + on: vi.fn((eventName: string, handler: (event: unknown) => void) => { + let set = handlers.get(eventName) + + if (!set) { + set = new Set() + handlers.set(eventName, set) + } + + set.add(handler) + + return () => set?.delete(handler) + }) + } + + return { + count: (eventName: string) => handlers.get(eventName)?.size ?? 0, + emit: (eventName: string, event: unknown) => { + handlers.get(eventName)?.forEach(handler => handler(event)) + }, + gateway, + reset: () => { + handlers.clear() + gateway.on.mockClear() + } + } +}) + +vi.mock('@/store/gateway', async () => { + const { atom } = (await vi.importActual('nanostores')) as { atom: (value: unknown) => unknown } + + return { + $gateway: atom(gatewayMock.gateway) + } +}) + +vi.mock('./api', () => ({ + useBillingApi: () => ({ + stepUp: apiMocks.stepUp + }) +})) + +import { useStepUpFlow } from './use-step-up' + +function createWrapper(client: QueryClient) { + return function wrapper({ children }: PropsWithChildren) { + return createElement(QueryClientProvider, { client }, children) + } +} + +beforeEach(() => { + apiMocks.stepUp.mockReset() + gatewayMock.reset() +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('useStepUpFlow', () => { + it('subscribes for verification, opens the verification URL, cleans up, and invalidates on completion', async () => { + let resolveStepUp: (value: unknown) => void = () => {} + + const stepUpPromise = new Promise(resolve => { + resolveStepUp = resolve + }) + + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const invalidate = vi.spyOn(client, 'invalidateQueries') + + apiMocks.stepUp.mockReturnValue(stepUpPromise) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + openExternal: vi.fn() + } + }) + + const { result, unmount } = renderHook(() => useStepUpFlow(), { wrapper: createWrapper(client) }) + + act(() => { + void result.current.start() + }) + + expect(result.current.phase).toBe('waiting') + expect(gatewayMock.count('billing.step_up.verification')).toBe(1) + + act(() => { + gatewayMock.emit('billing.step_up.verification', { + payload: { + user_code: 'ABCD-1234', + verification_url: 'https://portal.nousresearch.com/device' + }, + type: 'billing.step_up.verification' + }) + }) + + expect(result.current.phase).toBe('verifying') + expect(result.current.verification).toEqual({ + code: 'ABCD-1234', + url: 'https://portal.nousresearch.com/device' + }) + + result.current.openVerification() + expect(window.hermesDesktop?.openExternal).toHaveBeenCalledWith('https://portal.nousresearch.com/device') + + await act(async () => { + resolveStepUp({ data: { granted: true, ok: true }, ok: true }) + await stepUpPromise + }) + + await waitFor(() => { + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] }) + expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }) + }) + + unmount() + expect(gatewayMock.count('billing.step_up.verification')).toBe(0) + }) +}) diff --git a/apps/desktop/src/app/settings/billing/use-step-up.ts b/apps/desktop/src/app/settings/billing/use-step-up.ts new file mode 100644 index 000000000000..7658dc917567 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/use-step-up.ts @@ -0,0 +1,143 @@ +import { useStore } from '@nanostores/react' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { $gateway } from '@/store/gateway' + +import { useBillingApi } from './api' +import { resolveRefusal } from './errors' + +export type StepUpPhase = 'idle' | 'verifying' | 'waiting' + +export interface StepUpVerification { + code: string | null + url: string +} + +export interface StepUpMessage { + kind: 'error' | 'success' + text: string + title: string +} + +interface StepUpVerificationPayload { + user_code?: unknown + verification_url?: unknown +} + +export function useStepUpFlow() { + const api = useBillingApi() + const gateway = useStore($gateway) + const queryClient = useQueryClient() + const offRef = useRef<(() => void) | null>(null) + const runningRef = useRef(false) + const runIdRef = useRef(0) + const [message, setMessage] = useState(null) + const [phase, setPhase] = useState('idle') + const [verification, setVerification] = useState(null) + + const unsubscribe = useCallback(() => { + offRef.current?.() + offRef.current = null + }, []) + + const dismiss = useCallback(() => { + runIdRef.current += 1 + runningRef.current = false + unsubscribe() + setMessage(null) + setPhase('idle') + setVerification(null) + }, [unsubscribe]) + + useEffect( + () => () => { + runIdRef.current += 1 + runningRef.current = false + unsubscribe() + }, + [unsubscribe] + ) + + const openVerification = useCallback(() => { + if (!verification?.url) { + return + } + + void window.hermesDesktop?.openExternal?.(verification.url) + }, [verification?.url]) + + const start = useCallback(async () => { + if (runningRef.current) { + return + } + + runningRef.current = true + const runId = runIdRef.current + 1 + + runIdRef.current = runId + unsubscribe() + setMessage(null) + setVerification(null) + setPhase('waiting') + + offRef.current = + gateway?.on('billing.step_up.verification', event => { + const payload = event.payload + const url = typeof payload?.verification_url === 'string' ? payload.verification_url : null + + if (!url) { + return + } + + setVerification({ + code: typeof payload?.user_code === 'string' ? payload.user_code : null, + url + }) + setPhase('verifying') + }) ?? null + + const result = await api.stepUp() + + if (runIdRef.current !== runId) { + return + } + + runningRef.current = false + unsubscribe() + + if (!result.ok) { + const resolved = resolveRefusal(result.refusal) + + setMessage({ + kind: 'error', + text: resolved.message, + title: resolved.title + }) + + return + } + + if (!result.data.granted) { + setMessage({ + kind: 'error', + text: 'Verification finished without granting billing management access.', + title: 'Verification was not approved' + }) + + return + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }), + queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] }) + ]) + setMessage({ + kind: 'success', + text: 'Billing management access was verified.', + title: 'Verification complete' + }) + }, [api, gateway, queryClient, unsubscribe]) + + return { dismiss, message, openVerification, phase, start, verification } +} diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index f50047b1b7e3..e81cce91e06f 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -8,6 +8,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Archive, + BarChart3, Bell, Download, Globe, @@ -31,6 +32,7 @@ import { SKILLS_ROUTE } from '../routes' import { AboutSettings } from './about-settings' import { AppearanceSettings } from './appearance-settings' +import { BillingSettings } from './billing' import { ConfigSettings } from './config-settings' import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' @@ -49,6 +51,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'keybinds', 'keys', 'notifications', + 'billing', 'plugins', 'sessions', 'about' @@ -150,6 +153,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.notifications, onSelect: () => setActiveView('notifications') }, + { + active: activeView === 'billing', + icon: BarChart3, + id: 'billing', + label: t.settings.nav.billing, + onSelect: () => setActiveView('billing') + }, { active: activeView === 'providers', children: [ @@ -293,6 +303,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'notifications' ? ( + ) : activeView === 'billing' ? ( + ) : activeView === 'plugins' ? ( ) : ( diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index f7fd585547b8..2828609ef63f 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -6,6 +6,7 @@ import type { EnvVarInfo } from '@/types/hermes' export type SettingsView = | 'about' + | 'billing' | 'gateway' | 'keybinds' | 'keys' diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a5262b6ba65c..62bf96932251 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -322,6 +322,7 @@ export const en: Translations = { mcp: 'MCP', archivedChats: 'Archived Chats', about: 'About', + billing: 'Billing', notifications: 'Notifications', plugins: 'Plugins' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index bd7247976b37..31f6d486208a 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -223,6 +223,7 @@ export const ja = defineLocale({ mcp: 'MCP', archivedChats: 'アーカイブ済みチャット', about: '情報', + billing: '請求', notifications: '通知' }, notifications: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 1c38f6201353..851c794c2091 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -280,6 +280,7 @@ export interface Translations { mcp: string archivedChats: string about: string + billing: string notifications: string plugins: string } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 9aa700ffab7d..c8e3f04a33cc 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -217,6 +217,7 @@ export const zhHant = defineLocale({ mcp: 'MCP', archivedChats: '已封存聊天', about: '關於', + billing: '帳單', notifications: '通知' }, notifications: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a68a1b250746..8928b646158f 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -313,6 +313,7 @@ export const zh: Translations = { mcp: 'MCP', archivedChats: '已归档对话', about: '关于', + billing: '账单', notifications: '通知', plugins: '插件' }, diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 43ed639c3c99..8af5d47ffb29 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -18,6 +18,7 @@ "paths": { "@/*": ["./src/*"], "@hermes/plugin-sdk": ["./src/sdk/index.ts"], + "@hermes/shared/billing": ["../shared/src/billing-types.ts"], "@hermes/shared": ["../shared/src/index.ts"] } }, diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 4110d53aeebe..2b0685c9a0b0 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -59,6 +59,7 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, './src'), '@hermes/plugin-sdk': path.resolve(__dirname, './src/sdk/index.ts'), + '@hermes/shared/billing': path.resolve(__dirname, '../shared/src/billing-types.ts'), '@hermes/shared': path.resolve(__dirname, '../shared/src'), react: path.resolve(__dirname, '../../node_modules/react'), 'react-dom': path.resolve(__dirname, '../../node_modules/react-dom'),