From 30a1254c366d75d81bbdd97a68bf7a953ca1d830 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 23 Jun 2026 19:45:27 +0530 Subject: [PATCH 01/62] feat(tui): rename /billing slash command to /topup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../{billingCommand.test.ts => topupCommand.test.ts} | 6 +++--- ui-tui/src/app/slash/commands/{billing.ts => topup.ts} | 6 +++--- ui-tui/src/app/slash/registry.ts | 4 ++-- ui-tui/src/components/billingOverlay.tsx | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) rename ui-tui/src/__tests__/{billingCommand.test.ts => topupCommand.test.ts} (98%) rename ui-tui/src/app/slash/commands/{billing.ts => topup.ts} (98%) diff --git a/ui-tui/src/__tests__/billingCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts similarity index 98% rename from ui-tui/src/__tests__/billingCommand.test.ts rename to ui-tui/src/__tests__/topupCommand.test.ts index f27f474e5618..9dd0e2618435 100644 --- a/ui-tui/src/__tests__/billingCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -1,14 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' -import { billingCommands } from '../app/slash/commands/billing.js' +import { topupCommands } from '../app/slash/commands/topup.js' import type { BillingStateResponse } from '../gatewayTypes.js' vi.mock('../lib/openExternalUrl.js', () => ({ openExternalUrl: vi.fn(() => true) })) -const billingCommand = billingCommands.find(cmd => cmd.name === 'billing')! +const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')! const ownerState = (overrides: Partial = {}): BillingStateResponse => ({ auto_reload: { @@ -72,7 +72,7 @@ const buildCtx = (results: Record) => { } const run = async (arg: string) => { - billingCommand.run(arg, ctx as any, 'billing') + topupCommand.run(arg, ctx as any, 'topup') await rpc.mock.results[0]?.value await Promise.resolve() await Promise.resolve() diff --git a/ui-tui/src/app/slash/commands/billing.ts b/ui-tui/src/app/slash/commands/topup.ts similarity index 98% rename from ui-tui/src/app/slash/commands/billing.ts rename to ui-tui/src/app/slash/commands/topup.ts index 6c3ddec0845c..a89e00f2c10b 100644 --- a/ui-tui/src/app/slash/commands/billing.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -297,10 +297,10 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B validate: (raw: string) => validateAmount(raw, s) }) -export const billingCommands: SlashCommand[] = [ +export const topupCommands: SlashCommand[] = [ { - help: 'Manage Nous terminal billing — buy credits, auto-reload, limits', - name: 'billing', + help: 'Top up Nous credits — buy credits, auto-reload, limits', + name: 'topup', // ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/billing` // fetches state and opens the interactive overlay (CLI/TUI parity). run: (_arg, ctx) => { diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index c9192f5d56d6..7a7357238c54 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -1,5 +1,5 @@ import { coreCommands } from './commands/core.js' -import { billingCommands } from './commands/billing.js' +import { topupCommands } from './commands/topup.js' import { creditsCommands } from './commands/credits.js' import { debugCommands } from './commands/debug.js' import { opsCommands } from './commands/ops.js' @@ -9,7 +9,7 @@ import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ ...coreCommands, - ...billingCommands, + ...topupCommands, ...creditsCommands, ...sessionCommands, ...opsCommands, diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 6fbe9cddc5f7..855f521625b6 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -196,7 +196,7 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { return ( - Usage credits + Top up credits {bar && {bar}} Balance: {s.balance_display} From aab4ba454fc500d96732b0cead58c0eedecf476b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 23 Jun 2026 20:15:39 +0530 Subject: [PATCH 02/62] refactor(tui): extract overlay primitives to shared module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/components/billingOverlay.tsx | 34 ++---------------- ui-tui/src/components/overlayPrimitives.tsx | 40 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 32 deletions(-) create mode 100644 ui-tui/src/components/overlayPrimitives.tsx diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 855f521625b6..fd835fa5a1bc 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -5,10 +5,9 @@ import type { BillingOverlayState } from '../app/interfaces.js' import type { BillingStateResponse } from '../gatewayTypes.js' import type { Theme } from '../theme.js' +import { ActionRow, barCells, footer, MenuRow } from './overlayPrimitives.js' import { TextInput } from './textInput.js' -const SPEND_BAR_CELLS = 10 - interface BillingOverlayProps { /** Replace the overlay slot (screen transitions + pending data). */ onPatch: (next: Partial) => void @@ -18,30 +17,6 @@ interface BillingOverlayProps { t: Theme } -/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */ -function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) { - return ( - - - {active ? '▸ ' : ' '} - {index}. {label} - - - ) -} - -/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */ -function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) { - return ( - - {active ? '▸ ' : ' '} - - {label} - - - ) -} - /** 10-cell spend bar + percent (omit entirely when there's no usable cap). */ function spendBar(s: BillingStateResponse): null | string { const cap = s.monthly_cap @@ -57,10 +32,7 @@ function spendBar(s: BillingStateResponse): null | string { return null } - const ratio = Math.max(0, Math.min(1, spent / limit)) - const filled = Math.round(ratio * SPEND_BAR_CELLS) - const bar = '█'.repeat(filled) + '░'.repeat(SPEND_BAR_CELLS - filled) - const pct = Math.round(ratio * 100) + const { bar, pct } = barCells(spent / limit) const ceiling = cap.is_default_ceiling ? ' (default ceiling)' : '' return `${cap.spent_display} of ${cap.limit_display} used ${bar} ${pct}%${ceiling}` @@ -76,8 +48,6 @@ function autoReloadLine(s: BillingStateResponse): null | string { : 'Auto-reload: off' } -const footer = (extra: string, t: Theme) => {extra} - /** * The /billing modal. A self-contained state machine: * overview → buy | autoreload | limit (and buy → confirm). diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx new file mode 100644 index 000000000000..cd7e5a7388dc --- /dev/null +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -0,0 +1,40 @@ +import { Text } from '@hermes/ink' + +import type { Theme } from '../theme.js' + +/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */ +export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) { + return ( + + + {active ? '▸ ' : ' '} + {index}. {label} + + + ) +} + +/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */ +export function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) { + return ( + + {active ? '▸ ' : ' '} + + {label} + + + ) +} + +export const BAR_CELLS = 10 + +/** ratio in [0,1] -> { bar: '█…░…', pct: 0-100 } using `cells` cells. */ +export function barCells(ratio: number, cells: number = BAR_CELLS): { bar: string; pct: number } { + const r = Math.max(0, Math.min(1, ratio)) + + const filled = Math.round(r * cells) + + return { bar: '█'.repeat(filled) + '░'.repeat(cells - filled), pct: Math.round(r * 100) } +} + +export const footer = (extra: string, t: Theme) => {extra} From 75e4bfd1831c7843dec296619bda977444d1841b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 23 Jun 2026 20:18:35 +0530 Subject: [PATCH 03/62] feat(tui): add /subscription + /topup CTAs to /usage output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/__tests__/usageCommand.test.ts | 100 ++++++++++++++++++++++ ui-tui/src/app/slash/commands/session.ts | 12 ++- 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 ui-tui/src/__tests__/usageCommand.test.ts diff --git a/ui-tui/src/__tests__/usageCommand.test.ts b/ui-tui/src/__tests__/usageCommand.test.ts new file mode 100644 index 000000000000..e237585f2f1e --- /dev/null +++ b/ui-tui/src/__tests__/usageCommand.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { sessionCommands } from '../app/slash/commands/session.js' +import type { SessionUsageResponse } from '../gatewayTypes.js' + +const usageCommand = sessionCommands.find(cmd => cmd.name === 'usage')! + +const USAGE_CTA = 'Run /subscription to change plan · /topup to add credits' + +const guarded = + (fn: (r: T) => void) => + (r: null | T) => { + if (r) { + fn(r) + } + } + +/** Build a ctx whose rpc routes by method name to a supplied map of results. */ +const buildCtx = (results: Record) => { + const sys = vi.fn() + const panel = vi.fn() + + const rpc = vi.fn((method: string, _params: unknown) => { + return Promise.resolve(results[method]) + }) + + const ctx = { + gateway: { rpc }, + guarded, + guardedErr: vi.fn(), + sid: 'sid-1', + stale: () => false, + transcript: { page: vi.fn(), panel, sys } + } + + const run = async (arg: string) => { + usageCommand.run(arg, ctx as any, 'usage') + await rpc.mock.results[0]?.value + await Promise.resolve() + await Promise.resolve() + } + + return { ctx, panel, run, sys } +} + +const baseUsage = (overrides: Partial = {}): SessionUsageResponse => + ({ + calls: 0, + input: 0, + output: 0, + total: 0, + ...overrides + }) as SessionUsageResponse + +const printed = (sys: ReturnType) => sys.mock.calls.map(c => c[0]).join('\n') + +describe('/usage slash command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('appends the CTA in the with-calls render (healthy path)', async () => { + const { run, sys } = buildCtx({ + 'session.usage': baseUsage({ + calls: 12, + input: 1000, + output: 500, + total: 1500, + model: 'test-model', + credits_lines: ['$50.00 remaining'] + }) + }) + + await run('') + + expect(printed(sys)).toContain(USAGE_CTA) + }) + + it('appends the CTA in the no-calls render (depleted/empty path)', async () => { + const { run, sys } = buildCtx({ + 'session.usage': baseUsage({ calls: 0, credits_lines: [] }) + }) + + await run('') + + expect(printed(sys)).toContain('no API calls yet') + expect(printed(sys)).toContain(USAGE_CTA) + }) + + it('appends the CTA when credits exist but there are no calls', async () => { + const { run, sys } = buildCtx({ + 'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] }) + }) + + await run('') + + expect(printed(sys)).toContain(USAGE_CTA) + expect(printed(sys)).not.toContain('no API calls yet') + }) +}) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index b716504b3532..4404ff760255 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -18,6 +18,8 @@ import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' +const USAGE_CTA = 'Run /subscription to change plan · /topup to add credits' + const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g') @@ -543,6 +545,8 @@ export const sessionCommands: SlashCommand[] = [ return } + const sys = ctx.transcript.sys + if (r) { patchUiState({ usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 } @@ -553,14 +557,18 @@ export const sessionCommands: SlashCommand[] = [ // even with zero API calls or on a resumed session. Render it whenever // present, before the token panel. const creditsLines = r?.credits_lines ?? [] + if (creditsLines.length) { ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }]) } if (!r?.calls) { if (!creditsLines.length) { - ctx.transcript.sys('no API calls yet') + sys('no API calls yet') } + + sys(USAGE_CTA) + return } @@ -592,6 +600,8 @@ export const sessionCommands: SlashCommand[] = [ } ctx.transcript.panel('Usage', sections) + + sys(USAGE_CTA) }) } } From df4350c5ade00b02f7b89f7cc12d29ceaa247878 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 23 Jun 2026 20:19:47 +0530 Subject: [PATCH 04/62] feat(tui): add subscription wire types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/gatewayTypes.ts | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 74a6f7627d12..456c60fa9f0e 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -142,6 +142,49 @@ export interface BillingMutationResponse { retry_after?: number | null } +export interface SubscriptionTierOption { + tier_id: string + name: string + tier_order: number + dollars_per_month_display: string + monthly_credits: string + is_current: boolean + is_enabled: boolean // false = grandfathered current tier +} + +export interface SubscriptionStateResponse { + ok: boolean + logged_in: boolean + is_admin: boolean + can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS + org_name: string | null + role: string | null + current: { + tier_id: string | null // null = free (no active sub) + tier_name: string | null + monthly_credits: string | null + credits_remaining: string | null + cycle_ends_at: string | null // ISO + pending_downgrade_tier_name: string | null + pending_downgrade_at: string | null + is_past_due: boolean // dunning: active=false but period live (WS1 M1 footgun) + } | null + tiers: SubscriptionTierOption[] + portal_url: string | null + error?: string | null +} + +export interface SubscriptionManageLinkResponse { + ok: boolean + kind?: 'checkout' | 'portal' + url?: string + error?: string + message?: string + portal_url?: string | null + retry_after?: number | null + payload?: BillingErrorPayload +} + export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } From ac8a790b677016974ddb1c230548d5842bdac19e Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 23 Jun 2026 20:25:58 +0530 Subject: [PATCH 05/62] feat(gateway): add subscription.state + subscription.manage_link RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- agent/subscription_view.py | 209 +++++++++++++++++++++++++++++++++++++ hermes_cli/nous_billing.py | 29 +++++ tui_gateway/server.py | 85 +++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 agent/subscription_view.py diff --git a/agent/subscription_view.py b/agent/subscription_view.py new file mode 100644 index 000000000000..b983d723a1a9 --- /dev/null +++ b/agent/subscription_view.py @@ -0,0 +1,209 @@ +"""Surface-agnostic core for the ``/subscription`` TUI screen. + +Companion to :mod:`agent.billing_view` — same fail-open philosophy: when not +logged in or the portal is unreachable, return a struct with ``logged_in=False`` +and let the surface degrade gracefully (never crash). Money is decimal end-to-end +(server emits decimal strings); we only format for display. + +The TUI ``SubscriptionOverlay`` is **deep-link only** — it never charges +in-terminal. :func:`get_subscription_manage_link` returns a Stripe-hosted URL +the TUI opens in the browser. + +WS1 dependency: ``GET /api/billing/subscription`` and +``POST /api/billing/subscription/manage-link`` are NAS endpoints (WS1 Phase A/C). +Until they ship, the fail-open contract handles 404s — the builder returns +``logged_in=False`` and the manage-link raises a :class:`BillingError` the +gateway serializes into a typed error envelope. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from decimal import Decimal +from typing import Any, Optional + +from agent.billing_view import format_money, parse_money + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Parsed sub-structures +# ============================================================================= + + +@dataclass(frozen=True) +class SubscriptionTier: + """A plan tier in the catalog.""" + + tier_id: str + name: str + tier_order: int + dollars_per_month: Optional[Decimal] = None + monthly_credits: Optional[Decimal] = None + is_current: bool = False + is_enabled: bool = True + + +@dataclass(frozen=True) +class CurrentSubscription: + """The user's active subscription (None fields = free / no active sub).""" + + tier_id: Optional[str] = None + tier_name: Optional[str] = None + monthly_credits: Optional[Decimal] = None + credits_remaining: Optional[Decimal] = None + cycle_ends_at: Optional[str] = None # ISO + pending_downgrade_tier_name: Optional[str] = None + pending_downgrade_at: Optional[str] = None # ISO + is_past_due: bool = False + + +@dataclass(frozen=True) +class SubscriptionState: + """Parsed ``GET /api/billing/subscription`` — the overview screen's data. + + Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the + portal is unreachable. + """ + + logged_in: bool + org_name: Optional[str] = None + role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER" + current: Optional[CurrentSubscription] = None + tiers: tuple[SubscriptionTier, ...] = () + portal_url: Optional[str] = None + # When the fetch failed (vs cleanly not-logged-in), the message for the surface. + error: Optional[str] = None + + @property + def is_admin(self) -> bool: + """True for OWNER/ADMIN — the roles that can change plans.""" + return (self.role or "").upper() in ("OWNER", "ADMIN") + + @property + def can_change_plan(self) -> bool: + """True when the UI should offer plan-change actions (role gate from NAS).""" + return self.is_admin + + +# ============================================================================= +# Payload parsing +# ============================================================================= + + +def _parse_tier(raw: Any) -> Optional[SubscriptionTier]: + if not isinstance(raw, dict): + return None + tier_id = raw.get("tierId") or raw.get("id") + name = raw.get("name") + if not (isinstance(tier_id, str) and isinstance(name, str)): + return None + return SubscriptionTier( + tier_id=tier_id, + name=name, + tier_order=int(raw.get("tierOrder") or raw.get("order") or 0), + dollars_per_month=parse_money(raw.get("dollarsPerMonth") or raw.get("priceUsd")), + monthly_credits=parse_money(raw.get("monthlyCredits")), + is_current=bool(raw.get("isCurrent")), + is_enabled=bool(raw.get("isEnabled", True)), + ) + + +def _parse_current(raw: Any) -> Optional[CurrentSubscription]: + if not isinstance(raw, dict): + return None + return CurrentSubscription( + tier_id=raw.get("tierId") or raw.get("id"), + tier_name=raw.get("tierName") or raw.get("name"), + monthly_credits=parse_money(raw.get("monthlyCredits")), + credits_remaining=parse_money(raw.get("creditsRemaining")), + cycle_ends_at=raw.get("cycleEndsAt"), + pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"), + pending_downgrade_at=raw.get("pendingDowngradeAt"), + is_past_due=bool(raw.get("isPastDue")), + ) + + +def subscription_state_from_payload( + payload: dict[str, Any], *, portal_url: Optional[str] = None +) -> SubscriptionState: + """Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`.""" + raw_org = payload.get("org") + org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {} + + tiers: list[SubscriptionTier] = [] + for item in payload.get("tiers") or (): + parsed = _parse_tier(item) + if parsed is not None: + tiers.append(parsed) + + return SubscriptionState( + logged_in=True, + org_name=org.get("name"), + role=org.get("role"), + current=_parse_current(payload.get("current")), + tiers=tuple(tiers), + portal_url=portal_url, + ) + + +# ============================================================================= +# Fail-open builders (the surface front doors) +# ============================================================================= + + +def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState: + """Fetch + parse ``GET /api/billing/subscription``. Fail-open. + + Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a + portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the + surface can show a clear message rather than crashing. + """ + try: + from hermes_cli.nous_billing import ( + BillingAuthError, + BillingError, + _absolutize_portal_url, + get_subscription_state, + resolve_portal_base_url, + ) + except Exception: + return SubscriptionState(logged_in=False, error="billing client unavailable") + + try: + payload = get_subscription_state(timeout=timeout) + except BillingAuthError: + return SubscriptionState(logged_in=False) + except BillingError as exc: + logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True) + return SubscriptionState(logged_in=False, error=str(exc)) + except Exception: + logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True) + return SubscriptionState(logged_in=False, error="could not load subscription state") + + raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None + portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None + if not portal_url: + try: + portal_url = resolve_portal_base_url() + except Exception: + portal_url = None + + return subscription_state_from_payload(payload, portal_url=portal_url) + + +def get_subscription_manage_link( + *, target_tier_id: Optional[str] = None, timeout: float = 15.0 +) -> dict[str, Any]: + """Fetch the Stripe-hosted manage-link URL from NAS. + + Returns ``{"kind": "checkout"|"portal", "url": "https://..."}``. Raises + :class:`BillingScopeRequired` when the Remote-Spending grant is missing + (Phase 4 step-up trigger) — the caller (gateway RPC) serializes that into + ``error:"insufficient_scope"``. + """ + from hermes_cli.nous_billing import post_subscription_manage_link + + return post_subscription_manage_link(target_tier_id=target_tier_id, timeout=timeout) diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index 8bca89ed36c9..b969bb1d8587 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -404,3 +404,32 @@ def get_charge_status( # guard against a stray slash that would change the path shape. safe_id = urllib.parse.quote(charge_id.strip(), safe="") return _request("GET", f"/api/billing/charge/{safe_id}", timeout=timeout) + + +def get_subscription_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: + """``GET /api/billing/subscription`` — current plan, tiers, usage (no scope). + + Returns the raw JSON dict from NAS (WS1 Phase A). Read-only — no + ``billing:manage`` scope required. Raises :class:`BillingAuthError` + on 401 and :class:`BillingError` on other non-2xx. + """ + return _request("GET", "/api/billing/subscription", timeout=timeout) + + +def post_subscription_manage_link( + *, + target_tier_id: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, +) -> dict[str, Any]: + """``POST /api/billing/subscription/manage-link`` → ``{kind, url}`` (scope required). + + Returns a Stripe-hosted URL the TUI opens via ``openExternalUrl``. + ``kind`` is ``"checkout"`` (free → first subscribe) or ``"portal"`` + (existing sub → NAS manage page). ``target_tier_id`` is needed only + for the free→paid checkout branch. Raises :class:`BillingScopeRequired` + when the Remote-Spending grant is missing (Phase 4 step-up trigger). + """ + body: dict[str, Any] = {} + if target_tier_id: + body["targetTierId"] = target_tier_id + return _request("POST", "/api/billing/subscription/manage-link", body=body, timeout=timeout) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ad3ea68cdd43..e22554b991a1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5580,6 +5580,91 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"}) +def _serialize_subscription_state(state) -> dict: + """Serialize a SubscriptionState for the wire (Decimals → strings).""" + from agent.billing_view import format_money + + def _s(value): + return None if value is None else str(value) + + current = None + if state.current is not None: + c = state.current + current = { + "tier_id": c.tier_id, + "tier_name": c.tier_name, + "monthly_credits": _s(c.monthly_credits), + "credits_remaining": _s(c.credits_remaining), + "cycle_ends_at": c.cycle_ends_at, + "pending_downgrade_tier_name": c.pending_downgrade_tier_name, + "pending_downgrade_at": c.pending_downgrade_at, + "is_past_due": c.is_past_due, + } + tiers = [] + for t in state.tiers: + tiers.append({ + "tier_id": t.tier_id, + "name": t.name, + "tier_order": t.tier_order, + "dollars_per_month_display": format_money(t.dollars_per_month), + "monthly_credits": _s(t.monthly_credits), + "is_current": t.is_current, + "is_enabled": t.is_enabled, + }) + return { + "ok": True, + "logged_in": state.logged_in, + "is_admin": state.is_admin, + "can_change_plan": state.can_change_plan, + "org_name": state.org_name, + "role": state.role, + "current": current, + "tiers": tiers, + "portal_url": state.portal_url, + "error": state.error, + } + + +@method("subscription.state") +def _(rid, params: dict) -> dict: + """GET /api/billing/subscription → serialized SubscriptionState. + + Fail-open like billing.state: logged-out / unreachable portal → + {ok:true, logged_in:false}. No scope required (read-only). + """ + try: + from agent.subscription_view import build_subscription_state + + state = build_subscription_state() + return _ok(rid, _serialize_subscription_state(state)) + except Exception: + return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"}) + + +@method("subscription.manage_link") +def _(rid, params: dict) -> dict: + """POST /api/billing/subscription/manage-link → {ok, kind, url} or typed error. + + kind: 'checkout' (free → first subscribe) | 'portal' (existing sub → + NAS manage page). The deep-link target is NAS's own /manage-subscription + page, NOT the Stripe hosted Billing Portal. The TUI just opens the URL. + Raises insufficient_scope when Remote-Spending is required (Phase 4). + """ + from hermes_cli.nous_billing import BillingError + + try: + from agent.subscription_view import get_subscription_manage_link + + result = get_subscription_manage_link( + target_tier_id=params.get("target_tier_id") or None + ) + return _ok(rid, {"ok": True, "kind": result.get("kind"), "url": result.get("url")}) + except BillingError as exc: + return _ok(rid, _serialize_billing_error(exc)) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + + @method("billing.charge") def _(rid, params: dict) -> dict: """POST /api/billing/charge → {ok, chargeId} or a typed error envelope. From 1cecb2fbb95956cf48f0f523d3280a59c7c411b8 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 23 Jun 2026 20:27:54 +0530 Subject: [PATCH 06/62] feat(tui): add subscription overlay state types + store slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/app/interfaces.ts | 32 +++++++++++++++++++++++++++++++- ui-tui/src/app/overlayStore.ts | 4 +++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index a4d21412c88b..9f23520c2a5f 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -3,7 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea import type { PasteEvent } from '../components/textInput.js' import type { GatewayClient } from '../gatewayClient.js' -import type { BillingStateResponse, ImageAttachResponse, SessionCloseResponse } from '../gatewayTypes.js' +import type { BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionStateResponse } from '../gatewayTypes.js' import type { ParsedVoiceRecordKey } from '../lib/platform.js' import type { RpcResult } from '../lib/rpc.js' import type { Theme } from '../theme.js' @@ -127,6 +127,35 @@ export interface BillingOverlayState { state: BillingStateResponse } +// ── Subscription overlay (deep-link only, NEVER charges in-terminal) ── + +export type SubscriptionScreen = + | 'overview' // shows plan + usage bar + tier list (states a–e collapse into this) + | 'confirm' // y/n confirm before opening the Stripe URL + | 'stepup' // Phase 4: "Allow Remote Spending" (resumable) + | 'handoff' // transient: "Opening Stripe in your browser…" + +export interface SubscriptionOverlayCtx { + /** Fetch the manage-link URL and open it (deep-link). Resolves ok/false. */ + openManageLink: (targetTierId?: string) => Promise + /** Re-fetch subscription.state (used by Phase-4 resume). */ + refreshState: () => Promise + /** Run billing.step_up (Remote Spending). Resolves granted. */ + requestRemoteSpending: () => Promise + /** Emit a transcript system line. */ + sys: (text: string) => void +} + +export interface SubscriptionOverlayState { + ctx: SubscriptionOverlayCtx + screen: SubscriptionScreen + state: SubscriptionStateResponse + /** Phase 4 resume bookkeeping: the screen to return to after step-up. */ + resumeScreen?: SubscriptionScreen | null + /** Phase 4: the pending tier the user confirmed, replayed post-grant. */ + pendingTargetTierId?: string | null +} + export interface OverlayState { agents: boolean agentsInitialHistoryIndex: number @@ -140,6 +169,7 @@ export interface OverlayState { secret: null | SecretReq sessions: boolean skillsHub: boolean + subscription: SubscriptionOverlayState | null sudo: null | SudoReq } diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index c0290d71cab5..b05387769a41 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -15,6 +15,7 @@ const buildOverlayState = (): OverlayState => ({ secret: null, sessions: false, skillsHub: false, + subscription: null, sudo: null }) @@ -22,7 +23,7 @@ export const $overlayState = atom(buildOverlayState()) export const $isBlocked = computed( $overlayState, - ({ agents, approval, billing, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) => + ({ agents, approval, billing, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, subscription, sudo }) => Boolean( agents || approval || @@ -35,6 +36,7 @@ export const $isBlocked = computed( secret || sessions || skillsHub || + subscription || sudo ) ) From 66d22cac973c71e9a48227f99f81bb771756e107 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 23 Jun 2026 20:39:50 +0530 Subject: [PATCH 07/62] =?UTF-8?q?feat(tui):=20build=20SubscriptionOverlay?= =?UTF-8?q?=20=E2=80=94=20overview=20+=20confirm=20+=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/subscriptionOverlay.test.tsx | 268 ++++++++++++++ ui-tui/src/components/subscriptionOverlay.tsx | 331 ++++++++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 ui-tui/src/__tests__/subscriptionOverlay.test.tsx create mode 100644 ui-tui/src/components/subscriptionOverlay.tsx diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx new file mode 100644 index 000000000000..b8ca18abc247 --- /dev/null +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -0,0 +1,268 @@ +import { PassThrough } from 'stream' + +import { renderSync } from '@hermes/ink' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +// Stub useInput so the overlay doesn't try to enter raw mode under renderSync +// (PassThrough stdin doesn't support it). Box/Text pass through to real Ink. +vi.mock('@hermes/ink', async importOriginal => { + const mod = await importOriginal() + + return { + ...mod, + useInput: () => {} + } +}) + +import type { SubscriptionOverlayState } from '../app/interfaces.js' +import { SubscriptionOverlay } from '../components/subscriptionOverlay.js' +import type { SubscriptionStateResponse, SubscriptionTierOption } from '../gatewayTypes.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +const t = DEFAULT_THEME + +/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */ +function render(overlay: SubscriptionOverlayState): string { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + + let output = '' + + Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync( + React.createElement(SubscriptionOverlay, { + onClose: () => {}, + onPatch: () => {}, + overlay, + t + }), + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + instance.unmount() + instance.cleanup() + + return stripAnsi(output) +} + +const tier = (overrides: Partial = {}): SubscriptionTierOption => ({ + tier_id: 'free', + name: 'Free', + tier_order: 0, + dollars_per_month_display: '$0', + monthly_credits: '0', + is_current: false, + is_enabled: true, + ...overrides +}) + +const state = (overrides: Partial = {}): SubscriptionStateResponse => ({ + ok: true, + logged_in: true, + is_admin: true, + can_change_plan: true, + org_name: 'Acme', + role: 'OWNER', + current: null, + tiers: [], + portal_url: 'https://portal.nousresearch.com/billing', + ...overrides +}) + +const ctx = { + openManageLink: vi.fn(() => Promise.resolve(true)), + refreshState: vi.fn(() => Promise.resolve(null)), + requestRemoteSpending: vi.fn(() => Promise.resolve(true)), + sys: vi.fn() +} + +const overlay = (s: SubscriptionStateResponse, screen: SubscriptionOverlayState['screen'] = 'overview'): SubscriptionOverlayState => ({ + ctx, + screen, + state: s, + resumeScreen: null, + pendingTargetTierId: null +}) + +describe('SubscriptionOverlay — overview screen', () => { + it('(a) free-upgradeable: shows tier list + subscribe header', () => { + const s = state({ + current: null, + tiers: [ + tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }), + tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: false }), + tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000', is_current: false }) + ] + }) + + const out = render(overlay(s)) + + expect(out).toContain('Subscribe to a plan') + expect(out).toContain('Pro') + expect(out).toContain('Scale') + expect(out).toContain('$20') + expect(out).toContain('1000 credits') + }) + + it('(b) subscriber mid-tier: shows current plan + usage bar', () => { + const s = state({ + current: { + tier_id: 'pro', + tier_name: 'Pro', + monthly_credits: '1000', + credits_remaining: '420', + cycle_ends_at: '2026-07-01T00:00:00Z', + pending_downgrade_tier_name: null, + pending_downgrade_at: null, + is_past_due: false + }, + tiers: [ + tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), + tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true }), + tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000' }) + ] + }) + + const out = render(overlay(s)) + + expect(out).toContain('Your plan: Pro') + expect(out).toContain('remaining') + expect(out).toContain('Scale') + }) + + it('(c) subscriber top-tier: shows top plan note', () => { + const s = state({ + current: { + tier_id: 'scale', + tier_name: 'Scale', + monthly_credits: '5000', + credits_remaining: '3000', + cycle_ends_at: '2026-07-01T00:00:00Z', + pending_downgrade_tier_name: null, + pending_downgrade_at: null, + is_past_due: false + }, + tiers: [ + tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), + tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: false }), + tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000', is_current: true }) + ] + }) + + const out = render(overlay(s)) + + expect(out).toContain('Your plan: Scale') + expect(out).toContain("You're on the top plan.") + }) + + it('(d) not-admin: shows read-only note + no tier list', () => { + const s = state({ + is_admin: false, + can_change_plan: false, + role: 'MEMBER', + current: { + tier_id: 'pro', + tier_name: 'Pro', + monthly_credits: '1000', + credits_remaining: '500', + cycle_ends_at: '2026-07-01T00:00:00Z', + pending_downgrade_tier_name: null, + pending_downgrade_at: null, + is_past_due: false + }, + tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] + }) + + const out = render(overlay(s)) + + expect(out).toContain('Plan changes need an org admin/owner.') + expect(out).toContain('Manage on portal') + }) + + it('(e) downgrade-pending: shows scheduled switch banner', () => { + const s = state({ + current: { + tier_id: 'pro', + tier_name: 'Pro', + monthly_credits: '1000', + credits_remaining: '500', + cycle_ends_at: '2026-07-01T00:00:00Z', + pending_downgrade_tier_name: 'Free', + pending_downgrade_at: '2026-07-15T00:00:00Z', + is_past_due: false + }, + tiers: [ + tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), + tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true }) + ] + }) + + const out = render(overlay(s)) + + expect(out).toContain('Scheduled to switch to Free') + expect(out).toContain('2026-07-15T00:00:00Z') + }) + + it('dunning: past due shows past-due banner (does NOT re-offer fresh subscribe)', () => { + const s = state({ + current: { + tier_id: 'pro', + tier_name: 'Pro', + monthly_credits: '1000', + credits_remaining: '500', + cycle_ends_at: '2026-07-01T00:00:00Z', + pending_downgrade_tier_name: null, + pending_downgrade_at: null, + is_past_due: true + }, + tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] + }) + + const out = render(overlay(s)) + + expect(out).toContain('Payment past due') + expect(out).toContain('still active until') + expect(out).toContain('2026-07-01T00:00:00Z') + }) +}) + +describe('SubscriptionOverlay — confirm screen', () => { + it('shows tier summary + Stripe disclosure', () => { + const s = state({ + current: null, + tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000' })] + }) + + const out = render({ ...overlay(s, 'confirm'), pendingTargetTierId: 'pro' }) + + expect(out).toContain('Confirm subscription') + expect(out).toContain('Pro') + expect(out).toContain('$20') + expect(out).toContain('securely on Stripe') + expect(out).toContain('Continue to Stripe') + }) +}) + +describe('SubscriptionOverlay — handoff screen', () => { + it('shows opening-stripe copy', () => { + const out = render(overlay(state(), 'handoff')) + + expect(out).toContain('Opening Stripe') + expect(out).toContain('browser') + expect(out).toContain('Re-run /subscription') + }) +}) diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx new file mode 100644 index 000000000000..d5bf57504723 --- /dev/null +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -0,0 +1,331 @@ +import { Box, Text, useInput } from '@hermes/ink' +import { useState } from 'react' + +import type { SubscriptionOverlayState } from '../app/interfaces.js' +import type { SubscriptionStateResponse } from '../gatewayTypes.js' +import type { Theme } from '../theme.js' + +import { ActionRow, barCells, footer, MenuRow } from './overlayPrimitives.js' + +interface SubscriptionOverlayProps { + /** Replace the overlay slot (screen transitions + pending data). */ + onPatch: (next: Partial) => void + /** Close the overlay entirely. */ + onClose: () => void + overlay: SubscriptionOverlayState + t: Theme +} + +/** + * The /subscription modal — deep-link only, NEVER charges in-terminal. + * Mirrors billingOverlay.tsx's structure: a pure-render state machine + * (overview → confirm → handoff, plus stepup for Phase 4) where all RPCs + * live in subscription.ts and are reached through `overlay.ctx`. + */ +export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) { + const { ctx, screen, state: s } = overlay + + return ( + + {screen === 'overview' && } + {screen === 'confirm' && ( + onPatch({ screen: 'overview' })} + onClose={onClose} + onPatch={onPatch} + overlay={overlay} + s={s} + t={t} + /> + )} + {screen === 'handoff' && } + {/* stepup screen is built in Phase 4 (U9) */} + + ) +} + +// ── Screen: Overview (covers states a–e + dunning) ─────────────────── + +interface ScreenProps { + ctx: SubscriptionOverlayState['ctx'] + onClose: () => void + onPatch: (next: Partial) => void + s: SubscriptionStateResponse + t: Theme +} + +/** Usage bar from subscription allowance (monthly_credits vs credits_remaining). */ +function usageBar(s: SubscriptionStateResponse): null | string { + const c = s.current + + if (!c || !c.monthly_credits || !c.credits_remaining) { + return null + } + + const monthly = Number(c.monthly_credits) + const remaining = Number(c.credits_remaining) + + if (!(monthly > 0) || Number.isNaN(remaining)) { + return null + } + + const spent = Math.max(0, monthly - remaining) + const { bar, pct } = barCells(spent / monthly) + + return `${remaining} of ${c.monthly_credits} remaining ${bar} ${100 - pct}% left` +} + +function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { + // (d) not-admin: read-only + note + Manage/portal only. + const canChange = s.can_change_plan && s.is_admin + const c = s.current + const isFree = !c?.tier_id + const hasPendingDowngrade = !!c?.pending_downgrade_tier_name + const isPastDue = !!c?.is_past_due + + // Dunning: past due — don't re-offer fresh subscribe; route to manage/portal. + const dunningNote = isPastDue && c?.cycle_ends_at + ? `Payment past due — your plan is still active until ${c.cycle_ends_at}.` + : null + + const downgradeNote = hasPendingDowngrade + ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${c?.pending_downgrade_at}.` + : null + + const notAdminNote = !canChange ? 'Plan changes need an org admin/owner.' : null + + // Build the tier list (only enabled tiers for the menu; current marked). + const enabledTiers = s.tiers.filter(tier => tier.is_enabled) + const currentTierOrder = c?.tier_id ? s.tiers.find(tier => tier.tier_id === c.tier_id)?.tier_order : undefined + const isTopTier = currentTierOrder != null && enabledTiers.every(tier => tier.tier_order <= currentTierOrder) + const topTierNote = isTopTier ? "You're on the top plan." : null + + // Menu items: tiers (selectable) + Manage on portal + Close. + // For not-admin: just Manage on portal + Close. + const tierItems: { label: string; tierId?: string }[] = canChange + ? [ + ...enabledTiers.map(tier => ({ + label: `${tier.is_current ? '✓ ' : ''}${tier.name} — ${tier.dollars_per_month_display}/mo (${tier.monthly_credits} credits)`, + tierId: tier.tier_id + })), + { label: 'Manage on portal' }, + { label: 'Close' } + ] + : [{ label: 'Manage on portal' }, { label: 'Close' }] + + const [sel, setSel] = useState(0) + + const choose = (i: number) => { + const item = tierItems[i] + + if (!item) { + return + } + + if (item.label === 'Close') { + return onClose() + } + + if (item.label === 'Manage on portal') { + if (s.portal_url) { + ctx.sys('Opening portal in your browser…') + void ctx.openManageLink() + } + + return onClose() + } + + // A tier was selected — go to confirm (deep-link, no in-terminal charge). + if (item.tierId && item.tierId !== c?.tier_id) { + onPatch({ screen: 'confirm', pendingTargetTierId: item.tierId }) + } + } + + useInput((ch, key) => { + if (key.escape) { + return onClose() + } + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + } + + if (key.downArrow && sel < tierItems.length - 1) { + setSel(v => v + 1) + } + + if (key.return) { + return choose(sel) + } + + const n = parseInt(ch, 10) + + if (n >= 1 && n <= tierItems.length) { + return choose(n - 1) + } + }) + + const header = isFree ? 'Subscribe to a plan' : c?.tier_name ? `Your plan: ${c.tier_name}` : 'Subscription' + const bar = usageBar(s) + + return ( + + + {header} + + {bar && {bar}} + {c?.credits_remaining && !bar && ( + Credits remaining: {c.credits_remaining} + )} + {s.org_name && ( + + Org: {s.org_name} + {s.role ? ` · ${s.role}` : ''} + + )} + {dunningNote && ( + + {dunningNote} + + )} + {downgradeNote && ( + + {downgradeNote} + + )} + {notAdminNote && ( + + {notAdminNote} + + )} + {topTierNote && ( + + {topTierNote} + + )} + + + {tierItems.map((item, i) => ( + + ))} + + + {footer(`↑/↓ select · 1-${tierItems.length} quick pick · Enter confirm · Esc close`, t)} + + ) +} + +// ── Screen: Confirm (y/n deep-link, NO in-terminal charge) ─────────── + +interface ConfirmScreenProps extends ScreenProps { + onBack: () => void + onPatch: (next: Partial) => void + overlay: SubscriptionOverlayState +} + +function ConfirmScreen({ ctx, onBack, onClose, onPatch, overlay, s, t }: ConfirmScreenProps) { + const targetTierId = overlay.pendingTargetTierId ?? undefined + const targetTier = s.tiers.find(tier => tier.tier_id === targetTierId) + const isUpgrade = !s.current?.tier_id + + const [sel, setSel] = useState(0) + const items = ['Continue to Stripe', 'Cancel'] + const [transitioned, setTransitioned] = useState(false) + + const confirm = () => { + if (transitioned) { + return + } + + setTransitioned(true) + onPatch({ screen: 'handoff' }) + + void ctx.openManageLink(targetTierId).then(ok => { + if (!ok) { + // If openManageLink surfaces insufficient_scope, the ctx closure in + // subscription.ts handles the stepup transition (Phase 4 wiring). + // For now, return to overview on any failure. + onPatch({ screen: 'overview' }) + } + }) + } + + const choose = (i: number) => { + if (i === 0) { + return confirm() + } + + return onBack() + } + + useInput((ch, key) => { + if (key.escape) { + return onBack() + } + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + } + + if (key.downArrow && sel < items.length - 1) { + setSel(v => v + 1) + } + + if (key.return) { + return choose(sel) + } + + if (ch === 'y') { + return choose(0) + } + + if (ch === 'n') { + return choose(1) + } + }) + + return ( + + + {isUpgrade ? 'Confirm subscription' : 'Confirm plan change'} + + {targetTier && ( + + {targetTier.name} — {targetTier.dollars_per_month_display}/mo ({targetTier.monthly_credits} credits) + + )} + You'll finish this change securely on Stripe in your browser. + + + {items.map((label, i) => ( + + ))} + + + {footer('y/Enter confirm · n/Esc cancel', t)} + + ) +} + +// ── Screen: Handoff (transient) ────────────────────────────────────── + +function HandoffScreen({ onClose, t }: { onClose: () => void; t: Theme }) { + useInput((_ch, key) => { + if (key.escape) { + return onClose() + } + }) + + return ( + + + Opening Stripe… + + Opening Stripe in your browser… + Finish on the page that just opened. Re-run /subscription to see the change. + + {footer('Esc close', t)} + + ) +} From e854528a85303625c00b4d79666d8f0a9219f598 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 23 Jun 2026 20:45:31 +0530 Subject: [PATCH 08/62] 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). --- .../src/__tests__/subscriptionCommand.test.ts | 104 ++++++++++++++++++ ui-tui/src/app/slash/commands/subscription.ts | 92 ++++++++++++++++ ui-tui/src/app/slash/registry.ts | 4 +- ui-tui/src/app/useInputHandlers.ts | 6 +- ui-tui/src/components/appOverlays.tsx | 16 +++ 5 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/__tests__/subscriptionCommand.test.ts create mode 100644 ui-tui/src/app/slash/commands/subscription.ts diff --git a/ui-tui/src/__tests__/subscriptionCommand.test.ts b/ui-tui/src/__tests__/subscriptionCommand.test.ts new file mode 100644 index 000000000000..7c08912d808d --- /dev/null +++ b/ui-tui/src/__tests__/subscriptionCommand.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' +import { subscriptionCommands } from '../app/slash/commands/subscription.js' +import { findSlashCommand } from '../app/slash/registry.js' +import type { SubscriptionStateResponse } from '../gatewayTypes.js' + +vi.mock('../lib/openExternalUrl.js', () => ({ + openExternalUrl: vi.fn(() => true) +})) + +const subscriptionCommand = subscriptionCommands.find(cmd => cmd.name === 'subscription')! + +const loggedInState = (overrides: Partial = {}): SubscriptionStateResponse => ({ + ok: true, + logged_in: true, + is_admin: true, + can_change_plan: true, + org_name: 'Acme', + role: 'OWNER', + current: null, + tiers: [], + portal_url: 'https://portal.nousresearch.com/billing', + ...overrides +}) + +const guarded = + (fn: (r: T) => void) => + (r: null | T) => { + if (r) { + fn(r) + } + } + +/** Build a ctx whose rpc routes by method name to a supplied map of results. */ +const buildCtx = (results: Record) => { + const sys = vi.fn() + const calls: Array<{ method: string; params: unknown }> = [] + + const rpc = vi.fn((method: string, params: unknown) => { + calls.push({ method, params }) + + return Promise.resolve(results[method]) + }) + + const ctx = { + gateway: { rpc }, + guarded, + guardedErr: vi.fn(), + sid: 'sid-1', + stale: () => false, + transcript: { page: vi.fn(), panel: vi.fn(), sys } + } + + const run = async (arg: string) => { + subscriptionCommand.run(arg, ctx as any, 'subscription') + await rpc.mock.results[0]?.value + await Promise.resolve() + await Promise.resolve() + } + + return { calls, ctx, rpc, run, sys } +} + +const printed = (sys: ReturnType) => sys.mock.calls.map(c => c[0]).join('\n') + +describe('/subscription slash command', () => { + beforeEach(() => { + resetOverlayState() + }) + + it('fetches subscription.state and opens the overlay', async () => { + const { run } = buildCtx({ + 'subscription.state': loggedInState({ tiers: [{ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: false, is_enabled: true }] }) + }) + + await run('') + + const overlay = getOverlayState().subscription + + expect(overlay).not.toBeNull() + expect(overlay?.screen).toBe('overview') + expect(overlay?.state.tiers).toHaveLength(1) + }) + + it('shows portal-login sys line when not logged in', async () => { + const { run, sys } = buildCtx({ + 'subscription.state': loggedInState({ logged_in: false }) + }) + + await run('') + + expect(printed(sys)).toContain('Not logged into Nous Portal') + expect(getOverlayState().subscription).toBeNull() + }) + + it('/upgrade alias resolves to the same command', () => { + expect(findSlashCommand('upgrade')).toBe(subscriptionCommand) + }) + + it('/subscription resolves to the same command', () => { + expect(findSlashCommand('subscription')).toBe(subscriptionCommand) + }) +}) diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts new file mode 100644 index 000000000000..d59cc2e6282a --- /dev/null +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -0,0 +1,92 @@ +import type { + BillingMutationResponse, + SubscriptionManageLinkResponse, + SubscriptionStateResponse +} from '../../../gatewayTypes.js' +import { openExternalUrl } from '../../../lib/openExternalUrl.js' +import type { SubscriptionOverlayCtx } from '../../interfaces.js' +import { patchOverlayState } from '../../overlayStore.js' +import type { SlashCommand, SlashRunCtx } from '../types.js' + +type Sys = (text: string) => void + +/** + * Build the ctx the overlay uses to talk to the gateway + emit transcript + * lines. Mirrors topup.ts's buildOverlayCtx — all RPC + error-mapping logic + * lives here (single source of truth); the overlay only renders + routes keys. + */ +const buildSubscriptionCtx = (ctx: SlashRunCtx, sys: Sys): SubscriptionOverlayCtx => ({ + openManageLink: (targetTierId?: string) => + ctx.gateway + .rpc('subscription.manage_link', { + ...(targetTierId ? { target_tier_id: targetTierId } : {}) + }) + .then(r => { + if (r && r.ok && r.url) { + openExternalUrl(r.url) + sys('Opening Stripe — finish the change in your browser; re-run /subscription to confirm.') + + return true + } + + // insufficient_scope → Phase 4 step-up (handled by the overlay). + // For now, other errors just log + resolve false. + if (r && r.error) { + sys(r.message ?? r.error) + } + + return false + }) + .catch(e => { + ctx.guardedErr(e) + + return false + }), + refreshState: () => + ctx.gateway + .rpc('subscription.state', {}) + .then(r => r ?? null) + .catch(() => null), + requestRemoteSpending: () => + ctx.gateway + .rpc('billing.step_up', { session_id: ctx.sid ?? undefined }) + .then(r => !!(r && r.ok && r.granted)) + .catch(() => false), + sys +}) + +export const subscriptionCommands: SlashCommand[] = [ + { + help: 'View or change your Nous subscription plan', + name: 'subscription', + aliases: ['upgrade'], + // ZERO sub-commands: bare `/subscription` fetches state and opens the + // overlay (deep-link only — NEVER charges in-terminal). + run: (_arg, ctx) => { + const sys: Sys = ctx.transcript.sys + + ctx.gateway + .rpc('subscription.state', {}) + .then( + ctx.guarded(s => { + if (!s.logged_in) { + sys('Not logged into Nous Portal — run /portal to log in, then /subscription.') + + return + } + + patchOverlayState({ + subscription: { + ctx: buildSubscriptionCtx(ctx, sys), + pendingTargetTierId: null, + resumeScreen: null, + screen: 'overview', + state: s + } + }) + }) + ) + .catch(ctx.guardedErr) + } + } +] diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index 7a7357238c54..4d914bb9f9ac 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -1,10 +1,11 @@ import { coreCommands } from './commands/core.js' -import { topupCommands } from './commands/topup.js' import { creditsCommands } from './commands/credits.js' import { debugCommands } from './commands/debug.js' import { opsCommands } from './commands/ops.js' import { sessionCommands } from './commands/session.js' import { setupCommands } from './commands/setup.js' +import { subscriptionCommands } from './commands/subscription.js' +import { topupCommands } from './commands/topup.js' import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ @@ -12,6 +13,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [ ...topupCommands, ...creditsCommands, ...sessionCommands, + ...subscriptionCommands, ...opsCommands, ...setupCommands, ...debugCommands diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index f19cccfe5b5d..0c4621506d55 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -169,6 +169,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ billing: null }) } + if (overlay.subscription) { + return patchOverlayState({ subscription: null }) + } + if (overlay.skillsHub) { return patchOverlayState({ skillsHub: false }) } @@ -294,7 +298,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { // answering felt like the prompt had locked the entire UI. Explicitly // skip the prompt-overlay early-return for scroll keys so they fall // through to the wheel / PageUp / Shift+arrow handlers below. - const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm + const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm || overlay.subscription const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key) if (promptOverlay && !fallThroughForScroll) { diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index a7336d08f334..314b5f697a27 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -15,6 +15,7 @@ import { OverlayHint } from './overlayControls.js' import { PluginsHub } from './pluginsHub.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SkillsHub } from './skillsHub.js' +import { SubscriptionOverlay } from './subscriptionOverlay.js' const COMPLETION_WINDOW = 16 @@ -51,6 +52,21 @@ export function PromptZone({ ) } + if (overlay.subscription) { + const current = overlay.subscription + + const onPatch = (next: Partial) => + patchOverlayState(prev => (prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev)) + + const onClose = () => patchOverlayState({ subscription: null }) + + return ( + + + + ) + } + if (overlay.confirm) { const req = overlay.confirm From e4c46be204cf8df4a94b91bf8e844ebe6229e6b6 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 07:33:07 +0530 Subject: [PATCH 09/62] fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui-tui/src/app/slash/commands/subscription.ts | 2 +- ui-tui/src/components/subscriptionOverlay.tsx | 8 ++++---- ui-tui/src/gatewayTypes.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index d59cc2e6282a..167d51e71743 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -24,7 +24,7 @@ const buildSubscriptionCtx = (ctx: SlashRunCtx, sys: Sys): SubscriptionOverlayCt .then(r => { if (r && r.ok && r.url) { openExternalUrl(r.url) - sys('Opening Stripe — finish the change in your browser; re-run /subscription to confirm.') + sys('Opening your subscription page in the browser — finish the change there; re-run /subscription to confirm.') return true } diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index d5bf57504723..a9fd2a81a70b 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -230,7 +230,7 @@ function ConfirmScreen({ ctx, onBack, onClose, onPatch, overlay, s, t }: Confirm const isUpgrade = !s.current?.tier_id const [sel, setSel] = useState(0) - const items = ['Continue to Stripe', 'Cancel'] + const items = ['Continue to your subscription page', 'Cancel'] const [transitioned, setTransitioned] = useState(false) const confirm = () => { @@ -295,7 +295,7 @@ function ConfirmScreen({ ctx, onBack, onClose, onPatch, overlay, s, t }: Confirm {targetTier.name} — {targetTier.dollars_per_month_display}/mo ({targetTier.monthly_credits} credits) )} - You'll finish this change securely on Stripe in your browser. + You'll finish this change securely on your subscription page in your browser. {items.map((label, i) => ( @@ -320,9 +320,9 @@ function HandoffScreen({ onClose, t }: { onClose: () => void; t: Theme }) { return ( - Opening Stripe… + Opening your subscription page… - Opening Stripe in your browser… + Opening your subscription page in the browser… Finish on the page that just opened. Re-run /subscription to see the change. {footer('Esc close', t)} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 456c60fa9f0e..d91ffbb2ff4f 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -176,7 +176,7 @@ export interface SubscriptionStateResponse { export interface SubscriptionManageLinkResponse { ok: boolean - kind?: 'checkout' | 'portal' + kind?: 'checkout' | 'manage' | 'portal' url?: string error?: string message?: string From 2958145f11c72c9f5dfebdec4624ff099ece7efc Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 07:34:08 +0530 Subject: [PATCH 10/62] feat(tui/subscription): render cancellation-scheduled note with headline precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/subscription_view.py | 4 ++++ tui_gateway/server.py | 2 ++ ui-tui/src/components/subscriptionOverlay.tsx | 15 ++++++++++++++- ui-tui/src/gatewayTypes.ts | 2 ++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/agent/subscription_view.py b/agent/subscription_view.py index b983d723a1a9..79b58964d321 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -58,6 +58,8 @@ class CurrentSubscription: pending_downgrade_tier_name: Optional[str] = None pending_downgrade_at: Optional[str] = None # ISO is_past_due: bool = False + cancel_at_period_end: bool = False + cancellation_effective_at: Optional[str] = None # ISO @dataclass(frozen=True) @@ -123,6 +125,8 @@ def _parse_current(raw: Any) -> Optional[CurrentSubscription]: pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"), pending_downgrade_at=raw.get("pendingDowngradeAt"), is_past_due=bool(raw.get("isPastDue")), + cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")), + cancellation_effective_at=raw.get("cancellationEffectiveAt") or None, ) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e22554b991a1..34f2fe344932 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5599,6 +5599,8 @@ def _s(value): "pending_downgrade_tier_name": c.pending_downgrade_tier_name, "pending_downgrade_at": c.pending_downgrade_at, "is_past_due": c.is_past_due, + "cancel_at_period_end": c.cancel_at_period_end, + "cancellation_effective_at": c.cancellation_effective_at, } tiers = [] for t in state.tiers: diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index a9fd2a81a70b..b71234d2b896 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -83,13 +83,21 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const isFree = !c?.tier_id const hasPendingDowngrade = !!c?.pending_downgrade_tier_name const isPastDue = !!c?.is_past_due + const isCancelScheduled = !!c?.cancel_at_period_end + // Headline precedence: past-due > cancel-scheduled > downgrade-pending > active. // Dunning: past due — don't re-offer fresh subscribe; route to manage/portal. const dunningNote = isPastDue && c?.cycle_ends_at ? `Payment past due — your plan is still active until ${c.cycle_ends_at}.` : null - const downgradeNote = hasPendingDowngrade + const cancellationNote = !isPastDue && isCancelScheduled + ? c?.cancellation_effective_at + ? `Cancels on ${c.cancellation_effective_at} — your plan stays active until then.` + : 'Cancellation scheduled — your plan stays active until the end of the billing period.' + : null + + const downgradeNote = !isPastDue && !isCancelScheduled && hasPendingDowngrade ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${c?.pending_downgrade_at}.` : null @@ -189,6 +197,11 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { {dunningNote} )} + {cancellationNote && ( + + {cancellationNote} + + )} {downgradeNote && ( {downgradeNote} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index d91ffbb2ff4f..6e74e6a790d8 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -168,6 +168,8 @@ export interface SubscriptionStateResponse { pending_downgrade_tier_name: string | null pending_downgrade_at: string | null is_past_due: boolean // dunning: active=false but period live (WS1 M1 footgun) + cancel_at_period_end: boolean // subscription scheduled to cancel at period end + cancellation_effective_at: string | null // ISO when cancellation takes effect } | null tiers: SubscriptionTierOption[] portal_url: string | null From 3831e78d37722f23ebe37b93821e35ab18062906 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 07:35:27 +0530 Subject: [PATCH 11/62] =?UTF-8?q?feat(tui/subscription):=20team-context=20?= =?UTF-8?q?screen=20=E2=80=94=20redirect=20to=20/topup=20for=20team=20orgs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/subscription_view.py | 5 ++ tui_gateway/server.py | 1 + ui-tui/src/components/subscriptionOverlay.tsx | 50 +++++++++++++++++++ ui-tui/src/gatewayTypes.ts | 1 + 4 files changed, 57 insertions(+) diff --git a/agent/subscription_view.py b/agent/subscription_view.py index 79b58964d321..bf503be5723a 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -73,6 +73,7 @@ class SubscriptionState: logged_in: bool org_name: Optional[str] = None role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER" + context: str = "personal" # "personal" | "team" current: Optional[CurrentSubscription] = None tiers: tuple[SubscriptionTier, ...] = () portal_url: Optional[str] = None @@ -143,10 +144,14 @@ def subscription_state_from_payload( if parsed is not None: tiers.append(parsed) + raw_context = payload.get("context") + context = raw_context if raw_context in ("personal", "team") else "personal" + return SubscriptionState( logged_in=True, org_name=org.get("name"), role=org.get("role"), + context=context, current=_parse_current(payload.get("current")), tiers=tuple(tiers), portal_url=portal_url, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 34f2fe344932..f85e8543f102 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5620,6 +5620,7 @@ def _s(value): "can_change_plan": state.can_change_plan, "org_name": state.org_name, "role": state.role, + "context": state.context, "current": current, "tiers": tiers, "portal_url": state.portal_url, diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index b71234d2b896..3587dca3342d 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -25,6 +25,15 @@ interface SubscriptionOverlayProps { export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) { const { ctx, screen, state: s } = overlay + // Team context: no tier picker — teams run on shared credits; redirect to /topup. + if (s.context === 'team') { + return ( + + + + ) + } + return ( {screen === 'overview' && } @@ -229,6 +238,47 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { ) } +// ── Screen: Team context (no tier picker — teams use shared credits) ── + +interface TeamContextScreenProps { + onClose: () => void + s: SubscriptionStateResponse + t: Theme +} + +function TeamContextScreen({ onClose, s, t }: TeamContextScreenProps) { + useInput((_ch, key) => { + if (key.escape || key.return) { + return onClose() + } + }) + + return ( + + + Team subscription + + {s.org_name && ( + + Org: {s.org_name} + {s.role ? ` · ${s.role}` : ''} + + )} + + + This terminal is connected to {s.org_name ?? 'a team org'}. Teams run on shared credits + — use /topup to add funds. + + + Personal subscriptions live on your personal account. + + + + {footer('Enter/Esc close', t)} + + ) +} + // ── Screen: Confirm (y/n deep-link, NO in-terminal charge) ─────────── interface ConfirmScreenProps extends ScreenProps { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6e74e6a790d8..5757451bf6f3 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -159,6 +159,7 @@ export interface SubscriptionStateResponse { can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS org_name: string | null role: string | null + context: 'personal' | 'team' // personal account vs team/org terminal current: { tier_id: string | null // null = free (no active sub) tier_name: string | null From a5902cd26781006c7b4d3ab6dbb17b15e193a6bb Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 08:03:45 +0530 Subject: [PATCH 12/62] fix(subscription): drop manage-link gateway RPC, build URL locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- agent/subscription_view.py | 29 ++----- hermes_cli/nous_billing.py | 17 ---- tui_gateway/server.py | 24 +----- .../__tests__/subscriptionOverlay.test.tsx | 5 +- ui-tui/src/app/interfaces.ts | 4 +- ui-tui/src/app/slash/commands/subscription.ts | 83 ++++++++++++------- ui-tui/src/components/subscriptionOverlay.tsx | 2 +- ui-tui/src/gatewayTypes.ts | 12 +-- 8 files changed, 70 insertions(+), 106 deletions(-) diff --git a/agent/subscription_view.py b/agent/subscription_view.py index bf503be5723a..cfe9ad3fd2f1 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -6,14 +6,12 @@ (server emits decimal strings); we only format for display. The TUI ``SubscriptionOverlay`` is **deep-link only** — it never charges -in-terminal. :func:`get_subscription_manage_link` returns a Stripe-hosted URL -the TUI opens in the browser. - -WS1 dependency: ``GET /api/billing/subscription`` and -``POST /api/billing/subscription/manage-link`` are NAS endpoints (WS1 Phase A/C). -Until they ship, the fail-open contract handles 404s — the builder returns -``logged_in=False`` and the manage-link raises a :class:`BillingError` the -gateway serializes into a typed error envelope. +in-terminal. The manage URL is built locally on the TUI side from the +``portal_url`` and ``org_id`` fields in the subscription state. + +WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A). +Until it ships, the fail-open contract handles 404s — the builder returns +``logged_in=False`` and the surface degrades gracefully. """ from __future__ import annotations @@ -72,6 +70,7 @@ class SubscriptionState: logged_in: bool org_name: Optional[str] = None + org_id: Optional[str] = None # org.id from the NAS response role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER" context: str = "personal" # "personal" | "team" current: Optional[CurrentSubscription] = None @@ -150,6 +149,7 @@ def subscription_state_from_payload( return SubscriptionState( logged_in=True, org_name=org.get("name"), + org_id=org.get("id") or None, role=org.get("role"), context=context, current=_parse_current(payload.get("current")), @@ -203,16 +203,3 @@ def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState: return subscription_state_from_payload(payload, portal_url=portal_url) -def get_subscription_manage_link( - *, target_tier_id: Optional[str] = None, timeout: float = 15.0 -) -> dict[str, Any]: - """Fetch the Stripe-hosted manage-link URL from NAS. - - Returns ``{"kind": "checkout"|"portal", "url": "https://..."}``. Raises - :class:`BillingScopeRequired` when the Remote-Spending grant is missing - (Phase 4 step-up trigger) — the caller (gateway RPC) serializes that into - ``error:"insufficient_scope"``. - """ - from hermes_cli.nous_billing import post_subscription_manage_link - - return post_subscription_manage_link(target_tier_id=target_tier_id, timeout=timeout) diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index b969bb1d8587..f88322638822 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -416,20 +416,3 @@ def get_subscription_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any return _request("GET", "/api/billing/subscription", timeout=timeout) -def post_subscription_manage_link( - *, - target_tier_id: Optional[str] = None, - timeout: float = DEFAULT_TIMEOUT, -) -> dict[str, Any]: - """``POST /api/billing/subscription/manage-link`` → ``{kind, url}`` (scope required). - - Returns a Stripe-hosted URL the TUI opens via ``openExternalUrl``. - ``kind`` is ``"checkout"`` (free → first subscribe) or ``"portal"`` - (existing sub → NAS manage page). ``target_tier_id`` is needed only - for the free→paid checkout branch. Raises :class:`BillingScopeRequired` - when the Remote-Spending grant is missing (Phase 4 step-up trigger). - """ - body: dict[str, Any] = {} - if target_tier_id: - body["targetTierId"] = target_tier_id - return _request("POST", "/api/billing/subscription/manage-link", body=body, timeout=timeout) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f85e8543f102..cd90fbf2f1a9 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5619,6 +5619,7 @@ def _s(value): "is_admin": state.is_admin, "can_change_plan": state.can_change_plan, "org_name": state.org_name, + "org_id": state.org_id, "role": state.role, "context": state.context, "current": current, @@ -5644,29 +5645,6 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"}) -@method("subscription.manage_link") -def _(rid, params: dict) -> dict: - """POST /api/billing/subscription/manage-link → {ok, kind, url} or typed error. - - kind: 'checkout' (free → first subscribe) | 'portal' (existing sub → - NAS manage page). The deep-link target is NAS's own /manage-subscription - page, NOT the Stripe hosted Billing Portal. The TUI just opens the URL. - Raises insufficient_scope when Remote-Spending is required (Phase 4). - """ - from hermes_cli.nous_billing import BillingError - - try: - from agent.subscription_view import get_subscription_manage_link - - result = get_subscription_manage_link( - target_tier_id=params.get("target_tier_id") or None - ) - return _ok(rid, {"ok": True, "kind": result.get("kind"), "url": result.get("url")}) - except BillingError as exc: - return _ok(rid, _serialize_billing_error(exc)) - except Exception as exc: - return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) - @method("billing.charge") def _(rid, params: dict) -> dict: diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index b8ca18abc247..f558759987e8 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -76,6 +76,7 @@ const state = (overrides: Partial = {}): Subscription is_admin: true, can_change_plan: true, org_name: 'Acme', + org_id: 'org_acme', role: 'OWNER', current: null, tiers: [], @@ -252,8 +253,8 @@ describe('SubscriptionOverlay — confirm screen', () => { expect(out).toContain('Confirm subscription') expect(out).toContain('Pro') expect(out).toContain('$20') - expect(out).toContain('securely on Stripe') - expect(out).toContain('Continue to Stripe') + expect(out).toContain('securely on your subscription page') + expect(out).toContain('Continue to your subscription page') }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9f23520c2a5f..5ceef0cdc936 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -136,8 +136,8 @@ export type SubscriptionScreen = | 'handoff' // transient: "Opening Stripe in your browser…" export interface SubscriptionOverlayCtx { - /** Fetch the manage-link URL and open it (deep-link). Resolves ok/false. */ - openManageLink: (targetTierId?: string) => Promise + /** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */ + openManageLink: () => Promise /** Re-fetch subscription.state (used by Phase-4 resume). */ refreshState: () => Promise /** Run billing.step_up (Remote Spending). Resolves granted. */ diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index 167d51e71743..520c31d93578 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -1,6 +1,5 @@ import type { BillingMutationResponse, - SubscriptionManageLinkResponse, SubscriptionStateResponse } from '../../../gatewayTypes.js' import { openExternalUrl } from '../../../lib/openExternalUrl.js' @@ -10,38 +9,64 @@ import type { SlashCommand, SlashRunCtx } from '../types.js' type Sys = (text: string) => void +/** + * Build the manage-subscription URL locally from the loaded subscription state. + * + * Uses `portal_url` (the resolved portal base URL carried in the state) and + * `org_id` to construct `{portal_base}/manage-subscription?org_id=`. + * `org_id` pins the page to the correct account in multi-org situations. + * Falls back to bare `/manage-subscription` if org_id is absent. + */ +function buildManageUrl(s: SubscriptionStateResponse): string | null { + // portal_url is already an absolute URL resolved by resolve_portal_base_url() + // on the Python side (e.g. https://portal.nousresearch.com/billing). Strip any + // path so we can attach /manage-subscription cleanly. + const base = s.portal_url + ? new URL(s.portal_url).origin + : null + + if (!base) { + return null + } + + const url = new URL('/manage-subscription', base) + + if (s.org_id) { + url.searchParams.set('org_id', s.org_id) + } + + return url.toString() +} + /** * Build the ctx the overlay uses to talk to the gateway + emit transcript * lines. Mirrors topup.ts's buildOverlayCtx — all RPC + error-mapping logic * lives here (single source of truth); the overlay only renders + routes keys. */ -const buildSubscriptionCtx = (ctx: SlashRunCtx, sys: Sys): SubscriptionOverlayCtx => ({ - openManageLink: (targetTierId?: string) => - ctx.gateway - .rpc('subscription.manage_link', { - ...(targetTierId ? { target_tier_id: targetTierId } : {}) - }) - .then(r => { - if (r && r.ok && r.url) { - openExternalUrl(r.url) - sys('Opening your subscription page in the browser — finish the change there; re-run /subscription to confirm.') - - return true - } - - // insufficient_scope → Phase 4 step-up (handled by the overlay). - // For now, other errors just log + resolve false. - if (r && r.error) { - sys(r.message ?? r.error) - } - - return false - }) - .catch(e => { - ctx.guardedErr(e) - - return false - }), +const buildSubscriptionCtx = ( + ctx: SlashRunCtx, + sys: Sys, + initialState: SubscriptionStateResponse +): SubscriptionOverlayCtx => ({ + openManageLink: () => { + const url = buildManageUrl(initialState) + + if (!url) { + sys('Could not build manage URL — is your portal configured?') + + return Promise.resolve(false) + } + + const opened = openExternalUrl(url) + + if (opened) { + sys('Opening your subscription page in the browser — finish the change there; re-run /subscription to confirm.') + } else { + sys('Could not open browser — visit your subscription page manually at ' + url) + } + + return Promise.resolve(opened) + }, refreshState: () => ctx.gateway .rpc('subscription.state', {}) @@ -77,7 +102,7 @@ export const subscriptionCommands: SlashCommand[] = [ patchOverlayState({ subscription: { - ctx: buildSubscriptionCtx(ctx, sys), + ctx: buildSubscriptionCtx(ctx, sys, s), pendingTargetTierId: null, resumeScreen: null, screen: 'overview', diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 3587dca3342d..29028080f857 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -304,7 +304,7 @@ function ConfirmScreen({ ctx, onBack, onClose, onPatch, overlay, s, t }: Confirm setTransitioned(true) onPatch({ screen: 'handoff' }) - void ctx.openManageLink(targetTierId).then(ok => { + void ctx.openManageLink().then(ok => { if (!ok) { // If openManageLink surfaces insufficient_scope, the ctx closure in // subscription.ts handles the stepup transition (Phase 4 wiring). diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 5757451bf6f3..6c4a90f35498 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -158,6 +158,7 @@ export interface SubscriptionStateResponse { is_admin: boolean can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS org_name: string | null + org_id: string | null // org.id from the NAS response role: string | null context: 'personal' | 'team' // personal account vs team/org terminal current: { @@ -177,17 +178,6 @@ export interface SubscriptionStateResponse { error?: string | null } -export interface SubscriptionManageLinkResponse { - ok: boolean - kind?: 'checkout' | 'manage' | 'portal' - url?: string - error?: string - message?: string - portal_url?: string | null - retry_after?: number | null - payload?: BillingErrorPayload -} - export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } From e78bf4b7d86abee99aa7ae97be43aca2d42dfa1a Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 08:12:02 +0530 Subject: [PATCH 13/62] chore(subscription): drop unused format_money import --- agent/subscription_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/subscription_view.py b/agent/subscription_view.py index cfe9ad3fd2f1..3ff63dc53182 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -21,7 +21,7 @@ from decimal import Decimal from typing import Any, Optional -from agent.billing_view import format_money, parse_money +from agent.billing_view import parse_money logger = logging.getLogger(__name__) From cb8a19ae3a4ea57a72943b675a914faef99211b2 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 15:06:01 +0530 Subject: [PATCH 14/62] =?UTF-8?q?feat(cli):=20/subscription=20+=20/upgrade?= =?UTF-8?q?,=20/billing=E2=86=92/topup=20rename,=20/usage=20CTAs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- hermes_cli/commands.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 540b2865df3c..52d817685902 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -218,8 +218,10 @@ class CommandDef: gateway_only=True), CommandDef("usage", "Show token usage and rate limits for the current session", "Info"), CommandDef("credits", "Show Nous credit balance and top up", "Info"), - CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info", - cli_only=True), + CommandDef("subscription", "View your Nous plan and change it in the browser", "Info", + cli_only=True, aliases=("upgrade",)), + CommandDef("topup", "Top up Nous credits — buy credits, auto-reload, limits", "Info", + cli_only=True, aliases=("billing",)), CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", @@ -1058,9 +1060,8 @@ def discord_skill_commands_by_category( # the telegram-parity test reads it so an entry here is a deliberate # "Slack-via-/hermes" decision, not a silent clamp. # - credits: the billing/top-up surface; reached via /hermes credits on Slack. -# - billing: the terminal-billing surface (buy/auto-reload/limit); /hermes billing. # - debug: the log/report upload surface; reached via /hermes debug on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "debug"}) +_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"}) def _sanitize_slack_name(raw: str) -> str: From 1a082b780cea74afb9fdb1c2278a89f37690db17 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 15:06:10 +0530 Subject: [PATCH 15/62] 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. --- agent/subscription_view.py | 156 +++++++++++- cli.py | 222 +++++++++++++++- tests/agent/test_subscription_view.py | 150 +++++++++++ ui-tui/scripts/billing-fixtures.tsx | 239 ++++++++++++++++++ .../__tests__/subscriptionOverlay.test.tsx | 38 +-- ui-tui/src/components/subscriptionOverlay.tsx | 21 +- 6 files changed, 767 insertions(+), 59 deletions(-) create mode 100644 tests/agent/test_subscription_view.py create mode 100644 ui-tui/scripts/billing-fixtures.tsx diff --git a/agent/subscription_view.py b/agent/subscription_view.py index 3ff63dc53182..3ec17c03313a 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +import os from dataclasses import dataclass from decimal import Decimal from typing import Any, Optional @@ -46,7 +47,13 @@ class SubscriptionTier: @dataclass(frozen=True) class CurrentSubscription: - """The user's active subscription (None fields = free / no active sub).""" + """The user's active subscription. ``None`` (not this object) = no plan. + + When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` / + ``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a + fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade + fields are optional. + """ tier_id: Optional[str] = None tier_name: Optional[str] = None @@ -55,7 +62,6 @@ class CurrentSubscription: cycle_ends_at: Optional[str] = None # ISO pending_downgrade_tier_name: Optional[str] = None pending_downgrade_at: Optional[str] = None # ISO - is_past_due: bool = False cancel_at_period_end: bool = False cancellation_effective_at: Optional[str] = None # ISO @@ -114,17 +120,22 @@ def _parse_tier(raw: Any) -> Optional[SubscriptionTier]: def _parse_current(raw: Any) -> Optional[CurrentSubscription]: + # "No plan" is wire-represented as current:null (free personal OR team) — + # the old all-null-object shape is gone. A present current is a real plan, + # so guard on a real tier id and return None otherwise. if not isinstance(raw, dict): return None + tier_id = raw.get("tierId") or raw.get("id") + if not tier_id: + return None return CurrentSubscription( - tier_id=raw.get("tierId") or raw.get("id"), + tier_id=tier_id, tier_name=raw.get("tierName") or raw.get("name"), monthly_credits=parse_money(raw.get("monthlyCredits")), credits_remaining=parse_money(raw.get("creditsRemaining")), cycle_ends_at=raw.get("cycleEndsAt"), pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"), pending_downgrade_at=raw.get("pendingDowngradeAt"), - is_past_due=bool(raw.get("isPastDue")), cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")), cancellation_effective_at=raw.get("cancellationEffectiveAt") or None, ) @@ -169,7 +180,17 @@ def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState: Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the surface can show a clear message rather than crashing. + + Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state, + ``/subscription`` renders from that fixture instead of the real portal — so + every plan/cancel/downgrade/team/not-admin state is testable on both + the CLI and TUI without a live account. Throwaway scaffolding; see + :func:`dev_fixture_subscription_state`. """ + fixture = dev_fixture_subscription_state() + if fixture is not None: + return fixture + try: from hermes_cli.nous_billing import ( BillingAuthError, @@ -203,3 +224,130 @@ def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState: return subscription_state_from_payload(payload, portal_url=portal_url) +def subscription_manage_url(state: SubscriptionState) -> Optional[str]: + """Build ``{portal_origin}/manage-subscription?org_id=`` from a state. + + Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link + target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing + Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled + internally. ``org_id`` pins the page to the right account in multi-org + situations. Returns ``None`` when no portal URL is resolvable. + """ + from urllib.parse import urlencode, urlsplit, urlunsplit + + if not state.portal_url: + return None + + try: + parts = urlsplit(state.portal_url) + except Exception: + return None + + if not parts.scheme or not parts.netloc: + return None + + query = urlencode({"org_id": state.org_id}) if state.org_id else "" + return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, "")) + + +# ============================================================================= +# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) +# ============================================================================= + +_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing" + + +def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]: + specs = [ + ("free", "Free", 0, Decimal("0"), Decimal("0")), + ("plus", "Plus", 1, Decimal("20"), Decimal("1000")), + ("super", "Super", 2, Decimal("50"), Decimal("3000")), + ("ultra", "Ultra", 3, Decimal("99"), Decimal("7000")), + ] + return tuple( + SubscriptionTier( + tier_id=tid, + name=name, + tier_order=order, + dollars_per_month=price, + monthly_credits=credits, + is_current=(tid == current_id), + is_enabled=True, + ) + for tid, name, order, price, credits in specs + ) + + +def _dev_current(**over: Any) -> CurrentSubscription: + base: dict[str, Any] = dict( + tier_id="plus", + tier_name="Plus", + monthly_credits=Decimal("1000"), + credits_remaining=Decimal("420"), + cycle_ends_at="2026-07-01", + ) + base.update(over) + return CurrentSubscription(**base) + + +def dev_fixture_subscription_state() -> Optional[SubscriptionState]: + """Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``. + + Lets every CLI/TUI subscription state be exercised without a live portal: + + free | mid | top | not-admin | downgrade | cancel | team | + logged-out + + Returns ``None`` when the env var is unset/empty (the real portal path runs). + Throwaway scaffolding — mirrors ``HERMES_DEV_CREDITS_FIXTURE``. + """ + name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower() + if not name: + return None + + common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL) + + if name in ("logged-out", "logged_out", "loggedout"): + return SubscriptionState(logged_in=False) + if name == "free": + return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common) + if name in ("mid", "mid-tier"): + return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common) + if name in ("top", "top-tier"): + return SubscriptionState( + logged_in=True, + current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")), + tiers=_dev_tiers("ultra"), + **common, + ) + if name in ("not-admin", "member"): + return SubscriptionState( + logged_in=True, + current=_dev_current(), + tiers=_dev_tiers("plus"), + org_name="Acme Inc", + org_id="org_acme", + role="MEMBER", + portal_url=_DEV_FIXTURE_PORTAL, + ) + if name == "downgrade": + return SubscriptionState( + logged_in=True, + current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"), + tiers=_dev_tiers("super"), + **common, + ) + if name == "cancel": + return SubscriptionState( + logged_in=True, + current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"), + tiers=_dev_tiers("plus"), + **common, + ) + if name == "team": + return SubscriptionState(logged_in=True, context="team", current=None, tiers=(), org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL) + + # Unknown name → behave as logged-out so the misconfiguration is visible. + return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}") + + diff --git a/cli.py b/cli.py index 39498e696d4a..5222c757d797 100644 --- a/cli.py +++ b/cli.py @@ -8012,7 +8012,9 @@ def process_command(self, command: str) -> bool: self._show_usage() elif canonical == "credits": self._show_credits() - elif canonical == "billing": + elif canonical == "subscription": + self._show_subscription() + elif canonical == "topup": self._show_billing(cmd_original) elif canonical == "insights": self._show_insights(cmd_original) @@ -8782,7 +8784,9 @@ def _show_usage(self): which would otherwise early-return before any credits showed. """ if not self.agent: - if not self._print_nous_credits_block(): + if self._print_nous_credits_block(): + self._print_usage_cta() + else: print("(._.) No active agent -- send a message first.") return @@ -8790,7 +8794,9 @@ def _show_usage(self): calls = agent.session_api_calls if calls == 0: - if not self._print_nous_credits_block(): + if self._print_nous_credits_block(): + self._print_usage_cta() + else: print("(._.) No API calls made yet in this session.") return @@ -8887,7 +8893,8 @@ def _show_usage(self): # Nous credits magnitudes + monthly-grant gauge (agent-independent — also # runs at the no-agent / no-calls early-returns above). See the helper. - self._print_nous_credits_block() + if self._print_nous_credits_block(): + self._print_usage_cta() if self.verbose: logging.getLogger().setLevel(logging.DEBUG) @@ -8925,6 +8932,187 @@ def _print_nous_credits_block(self) -> bool: print(f" {line}") return True + def _print_usage_cta(self) -> None: + """Print the `/usage` call-to-action pointing at /subscription + /topup. + + Mirrors the TUI's ``USAGE_CTA`` (``session.ts``) so every surface ends a + usage read with the same nudge. Only called when a Nous account is logged + in (the credits block printed), since both commands are Nous-account only. + """ + _cprint(f" {_d('Run /subscription to change plan · /topup to add credits')}") + + # ------------------------------------------------------------------ + # /subscription — view plan + change it in the browser (CLI surface) + # ------------------------------------------------------------------ + + def _show_subscription(self): + """`/subscription` (alias `/upgrade`) — view the Nous plan + browser hand-off. + + The CLI mirror of the TUI ``SubscriptionOverlay``: a read of the current + plan, this cycle's subscription credits, renewal date, and the plans you + could switch to — then a deep-link to NAS's own ``/manage-subscription`` + page (NOT the Stripe portal; that page routes upgrade→Checkout / + downgrade→scheduled internally). The terminal NEVER charges for a + subscription. Fail-open: logged-out / portal hiccup degrades to a clear + message, never a crash. Mirrors ``_show_credits`` / ``_show_billing`` + discipline for the interactive-vs-text split. + """ + from agent.subscription_view import build_subscription_state, subscription_manage_url + + state = build_subscription_state() + + if not state.logged_in: + print() + if state.error: + _cprint(f" 💳 {_d(f'Could not load subscription: {state.error}')}") + else: + _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") + print(" Run `hermes portal` to log in, then /subscription.") + return + + # Team context: no personal plan — teams run on shared credits. + if state.context == "team": + print() + _cprint(f" ⚕ {_b('Team subscription')}") + print(f" {'─' * 41}") + if state.org_name: + role = (state.role or "").title() + _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" + _cprint(f" {_d(_org_line)}") + org = state.org_name or "a team org" + print(f" This terminal is connected to {org}. Teams run on shared") + print(" credits — use /topup to add funds.") + _cprint(f" {_d('Personal subscriptions live on your personal account.')}") + return + + self._subscription_overview(state, subscription_manage_url(state)) + + def _subscription_overview(self, state, manage_url): + """Print the plan read block + tier list, then the browser hand-off.""" + from agent.billing_view import format_money + + c = state.current + is_free = not (c and c.tier_id) + can_change = state.can_change_plan and state.is_admin + + print() + if is_free: + _cprint(f" ⚕ {_b('Subscribe to a plan')}") + else: + _cprint(f" ⚕ {_b(f'Your plan: {c.tier_name or c.tier_id}')}") + print(f" {'─' * 41}") + + # Usage bar from the subscription allowance (monthly vs remaining). + if c and c.monthly_credits and c.credits_remaining: + try: + monthly = float(c.monthly_credits) + remaining = float(c.credits_remaining) + except (TypeError, ValueError): + monthly = remaining = 0.0 + if monthly > 0: + spent = max(0.0, monthly - remaining) + bar, pct = self._billing_spend_bar(spent, monthly) + # Credits are a COUNT, not money — grouped integers, no "$". + def _grp(v): + try: + return f"{int(float(v)):,}" + except (TypeError, ValueError): + return str(v) + print( + f" {_grp(c.credits_remaining)} of {_grp(c.monthly_credits)} " + f"remaining {bar} {100 - pct}% left" + ) + if c and c.cycle_ends_at: + print(f" Renews: {c.cycle_ends_at}") + + if state.org_name: + role = (state.role or "").title() + _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" + _cprint(f" {_d(_org_line)}") + print(f" {'─' * 41}") + + # Headline precedence: cancel-scheduled > downgrade-pending. + if c and c.cancel_at_period_end: + if c.cancellation_effective_at: + _cprint(f" {_d(f'Cancels on {c.cancellation_effective_at} — your plan stays active until then.')}") + else: + _cprint(f" {_d('Cancellation scheduled — your plan stays active until the end of the billing period.')}") + elif c and c.pending_downgrade_tier_name: + when = c.pending_downgrade_at or "the end of the cycle" + _cprint(f" {_d(f'Scheduled to switch to {c.pending_downgrade_tier_name} on {when}.')}") + + # Tier catalog (enabled tiers, current marked). Read-only for members. + enabled = [t for t in state.tiers if t.is_enabled] + if enabled: + print() + for t in enabled: + mark = "✓ " if t.is_current else " " + price = format_money(t.dollars_per_month) if t.dollars_per_month is not None else "$0" + # Credits are a COUNT, not money — render grouped, no "$". + if t.monthly_credits is not None: + try: + credits = f"{int(t.monthly_credits):,}" + except (TypeError, ValueError): + credits = str(t.monthly_credits) + else: + credits = "0" + _cprint(f" {mark}{t.name} — {price}/mo ({credits} credits)") + + if not can_change: + print() + _cprint(f" {_d('Plan changes need an org admin/owner.')}") + if manage_url: + print(f" Manage on portal: {manage_url}") + return + + if not manage_url: + print() + _cprint(f" {_d('No manage URL available — is your portal configured?')}") + return + + # Non-interactive (TUI slash-worker / piped / no live app): the + # prompt_toolkit modal can't run here. Render the text hand-off — the + # URL is the affordance, same discipline as _show_credits. + if not getattr(self, "_app", None): + print() + print(f" Change plan: {manage_url}") + print(" Finish the change in your browser, then re-run /subscription.") + return + + print() + choices = [ + ("open", "Open subscription page", "change your plan in the browser"), + ("copy", "Copy link", "copy the manage-subscription URL to your clipboard"), + ("cancel", "Cancel", "do nothing"), + ] + raw = self._prompt_text_input_modal( + title="⚕ Change your plan?", + detail=f"Manage your subscription in your browser:\n{manage_url}", + choices=choices, + ) + choice = self._normalize_slash_confirm_choice(raw, choices) + + if choice == "open": + opened = False + try: + import webbrowser + + opened = webbrowser.open(manage_url) + except Exception: + opened = False + if not opened: + print(f" Open this URL to change your plan: {manage_url}") + print() + print(" Finish the change in your browser, then re-run /subscription.") + elif choice == "copy": + try: + self._write_osc52_clipboard(manage_url) + print(f" 📋 Copied: {manage_url}") + except Exception: + print(f" Manage URL: {manage_url}") + else: + print(" 🟡 Cancelled. No plan change.") + def _show_credits(self): """`/credits` — focused Nous credit balance + top-up handoff. @@ -9363,15 +9551,33 @@ def _billing_render_charge_failed(self, state, reason): def _billing_render_charge_error(self, state, exc): """Render a typed BillingError at submit time (pre-poll).""" - from hermes_cli.nous_billing import BillingRateLimited + from hermes_cli.nous_billing import ( + BillingRateLimited, + BillingRemoteSpendingRevoked, + BillingSessionRevoked, + ) code = getattr(exc, "error", None) + actor = getattr(exc, "actor", None) portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None) - if code == "no_payment_method": + if isinstance(exc, BillingRemoteSpendingRevoked) or code == "remote_spending_revoked": + # CF-4: this terminal's spend was revoked. Recovery is reconnect. + who = ("An admin stopped this terminal's spending." + if actor == "admin" + else "You stopped this terminal's spending.") + print(f" 🔴 {who} Reconnect to restore — run `hermes portal` to re-authorize.") + elif isinstance(exc, BillingSessionRevoked) or code == "session_revoked": + print(" 🔴 Your session was logged out. Run `hermes portal` to log in again.") + elif code == "no_payment_method": print(" 💳 No saved card for terminal charges yet. Set one up on the " "portal (one-time credit buys don't save a reusable card).") - elif code == "cli_billing_disabled": - print(" 🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.") + elif code in ("cli_billing_disabled", "remote_spending_disabled") or \ + getattr(exc, "code", None) == "remote_spending_disabled": + print(" 🔴 Remote Spending is off for this account — an admin must enable it on the portal.") + elif code == "role_required": + print(" 🔴 Buying credits needs an org admin/owner. Ask an admin, or manage on the portal.") + elif code == "idempotency_conflict": + print(" 🔴 That charge key was already used for a different amount. Start a fresh top-up.") elif code == "monthly_cap_exceeded": remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd") if remaining is not None: diff --git a/tests/agent/test_subscription_view.py b/tests/agent/test_subscription_view.py new file mode 100644 index 000000000000..30139319e293 --- /dev/null +++ b/tests/agent/test_subscription_view.py @@ -0,0 +1,150 @@ +"""Tests for agent.subscription_view — the surface-agnostic /subscription core. + +Behavior contracts (not change-detectors): the manage-URL builder's shape, the +payload parser's field mapping + fail-open posture, and the dev-fixture states +that drive the CLI/TUI without a live portal. +""" + +from decimal import Decimal + +import pytest + +from agent.subscription_view import ( + SubscriptionState, + build_subscription_state, + dev_fixture_subscription_state, + subscription_manage_url, + subscription_state_from_payload, +) + + +# ── subscription_manage_url ────────────────────────────────────────── + + +def test_manage_url_attaches_org_and_path_to_portal_origin(): + s = SubscriptionState( + logged_in=True, + org_id="org_x", + portal_url="https://portal.nousresearch.com/billing/whatever", + ) + # Path is replaced with /manage-subscription; org_id is pinned; origin kept. + assert ( + subscription_manage_url(s) + == "https://portal.nousresearch.com/manage-subscription?org_id=org_x" + ) + + +def test_manage_url_omits_org_when_absent(): + s = SubscriptionState(logged_in=True, org_id=None, portal_url="https://p.example.com/") + url = subscription_manage_url(s) + assert url == "https://p.example.com/manage-subscription" + assert "org_id" not in url + + +def test_manage_url_none_without_portal(): + assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url=None)) is None + + +def test_manage_url_none_for_garbage_portal(): + # No scheme/netloc → can't build a deep-link; fail closed (None), not crash. + assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url="not a url")) is None + + +# ── payload parser ─────────────────────────────────────────────────── + + +def test_parser_maps_camelCase_payload_fields(): + payload = { + "org": {"name": "Acme", "id": "org_1", "role": "ADMIN"}, + "context": "personal", + "current": { + "tierId": "plus", + "tierName": "Plus", + "monthlyCredits": "1000", + "creditsRemaining": "420", + "cycleEndsAt": "2026-07-01", + "cancelAtPeriodEnd": True, + "cancellationEffectiveAt": "2026-07-01", + }, + "tiers": [ + {"tierId": "free", "name": "Free", "tierOrder": 0, "monthlyCredits": "0"}, + {"tierId": "plus", "name": "Plus", "tierOrder": 1, "dollarsPerMonth": "20", "monthlyCredits": "1000", "isCurrent": True}, + ], + } + s = subscription_state_from_payload(payload, portal_url="https://p/billing") + + assert s.logged_in is True + assert s.org_name == "Acme" and s.org_id == "org_1" + assert s.is_admin is True and s.can_change_plan is True + assert s.current is not None + assert s.current.tier_name == "Plus" + assert s.current.cancel_at_period_end is True + assert s.current.monthly_credits == Decimal("1000") + assert len(s.tiers) == 2 + assert s.tiers[1].is_current is True + + +def test_parser_no_plan_is_none_not_all_null_object(): + # "No plan" is current:null on the wire; a current-shaped dict with no + # tierId must parse to None (not an all-null CurrentSubscription). + s = subscription_state_from_payload({"current": {"tierId": None}}, portal_url=None) + assert s.current is None + + +def test_parser_member_role_cannot_change_plan(): + s = subscription_state_from_payload({"org": {"role": "MEMBER"}}, portal_url=None) + assert s.is_admin is False + assert s.can_change_plan is False + + +def test_parser_defaults_unknown_context_to_personal(): + s = subscription_state_from_payload({"context": "wat"}, portal_url=None) + assert s.context == "personal" + + +# ── dev fixtures (env-driven, no live portal) ──────────────────────── + + +def test_no_fixture_when_env_unset(monkeypatch): + monkeypatch.delenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", raising=False) + assert dev_fixture_subscription_state() is None + + +@pytest.mark.parametrize( + "name,checker", + [ + ("free", lambda s: s.logged_in and s.current is None and len(s.tiers) == 4), + ("mid", lambda s: s.current and s.current.tier_id == "plus"), + ("top", lambda s: s.current and s.current.tier_id == "ultra"), + ("not-admin", lambda s: s.role == "MEMBER" and not s.can_change_plan), + ("downgrade", lambda s: s.current and s.current.pending_downgrade_tier_name == "Plus"), + ("cancel", lambda s: s.current and s.current.cancel_at_period_end), + ("team", lambda s: s.context == "team" and s.current is None), + ("logged-out", lambda s: not s.logged_in), + ], +) +def test_dev_fixture_states(monkeypatch, name, checker): + monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", name) + s = dev_fixture_subscription_state() + assert s is not None + assert checker(s) + + +def test_dev_fixture_unknown_name_fails_safe(monkeypatch): + monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "bogus") + s = dev_fixture_subscription_state() + assert s is not None + assert s.logged_in is False + assert s.error and "bogus" in s.error + + +def test_build_subscription_state_uses_fixture(monkeypatch): + # build_subscription_state must short-circuit to the fixture (no portal call). + monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid") + s = build_subscription_state() + assert s.logged_in is True + assert s.current is not None and s.current.tier_id == "plus" + # The manage URL is buildable from the fixture's portal_url + org_id. + url = subscription_manage_url(s) + assert url is not None + assert url.endswith("/manage-subscription?org_id=org_acme") diff --git a/ui-tui/scripts/billing-fixtures.tsx b/ui-tui/scripts/billing-fixtures.tsx new file mode 100644 index 000000000000..6b077aaefcf5 --- /dev/null +++ b/ui-tui/scripts/billing-fixtures.tsx @@ -0,0 +1,239 @@ +/** + * Billing/Subscription TUI fixture harness — renders any single overlay STATE + * live in the terminal so it can be screenshotted (tmux) and UX-reviewed. + * + * This is a DEV/REVIEW tool, not shipped behaviour. It bypasses the gateway and + * mounts the real Ink overlay components directly with a hand-built state object, + * exactly the way the vitest render tests do — so what you see is pixel-identical + * to what `/subscription` and `/topup` draw at runtime. + * + * Usage: + * npx tsx scripts/billing-fixtures.tsx + * npx tsx scripts/billing-fixtures.tsx --list + * + * Drive a specific screen of a fixture with SCREEN=, e.g.: + * SCREEN=confirm npx tsx scripts/billing-fixtures.tsx sub-free + * SCREEN=handoff npx tsx scripts/billing-fixtures.tsx sub-mid + * + * The selection cursor can be moved with ↑/↓ once it's live (the components own + * their own useInput); Esc/Enter behave as in production. Ctrl-C to exit. + */ +import { render } from '@hermes/ink' +import React from 'react' + +import type { BillingOverlayState, SubscriptionOverlayState, SubscriptionScreen } from '../src/app/interfaces.js' +import { BillingOverlay } from '../src/components/billingOverlay.js' +import { SubscriptionOverlay } from '../src/components/subscriptionOverlay.js' +import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from '../src/gatewayTypes.js' +import { DEFAULT_THEME } from '../src/theme.js' + +const t = DEFAULT_THEME + +// ── helpers ────────────────────────────────────────────────────────── + +const tier = (o: Partial = {}): SubscriptionTierOption => ({ + tier_id: 'free', + name: 'Free', + tier_order: 0, + dollars_per_month_display: '$0', + monthly_credits: '0', + is_current: false, + is_enabled: true, + ...o +}) + +const TIERS = { + free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }), + plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }), + super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }), + ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' }) +} + +const tierList = (currentId?: string): SubscriptionTierOption[] => + Object.values(TIERS).map(x => ({ ...x, is_current: x.tier_id === currentId })) + +const subState = (o: Partial = {}): SubscriptionStateResponse => ({ + ok: true, + logged_in: true, + is_admin: true, + can_change_plan: true, + org_name: 'Acme Inc', + org_id: 'org_acme', + role: 'OWNER', + context: 'personal', + current: null, + tiers: tierList(), + portal_url: 'https://portal.nousresearch.com/billing', + ...o +}) + +const cur = (o: Record = {}) => ({ + tier_id: 'plus', + tier_name: 'Plus', + monthly_credits: '1000', + credits_remaining: '420', + cycle_ends_at: '2026-07-01', + pending_downgrade_tier_name: null, + pending_downgrade_at: null, + cancel_at_period_end: false, + cancellation_effective_at: null, + ...o +}) + +const subCtx: SubscriptionOverlayState['ctx'] = { + openManageLink: () => Promise.resolve(true), + refreshState: () => Promise.resolve(null), + requestRemoteSpending: () => Promise.resolve(true), + sys: () => {} +} + +const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pendingTargetTierId: string | null = null): SubscriptionOverlayState => ({ + ctx: subCtx, + screen, + state: s, + resumeScreen: null, + pendingTargetTierId +}) + +// ── billing/topup fixtures ─────────────────────────────────────────── + +const billState = (o: Partial = {}): BillingStateResponse => ({ + ok: true, + logged_in: true, + is_admin: true, + cli_billing_enabled: true, + can_charge: true, + card: { brand: 'Visa', last4: '4242', masked: 'Visa •••• 4242' }, + balance_display: '$12.00', + balance_usd: '12.00', + min_usd: '5', + max_usd: '500', + monthly_cap: { + is_default_ceiling: false, + limit_display: '$20', + limit_usd: '20', + spent_display: '$8.00', + spent_this_month_usd: '8' + }, + auto_reload: { enabled: false, reload_to_display: '$25', reload_to_usd: '25', threshold_display: '$5', threshold_usd: '5' }, + org_name: 'Acme Inc', + role: 'OWNER', + portal_url: 'https://portal.nousresearch.com/billing', + charge_presets: ['10', '25', '50', '100'], + charge_presets_display: ['$10', '$25', '$50', '$100'], + ...o +}) + +const billCtx = { + applyAutoReload: () => Promise.resolve(true), + charge: () => {}, + openPortal: () => {}, + sys: () => {}, + validate: (raw: string) => ({ amount: raw }) +} + +const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({ + ctx: billCtx, + pendingCharge: screen === 'confirm' ? { amount: '25' } : null, + screen, + state: s +}) + +// ── fixture registry ───────────────────────────────────────────────── + +type Fixture = { desc: string; node: React.ReactElement } + +const subEl = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pending: string | null = null) => + React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay: sub(s, screen, pending), t }) + +const billEl = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview') => + React.createElement(BillingOverlay, { onClose: () => {}, onPatch: () => {}, overlay: bill(s, screen), t }) + +const FIXTURES: Record = { + // /subscription — overview states + 'sub-free': { + desc: 'Free / no sub — upgradeable (primary conversion state)', + node: subEl(subState({ current: null })) + }, + 'sub-mid': { + desc: 'Subscriber mid-tier (Plus) — usage bar + up/downgrade targets', + node: subEl(subState({ current: cur(), tiers: tierList('plus') })) + }, + 'sub-top': { + desc: 'Subscriber top-tier (Ultra) — "on the top plan"', + node: subEl(subState({ current: cur({ tier_id: 'ultra', tier_name: 'Ultra', monthly_credits: '7000', credits_remaining: '5000' }), tiers: tierList('ultra') })) + }, + 'sub-not-admin': { + desc: 'Member (not admin/owner) — read-only, no tier picker', + node: subEl(subState({ is_admin: false, can_change_plan: false, role: 'MEMBER', current: cur(), tiers: tierList('plus') })) + }, + 'sub-downgrade': { + desc: 'Downgrade scheduled — pending-switch banner', + node: subEl(subState({ current: cur({ pending_downgrade_tier_name: 'Plus', pending_downgrade_at: '2026-07-15' }), tiers: tierList('super') })) + }, + 'sub-cancel': { + desc: 'Cancellation scheduled — stays active until effective date', + node: subEl(subState({ current: cur({ cancel_at_period_end: true, cancellation_effective_at: '2026-07-01' }), tiers: tierList('plus') })) + }, + 'sub-team': { + desc: 'Team org context — shared credits, redirect to /topup', + node: subEl(subState({ context: 'team', current: null, org_name: 'Acme Engineering' })) + }, + // /subscription — non-overview screens + 'sub-confirm': { + desc: 'Confirm plan change (deep-link, no in-terminal charge)', + node: subEl(subState({ current: cur(), tiers: tierList('plus') }), 'confirm', 'super') + }, + 'sub-confirm-new': { + desc: 'Confirm first subscription (free → paid)', + node: subEl(subState({ current: null }), 'confirm', 'plus') + }, + 'sub-handoff': { + desc: 'Handoff transient — opening subscription page in browser', + node: subEl(subState({ current: cur() }), 'handoff') + }, + // /topup (renamed /billing) + 'topup-overview': { + desc: '/topup overview — admin, card on file, full menu', + node: billEl(billState()) + }, + 'topup-no-card': { + desc: '/topup overview — admin, NO saved card (card hint)', + node: billEl(billState({ card: null })) + }, + 'topup-not-admin': { + desc: '/topup overview — member, read-only', + node: billEl(billState({ is_admin: false })) + }, + 'topup-disabled': { + desc: '/topup overview — terminal billing OFF for org', + node: billEl(billState({ cli_billing_enabled: false })) + }, + 'topup-buy': { + desc: '/topup buy screen — presets', + node: billEl(billState(), 'buy') + } +} + +// ── driver ─────────────────────────────────────────────────────────── + +const arg = process.argv[2] + +if (!arg || arg === '--list' || arg === '-l') { + const names = Object.keys(FIXTURES) + process.stdout.write('Billing/Subscription TUI fixtures:\n\n') + for (const name of names) { + process.stdout.write(` ${name.padEnd(18)} ${FIXTURES[name]!.desc}\n`) + } + process.stdout.write(`\n ${names.length} fixtures. Run: npx tsx scripts/billing-fixtures.tsx \n`) + process.exit(0) +} + +const fixture = FIXTURES[arg] + +if (!fixture) { + process.stderr.write(`Unknown fixture: ${arg}\nRun with --list to see all.\n`) + process.exit(1) +} + +render(fixture.node) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index f558759987e8..7e7ec05071f8 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -128,8 +128,7 @@ describe('SubscriptionOverlay — overview screen', () => { credits_remaining: '420', cycle_ends_at: '2026-07-01T00:00:00Z', pending_downgrade_tier_name: null, - pending_downgrade_at: null, - is_past_due: false + pending_downgrade_at: null }, tiers: [ tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), @@ -154,8 +153,7 @@ describe('SubscriptionOverlay — overview screen', () => { credits_remaining: '3000', cycle_ends_at: '2026-07-01T00:00:00Z', pending_downgrade_tier_name: null, - pending_downgrade_at: null, - is_past_due: false + pending_downgrade_at: null }, tiers: [ tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), @@ -182,8 +180,7 @@ describe('SubscriptionOverlay — overview screen', () => { credits_remaining: '500', cycle_ends_at: '2026-07-01T00:00:00Z', pending_downgrade_tier_name: null, - pending_downgrade_at: null, - is_past_due: false + pending_downgrade_at: null }, tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] }) @@ -203,8 +200,7 @@ describe('SubscriptionOverlay — overview screen', () => { credits_remaining: '500', cycle_ends_at: '2026-07-01T00:00:00Z', pending_downgrade_tier_name: 'Free', - pending_downgrade_at: '2026-07-15T00:00:00Z', - is_past_due: false + pending_downgrade_at: '2026-07-15T00:00:00Z' }, tiers: [ tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), @@ -217,28 +213,6 @@ describe('SubscriptionOverlay — overview screen', () => { expect(out).toContain('Scheduled to switch to Free') expect(out).toContain('2026-07-15T00:00:00Z') }) - - it('dunning: past due shows past-due banner (does NOT re-offer fresh subscribe)', () => { - const s = state({ - current: { - tier_id: 'pro', - tier_name: 'Pro', - monthly_credits: '1000', - credits_remaining: '500', - cycle_ends_at: '2026-07-01T00:00:00Z', - pending_downgrade_tier_name: null, - pending_downgrade_at: null, - is_past_due: true - }, - tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] - }) - - const out = render(overlay(s)) - - expect(out).toContain('Payment past due') - expect(out).toContain('still active until') - expect(out).toContain('2026-07-01T00:00:00Z') - }) }) describe('SubscriptionOverlay — confirm screen', () => { @@ -259,10 +233,10 @@ describe('SubscriptionOverlay — confirm screen', () => { }) describe('SubscriptionOverlay — handoff screen', () => { - it('shows opening-stripe copy', () => { + it('shows opening-subscription-page copy', () => { const out = render(overlay(state(), 'handoff')) - expect(out).toContain('Opening Stripe') + expect(out).toContain('Opening your subscription page') expect(out).toContain('browser') expect(out).toContain('Re-run /subscription') }) diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 29028080f857..ac29b3f1cc2b 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -54,7 +54,7 @@ export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: Subscripti ) } -// ── Screen: Overview (covers states a–e + dunning) ─────────────────── +// ── Screen: Overview (covers states a–e: free/mid/top/not-admin/downgrade) ── interface ScreenProps { ctx: SubscriptionOverlayState['ctx'] @@ -91,22 +91,18 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const c = s.current const isFree = !c?.tier_id const hasPendingDowngrade = !!c?.pending_downgrade_tier_name - const isPastDue = !!c?.is_past_due const isCancelScheduled = !!c?.cancel_at_period_end - // Headline precedence: past-due > cancel-scheduled > downgrade-pending > active. - // Dunning: past due — don't re-offer fresh subscribe; route to manage/portal. - const dunningNote = isPastDue && c?.cycle_ends_at - ? `Payment past due — your plan is still active until ${c.cycle_ends_at}.` - : null - - const cancellationNote = !isPastDue && isCancelScheduled + // Headline precedence: cancel-scheduled > downgrade-pending > active. + // (Past-due/dunning was removed from the NAS read — a card-failing + // subscriber now returns as a normal plan; no special-casing here.) + const cancellationNote = isCancelScheduled ? c?.cancellation_effective_at ? `Cancels on ${c.cancellation_effective_at} — your plan stays active until then.` : 'Cancellation scheduled — your plan stays active until the end of the billing period.' : null - const downgradeNote = !isPastDue && !isCancelScheduled && hasPendingDowngrade + const downgradeNote = !isCancelScheduled && hasPendingDowngrade ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${c?.pending_downgrade_at}.` : null @@ -201,11 +197,6 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { {s.role ? ` · ${s.role}` : ''} )} - {dunningNote && ( - - {dunningNote} - - )} {cancellationNote && ( {cancellationNote} From 37154fa36f1fb2208d4796ddd07bfa1e36a2c87f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 15:06:16 +0530 Subject: [PATCH 16/62] feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/nous_billing.py | 84 ++++++++++-- .../test_remote_spending_gate_contract.py | 120 ++++++++++++++++++ tui_gateway/server.py | 14 +- ui-tui/src/__tests__/topupCommand.test.ts | 71 +++++++++++ ui-tui/src/app/slash/commands/topup.ts | 62 ++++++++- ui-tui/src/gatewayTypes.ts | 7 +- 6 files changed, 338 insertions(+), 20 deletions(-) create mode 100644 tests/hermes_cli/test_remote_spending_gate_contract.py diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index f88322638822..fbab184622a9 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -67,6 +67,9 @@ def __init__( portal_url: Optional[str] = None, retry_after: Optional[int] = None, payload: Optional[dict[str, Any]] = None, + actor: Optional[str] = None, + code: Optional[str] = None, + recovery: Optional[str] = None, ) -> None: super().__init__(message) self.status = status @@ -74,6 +77,13 @@ def __init__( self.portal_url = portal_url self.retry_after = retry_after self.payload = payload or {} + # Remote-Spending contract extras (NAS PR #481): `actor` (self|admin) on a + # revoke, `code` (the new machine code dual-emitted alongside `error`), and + # `recovery` (reconnect|login|enable_account_toggle). Additive — absent on + # older NAS / unrelated errors. + self.actor = actor + self.code = code + self.recovery = recovery class BillingScopeRequired(BillingError): @@ -86,19 +96,42 @@ class BillingScopeRequired(BillingError): """ +class BillingAuthError(BillingError): + """``401`` — missing/invalid bearer token (not logged in / expired).""" + + +class BillingRemoteSpendingRevoked(BillingError): + """``403 remote_spending_revoked`` — THIS terminal's spending was revoked. + + Distinct from ``insufficient_scope`` (never had the grant) and from + ``session_revoked`` (full logout). The terminal stays logged in; only the + money path is cut. ``actor`` is ``"admin"`` or ``"self"`` (absent → treat as + ``"self"``); recovery is **reconnect** (re-consent device-auth). The terminal + MUST disable charge/auto-reload immediately, without waiting for the next + token refresh (the current token still claims the scope for ~15 min). + """ + + +class BillingSessionRevoked(BillingAuthError): + """``401 session_revoked`` — the whole session was logged out. + + Stronger than a spend-revoke: recovery is **re-login** (full device-auth), + not just reconnect. Subclass of :class:`BillingAuthError` so existing 401 + handling still treats it as not-logged-in, but the typed code lets the + surface route to re-login with the right copy. + """ + + class BillingRateLimited(BillingError): """``429 rate_limited`` or ``503 temporarily_unavailable``. NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell the user "try again in N min"; never auto-retry-spam (the limiter is - 5/org/hr + 5/token/hr and easy to dig deeper into). + 5/org/hr + 5/token/hr and easy to dig deeper into). A 503 is the gate backend + failing closed — back off, do NOT treat as revoked. """ -class BillingAuthError(BillingError): - """``401`` — missing/invalid bearer token (not logged in / expired).""" - - # ============================================================================= # Base-URL + auth resolution # ============================================================================= @@ -234,9 +267,21 @@ def _retry_after_seconds(headers: Any) -> Optional[int]: def _raise_for_error( status: int, payload: dict[str, Any], headers: Any = None ) -> None: - """Map an HTTP error response to the right typed :class:`BillingError`.""" + """Map an HTTP error response to the right typed :class:`BillingError`. + + Recognizes the Remote-Spending gate contract (NAS PR #481): + 403 ``remote_spending_revoked`` (this terminal's spend revoked → reconnect), + 401 ``session_revoked`` (full logout → re-login), 503 ``temporarily_unavailable`` + (gate fail-closed → back off, NOT revoked). The business-denial codes + (``cli_billing_disabled`` + dual ``code:remote_spending_disabled``, + ``role_required``, ``idempotency_conflict``, …) flow through as a generic + BillingError carrying ``error``/``code``/``recovery`` for the surface to map. + """ error = payload.get("error") if isinstance(payload, dict) else None message = payload.get("message") if isinstance(payload, dict) else None + code = payload.get("code") if isinstance(payload, dict) else None + actor = payload.get("actor") if isinstance(payload, dict) else None + recovery = payload.get("recovery") if isinstance(payload, dict) else None portal_url = _absolutize_portal_url( payload.get("portalUrl") if isinstance(payload, dict) else None ) @@ -248,14 +293,33 @@ def _raise_for_error( "portal_url": portal_url, "retry_after": retry_after, "payload": payload if isinstance(payload, dict) else None, + "actor": actor, + "code": code, + "recovery": recovery, } if status == 401: + # session_revoked is a full logout (→ re-login), stronger than a 401 + # expired-token. Both stay BillingAuthError-compatible for legacy callers. + if error == "session_revoked": + raise BillingSessionRevoked( + message or "Your session was logged out — log in again.", **common + ) raise BillingAuthError(message or "Authentication required.", **common) - if status == 403 and error == "insufficient_scope": - raise BillingScopeRequired( - message or "This action needs the billing:manage scope.", **common - ) + if status == 403: + # This terminal's spending was revoked (NOT the same as never having the + # scope). Disable spend UI immediately; recovery is reconnect. + if error == "remote_spending_revoked": + raise BillingRemoteSpendingRevoked( + message or "Remote Spending was revoked for this terminal.", **common + ) + if error == "insufficient_scope": + raise BillingScopeRequired( + message or "This action needs the billing:manage scope.", **common + ) + # Business 403s (cli_billing_disabled / role_required / no_payment_method / + # monthly_cap_exceeded / …) → generic BillingError with code/recovery. + raise BillingError(message or error or "Billing request denied.", **common) if status in (429, 503): raise BillingRateLimited( message or "Rate limited — try again shortly.", **common diff --git a/tests/hermes_cli/test_remote_spending_gate_contract.py b/tests/hermes_cli/test_remote_spending_gate_contract.py new file mode 100644 index 000000000000..dfd0daeba15b --- /dev/null +++ b/tests/hermes_cli/test_remote_spending_gate_contract.py @@ -0,0 +1,120 @@ +"""Tests for the Remote-Spending gate denial contract (NAS PR #481). + +Behavior contracts: the HTTP→exception mapping in +``hermes_cli.nous_billing._raise_for_error`` and the +``tui_gateway.server._serialize_billing_error`` envelope the TUI branches on. +These assert the wire contract (CF-4) — error code, actor, recovery, retry — +not specific copy. +""" + +import pytest + +from hermes_cli.nous_billing import ( + BillingError, + BillingRateLimited, + BillingRemoteSpendingRevoked, + BillingScopeRequired, + BillingSessionRevoked, + _raise_for_error, +) + + +def _raise(status, payload, headers=None): + """Run _raise_for_error and return the exception it raises.""" + with pytest.raises(BillingError) as ei: + _raise_for_error(status, payload, headers) + return ei.value + + +# ── exception mapping (hermes_cli.nous_billing) ────────────────────── + + +def test_403_remote_spending_revoked_maps_to_typed_exc_with_actor(): + exc = _raise(403, {"error": "remote_spending_revoked", "recovery": "reconnect", "actor": "admin"}) + assert isinstance(exc, BillingRemoteSpendingRevoked) + assert exc.actor == "admin" + assert exc.recovery == "reconnect" + + +def test_403_revoked_absent_actor_is_none_not_crash(): + exc = _raise(403, {"error": "remote_spending_revoked"}) + assert isinstance(exc, BillingRemoteSpendingRevoked) + assert exc.actor is None # surface treats absent as "self" + + +def test_401_session_revoked_is_distinct_from_plain_401(): + revoked = _raise(401, {"error": "session_revoked", "recovery": "login"}) + assert isinstance(revoked, BillingSessionRevoked) + assert revoked.recovery == "login" + + plain = _raise(401, {"error": "invalid_token"}) + assert not isinstance(plain, BillingSessionRevoked) + + +def test_403_insufficient_scope_still_maps_to_scope_required(): + exc = _raise(403, {"error": "insufficient_scope"}) + assert isinstance(exc, BillingScopeRequired) + # NOT mistaken for a revoke. + assert not isinstance(exc, BillingRemoteSpendingRevoked) + + +def test_503_is_rate_limited_not_revoked_and_carries_retry_after(): + exc = _raise(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"}) + assert isinstance(exc, BillingRateLimited) + assert not isinstance(exc, BillingRemoteSpendingRevoked) + assert exc.retry_after == 30 + + +def test_403_business_denial_carries_code_and_recovery(): + exc = _raise(403, { + "error": "cli_billing_disabled", + "code": "remote_spending_disabled", + "recovery": "enable_account_toggle", + "portalUrl": "/billing", + }) + # Generic BillingError (not a typed revoke) — the surface maps on code. + assert type(exc) is BillingError + assert exc.error == "cli_billing_disabled" + assert exc.code == "remote_spending_disabled" + assert exc.recovery == "enable_account_toggle" + + +def test_409_idempotency_conflict_passes_through(): + exc = _raise(409, {"error": "idempotency_conflict", "message": "same key, different amount"}) + assert exc.error == "idempotency_conflict" + + +# ── envelope serialization (tui_gateway.server) ────────────────────── + + +def _serialize(status, payload, headers=None): + import tui_gateway.server as srv + + return srv._serialize_billing_error(_raise(status, payload, headers)) + + +def test_envelope_threads_actor_code_recovery(): + env = _serialize(403, {"error": "remote_spending_revoked", "actor": "admin", "recovery": "reconnect"}) + assert env["error"] == "remote_spending_revoked" + assert env["actor"] == "admin" + assert env["recovery"] == "reconnect" + assert env["ok"] is False + + +def test_envelope_session_revoked_kind(): + env = _serialize(401, {"error": "session_revoked", "recovery": "login"}) + assert env["error"] == "session_revoked" + assert env["recovery"] == "login" + + +def test_envelope_503_is_rate_limited_with_retry(): + env = _serialize(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"}) + assert env["error"] == "rate_limited" + assert env["retry_after"] == 30 + + +def test_envelope_business_code_survives(): + env = _serialize(403, {"error": "cli_billing_disabled", "code": "remote_spending_disabled", "recovery": "enable_account_toggle"}) + assert env["error"] == "cli_billing_disabled" + assert env["code"] == "remote_spending_disabled" + assert env["recovery"] == "enable_account_toggle" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index cd90fbf2f1a9..252b193a93b8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5491,11 +5491,17 @@ def _serialize_billing_error(exc) -> dict: """Map a BillingError into the result.error envelope the TUI branches on.""" from hermes_cli.nous_billing import ( BillingRateLimited, + BillingRemoteSpendingRevoked, BillingScopeRequired, + BillingSessionRevoked, ) kind = "error" - if isinstance(exc, BillingScopeRequired): + if isinstance(exc, BillingRemoteSpendingRevoked): + kind = "remote_spending_revoked" + elif isinstance(exc, BillingSessionRevoked): + kind = "session_revoked" + elif isinstance(exc, BillingScopeRequired): kind = "insufficient_scope" elif isinstance(exc, BillingRateLimited): kind = "rate_limited" @@ -5508,6 +5514,11 @@ def _serialize_billing_error(exc) -> dict: "portal_url": getattr(exc, "portal_url", None), "retry_after": getattr(exc, "retry_after", None), "payload": getattr(exc, "payload", {}) or {}, + # Remote-Spending contract extras (threaded so the TUI can render + # actor-aware copy + route recovery without re-parsing the message). + "actor": getattr(exc, "actor", None), + "code": getattr(exc, "code", None), + "recovery": getattr(exc, "recovery", None), } @@ -5598,7 +5609,6 @@ def _s(value): "cycle_ends_at": c.cycle_ends_at, "pending_downgrade_tier_name": c.pending_downgrade_tier_name, "pending_downgrade_at": c.pending_downgrade_at, - "is_past_due": c.is_past_due, "cancel_at_period_end": c.cancel_at_period_end, "cancellation_effective_at": c.cancellation_effective_at, } diff --git a/ui-tui/src/__tests__/topupCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts index 9dd0e2618435..55c5cfe564d2 100644 --- a/ui-tui/src/__tests__/topupCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -246,6 +246,77 @@ describe('/billing slash command (overlay-driven)', () => { expect(stepUp?.title).toBe('Grant terminal billing access?') }) + // ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ── + + it('ctx.charge remote_spending_revoked (admin) → clears overlay + admin copy', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: false, error: 'remote_spending_revoked', actor: 'admin', recovery: 'reconnect', idempotency_key: 'k' } + }) + + await run('') + expect(getOverlayState().billing).toBeTruthy() + getOverlayState().billing!.ctx.charge('100') + await Promise.resolve() + await Promise.resolve() + const out = printed(sys) + expect(out).toContain('An admin stopped this terminal') + expect(out).toContain('Reconnect to restore') + // Spend UI is killed immediately — no zombie button waiting for refresh. + expect(getOverlayState().billing).toBeNull() + }) + + it('ctx.charge remote_spending_revoked (self) → self copy', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: false, error: 'remote_spending_revoked', actor: 'self', recovery: 'reconnect', idempotency_key: 'k' } + }) + + await run('') + getOverlayState().billing!.ctx.charge('100') + await Promise.resolve() + await Promise.resolve() + expect(printed(sys)).toContain('You stopped this terminal') + expect(getOverlayState().billing).toBeNull() + }) + + it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' } + }) + + await run('') + getOverlayState().billing!.ctx.charge('100') + await Promise.resolve() + await Promise.resolve() + expect(printed(sys)).toContain('Your session was logged out') + expect(getOverlayState().billing).toBeNull() + }) + + it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { + ok: false, + error: 'cli_billing_disabled', + code: 'remote_spending_disabled', + recovery: 'enable_account_toggle', + portal_url: '/billing', + idempotency_key: 'k' + } + }) + + await run('') + getOverlayState().billing!.ctx.charge('100') + await Promise.resolve() + await Promise.resolve() + const out = printed(sys) + expect(out).toContain('Remote Spending is off for this account') + // Account-wide switch is NOT a per-terminal revoke — overlay stays open. + expect(getOverlayState().billing).toBeTruthy() + }) + it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => { const { run, calls } = buildCtx({ 'billing.state': ownerState(), diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index a89e00f2c10b..eeb506e09158 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -21,10 +21,13 @@ const renderBillingError = ( sys: Sys, ctx: SlashRunCtx, env: { + actor?: string + code?: string error?: string message?: string payload?: BillingErrorPayload portal_url?: string | null + recovery?: string retry_after?: number | null } ): void => { @@ -36,6 +39,43 @@ const renderBillingError = ( return + case 'remote_spending_revoked': { + // CF-4: this terminal's spend was revoked. Kill the spend UI NOW (don't + // wait for the token refresh ~15 min away) and tell the user who did it. + patchOverlayState({ billing: null }) + const who = env.actor === 'admin' + ? 'An admin stopped this terminal’s spending.' + : 'You stopped this terminal’s spending.' + sys(`🔴 ${who} Reconnect to restore — run /portal to re-authorize this terminal.`) + + return + } + + case 'session_revoked': + // Stronger than a spend-revoke: the whole session is gone → full re-login. + patchOverlayState({ billing: null }) + sys('🔴 Your session was logged out. Run /portal to log in again.') + + return + + case 'cli_billing_disabled': + case 'remote_spending_disabled': + // Account-wide switch is OFF (dual-emitted error/code). An admin must flip + // it on the portal; this is NOT a per-terminal revoke. + sys('🔴 Remote Spending is off for this account — an admin must enable it on the portal.') + + break + + case 'role_required': + sys('🔴 Buying credits needs an org admin/owner. Ask an admin, or manage on the portal.') + + break + + case 'idempotency_conflict': + sys('🔴 That charge key was already used for a different amount. Start a fresh top-up.') + + break + case 'no_payment_method': sys( '💳 No saved card for terminal charges yet. Set one up on the portal ' + @@ -44,11 +84,6 @@ const renderBillingError = ( break - case 'cli_billing_disabled': - sys('🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.') - - break - case 'monthly_cap_exceeded': { // Surface the remaining headroom the server attaches (parity with the CLI). const remaining = env.payload?.remainingUsd @@ -56,7 +91,10 @@ const renderBillingError = ( break } - case 'rate_limited': { + case 'rate_limited': + case 'temporarily_unavailable': { + // 429 throttle OR 503 gate-fail-closed: NOT a payment failure, NOT a + // revoke. Back off and tell the user to retry. const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : '' sys(`🟡 Too many charges right now${mins}. This isn't a payment failure.`) @@ -147,13 +185,23 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st ctx.guarded(r => { if (!r.ok) { // 429/503 while polling = retry-after, NOT a failure. Back off + continue. - if (r.error === 'rate_limited') { + if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable') { const wait = (r.retry_after ?? 5) * 1000 setTimeout(tick, Math.min(wait, 30000)) return } + // CF-7 rule 4: a post-revoke 403 (or session loss) while polling means + // the prior charge's outcome is AMBIGUOUS — it may have settled. Do not + // call it failed; surface the revoke + tell the user to verify balance. + if (r.error === 'remote_spending_revoked' || r.error === 'session_revoked') { + renderBillingError(sys, ctx, r) + sys('🟡 Your last charge’s outcome is unconfirmed — check your balance/history before retrying.') + + return + } + sys(`🔴 Could not check the charge: ${r.message || r.error || 'error'}`) return diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6c4a90f35498..88ca7a794ff9 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -109,13 +109,16 @@ export interface BillingErrorPayload { } export interface BillingChargeResponse { + actor?: string charge_id?: string + code?: string error?: string idempotency_key?: string message?: string ok: boolean payload?: BillingErrorPayload portal_url?: string | null + recovery?: string retry_after?: number | null } @@ -133,12 +136,15 @@ export interface BillingChargeStatusResponse { } export interface BillingMutationResponse { + actor?: string + code?: string error?: string granted?: boolean message?: string ok: boolean payload?: BillingErrorPayload portal_url?: string | null + recovery?: string retry_after?: number | null } @@ -169,7 +175,6 @@ export interface SubscriptionStateResponse { cycle_ends_at: string | null // ISO pending_downgrade_tier_name: string | null pending_downgrade_at: string | null - is_past_due: boolean // dunning: active=false but period live (WS1 M1 footgun) cancel_at_period_end: boolean // subscription scheduled to cancel at period end cancellation_effective_at: string | null // ISO when cancellation takes effect } | null From a75aea8f8a274729c95e2df8fab7359c28ddf2cf Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 15:31:44 +0530 Subject: [PATCH 17/62] refactor(subscription): remove dead step-up scaffolding from /subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- ui-tui/scripts/billing-fixtures.tsx | 11 +++--- .../__tests__/subscriptionOverlay.test.tsx | 2 -- ui-tui/src/app/interfaces.ts | 36 ++++++++++++------- ui-tui/src/app/slash/commands/subscription.ts | 11 +----- ui-tui/src/components/subscriptionOverlay.tsx | 12 +++---- 5 files changed, 38 insertions(+), 34 deletions(-) diff --git a/ui-tui/scripts/billing-fixtures.tsx b/ui-tui/scripts/billing-fixtures.tsx index 6b077aaefcf5..f55eb725db14 100644 --- a/ui-tui/scripts/billing-fixtures.tsx +++ b/ui-tui/scripts/billing-fixtures.tsx @@ -83,7 +83,6 @@ const cur = (o: Record = {}) => ({ const subCtx: SubscriptionOverlayState['ctx'] = { openManageLink: () => Promise.resolve(true), refreshState: () => Promise.resolve(null), - requestRemoteSpending: () => Promise.resolve(true), sys: () => {} } @@ -91,7 +90,6 @@ const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overvie ctx: subCtx, screen, state: s, - resumeScreen: null, pendingTargetTierId }) @@ -126,15 +124,16 @@ const billState = (o: Partial = {}): BillingStateResponse const billCtx = { applyAutoReload: () => Promise.resolve(true), - charge: () => {}, + charge: () => Promise.resolve('submitted' as const), openPortal: () => {}, + requestRemoteSpending: () => Promise.resolve(true), sys: () => {}, validate: (raw: string) => ({ amount: raw }) } const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({ ctx: billCtx, - pendingCharge: screen === 'confirm' ? { amount: '25' } : null, + pendingCharge: screen === 'confirm' || screen === 'stepup' ? { amount: '100' } : null, screen, state: s }) @@ -212,6 +211,10 @@ const FIXTURES: Record = { 'topup-buy': { desc: '/topup buy screen — presets', node: billEl(billState(), 'buy') + }, + 'topup-stepup': { + desc: '/topup step-up — "Allow Remote Spending" (resumable, holds $100 buy)', + node: billEl(billState(), 'stepup') } } diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index 7e7ec05071f8..bb457220b3d3 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -87,7 +87,6 @@ const state = (overrides: Partial = {}): Subscription const ctx = { openManageLink: vi.fn(() => Promise.resolve(true)), refreshState: vi.fn(() => Promise.resolve(null)), - requestRemoteSpending: vi.fn(() => Promise.resolve(true)), sys: vi.fn() } @@ -95,7 +94,6 @@ const overlay = (s: SubscriptionStateResponse, screen: SubscriptionOverlayState[ ctx, screen, state: s, - resumeScreen: null, pendingTargetTierId: null }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 5ceef0cdc936..1422b9e42510 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -92,7 +92,13 @@ export interface GatewayProviderProps { // the SAME RPCs as the old slash flows (billing.charge / charge_status / // auto_reload / step_up). Backend is unchanged & shared with the CLI. -export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview' +export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview' | 'stepup' + +/** Outcome of a charge attempt — lets the overlay route without tearing down. */ +export type BillingChargeOutcome = + | 'submitted' // 202 accepted; settlement is reported via transcript lines + | 'needs_remote_spending' // insufficient_scope → route to the stepup screen + | 'error' // any other failure (already surfaced via sys) /** * The functions the overlay needs to talk to the gateway and emit @@ -104,8 +110,19 @@ export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overvi export interface BillingOverlayCtx { /** Run `billing.auto_reload` (enabled/threshold/top_up) → resolve ok/false. */ applyAutoReload: (enabled: boolean, threshold?: number, topUp?: number) => Promise - /** Submit `billing.charge` for `amount` and poll to settlement (non-blocking). */ - charge: (amount: string) => void + /** + * Submit `billing.charge` for `amount` and poll to settlement. Resolves a + * discriminated outcome so the overlay can route to the resumable step-up on + * `needs_remote_spending` instead of tearing down. Settlement/most errors are + * still reported via transcript lines (the poll is non-blocking). + */ + charge: (amount: string) => Promise + /** + * Run the `billing.step_up` device flow (grant Remote Spending). Resolves + * `true` when the grant lands. The browser opens via the gateway's + * out-of-band `billing.step_up.verification` event — the overlay just awaits. + */ + requestRemoteSpending: () => Promise /** Open the portal in the browser + echo a transcript line. */ openPortal: (url: string) => void /** Emit a transcript system line. */ @@ -131,17 +148,14 @@ export interface BillingOverlayState { export type SubscriptionScreen = | 'overview' // shows plan + usage bar + tier list (states a–e collapse into this) - | 'confirm' // y/n confirm before opening the Stripe URL - | 'stepup' // Phase 4: "Allow Remote Spending" (resumable) - | 'handoff' // transient: "Opening Stripe in your browser…" + | 'confirm' // y/n confirm before opening the manage-subscription URL + | 'handoff' // transient: "Opening your subscription page in your browser…" export interface SubscriptionOverlayCtx { /** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */ openManageLink: () => Promise - /** Re-fetch subscription.state (used by Phase-4 resume). */ + /** Re-fetch subscription.state. */ refreshState: () => Promise - /** Run billing.step_up (Remote Spending). Resolves granted. */ - requestRemoteSpending: () => Promise /** Emit a transcript system line. */ sys: (text: string) => void } @@ -150,9 +164,7 @@ export interface SubscriptionOverlayState { ctx: SubscriptionOverlayCtx screen: SubscriptionScreen state: SubscriptionStateResponse - /** Phase 4 resume bookkeeping: the screen to return to after step-up. */ - resumeScreen?: SubscriptionScreen | null - /** Phase 4: the pending tier the user confirmed, replayed post-grant. */ + /** The tier the user selected on the overview, summarized on the confirm screen. */ pendingTargetTierId?: string | null } diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index 520c31d93578..0d3aecbe7dbc 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -1,7 +1,4 @@ -import type { - BillingMutationResponse, - SubscriptionStateResponse -} from '../../../gatewayTypes.js' +import type { SubscriptionStateResponse } from '../../../gatewayTypes.js' import { openExternalUrl } from '../../../lib/openExternalUrl.js' import type { SubscriptionOverlayCtx } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' @@ -72,11 +69,6 @@ const buildSubscriptionCtx = ( .rpc('subscription.state', {}) .then(r => r ?? null) .catch(() => null), - requestRemoteSpending: () => - ctx.gateway - .rpc('billing.step_up', { session_id: ctx.sid ?? undefined }) - .then(r => !!(r && r.ok && r.granted)) - .catch(() => false), sys }) @@ -104,7 +96,6 @@ export const subscriptionCommands: SlashCommand[] = [ subscription: { ctx: buildSubscriptionCtx(ctx, sys, s), pendingTargetTierId: null, - resumeScreen: null, screen: 'overview', state: s } diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index ac29b3f1cc2b..35054664ab27 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -19,8 +19,10 @@ interface SubscriptionOverlayProps { /** * The /subscription modal — deep-link only, NEVER charges in-terminal. * Mirrors billingOverlay.tsx's structure: a pure-render state machine - * (overview → confirm → handoff, plus stepup for Phase 4) where all RPCs - * live in subscription.ts and are reached through `overlay.ctx`. + * (overview → confirm → handoff) where all RPCs live in subscription.ts and + * are reached through `overlay.ctx`. No step-up here: changing a plan is a + * browser deep-link, which needs no billing scope (the scope gate is on + * /topup's charge, where the resumable step-up lives). */ export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) { const { ctx, screen, state: s } = overlay @@ -49,7 +51,6 @@ export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: Subscripti /> )} {screen === 'handoff' && } - {/* stepup screen is built in Phase 4 (U9) */} ) } @@ -297,9 +298,8 @@ function ConfirmScreen({ ctx, onBack, onClose, onPatch, overlay, s, t }: Confirm void ctx.openManageLink().then(ok => { if (!ok) { - // If openManageLink surfaces insufficient_scope, the ctx closure in - // subscription.ts handles the stepup transition (Phase 4 wiring). - // For now, return to overview on any failure. + // openManageLink only fails if the portal URL can't be built or the + // browser won't open — return to overview (it sys()'d the reason). onPatch({ screen: 'overview' }) } }) From a83550b5abb2fa24b46bb57cd38a9d1cda184c05 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 15:31:50 +0530 Subject: [PATCH 18/62] feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui-tui/src/__tests__/billingStepUp.test.tsx | 107 +++++++++++++++ ui-tui/src/__tests__/topupCommand.test.ts | 56 +++++++- ui-tui/src/app/slash/commands/topup.ts | 127 +++++++---------- ui-tui/src/components/billingOverlay.tsx | 145 +++++++++++++++++++- 4 files changed, 351 insertions(+), 84 deletions(-) create mode 100644 ui-tui/src/__tests__/billingStepUp.test.tsx diff --git a/ui-tui/src/__tests__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx new file mode 100644 index 000000000000..3dee2fc00e29 --- /dev/null +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -0,0 +1,107 @@ +import { PassThrough } from 'stream' + +import { renderSync } from '@hermes/ink' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +// Stub useInput so the overlay doesn't enter raw mode under renderSync. +vi.mock('@hermes/ink', async importOriginal => { + const mod = await importOriginal() + + return { ...mod, useInput: () => {} } +}) + +import type { BillingOverlayState } from '../app/interfaces.js' +import { BillingOverlay } from '../components/billingOverlay.js' +import type { BillingStateResponse } from '../gatewayTypes.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +const t = DEFAULT_THEME + +function render(overlay: BillingOverlayState): string { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + + let output = '' + + Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync( + React.createElement(BillingOverlay, { + onClose: () => {}, + onPatch: () => {}, + overlay, + t + }), + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + instance.unmount() + instance.cleanup() + + return stripAnsi(output) +} + +const billState = (): BillingStateResponse => + ({ + auto_reload: null, + balance_display: '$12.00', + balance_usd: '12', + can_charge: true, + card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' }, + charge_presets: ['25', '50'], + charge_presets_display: ['$25', '$50'], + cli_billing_enabled: true, + is_admin: true, + logged_in: true, + max_usd: '1000', + min_usd: '10', + monthly_cap: null, + ok: true, + org_name: 'Acme', + portal_url: 'https://portal/billing', + role: 'OWNER' + }) as BillingStateResponse + +const ctx = { + applyAutoReload: vi.fn(() => Promise.resolve(true)), + charge: vi.fn(() => Promise.resolve('submitted' as const)), + openPortal: vi.fn(), + requestRemoteSpending: vi.fn(() => Promise.resolve(true)), + sys: vi.fn(), + validate: vi.fn((raw: string) => ({ amount: raw })) +} + +const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => ({ + ctx, + pendingCharge: { amount: '100' }, + screen, + state: billState() +}) + +describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => { + it('renders the Allow Remote Spending prompt with the held amount', () => { + const out = render(overlay('stepup')) + expect(out).toContain('Allow Remote Spending') + expect(out).toContain('one-time browser authorization') + expect(out).toContain('$100') // resumes the held purchase + expect(out).toContain('Not now') + }) + + it('NEVER leaks the raw billing:manage scope in copy', () => { + const out = render(overlay('stepup')) + expect(out).not.toContain('billing:manage') + }) +}) diff --git a/ui-tui/src/__tests__/topupCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts index 55c5cfe564d2..3d32530c6f66 100644 --- a/ui-tui/src/__tests__/topupCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -231,19 +231,61 @@ describe('/billing slash command (overlay-driven)', () => { expect(out).toContain('Portal: /billing?topup=open') }) - it('ctx.charge insufficient_scope → arms step-up confirm', async () => { + it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => { const { run } = buildCtx({ 'billing.state': ownerState(), 'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' } }) await run('') - getOverlayState().billing!.ctx.charge('100') - await Promise.resolve() - await Promise.resolve() - // The charge failed with insufficient_scope → a NEW confirm (step-up) is armed. - const stepUp = getOverlayState().confirm - expect(stepUp?.title).toBe('Grant terminal billing access?') + const outcome = await getOverlayState().billing!.ctx.charge('100') + // No separate confirm overlay is armed anymore — the overlay's stepup + // screen owns the UX; the ctx just reports the outcome. + expect(outcome).toBe('needs_remote_spending') + expect(getOverlayState().confirm).toBeNull() + }) + + it('ctx.requestRemoteSpending → billing.step_up, resolves granted', async () => { + const { run, calls } = buildCtx({ + 'billing.state': ownerState(), + 'billing.step_up': { ok: true, granted: true } + }) + + await run('') + const granted = await getOverlayState().billing!.ctx.requestRemoteSpending() + expect(granted).toBe(true) + const su = calls.find(c => c.method === 'billing.step_up') + expect(su).toBeTruthy() + }) + + it('ctx.requestRemoteSpending → not granted resolves false', async () => { + const { run } = buildCtx({ + 'billing.state': ownerState(), + 'billing.step_up': { ok: true, granted: false } + }) + + await run('') + const granted = await getOverlayState().billing!.ctx.requestRemoteSpending() + expect(granted).toBe(false) + }) + + it('ctx.charge happy path resolves submitted', async () => { + vi.useFakeTimers() + + try { + const { run } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }, + 'billing.charge_status': { ok: true, status: 'settled', amount_usd: '100' } + }) + + await run('') + const outcome = await getOverlayState().billing!.ctx.charge('100') + expect(outcome).toBe('submitted') + await vi.runAllTimersAsync() + } finally { + vi.useRealTimers() + } }) // ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ── diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index eeb506e09158..508d7cc49462 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -6,7 +6,7 @@ import type { BillingStateResponse } from '../../../gatewayTypes.js' import { openExternalUrl } from '../../../lib/openExternalUrl.js' -import type { BillingOverlayCtx } from '../../interfaces.js' +import type { BillingChargeOutcome, BillingOverlayCtx } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' import type { SlashCommand, SlashRunCtx } from '../types.js' @@ -35,9 +35,12 @@ const renderBillingError = ( switch (env.error) { case 'insufficient_scope': - armStepUp(sys, ctx) + // Reached by non-charge mutations (e.g. auto-reload config) that need the + // Remote-Spending grant. The resumable step-up lives on the buy/charge + // path; point the user there rather than leaking the raw scope name. + sys('💳 This needs Remote Spending authorization. Start a credit purchase to authorize, then retry.') - return + break case 'remote_spending_revoked': { // CF-4: this terminal's spend was revoked. Kill the spend UI NOW (don't @@ -110,65 +113,23 @@ const renderBillingError = ( } } -/** 403 insufficient_scope → arm a ConfirmReq that runs the lazy step-up. */ -const armStepUp = (sys: Sys, ctx: SlashRunCtx): void => { - sys('💳 Terminal billing needs an extra permission (billing:manage).') - patchOverlayState({ - confirm: { - cancelLabel: 'Not now', - confirmLabel: 'Re-authorize', - detail: 'An org admin/owner must tick "Allow terminal billing" in the portal.', - onConfirm: () => { - // session_id lets the gateway route the billing.step_up.verification - // event (the verification link) back to this session — the device flow - // runs headless in the gateway, so the link can't be printed there. - ctx.gateway - .rpc('billing.step_up', { session_id: ctx.sid ?? undefined }) - .then( - ctx.guarded(r => { - if (r.ok && r.granted) { - // Step-up only grants the billing:manage TOKEN scope — the ORG - // kill-switch (cli_billing_enabled) is a separate gate. Re-fetch - // /state so we don't over-promise "enabled" when a charge would - // still hit cli_billing_disabled. - sys('✅ Billing permission granted.') - ctx.gateway - .rpc('billing.state', {}) - .then( - ctx.guarded(s => { - if (s.cli_billing_enabled) { - sys('Run /billing again to continue.') - } else { - sys( - '🟡 Permission granted, but terminal billing is still turned off ' + - 'for this org. Enable it in the portal, then run /billing again.' - ) - if (s.portal_url) { - sys(`Portal: ${s.portal_url}`) - } - } - }) - ) - .catch(() => { - sys('Run /billing again to continue.') - }) - } else { - sys('🟡 Terminal billing was not granted (an admin must tick the box).') - } - }) - ) - .catch(() => { - // The device flow can outlive the RPC's 120s timeout while the user - // is still authorizing in the browser. A reject here is NOT a hard - // failure — the grant (if it lands) is persisted gateway-side; tell - // the user to re-run /billing rather than reporting an error. - sys('🟡 Still waiting on approval — finish in the browser, then run /billing again.') - }) - }, - title: 'Grant terminal billing access?' - } - }) -} +/** + * Run the Remote-Spending device flow and resolve whether the grant landed. + * + * The browser opens via the gateway's out-of-band `billing.step_up.verification` + * event (handled globally in createGatewayEventHandler), so this just kicks the + * blocking `billing.step_up` RPC and awaits its result. A reject (the device + * flow can outlive the RPC's timeout while the user is still authorizing) is + * treated as "not yet granted" — non-fatal; the grant persists gateway-side. + * + * NOTE: never surface the raw `billing:manage` scope — the user-facing concept + * is "Remote Spending". + */ +const requestRemoteSpending = (ctx: SlashRunCtx): Promise => + ctx.gateway + .rpc('billing.step_up', { session_id: ctx.sid ?? undefined }) + .then(r => !!(r && r.ok && r.granted)) + .catch(() => false) /** Poll a charge to a terminal state (settled/failed/timeout). Non-blocking. */ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: string | null): void => { @@ -322,21 +283,39 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B return false }), - charge: (amount: string) => { + charge: (amount: string): Promise => { sys('💳 Charge submitted — confirming settlement…') - ctx.gateway + + return ctx.gateway .rpc('billing.charge', { amount_usd: amount }) - .then( - ctx.guarded(r => { - if (r.ok && r.charge_id) { - pollCharge(sys, ctx, r.charge_id, s.portal_url) - } else { - renderBillingError(sys, ctx, r) - } - }) - ) - .catch(ctx.guardedErr) + .then((r): BillingChargeOutcome => { + if (!r) { + return 'error' + } + + if (r.ok && r.charge_id) { + pollCharge(sys, ctx, r.charge_id, s.portal_url) + + return 'submitted' + } + + // insufficient_scope → the overlay routes to the resumable step-up + // (no error line here; the stepup screen owns that UX). + if (r.error === 'insufficient_scope') { + return 'needs_remote_spending' + } + + renderBillingError(sys, ctx, r) + + return 'error' + }) + .catch((e): BillingChargeOutcome => { + ctx.guardedErr(e) + + return 'error' + }) }, + requestRemoteSpending: () => requestRemoteSpending(ctx), openPortal: (url: string) => { openExternalUrl(url) sys(`Opening portal: ${url}`) diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index fd835fa5a1bc..167022169333 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -68,12 +68,21 @@ export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayP ctx={ctx} onBack={() => onPatch({ pendingCharge: null, screen: 'buy' })} onClose={onClose} + onPatch={onPatch} s={s} t={t} /> )} {screen === 'autoreload' && } {screen === 'limit' && } + {screen === 'stepup' && ( + + )} ) } @@ -329,6 +338,7 @@ function ConfirmScreen({ ctx, onBack, onClose, + onPatch, s, t }: { @@ -336,16 +346,33 @@ function ConfirmScreen({ ctx: BillingOverlayState['ctx'] onBack: () => void onClose: () => void + onPatch: (next: Partial) => void s: BillingStateResponse t: Theme }) { // rows: Pay $X now / Cancel const [sel, setSel] = useState(0) + const [submitting, setSubmitting] = useState(false) const pay = () => { - ctx.charge(amount) - // Settlement is reported via transcript lines; close the overlay now. - onClose() + if (submitting) { + return + } + + setSubmitting(true) + void ctx.charge(amount).then(outcome => { + if (outcome === 'needs_remote_spending') { + // Resumable step-up: keep the modal MOUNTED, switch to the stepup + // screen (which holds pendingCharge.amount for the post-grant replay). + onPatch({ screen: 'stepup' }) + + return + } + + // submitted (settlement reported via transcript) or error (already + // surfaced) → close the overlay. The transcript carries the outcome. + onClose() + }) } const back = () => onBack() @@ -397,6 +424,118 @@ function ConfirmScreen({ ) } +// ── Screen: Step-up (resumable "Allow Remote Spending") ─────────────── +// Reached when a charge returns insufficient_scope. The modal stays MOUNTED +// through the browser device-flow: Allow → await the grant → replay the held +// charge (pendingCharge.amount) without a command re-run. Never leaks the raw +// billing:manage scope — the user-facing concept is "Remote Spending". + +function StepUpScreen({ + amount, + ctx, + onClose, + t +}: { + amount: string + ctx: BillingOverlayState['ctx'] + onClose: () => void + t: Theme +}) { + const [sel, setSel] = useState(0) + const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt') + + const allow = () => { + if (phase === 'waiting') { + return + } + + setPhase('waiting') + ctx.sys('Opening your browser to authorize Remote Spending…') + + void ctx.requestRemoteSpending().then(granted => { + if (!granted) { + ctx.sys('🟡 Remote Spending was not granted (an org admin/owner must approve). Run /topup to try again.') + onClose() + + return + } + + // Granted → resume the held charge. Replay it; the confirm/poll lines + // report settlement, then close. + ctx.sys('✅ Remote Spending enabled — resuming your purchase.') + void ctx.charge(amount).then(() => onClose()) + }) + } + + const decline = () => onClose() + + useInput((ch, key) => { + if (phase === 'waiting') { + // While the device flow runs, only Esc (give up) is live. + if (key.escape) { + onClose() + } + + return + } + + if (key.escape) { + return decline() + } + + const lower = ch.toLowerCase() + + if (lower === 'y') { + return allow() + } + + if (lower === 'n') { + return decline() + } + + if (key.upArrow) { + setSel(0) + } + + if (key.downArrow) { + setSel(1) + } + + if (key.return) { + return sel === 0 ? allow() : decline() + } + }) + + if (phase === 'waiting') { + return ( + + + Allow Remote Spending + + Waiting for browser authorization… + Approve in the page that just opened — your purchase resumes here automatically. + + {footer('Esc cancel', t)} + + ) + } + + return ( + + + Allow Remote Spending + + Charging from the terminal needs a one-time browser authorization. + We'll resume your ${amount} purchase right here once it's granted. + + + + + {footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)} + + ) +} + // ── Screen 4: Auto-reload (the 2-field form) ────────────────────────── function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { From 1a15c4c34ca938f9f561e0ebcfe3d90c3b1d5a56 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 17:56:37 +0530 Subject: [PATCH 19/62] 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. --- agent/billing_usage.py | 331 ++++++++++++++++++++++++++++++ tests/agent/test_billing_usage.py | 232 +++++++++++++++++++++ tui_gateway/server.py | 98 +++++++++ 3 files changed, 661 insertions(+) create mode 100644 agent/billing_usage.py create mode 100644 tests/agent/test_billing_usage.py diff --git a/agent/billing_usage.py b/agent/billing_usage.py new file mode 100644 index 000000000000..4d3c7af7e87d --- /dev/null +++ b/agent/billing_usage.py @@ -0,0 +1,331 @@ +"""Shared dollar-denominated usage model for the billing/subscription surfaces. + +The single source of truth behind the ``/usage`` and ``/subscription`` usage +bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show +**dollars**, never "credits", and every usage bar must make the monthly +subscription allowance and separately-purchased top-up dollars distinctly +visible. + +Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose +``paid_service_access_info`` carries the three dollar magnitudes we render +(despite the legacy ``*_credits`` field names, these are USD floats): + + - ``subscription_credits_remaining`` -> plan dollars left this month + - ``purchased_credits_remaining`` -> top-up dollars left (rolls over) + - ``total_usable_credits`` -> total spendable + +plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the +denominator for the "% used" plan bar) and ``current_period_end`` (renewal). + +Design: two SEPARATE bars (decided with the user) rather than one crammed +three-segment bar — at terminal widths three same-glyph density segments are +unreadable. The plan bar is "spent vs allowance this month" (carries % used); +the top-up bar is "money you bought, doesn't expire". Each gets full +resolution and a single fill glyph, so the bar is never ambiguous and never +relies on color. + +Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a +magnitudes-only view; a logged-out / unreachable portal yields +``available=False`` and the surface shows nothing. +""" + +from __future__ import annotations + +import logging +import math +import os +from dataclasses import dataclass, field +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert +# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold +# (user feedback): "any amount below $5 should be an alert status." +LOW_BALANCE_THRESHOLD_USD = 5.0 + + +def _finite(value: Any) -> Optional[float]: + """Return value as a float iff it's a real finite number (not bool/NaN/Inf).""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + f = float(value) + return f if math.isfinite(f) else None + + +def _fmt_usd(value: Optional[float]) -> str: + """``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence).""" + return f"${(value or 0.0):,.2f}" + + +def format_renews(value: Optional[str]) -> Optional[str]: + """Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``. + + Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw + string unchanged if it can't be parsed (never raises), and ``None`` for + empty input. + """ + if not value: + return None + from datetime import datetime + + text = str(value).strip() + if not text: + return None + iso = text[:-1] + "+00:00" if text.endswith("Z") else text + try: + dt = datetime.fromisoformat(iso) + except ValueError: + # Fall back to a bare date prefix (YYYY-MM-DD) if present. + try: + dt = datetime.strptime(text[:10], "%Y-%m-%d") + except ValueError: + return text + # %-d isn't portable to Windows; build the day without a leading zero. + return f"{dt.strftime('%b')} {dt.day}, {dt.year}" + + +@dataclass(frozen=True) +class UsageBar: + """One full-resolution bar: ``spent`` of ``total``, plus a remaining figure. + + ``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"`` + (purchased dollars, no denominator — ``spent`` is 0 and ``total`` == + ``remaining`` so it renders as a full bar of available balance). + """ + + kind: str # "plan" | "topup" + remaining_usd: float + total_usd: float + spent_usd: float = 0.0 + + @property + def pct_used(self) -> Optional[int]: + if self.kind != "plan" or self.total_usd <= 0: + return None + return max(0, min(100, round(self.spent_usd / self.total_usd * 100))) + + @property + def fill_fraction(self) -> float: + """Fraction of the bar that should read as 'remaining' (filled).""" + if self.total_usd <= 0: + return 0.0 + return max(0.0, min(1.0, self.remaining_usd / self.total_usd)) + + +@dataclass(frozen=True) +class UsageModel: + """Surface-agnostic dollar usage model shared by /usage and /subscription. + + ``status`` classifies the account for copy selection: + - ``"free"`` : no paid access / no subscription (free models only) + - ``"low"`` : paid, but total spendable < $5 (ALERT) + - ``"healthy"`` : paid, total spendable >= $5 + - ``"depleted"`` : paid access lost (balance exhausted) + """ + + available: bool + status: str = "free" + plan_name: Optional[str] = None + renews_at: Optional[str] = None + renews_display: Optional[str] = None + subscription_remaining_usd: Optional[float] = None + topup_remaining_usd: Optional[float] = None + total_spendable_usd: Optional[float] = None + plan_bar: Optional[UsageBar] = None + topup_bar: Optional[UsageBar] = None + + @property + def is_low(self) -> bool: + return self.status == "low" + + @property + def is_free(self) -> bool: + return self.status == "free" + + @property + def has_topup(self) -> bool: + return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0) + + +def usage_model_from_account(account_info: Any) -> UsageModel: + """Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open. + + Returns ``UsageModel(available=False)`` when there's no usable account info + (logged out, no entitlement block). Never raises. + """ + try: + if account_info is None or not getattr(account_info, "logged_in", False): + return UsageModel(available=False) + + access = getattr(account_info, "paid_service_access_info", None) + sub = getattr(account_info, "subscription", None) + paid = getattr(account_info, "paid_service_access", None) + + sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None + topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None + total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None + + plan_name = getattr(sub, "plan", None) if sub is not None else None + renews_at = getattr(sub, "current_period_end", None) if sub is not None else None + monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None + + has_subscription = bool(plan_name) or (monthly is not None and monthly > 0) + + # Total spendable: prefer the server's total; else sum the parts we have. + if total_usable is not None: + total_spendable = total_usable + else: + parts = [v for v in (sub_remaining, topup_remaining) if v is not None] + total_spendable = sum(parts) if parts else None + + # Status classification. + if paid is False: + status = "depleted" + elif not has_subscription and not (topup_remaining and topup_remaining > 0): + # No plan and no purchased balance -> free-models-only. + status = "free" + elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD: + status = "low" + else: + status = "healthy" + + # Plan bar — only with a positive monthly allowance AND a remaining we + # can place on it. spent = cap - remaining, clamped (a debt/over-cap + # balance reads as fully spent rather than a nonsensical negative). + plan_bar: Optional[UsageBar] = None + if monthly is not None and monthly > 0 and sub_remaining is not None: + remaining = max(0.0, min(monthly, sub_remaining)) + plan_bar = UsageBar( + kind="plan", + remaining_usd=remaining, + total_usd=monthly, + spent_usd=max(0.0, monthly - sub_remaining), + ) + + # Top-up bar — only when there are purchased dollars to show. No + # denominator (top-up has no monthly cap), so it renders full = balance. + topup_bar: Optional[UsageBar] = None + if topup_remaining is not None and topup_remaining > 0: + topup_bar = UsageBar( + kind="topup", + remaining_usd=topup_remaining, + total_usd=topup_remaining, + spent_usd=0.0, + ) + + return UsageModel( + available=True, + status=status, + plan_name=plan_name, + renews_at=renews_at, + renews_display=format_renews(renews_at), + subscription_remaining_usd=sub_remaining, + topup_remaining_usd=topup_remaining, + total_spendable_usd=total_spendable, + plan_bar=plan_bar, + topup_bar=topup_bar, + ) + except Exception: + logger.debug("usage ▸ model build failed (fail-open)", exc_info=True) + return UsageModel(available=False) + + +def build_usage_model(*, timeout: float = 10.0) -> UsageModel: + """Fetch account-info and build the shared usage model. Fail-open. + + Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so + every usage state is testable without a live account (mirrors the existing + ``/usage`` credits-block fixture path). + """ + fixture = _dev_fixture_usage_model() + if fixture is not None: + return fixture + + try: + from hermes_cli.auth import get_provider_auth_state + + tok = (get_provider_auth_state("nous") or {}).get("access_token") + if not (isinstance(tok, str) and tok.strip()): + return UsageModel(available=False) + except Exception: + return UsageModel(available=False) + + try: + import concurrent.futures + + from hermes_cli.nous_account import get_nous_portal_account_info + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout) + return usage_model_from_account(account) + except Exception: + logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True) + return UsageModel(available=False) + + +# ============================================================================= +# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) +# ============================================================================= + + +def _dev_fixture_usage_model() -> Optional[UsageModel]: + """Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work. + + Recognized names: ``free | healthy | low | topup | depleted``. Returns + ``None`` when the env var is unset (real portal path runs). + """ + name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower() + if not name: + return None + + if name == "free": + return UsageModel(available=True, status="free", plan_name=None) + + if name in ("healthy", "mid"): + return UsageModel( + available=True, + status="healthy", + plan_name="Plus", + renews_at="2026-07-01", + subscription_remaining_usd=14.0, + total_spendable_usd=14.0, + plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0), + ) + + if name in ("topup", "top-up"): + return UsageModel( + available=True, + status="healthy", + plan_name="Plus", + renews_at="2026-07-01", + subscription_remaining_usd=14.0, + topup_remaining_usd=12.0, + total_spendable_usd=26.0, + plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0), + topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0), + ) + + if name == "low": + return UsageModel( + available=True, + status="low", + plan_name="Plus", + renews_at="2026-07-01", + subscription_remaining_usd=3.4, + total_spendable_usd=3.4, + plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6), + ) + + if name == "depleted": + return UsageModel( + available=True, + status="depleted", + plan_name="Plus", + renews_at="2026-07-01", + subscription_remaining_usd=0.0, + total_spendable_usd=0.0, + plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0), + ) + + return None diff --git a/tests/agent/test_billing_usage.py b/tests/agent/test_billing_usage.py new file mode 100644 index 000000000000..da6390594926 --- /dev/null +++ b/tests/agent/test_billing_usage.py @@ -0,0 +1,232 @@ +"""Tests for the shared dollar usage model (agent/billing_usage.py). + +Behavior contracts, not snapshots: status classification, bar math, fail-open, +and the dollars-only / topup-split invariants the billing UX feedback requires. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import pytest + +from agent.billing_usage import ( + LOW_BALANCE_THRESHOLD_USD, + UsageBar, + UsageModel, + usage_model_from_account, +) + + +# ── Lightweight stand-ins for NousPortalAccountInfo shape ──────────────────── + + +@dataclass +class _Access: + subscription_credits_remaining: Optional[float] = None + purchased_credits_remaining: Optional[float] = None + total_usable_credits: Optional[float] = None + + +@dataclass +class _Sub: + plan: Optional[str] = None + monthly_credits: Optional[float] = None + current_period_end: Optional[str] = None + + +@dataclass +class _Account: + logged_in: bool = True + paid_service_access: Optional[bool] = None + paid_service_access_info: Optional[_Access] = None + subscription: Optional[_Sub] = None + + +def _acct(**over): + return _Account(**over) + + +# ── Fail-open ──────────────────────────────────────────────────────────────── + + +def test_none_account_is_unavailable(): + assert usage_model_from_account(None).available is False + + +def test_logged_out_is_unavailable(): + assert usage_model_from_account(_acct(logged_in=False)).available is False + + +def test_garbage_account_fails_open(): + class Boom: + @property + def logged_in(self): + raise RuntimeError("kaboom") + + assert usage_model_from_account(Boom()).available is False + + +# ── Status classification ──────────────────────────────────────────────────── + + +def test_no_plan_no_balance_is_free(): + m = usage_model_from_account(_acct(paid_service_access=None, subscription=None, paid_service_access_info=_Access())) + assert m.available is True + assert m.status == "free" + + +def test_paid_access_lost_is_depleted(): + m = usage_model_from_account( + _acct( + paid_service_access=False, + subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=0.0, total_usable_credits=0.0), + ) + ) + assert m.status == "depleted" + + +def test_healthy_when_above_threshold(): + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=_Sub(plan="Plus", monthly_credits=20.0, current_period_end="2026-07-01"), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0), + ) + ) + assert m.status == "healthy" + assert m.plan_name == "Plus" + assert m.renews_at == "2026-07-01" + + +def test_low_when_total_spendable_under_threshold(): + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=3.4, total_usable_credits=3.4), + ) + ) + assert m.status == "low" + assert m.is_low is True + # Invariant: the threshold boundary is exclusive — exactly $5 is NOT low. + assert LOW_BALANCE_THRESHOLD_USD == 5.0 + + +def test_exactly_threshold_is_healthy(): + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=5.0, total_usable_credits=5.0), + ) + ) + assert m.status == "healthy" + + +def test_topup_only_no_subscription_is_not_free(): + # Purchased balance but no plan -> still a usable (healthy) account, not free. + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=None, + paid_service_access_info=_Access(purchased_credits_remaining=30.0, total_usable_credits=30.0), + ) + ) + assert m.status == "healthy" + assert m.has_topup is True + + +# ── Bar math ───────────────────────────────────────────────────────────────── + + +def test_plan_bar_spent_and_pct(): + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0), + ) + ) + bar = m.plan_bar + assert bar is not None + assert bar.kind == "plan" + assert bar.remaining_usd == 14.0 + assert bar.total_usd == 20.0 + assert bar.spent_usd == pytest.approx(6.0) + assert bar.pct_used == 30 # 6/20 + + +def test_plan_bar_clamps_over_cap_remaining(): + # Rollover/debt: remaining > cap should clamp the bar's remaining to the cap + # and read as zero spent, not a negative. + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=25.0, total_usable_credits=25.0), + ) + ) + bar = m.plan_bar + assert bar is not None + assert bar.remaining_usd == 20.0 + assert bar.spent_usd == 0.0 + + +def test_topup_bar_full_no_denominator(): + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access( + subscription_credits_remaining=14.0, purchased_credits_remaining=12.0, total_usable_credits=26.0 + ), + ) + ) + tb = m.topup_bar + assert tb is not None + assert tb.kind == "topup" + assert tb.remaining_usd == 12.0 + assert tb.fill_fraction == 1.0 # full bar = balance + assert tb.pct_used is None # no monthly denominator + assert m.total_spendable_usd == 26.0 + + +def test_no_plan_bar_without_monthly_denominator(): + # Top-up only, no monthly cap -> no plan bar, but a top-up bar. + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=None, + paid_service_access_info=_Access(purchased_credits_remaining=8.0, total_usable_credits=8.0), + ) + ) + assert m.plan_bar is None + assert m.topup_bar is not None + + +def test_non_finite_values_are_ignored(): + m = usage_model_from_account( + _acct( + paid_service_access=True, + subscription=_Sub(plan="Plus", monthly_credits=float("nan")), + paid_service_access_info=_Access(subscription_credits_remaining=float("inf")), + ) + ) + # NaN cap and Inf remaining must not produce a bar or a bogus total. + assert m.plan_bar is None + + +# ── UsageBar property edge cases ───────────────────────────────────────────── + + +def test_usage_bar_fill_fraction_clamped(): + assert UsageBar(kind="plan", remaining_usd=30.0, total_usd=20.0).fill_fraction == 1.0 + assert UsageBar(kind="plan", remaining_usd=-5.0, total_usd=20.0).fill_fraction == 0.0 + assert UsageBar(kind="plan", remaining_usd=0.0, total_usd=0.0).fill_fraction == 0.0 + + +def test_topup_bar_pct_used_is_none(): + assert UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0).pct_used is None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 252b193a93b8..7ffcfb28bdf4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5441,6 +5441,15 @@ def _(rid, params: dict) -> dict: usage["credits_lines"] = credits except Exception: pass + # Shared dollar usage model (two-bar view) — the dollars-only replacement for + # the legacy credits text block. Same fail-open posture. The client renders + # these bars and a clean balance summary in place of credits_lines. + try: + from agent.billing_usage import build_usage_model + + usage["usage"] = _serialize_usage_model(build_usage_model()) + except Exception: + pass return _ok(rid, usage) @@ -5591,6 +5600,73 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"}) +def _serialize_usage_bar(bar) -> Optional[dict]: + """Serialize a UsageBar (dollar magnitudes → display strings + fractions).""" + if bar is None: + return None + from agent.billing_usage import _fmt_usd + + return { + "kind": bar.kind, + "remaining_usd": bar.remaining_usd, + "remaining_display": _fmt_usd(bar.remaining_usd), + "total_usd": bar.total_usd, + "total_display": _fmt_usd(bar.total_usd), + "spent_usd": bar.spent_usd, + "spent_display": _fmt_usd(bar.spent_usd), + "pct_used": bar.pct_used, + "fill_fraction": bar.fill_fraction, + } + + +def _serialize_usage_model(model) -> dict: + """Serialize a UsageModel for the wire — the shared two-bar dollar view. + + Dollars-only (no 'credits'); fail-open shape mirrors the other billing RPCs + ({ok, available:false} when logged out / unreachable). + """ + from agent.billing_usage import _fmt_usd, format_renews + + if model is None or not getattr(model, "available", False): + return {"ok": True, "available": False} + + return { + "ok": True, + "available": True, + "status": model.status, + "plan_name": model.plan_name, + "renews_at": model.renews_at, + "renews_display": getattr(model, "renews_display", None) or format_renews(model.renews_at), + "subscription_remaining_display": ( + None if model.subscription_remaining_usd is None else _fmt_usd(model.subscription_remaining_usd) + ), + "topup_remaining_display": ( + None if model.topup_remaining_usd is None else _fmt_usd(model.topup_remaining_usd) + ), + "total_spendable_display": ( + None if model.total_spendable_usd is None else _fmt_usd(model.total_spendable_usd) + ), + "has_topup": model.has_topup, + "plan_bar": _serialize_usage_bar(model.plan_bar), + "topup_bar": _serialize_usage_bar(model.topup_bar), + } + + +@method("usage.bars") +def _(rid, params: dict) -> dict: + """Shared dollar usage model (two-bar view) for /usage + /subscription. + + Fail-open: logged-out / unreachable portal → {ok:true, available:false}. + No scope required (read-only). + """ + try: + from agent.billing_usage import build_usage_model + + return _ok(rid, _serialize_usage_model(build_usage_model())) + except Exception: + return _ok(rid, {"ok": True, "available": False}) + + def _serialize_subscription_state(state) -> dict: """Serialize a SubscriptionState for the wire (Decimals → strings).""" from agent.billing_view import format_money @@ -5636,9 +5712,31 @@ def _s(value): "tiers": tiers, "portal_url": state.portal_url, "error": state.error, + # Shared dollar usage model (two-bar view) embedded so /subscription + # renders the same bars as /usage from its single fetch. Built from the + # separate account-info path (the only source with top-up dollars); + # fail-open → {available:false}. Computed lazily so a logged-out state + # adds no cost. + "usage": _subscription_usage_payload(state), } +def _subscription_usage_payload(state) -> dict: + """Best-effort shared usage model for the /subscription overlay's bars. + + Only fetched when logged in; fail-open to {available:false} so the overview + still renders if the account-info path is down. + """ + if not getattr(state, "logged_in", False): + return {"available": False} + try: + from agent.billing_usage import build_usage_model + + return _serialize_usage_model(build_usage_model()) + except Exception: + return {"available": False} + + @method("subscription.state") def _(rid, params: dict) -> dict: """GET /api/billing/subscription → serialized SubscriptionState. From 46176e7cb1f1ceb7b87a16e2f9a8cc2fbc2358a5 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 17:56:53 +0530 Subject: [PATCH 20/62] feat(tui): dollar usage bars on /usage + /subscription, drop tier picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../__tests__/subscriptionOverlay.test.tsx | 107 ++++++++++--- ui-tui/src/__tests__/usageCommand.test.ts | 63 +++++++- ui-tui/src/app/slash/commands/session.ts | 47 +++++- ui-tui/src/components/overlayPrimitives.tsx | 95 ++++++++++++ ui-tui/src/components/subscriptionOverlay.tsx | 140 +++++++----------- ui-tui/src/gatewayTypes.ts | 31 ++++ 6 files changed, 374 insertions(+), 109 deletions(-) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index bb457220b3d3..bf68ac633f07 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -98,9 +98,10 @@ const overlay = (s: SubscriptionStateResponse, screen: SubscriptionOverlayState[ }) describe('SubscriptionOverlay — overview screen', () => { - it('(a) free-upgradeable: shows tier list + subscribe header', () => { + it('(a) free: shows free upsell + manage, no tier list, no "credits"', () => { const s = state({ current: null, + usage: { available: true, status: 'free', plan_name: null }, tiers: [ tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }), tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: false }), @@ -110,24 +111,40 @@ describe('SubscriptionOverlay — overview screen', () => { const out = render(overlay(s)) - expect(out).toContain('Subscribe to a plan') - expect(out).toContain('Pro') - expect(out).toContain('Scale') - expect(out).toContain('$20') - expect(out).toContain('1000 credits') + expect(out).toContain('Plan: Free · free models only') + expect(out).toContain('Paid models need a subscription') + expect(out).toContain('Start a subscription') + // No selectable tier menu — tiers are not listed in-terminal anymore. + expect(out).not.toContain('$20/mo') + expect(out.toLowerCase()).not.toContain('credits') }) - it('(b) subscriber mid-tier: shows current plan + usage bar', () => { + it('(b) subscriber mid-tier: status line + dollar plan bar', () => { const s = state({ current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '420', - cycle_ends_at: '2026-07-01T00:00:00Z', + cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { + available: true, + status: 'healthy', + plan_name: 'Pro', + renews_at: '2026-07-01', + total_spendable_display: '$14.00', + plan_bar: { + kind: 'plan', + remaining_display: '$14.00', + total_display: '$20.00', + spent_display: '$6.00', + pct_used: 30, + fill_fraction: 0.7 + } + }, tiers: [ tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true }), @@ -137,9 +154,62 @@ describe('SubscriptionOverlay — overview screen', () => { const out = render(overlay(s)) - expect(out).toContain('Your plan: Pro') - expect(out).toContain('remaining') - expect(out).toContain('Scale') + expect(out).toContain('Plan: Pro') + expect(out).toContain('$14.00 left of $20.00') + expect(out).toContain('30% used') + expect(out.toLowerCase()).not.toContain('credits') + }) + + it('(b2) subscriber + top-up: shows both bars', () => { + const s = state({ + current: { + tier_id: 'pro', + tier_name: 'Pro', + monthly_credits: '1000', + credits_remaining: '700', + cycle_ends_at: '2026-07-01', + pending_downgrade_tier_name: null, + pending_downgrade_at: null + }, + usage: { + available: true, + status: 'healthy', + plan_name: 'Pro', + renews_at: '2026-07-01', + total_spendable_display: '$26.00', + has_topup: true, + plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 }, + topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 } + }, + tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] + }) + + const out = render(overlay(s)) + + expect(out).toContain('Pro') + expect(out).toContain('top-up') + expect(out).toContain('$12.00') + expect(out).toContain('never expires') + }) + + it('(b3) low balance: shows alert nudge', () => { + const s = state({ + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '170', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { + available: true, + status: 'low', + plan_name: 'Pro', + renews_at: '2026-07-01', + total_spendable_display: '$3.40', + plan_bar: { kind: 'plan', remaining_display: '$3.40', total_display: '$20.00', spent_display: '$16.60', pct_used: 83, fill_fraction: 0.17 } + }, + tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] + }) + + const out = render(overlay(s)) + + expect(out).toContain('Plan: Pro · $3.40 left') + expect(out).toContain('Low balance') }) it('(c) subscriber top-tier: shows top plan note', () => { @@ -149,10 +219,11 @@ describe('SubscriptionOverlay — overview screen', () => { tier_name: 'Scale', monthly_credits: '5000', credits_remaining: '3000', - cycle_ends_at: '2026-07-01T00:00:00Z', + cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { available: true, status: 'healthy', plan_name: 'Scale', renews_at: '2026-07-01' }, tiers: [ tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: false }), @@ -162,8 +233,8 @@ describe('SubscriptionOverlay — overview screen', () => { const out = render(overlay(s)) - expect(out).toContain('Your plan: Scale') - expect(out).toContain("You're on the top plan.") + expect(out).toContain('Plan: Scale') + expect(out).toContain('Manage on portal') }) it('(d) not-admin: shows read-only note + no tier list', () => { @@ -176,16 +247,17 @@ describe('SubscriptionOverlay — overview screen', () => { tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', - cycle_ends_at: '2026-07-01T00:00:00Z', + cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { available: true, status: 'healthy', plan_name: 'Pro', renews_at: '2026-07-01' }, tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] }) const out = render(overlay(s)) - expect(out).toContain('Plan changes need an org admin/owner.') + expect(out).toContain('view only') expect(out).toContain('Manage on portal') }) @@ -196,10 +268,11 @@ describe('SubscriptionOverlay — overview screen', () => { tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', - cycle_ends_at: '2026-07-01T00:00:00Z', + cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: 'Free', pending_downgrade_at: '2026-07-15T00:00:00Z' }, + usage: { available: true, status: 'healthy', plan_name: 'Pro', renews_at: '2026-07-01' }, tiers: [ tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true }) diff --git a/ui-tui/src/__tests__/usageCommand.test.ts b/ui-tui/src/__tests__/usageCommand.test.ts index e237585f2f1e..f5c99b57498d 100644 --- a/ui-tui/src/__tests__/usageCommand.test.ts +++ b/ui-tui/src/__tests__/usageCommand.test.ts @@ -5,7 +5,7 @@ import type { SessionUsageResponse } from '../gatewayTypes.js' const usageCommand = sessionCommands.find(cmd => cmd.name === 'usage')! -const USAGE_CTA = 'Run /subscription to change plan · /topup to add credits' +const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance' const guarded = (fn: (r: T) => void) => @@ -97,4 +97,65 @@ describe('/usage slash command', () => { expect(printed(sys)).toContain(USAGE_CTA) expect(printed(sys)).not.toContain('no API calls yet') }) + + it('renders the dollar two-bar model (no "credits" wording) when available', async () => { + const { panel, run } = buildCtx({ + 'session.usage': baseUsage({ + calls: 0, + usage: { + available: true, + status: 'healthy', + plan_name: 'Plus', + renews_at: '2026-07-01', + total_spendable_display: '$26.00', + has_topup: true, + plan_bar: { + kind: 'plan', + remaining_display: '$14.00', + total_display: '$20.00', + spent_display: '$6.00', + pct_used: 30, + fill_fraction: 0.7 + }, + topup_bar: { + kind: 'topup', + remaining_display: '$12.00', + total_display: '$12.00', + spent_display: '$0.00', + pct_used: null, + fill_fraction: 1 + } + } + }) + }) + + await run('') + + const titles = panel.mock.calls.map(c => c[0]) + expect(titles).toContain('Balance') + const sections = panel.mock.calls.find(c => c[0] === 'Balance')![1] as { text?: string }[] + const body = sections.map(s => s.text ?? '').join('\n') + expect(body).toContain('Plus') + expect(body).toContain('$14.00 left of $20.00') + expect(body).toContain('30% used') + expect(body).toContain('top-up') + expect(body).toContain('$12.00') + expect(body.toLowerCase()).not.toContain('credits') + }) + + it('shows the free-models upsell for a free account', async () => { + const { panel, run } = buildCtx({ + 'session.usage': baseUsage({ + calls: 0, + usage: { available: true, status: 'free', plan_name: null } + }) + }) + + await run('') + + const sections = panel.mock.calls.find(c => c[0] === 'Balance')![1] as { text?: string }[] + const body = sections.map(s => s.text ?? '').join('\n') + expect(body).toContain('free models only') + expect(body).toContain('/subscription') + }) }) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 4404ff760255..a74bcd1f8469 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -1,3 +1,4 @@ +import { usageBarsText } from '../../../components/overlayPrimitives.js' import { attachedImageNotice, introMsg, toTranscriptMessages } from '../../../domain/messages.js' import { TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { @@ -18,7 +19,7 @@ import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' -const USAGE_CTA = 'Run /subscription to change plan · /topup to add credits' +const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance' const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g') @@ -553,17 +554,47 @@ export const sessionCommands: SlashCommand[] = [ }) } - // Nous credits block is agent-independent (a portal fetch), so it shows - // even with zero API calls or on a resumed session. Render it whenever - // present, before the token panel. - const creditsLines = r?.credits_lines ?? [] + // Nous balance block is agent-independent (a portal fetch), so it shows + // even with zero API calls or on a resumed session. Prefer the shared + // dollar usage model (two-bar view, dollars-only); fall back to the + // legacy text lines only when the model is unavailable. + const usageModel = r?.usage + const barLines = usageBarsText(usageModel) + let showedBalance = false - if (creditsLines.length) { - ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }]) + if (usageModel?.available && (barLines.length || usageModel.status === 'free')) { + const sections: PanelSection[] = [] + const plan = usageModel.plan_name ?? (usageModel.status === 'free' ? 'Free' : null) + + if (plan) { + sections.push({ text: `Plan: ${plan}${usageModel.renews_at ? ` · renews ${usageModel.renews_at}` : ''}` }) + } + + if (barLines.length) { + sections.push({ text: barLines.join('\n') }) + } + + if (usageModel.status === 'free') { + sections.push({ text: '> Free · free models only. Run /subscription to reach paid models.' }) + } else if (usageModel.status === 'low') { + sections.push({ + text: `! Low balance · ${usageModel.total_spendable_display ?? 'under $5'} left. Run /topup or /subscription.` + }) + } + + ctx.transcript.panel('Balance', sections) + showedBalance = true + } else { + const creditsLines = r?.credits_lines ?? [] + + if (creditsLines.length) { + ctx.transcript.panel('Nous balance', [{ text: creditsLines.join('\n') }]) + showedBalance = true + } } if (!r?.calls) { - if (!creditsLines.length) { + if (!showedBalance) { sys('no API calls yet') } diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index cd7e5a7388dc..e97639ba2bec 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -1,5 +1,7 @@ import { Text } from '@hermes/ink' +import type { ReactNode } from 'react' +import type { UsageModelData } from '../gatewayTypes.js' import type { Theme } from '../theme.js' /** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */ @@ -37,4 +39,97 @@ export function barCells(ratio: number, cells: number = BAR_CELLS): { bar: strin return { bar: '█'.repeat(filled) + '░'.repeat(cells - filled), pct: Math.round(r * 100) } } +/** + * Two-bar dollar usage view (decided with the user over a crammed three-segment + * bar: at terminal widths a single fill glyph per full-resolution bar is the + * only legible option). The plan bar is labeled with the plan name and shows + * the allowance detail + % used; the top-up bar shows purchased dollars (no + * denominator, renders full = balance, rolls over). Dollars only — never + * "credits". Each row: + * `Plus [██████░░░░] $14.00 of $20.00 · 30% used` + * Renders nothing for a free account (no bars to draw — caller shows upsell). + */ +export function UsageBars({ model, t }: { model: undefined | UsageModelData; t: Theme }) { + if (!model || !model.available) { + return null + } + + const rows: ReactNode[] = [] + // Label the plan bar with the plan name (padded for column alignment with the + // top-up row). Falls back to 'plan' when the name is absent. + const planLabel = (model.plan_name || 'plan').padEnd(8).slice(0, 8) + + if (model.plan_bar) { + const b = model.plan_bar + const { bar } = barCells(b.fill_fraction) + const pct = b.pct_used == null ? '' : ` · ${b.pct_used}% used` + + rows.push( + + {planLabel} + [ + {bar} + ] + {` ${b.remaining_display} left of ${b.total_display}${pct}`} + + ) + } + + if (model.topup_bar) { + const b = model.topup_bar + const { bar } = barCells(1) + + rows.push( + + {'top-up '} + [ + {bar} + ] + {` ${b.remaining_display} · never expires`} + + ) + } + + if (rows.length === 0) { + return null + } + + return <>{rows} +} + +/** + * Plain-text version of the two-bar usage view, for text-only surfaces (the + * /usage transcript panel). Returns one string per line: a plan bar, a top-up + * bar, and a total-spendable summary, whichever apply. Dollars only. + */ +export function usageBarsText(model: undefined | UsageModelData): string[] { + if (!model || !model.available) { + return [] + } + + const lines: string[] = [] + const planLabel = (model.plan_name || 'plan').padEnd(8).slice(0, 8) + + if (model.plan_bar) { + const b = model.plan_bar + const { bar } = barCells(b.fill_fraction) + const pct = b.pct_used == null ? '' : ` · ${b.pct_used}% used` + + lines.push(`${planLabel}[${bar}] ${b.remaining_display} left of ${b.total_display}${pct}`) + } + + if (model.topup_bar) { + const b = model.topup_bar + const { bar } = barCells(1) + + lines.push(`top-up [${bar}] ${b.remaining_display} · never expires`) + } + + if (model.total_spendable_display && model.has_topup) { + lines.push(`Total spendable: ${model.total_spendable_display}`) + } + + return lines +} + export const footer = (extra: string, t: Theme) => {extra} diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 35054664ab27..63f2626ec154 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -5,7 +5,7 @@ import type { SubscriptionOverlayState } from '../app/interfaces.js' import type { SubscriptionStateResponse } from '../gatewayTypes.js' import type { Theme } from '../theme.js' -import { ActionRow, barCells, footer, MenuRow } from './overlayPrimitives.js' +import { ActionRow, footer, MenuRow, UsageBars } from './overlayPrimitives.js' interface SubscriptionOverlayProps { /** Replace the overlay slot (screen transitions + pending data). */ @@ -65,34 +65,35 @@ interface ScreenProps { t: Theme } -/** Usage bar from subscription allowance (monthly_credits vs credits_remaining). */ -function usageBar(s: SubscriptionStateResponse): null | string { - const c = s.current - - if (!c || !c.monthly_credits || !c.credits_remaining) { - return null +/** Status line — dollars-only, state-matched. The allowance detail ($X of $Y · + * % used) lives on the bar line, so this line shows only the spendable total + + * renewal — no duplicated "of $Y left". */ +function statusLine(s: SubscriptionStateResponse): string { + const u = s.usage + const plan = s.current?.tier_name ?? u?.plan_name ?? null + const renewsRaw = u?.renews_display ?? null + const renews = renewsRaw ? ` · renews ${renewsRaw}` : '' + const viewOnly = !(s.can_change_plan && s.is_admin) + + if (!plan) { + return 'Plan: Free · free models only' } - const monthly = Number(c.monthly_credits) - const remaining = Number(c.credits_remaining) - - if (!(monthly > 0) || Number.isNaN(remaining)) { - return null + if (u?.status === 'low' && u.total_spendable_display) { + return `Plan: ${plan} · ${u.total_spendable_display} left` } - const spent = Math.max(0, monthly - remaining) - const { bar, pct } = barCells(spent / monthly) + // Healthy/top: show the spendable total once; the bar carries the breakdown. + const left = u?.total_spendable_display ? ` · ${u.total_spendable_display} left` : '' - return `${remaining} of ${c.monthly_credits} remaining ${bar} ${100 - pct}% left` + return `Plan: ${plan}${left}${viewOnly ? ' · view only' : renews}` } -function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { - // (d) not-admin: read-only + note + Manage/portal only. - const canChange = s.can_change_plan && s.is_admin +function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { const c = s.current const isFree = !c?.tier_id - const hasPendingDowngrade = !!c?.pending_downgrade_tier_name const isCancelScheduled = !!c?.cancel_at_period_end + const hasPendingDowngrade = !!c?.pending_downgrade_tier_name // Headline precedence: cancel-scheduled > downgrade-pending > active. // (Past-due/dunning was removed from the NAS read — a card-failing @@ -107,53 +108,32 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${c?.pending_downgrade_at}.` : null - const notAdminNote = !canChange ? 'Plan changes need an org admin/owner.' : null - - // Build the tier list (only enabled tiers for the menu; current marked). - const enabledTiers = s.tiers.filter(tier => tier.is_enabled) - const currentTierOrder = c?.tier_id ? s.tiers.find(tier => tier.tier_id === c.tier_id)?.tier_order : undefined - const isTopTier = currentTierOrder != null && enabledTiers.every(tier => tier.tier_order <= currentTierOrder) - const topTierNote = isTopTier ? "You're on the top plan." : null - - // Menu items: tiers (selectable) + Manage on portal + Close. - // For not-admin: just Manage on portal + Close. - const tierItems: { label: string; tierId?: string }[] = canChange - ? [ - ...enabledTiers.map(tier => ({ - label: `${tier.is_current ? '✓ ' : ''}${tier.name} — ${tier.dollars_per_month_display}/mo (${tier.monthly_credits} credits)`, - tierId: tier.tier_id - })), - { label: 'Manage on portal' }, - { label: 'Close' } - ] - : [{ label: 'Manage on portal' }, { label: 'Close' }] - + // State-matched upsell/alert nudge (dollars-only; healthy stays silent). + const u = s.usage + const freeNudge = isFree ? 'Paid models need a subscription. Start one to reach them.' : null + + const lowNudge = + u?.status === 'low' + ? `Low balance · ${u.total_spendable_display ?? 'under $5'} left. Top up or upgrade before a mid-run cutoff.` + : null + + // No in-terminal plan picker (decided with the user): just show usage + the + // current plan, then hand off to the portal to manage it. Members (non-admin) + // get the same two actions — the portal enforces who can actually change it. + // Free users have nothing to "manage" yet, so the verb matches their job. + const manageLabel = isFree ? 'Start a subscription' : 'Manage on portal' + const items = [manageLabel, 'Close'] const [sel, setSel] = useState(0) const choose = (i: number) => { - const item = tierItems[i] - - if (!item) { - return - } - - if (item.label === 'Close') { - return onClose() - } - - if (item.label === 'Manage on portal') { + if (i === 0) { if (s.portal_url) { - ctx.sys('Opening portal in your browser…') + ctx.sys('Opening your subscription page in the browser…') void ctx.openManageLink() } - - return onClose() } - // A tier was selected — go to confirm (deep-link, no in-terminal charge). - if (item.tierId && item.tierId !== c?.tier_id) { - onPatch({ screen: 'confirm', pendingTargetTierId: item.tierId }) - } + return onClose() } useInput((ch, key) => { @@ -165,7 +145,7 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { setSel(v => v - 1) } - if (key.downArrow && sel < tierItems.length - 1) { + if (key.downArrow && sel < items.length - 1) { setSel(v => v + 1) } @@ -175,22 +155,26 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const n = parseInt(ch, 10) - if (n >= 1 && n <= tierItems.length) { + if (n >= 1 && n <= items.length) { return choose(n - 1) } }) - const header = isFree ? 'Subscribe to a plan' : c?.tier_name ? `Your plan: ${c.tier_name}` : 'Subscription' - const bar = usageBar(s) - return ( - {header} + {statusLine(s)} - {bar && {bar}} - {c?.credits_remaining && !bar && ( - Credits remaining: {c.credits_remaining} + + {freeNudge && ( + + {'> '}{freeNudge} + + )} + {lowNudge && ( + + {'! '}{lowNudge} + )} {s.org_name && ( @@ -208,24 +192,14 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { {downgradeNote} )} - {notAdminNote && ( - - {notAdminNote} - - )} - {topTierNote && ( - - {topTierNote} - - )} - {tierItems.map((item, i) => ( - + {items.map((label, i) => ( + ))} - {footer(`↑/↓ select · 1-${tierItems.length} quick pick · Enter confirm · Esc close`, t)} + {footer('↑/↓ select · Enter confirm · Esc close', t)} ) } @@ -258,8 +232,8 @@ function TeamContextScreen({ onClose, s, t }: TeamContextScreenProps) { )} - This terminal is connected to {s.org_name ?? 'a team org'}. Teams run on shared credits - — use /topup to add funds. + This terminal is connected to {s.org_name ?? 'a team org'}. Teams run on a shared + balance · use /topup to add funds. Personal subscriptions live on your personal account. @@ -346,7 +320,7 @@ function ConfirmScreen({ ctx, onBack, onClose, onPatch, overlay, s, t }: Confirm {targetTier && ( - {targetTier.name} — {targetTier.dollars_per_month_display}/mo ({targetTier.monthly_credits} credits) + {targetTier.name} · {targetTier.dollars_per_month_display}/mo )} You'll finish this change securely on your subscription page in your browser. diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 88ca7a794ff9..59540cec796f 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -181,6 +181,9 @@ export interface SubscriptionStateResponse { tiers: SubscriptionTierOption[] portal_url: string | null error?: string | null + // Shared dollar usage model (two-bar view), embedded by the gateway so the + // overlay renders the same bars as /usage from this single fetch. + usage?: UsageModelData } export type CommandDispatchResponse = @@ -365,6 +368,34 @@ export interface SessionUsageResponse { model?: string output?: number total?: number + // Shared dollar usage model (two-bar view) so /usage renders the same bars + // as /subscription. Dollars only — never "credits". + usage?: UsageModelData +} + +/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */ +export interface UsageBarData { + kind: 'plan' | 'topup' + remaining_display: string + total_display: string + spent_display: string + pct_used: null | number + fill_fraction: number +} + +/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */ +export interface UsageModelData { + available: boolean + status?: string + plan_name?: null | string + renews_at?: null | string + renews_display?: null | string + subscription_remaining_display?: null | string + topup_remaining_display?: null | string + total_spendable_display?: null | string + has_topup?: boolean + plan_bar?: null | UsageBarData + topup_bar?: null | UsageBarData } export interface SessionStatusResponse { From 14295741ec5f65e6761051a8424cf31b8bac9a62 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 17:56:59 +0530 Subject: [PATCH 21/62] feat(cli): mirror dollar usage bars on /usage + /subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 190 +++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 124 insertions(+), 66 deletions(-) diff --git a/cli.py b/cli.py index 5222c757d797..603f52ca355f 100644 --- a/cli.py +++ b/cli.py @@ -8911,17 +8911,62 @@ def _show_usage(self): # installing a console StreamHandler in non-verbose mode. def _print_nous_credits_block(self) -> bool: - """Print the Nous credits magnitudes + monthly-grant gauge when a Nous account + """Print the Nous dollar balance block (two-bar view) when a Nous account is logged in. Returns True if it printed anything. - Delegates to the shared ``agent.account_usage.nous_credits_lines`` helper — - the single source for the /usage credits block across CLI, gateway, and TUI. - It's agent-independent (a portal fetch gated on "a Nous account is logged in", - NOT the inference-provider string), so /usage shows the block even in the TUI + Prefers the shared dollar usage model (``agent.billing_usage`` — two-bar + plan/top-up view, dollars-only, the /usage + /subscription source of + truth). Falls back to the legacy ``nous_credits_lines`` text only when the + model is unavailable. Agent-independent (a portal fetch gated on "a Nous + account is logged in"), so /usage shows the block even in the TUI slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and - wall-clock-bounded inside the helper; also honors HERMES_DEV_CREDITS_FIXTURE - for offline testing — same behavior as every other surface. + wall-clock-bounded; honors HERMES_DEV_CREDITS_FIXTURE for offline testing. """ + try: + from agent.billing_usage import build_usage_model, format_renews + + usage = build_usage_model() + except Exception: + usage = None + format_renews = None # type: ignore + + if usage is not None and usage.available and format_renews is not None: + printed_any = False + plan = usage.plan_name or ("Free" if usage.status == "free" else None) + renews_display = getattr(usage, "renews_display", None) or format_renews(usage.renews_at) + renews = f" · renews {renews_display}" if renews_display else "" + if plan: + print() + _cprint(f" {_b(f'Plan: {plan}{renews}')}") + printed_any = True + + pb = usage.plan_bar + if pb is not None and pb.total_usd > 0: + bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) + _pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else "" + _label = (usage.plan_name or "plan").ljust(8)[:8] + print(f" {_label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{_pct_s}") + printed_any = True + tb = usage.topup_bar + if tb is not None and tb.remaining_usd > 0: + print(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") + printed_any = True + if usage.has_topup and usage.total_spendable_usd is not None: + print(f" Total spendable: ${usage.total_spendable_usd:,.2f}") + + if usage.status == "free": + _cprint(f" {_d('> Free · free models only. Run /subscription to reach paid models.')}") + printed_any = True + elif usage.status == "low": + _amt = f"${usage.total_spendable_usd:,.2f}" if usage.total_spendable_usd is not None else "under $5" + _low = f"! Low balance · {_amt} left. Run /topup or /subscription." + _cprint(f" {_low}") + printed_any = True + + if printed_any: + return True + + # Fallback: legacy text lines (only when the model is unavailable). from agent.account_usage import nous_credits_lines lines = nous_credits_lines() @@ -8937,9 +8982,9 @@ def _print_usage_cta(self) -> None: Mirrors the TUI's ``USAGE_CTA`` (``session.ts``) so every surface ends a usage read with the same nudge. Only called when a Nous account is logged - in (the credits block printed), since both commands are Nous-account only. + in (the balance block printed), since both commands are Nous-account only. """ - _cprint(f" {_d('Run /subscription to change plan · /topup to add credits')}") + _cprint(f" {_d('Run /subscription to change plan · /topup to add to your balance')}") # ------------------------------------------------------------------ # /subscription — view plan + change it in the browser (CLI surface) @@ -8970,7 +9015,7 @@ def _show_subscription(self): print(" Run `hermes portal` to log in, then /subscription.") return - # Team context: no personal plan — teams run on shared credits. + # Team context: no personal plan — teams run on a shared balance. if state.context == "team": print() _cprint(f" ⚕ {_b('Team subscription')}") @@ -8980,50 +9025,79 @@ def _show_subscription(self): _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" _cprint(f" {_d(_org_line)}") org = state.org_name or "a team org" - print(f" This terminal is connected to {org}. Teams run on shared") - print(" credits — use /topup to add funds.") + print(f" This terminal is connected to {org}. Teams run on a shared") + print(" balance · use /topup to add funds.") _cprint(f" {_d('Personal subscriptions live on your personal account.')}") return self._subscription_overview(state, subscription_manage_url(state)) def _subscription_overview(self, state, manage_url): - """Print the plan read block + tier list, then the browser hand-off.""" - from agent.billing_view import format_money + """Print the plan read block, then the browser hand-off to manage it. + + Dollars-only (no "credits") — mirrors the TUI overlay: a status line, the + shared two-bar dollar usage view (plan + top-up) with the plan name on + the bar, and state-matched free/low nudges. No in-terminal tier picker — + the only action is managing the subscription on the portal. + """ + # Shared dollar usage model (the only source with top-up dollars). + from agent.billing_usage import format_renews + try: + from agent.billing_usage import build_usage_model + + usage = build_usage_model() + except Exception: + usage = None c = state.current is_free = not (c and c.tier_id) can_change = state.can_change_plan and state.is_admin - print() - if is_free: - _cprint(f" ⚕ {_b('Subscribe to a plan')}") + plan_name = (c.tier_name or c.tier_id) if c else (usage.plan_name if usage else None) + u_status = getattr(usage, "status", None) if usage else None + view_only = not can_change + renews_display = getattr(usage, "renews_display", None) if usage else None + if not renews_display and c and c.cycle_ends_at: + renews_display = format_renews(c.cycle_ends_at) + + # Status line — dollars-only, no duplicated "of $Y" (the bar carries that). + if not plan_name: + status = "Plan: Free · free models only" + elif usage is not None and u_status == "low" and usage.total_spendable_usd is not None: + _tot = f"${usage.total_spendable_usd:,.2f}" + status = f"Plan: {plan_name} · {_tot} left" else: - _cprint(f" ⚕ {_b(f'Your plan: {c.tier_name or c.tier_id}')}") + _spend = getattr(usage, "total_spendable_usd", None) if usage else None + _left = f" · ${_spend:,.2f} left" if _spend is not None else "" + _tail = " · view only" if view_only else (f" · renews {renews_display}" if renews_display else "") + status = f"Plan: {plan_name}{_left}{_tail}" + + print() + _cprint(f" ⚕ {_b(status)}") print(f" {'─' * 41}") - # Usage bar from the subscription allowance (monthly vs remaining). - if c and c.monthly_credits and c.credits_remaining: - try: - monthly = float(c.monthly_credits) - remaining = float(c.credits_remaining) - except (TypeError, ValueError): - monthly = remaining = 0.0 - if monthly > 0: - spent = max(0.0, monthly - remaining) - bar, pct = self._billing_spend_bar(spent, monthly) - # Credits are a COUNT, not money — grouped integers, no "$". - def _grp(v): - try: - return f"{int(float(v)):,}" - except (TypeError, ValueError): - return str(v) - print( - f" {_grp(c.credits_remaining)} of {_grp(c.monthly_credits)} " - f"remaining {bar} {100 - pct}% left" - ) - if c and c.cycle_ends_at: - print(f" Renews: {c.cycle_ends_at}") + # Two-bar dollar usage view — plan name labels the plan bar. + pb = getattr(usage, "plan_bar", None) if usage else None + if pb is not None and pb.total_usd > 0: + bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) + _pct = pb.pct_used + _pct_s = f" · {_pct}% used" if _pct is not None else "" + _label = (plan_name or "plan").ljust(8)[:8] + print(f" {_label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{_pct_s}") + tb = getattr(usage, "topup_bar", None) if usage else None + if tb is not None and tb.remaining_usd > 0: + full = "█" * 10 + print(f" {'top-up'.ljust(8)}[{full}] ${tb.remaining_usd:,.2f} · never expires") + if usage and getattr(usage, "has_topup", False) and getattr(usage, "total_spendable_usd", None) is not None: + print(f" Total spendable: ${usage.total_spendable_usd:,.2f}") + + # State-matched nudge (free upsell / low alert; healthy stays silent). + if is_free: + _cprint(f" {_d('> Paid models need a subscription. Start one to reach them.')}") + elif u_status == "low": + _amt = f"${usage.total_spendable_usd:,.2f}" if usage is not None and usage.total_spendable_usd is not None else "under $5" + _low = f"! Low balance · {_amt} left. Top up or upgrade before a mid-run cutoff." + _cprint(f" {_low}") if state.org_name: role = (state.role or "").title() @@ -9033,30 +9107,14 @@ def _grp(v): # Headline precedence: cancel-scheduled > downgrade-pending. if c and c.cancel_at_period_end: - if c.cancellation_effective_at: - _cprint(f" {_d(f'Cancels on {c.cancellation_effective_at} — your plan stays active until then.')}") + _eff = format_renews(c.cancellation_effective_at) if c.cancellation_effective_at else None + if _eff: + _cprint(f" {_d(f'Cancels on {_eff} — your plan stays active until then.')}") else: _cprint(f" {_d('Cancellation scheduled — your plan stays active until the end of the billing period.')}") elif c and c.pending_downgrade_tier_name: - when = c.pending_downgrade_at or "the end of the cycle" - _cprint(f" {_d(f'Scheduled to switch to {c.pending_downgrade_tier_name} on {when}.')}") - - # Tier catalog (enabled tiers, current marked). Read-only for members. - enabled = [t for t in state.tiers if t.is_enabled] - if enabled: - print() - for t in enabled: - mark = "✓ " if t.is_current else " " - price = format_money(t.dollars_per_month) if t.dollars_per_month is not None else "$0" - # Credits are a COUNT, not money — render grouped, no "$". - if t.monthly_credits is not None: - try: - credits = f"{int(t.monthly_credits):,}" - except (TypeError, ValueError): - credits = str(t.monthly_credits) - else: - credits = "0" - _cprint(f" {mark}{t.name} — {price}/mo ({credits} credits)") + _when = format_renews(c.pending_downgrade_at) or "the end of the cycle" + _cprint(f" {_d(f'Scheduled to switch to {c.pending_downgrade_tier_name} on {_when}.')}") if not can_change: print() @@ -9075,19 +9133,19 @@ def _grp(v): # URL is the affordance, same discipline as _show_credits. if not getattr(self, "_app", None): print() - print(f" Change plan: {manage_url}") - print(" Finish the change in your browser, then re-run /subscription.") + print(f" Manage your subscription: {manage_url}") + print(" Open it in your browser, then re-run /subscription.") return print() choices = [ - ("open", "Open subscription page", "change your plan in the browser"), + ("open", "Open subscription page", "manage your subscription in the browser"), ("copy", "Copy link", "copy the manage-subscription URL to your clipboard"), ("cancel", "Cancel", "do nothing"), ] raw = self._prompt_text_input_modal( - title="⚕ Change your plan?", - detail=f"Manage your subscription in your browser:\n{manage_url}", + title="⚕ Manage your subscription", + detail="Pick an option to manage your subscription in the browser.", choices=choices, ) choice = self._normalize_slash_confirm_choice(raw, choices) From cd0c6622730868dff56902e69e636aaaf001c24b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 19:51:12 +0530 Subject: [PATCH 22/62] 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. --- tui_gateway/server.py | 21 +++++++++++++++++++++ ui-tui/src/gatewayTypes.ts | 3 +++ 2 files changed, 24 insertions(+) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7ffcfb28bdf4..ebe5bd96f3cd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5581,9 +5581,30 @@ def _s(value): "auto_reload": auto_reload, "portal_url": state.portal_url, "error": state.error, + # Shared dollar usage model (two-bar view) embedded so /topup renders the + # same plan + top-up bars as /usage and /subscription from its single + # fetch. Built from the separate account-info path; fail-open when logged + # out or the portal is down. + "usage": _billing_usage_payload(state), } +def _billing_usage_payload(state) -> dict: + """Best-effort shared usage model for the /topup overview's bars. + + Only fetched when logged in; fail-open to {available:false} so the overview + still renders if the account-info path is down. + """ + if not getattr(state, "logged_in", False): + return {"available": False} + try: + from agent.billing_usage import build_usage_model + + return _serialize_usage_model(build_usage_model()) + except Exception: + return {"available": False} + + @method("billing.state") def _(rid, params: dict) -> dict: """GET /api/billing/state → serialized BillingState (Screen 1 + 5). diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 59540cec796f..1915e147f573 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -96,6 +96,9 @@ export interface BillingStateResponse { org_name: string | null portal_url: string | null role: string | null + // Shared dollar usage model (two-bar view), embedded by the gateway so /topup + // renders the same bars as /usage and /subscription from this single fetch. + usage?: UsageModelData } /** From a15e7b7789467eba8b2f2049b8762b5ff9841828 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 19:51:21 +0530 Subject: [PATCH 23/62] feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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"). --- ui-tui/src/__tests__/billingStepUp.test.tsx | 43 ++++- ui-tui/src/__tests__/topupCommand.test.ts | 6 +- ui-tui/src/app/slash/commands/topup.ts | 27 ++-- ui-tui/src/components/billingOverlay.tsx | 164 +++++++++++++------- 4 files changed, 162 insertions(+), 78 deletions(-) diff --git a/ui-tui/src/__tests__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx index 3dee2fc00e29..85ee365ab89d 100644 --- a/ui-tui/src/__tests__/billingStepUp.test.tsx +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -91,11 +91,11 @@ const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => state: billState() }) -describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => { - it('renders the Allow Remote Spending prompt with the held amount', () => { +describe('BillingOverlay — step-up screen (Enable terminal billing)', () => { + it('renders the one-time-setup prompt with the held amount', () => { const out = render(overlay('stepup')) - expect(out).toContain('Allow Remote Spending') - expect(out).toContain('one-time browser authorization') + expect(out).toContain('One-time setup') + expect(out).toContain('Enable terminal billing') expect(out).toContain('$100') // resumes the held purchase expect(out).toContain('Not now') }) @@ -105,3 +105,38 @@ describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => { expect(out).not.toContain('billing:manage') }) }) + +describe('BillingOverlay — overview (reordered, dollars)', () => { + it('leads with balance in the title, Add funds first, no "credits"', () => { + const out = render(overlay('overview')) + expect(out).toContain('Top up · balance $12.00') // balance in the title + expect(out).toContain('Add funds') // buy action, renamed + expect(out).toContain('Auto-reload') + expect(out).toContain('Manage on portal') + expect(out.toLowerCase()).not.toContain('credits') // dollars only + // No standalone "Enable terminal billing" item — discovered at pay time. + expect(out).not.toContain('Enable terminal billing') + }) + + it('renders the two-bar dollar usage when a usage model is present', () => { + const withUsage: BillingOverlayState = { + ...overlay('overview'), + state: { + ...billState(), + usage: { + available: true, + status: 'healthy', + plan_name: 'Plus', + has_topup: true, + plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 }, + topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 } + } + } + } + + const out = render(withUsage) + expect(out).toContain('$14.00 left of $20.00') + expect(out).toContain('30% used') + expect(out).toContain('never expires') + }) +}) diff --git a/ui-tui/src/__tests__/topupCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts index 3d32530c6f66..aae340f10cb1 100644 --- a/ui-tui/src/__tests__/topupCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -302,7 +302,7 @@ describe('/billing slash command (overlay-driven)', () => { await Promise.resolve() await Promise.resolve() const out = printed(sys) - expect(out).toContain('An admin stopped this terminal') + expect(out).toContain('An admin turned off terminal billing for this terminal') expect(out).toContain('Reconnect to restore') // Spend UI is killed immediately — no zombie button waiting for refresh. expect(getOverlayState().billing).toBeNull() @@ -318,7 +318,7 @@ describe('/billing slash command (overlay-driven)', () => { getOverlayState().billing!.ctx.charge('100') await Promise.resolve() await Promise.resolve() - expect(printed(sys)).toContain('You stopped this terminal') + expect(printed(sys)).toContain('You turned off terminal billing for this terminal') expect(getOverlayState().billing).toBeNull() }) @@ -354,7 +354,7 @@ describe('/billing slash command (overlay-driven)', () => { await Promise.resolve() await Promise.resolve() const out = printed(sys) - expect(out).toContain('Remote Spending is off for this account') + expect(out).toContain('Terminal billing is off for this account') // Account-wide switch is NOT a per-terminal revoke — overlay stays open. expect(getOverlayState().billing).toBeTruthy() }) diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 508d7cc49462..39dcdd197905 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -35,21 +35,22 @@ const renderBillingError = ( switch (env.error) { case 'insufficient_scope': - // Reached by non-charge mutations (e.g. auto-reload config) that need the - // Remote-Spending grant. The resumable step-up lives on the buy/charge + // Reached by non-charge mutations (e.g. auto-reload config) that need + // terminal billing enabled. The resumable step-up lives on the buy/charge // path; point the user there rather than leaking the raw scope name. - sys('💳 This needs Remote Spending authorization. Start a credit purchase to authorize, then retry.') + sys('This needs terminal billing enabled. Start a top-up to enable it, then retry.') break - case 'remote_spending_revoked': { // CF-4: this terminal's spend was revoked. Kill the spend UI NOW (don't // wait for the token refresh ~15 min away) and tell the user who did it. patchOverlayState({ billing: null }) + const who = env.actor === 'admin' - ? 'An admin stopped this terminal’s spending.' - : 'You stopped this terminal’s spending.' - sys(`🔴 ${who} Reconnect to restore — run /portal to re-authorize this terminal.`) + ? 'An admin turned off terminal billing for this terminal.' + : 'You turned off terminal billing for this terminal.' + + sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`) return } @@ -57,20 +58,21 @@ const renderBillingError = ( case 'session_revoked': // Stronger than a spend-revoke: the whole session is gone → full re-login. patchOverlayState({ billing: null }) - sys('🔴 Your session was logged out. Run /portal to log in again.') + sys('Your session was logged out. Run /portal to log in again.') return case 'cli_billing_disabled': + case 'remote_spending_disabled': // Account-wide switch is OFF (dual-emitted error/code). An admin must flip // it on the portal; this is NOT a per-terminal revoke. - sys('🔴 Remote Spending is off for this account — an admin must enable it on the portal.') + sys('Terminal billing is off for this account — an admin must enable it on the portal.') break case 'role_required': - sys('🔴 Buying credits needs an org admin/owner. Ask an admin, or manage on the portal.') + sys('Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.') break @@ -86,7 +88,6 @@ const renderBillingError = ( ) break - case 'monthly_cap_exceeded': { // Surface the remaining headroom the server attaches (parity with the CLI). const remaining = env.payload?.remainingUsd @@ -94,6 +95,7 @@ const renderBillingError = ( break } + case 'rate_limited': case 'temporarily_unavailable': { // 429 throttle OR 503 gate-fail-closed: NOT a payment failure, NOT a @@ -186,6 +188,7 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + 'Check /billing or the portal shortly.' ) + if (portalUrl) { sys(`Portal: ${portalUrl}`) } @@ -326,7 +329,7 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B export const topupCommands: SlashCommand[] = [ { - help: 'Top up Nous credits — buy credits, auto-reload, limits', + help: 'Top up your balance — add funds, auto-reload, limits', name: 'topup', // ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/billing` // fetches state and opens the interactive overlay (CLI/TUI parity). diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 167022169333..dc6a295f1116 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -5,7 +5,7 @@ import type { BillingOverlayState } from '../app/interfaces.js' import type { BillingStateResponse } from '../gatewayTypes.js' import type { Theme } from '../theme.js' -import { ActionRow, barCells, footer, MenuRow } from './overlayPrimitives.js' +import { ActionRow, footer, MenuRow, UsageBars } from './overlayPrimitives.js' import { TextInput } from './textInput.js' interface BillingOverlayProps { @@ -17,27 +17,6 @@ interface BillingOverlayProps { t: Theme } -/** 10-cell spend bar + percent (omit entirely when there's no usable cap). */ -function spendBar(s: BillingStateResponse): null | string { - const cap = s.monthly_cap - - if (!cap || cap.limit_usd == null) { - return null - } - - const limit = Number(cap.limit_usd) - const spent = Number(cap.spent_this_month_usd ?? '0') - - if (!(limit > 0) || Number.isNaN(spent)) { - return null - } - - const { bar, pct } = barCells(spent / limit) - const ceiling = cap.is_default_ceiling ? ' (default ceiling)' : '' - - return `${cap.spent_display} of ${cap.limit_display} used ${bar} ${pct}%${ceiling}` -} - function autoReloadLine(s: BillingStateResponse): null | string { if (!s.auto_reload) { return null @@ -98,8 +77,12 @@ interface ScreenProps { } function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { - // Gate: full menu only for an admin with the kill-switch on. Otherwise the - // menu collapses to Manage-on-portal / Cancel + a one-line note. + // Full charge menu only for an admin with the org kill-switch on; otherwise it + // collapses to Manage-on-portal / Close + a one-line note. NOTE: this is the + // ORG-level gate (cli_billing_enabled), NOT the per-terminal billing scope — + // that's discovered reactively at pay time (a charge 403s insufficient_scope + // and the confirm screen routes into the resumable step-up). We deliberately + // do NOT preflight the scope here. const full = s.is_admin && s.cli_billing_enabled const note = !s.is_admin @@ -109,12 +92,14 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { : null // Optimistic funnel: admin + kill-switch on but no saved card → a charge will - // 403 no_payment_method. Advise up front (Buy stays available — /state.card - // can't fully prove CLI-chargeability, so we hint rather than hide). + // 403 no_payment_method. Advise up front (Add funds stays available — the card + // field can't fully prove chargeability, so we hint rather than hide). const cardHint = full && !s.card ? 'No saved card for terminal charges yet — set one up on the portal first.' : null + // Add funds first, then settings, then the scopeless browser handoff. Dollars, + // never "credits". No "Enable terminal billing" item — see the note above. const items = full - ? ['Buy credits', 'Adjust auto-reload', 'Adjust monthly limit', 'Manage on portal', 'Cancel'] + ? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel'] : ['Manage on portal', 'Cancel'] const [sel, setSel] = useState(0) @@ -169,23 +154,25 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { } }) - const bar = spendBar(s) const auto = autoReloadLine(s) + // Balance leads, in the title — the first thing seen (review feedback). + const title = `Top up · balance ${s.balance_display}` return ( - Top up credits + {title} - {bar && {bar}} - Balance: {s.balance_display} - {auto && {auto}} {s.org_name && ( Org: {s.org_name} {s.role ? ` · ${s.role}` : ''} )} + {/* The shared two-bar dollar usage (plan + top-up), same as /usage and + /subscription. Renders nothing when no usage model is available. */} + + {auto && {auto}} {note && ( {note} @@ -298,7 +285,7 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { return ( - Buy usage credits + Add funds {payLine} @@ -317,7 +304,7 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { return ( - Buy usage credits + Add funds {payLine} @@ -424,11 +411,14 @@ function ConfirmScreen({ ) } -// ── Screen: Step-up (resumable "Allow Remote Spending") ─────────────── -// Reached when a charge returns insufficient_scope. The modal stays MOUNTED -// through the browser device-flow: Allow → await the grant → replay the held -// charge (pendingCharge.amount) without a command re-run. Never leaks the raw -// billing:manage scope — the user-facing concept is "Remote Spending". +// ── Screen: Step-up (resumable "Enable terminal billing") ──────────── +// Reached ONLY when a charge returns insufficient_scope — there is no preflight +// or scope check anywhere; the buy path discovers it reactively. The modal stays +// MOUNTED through the browser device-flow: +// prompt (heads-up) → waiting (browser authorize) → granted (press Enter to +// resume) → replay the held charge (pendingCharge.amount) → settle → close. +// Never leaks the raw billing:manage scope — the user-facing concept is +// "terminal billing". function StepUpScreen({ amount, @@ -442,36 +432,48 @@ function StepUpScreen({ t: Theme }) { const [sel, setSel] = useState(0) - const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt') + const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt') const allow = () => { - if (phase === 'waiting') { + if (phase !== 'prompt') { return } setPhase('waiting') - ctx.sys('Opening your browser to authorize Remote Spending…') + ctx.sys('Opening your browser to enable terminal billing…') void ctx.requestRemoteSpending().then(granted => { if (!granted) { - ctx.sys('🟡 Remote Spending was not granted (an org admin/owner must approve). Run /topup to try again.') + ctx.sys("! Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.") onClose() return } - // Granted → resume the held charge. Replay it; the confirm/poll lines - // report settlement, then close. - ctx.sys('✅ Remote Spending enabled — resuming your purchase.') - void ctx.charge(amount).then(() => onClose()) + // Granted → hold here and wait for an explicit Enter to resume the held + // purchase (the reassuring "you're back, press Enter" beat). + setPhase('granted') }) } - const decline = () => onClose() + const resume = () => { + if (phase !== 'granted') { + return + } + + setPhase('resuming') + ctx.sys('✓ Terminal billing enabled — resuming your purchase.') + void ctx.charge(amount).then(() => onClose()) + } + + const decline = () => { + ctx.sys('No charge made. Run /topup when you want to enable terminal billing.') + onClose() + } useInput((ch, key) => { - if (phase === 'waiting') { - // While the device flow runs, only Esc (give up) is live. + if (phase === 'waiting' || phase === 'resuming') { + // While the device flow / replay runs, only Esc (give up) is live. if (key.escape) { onClose() } @@ -479,6 +481,20 @@ function StepUpScreen({ return } + if (phase === 'granted') { + // Back from the browser — Enter resumes, Esc abandons. + if (key.escape) { + return onClose() + } + + if (key.return) { + return resume() + } + + return + } + + // phase === 'prompt' if (key.escape) { return decline() } @@ -510,25 +526,55 @@ function StepUpScreen({ return ( - Allow Remote Spending + Enable terminal billing + + Waiting for your browser… + Approve in the page that just opened. + Your ${amount} top-up is held here and resumes when you're done. + + {footer('Esc cancel', t)} + + ) + } + + if (phase === 'granted') { + return ( + + + Terminal billing enabled + + Your ${amount} top-up is ready to finish. + + + + {footer('Enter resume · Esc cancel', t)} + + ) + } + + if (phase === 'resuming') { + return ( + + + Enable terminal billing - Waiting for browser authorization… - Approve in the page that just opened — your purchase resumes here automatically. + Resuming your ${amount} top-up… {footer('Esc cancel', t)} ) } + // phase === 'prompt' — the one heads-up, triggered only by the 403. return ( - - Allow Remote Spending + + One-time setup - Charging from the terminal needs a one-time browser authorization. - We'll resume your ${amount} purchase right here once it's granted. + To charge this terminal, enable terminal billing once. + It opens your browser to authorize, then your ${amount} top-up picks up right here. - + {footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)} @@ -701,7 +747,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { Auto-reload - Automatically buy more credits when your balance is low. + Automatically add funds when your balance is low. {cardLine} {fieldBox('When balance falls below:', threshold, setThreshold, row === 0, 'threshold')} From 70b9a72687d48a8cb2f49d34db2f6a125bd03336 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 24 Jun 2026 19:51:29 +0530 Subject: [PATCH 24/62] feat(cli/topup): mirror overview reorder + in-flight reauth resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 198 ++++++++++++++++++--------- tests/hermes_cli/test_billing_cli.py | 14 +- 2 files changed, 136 insertions(+), 76 deletions(-) diff --git a/cli.py b/cli.py index 603f52ca355f..77085b9a457a 100644 --- a/cli.py +++ b/cli.py @@ -9295,24 +9295,41 @@ def _billing_portal_hint(self, state, *, reason: str = "") -> None: print(f" Manage on portal: {url}") def _billing_overview(self, state): - """Screen 1 — overview: balance, spend bar, role-gated action menu.""" + """Screen 1 — overview: balance in title, two-bar dollar usage, action menu. + + Dollars-only (no "credits") — mirrors the TUI /topup overlay: balance + leads in the title, the shared plan + top-up bars render below, then the + reordered menu (Add funds first). No scope preflight — terminal billing + is discovered reactively when a charge 403s insufficient_scope. + """ from agent.billing_view import format_money + # Shared dollar usage model (plan + top-up bars), same source as /usage. + try: + from agent.billing_usage import build_usage_model + + usage = build_usage_model() + except Exception: + usage = None + print() - _cprint(f" 💳 {_b('Usage credits')}") + _cprint(f" 💳 {_b(f'Top up · balance {format_money(state.balance_usd)}')}") + if state.org_name: + role = (state.role or "").title() + _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" + _cprint(f" {_d(_org_line)}") print(f" {'─' * 41}") - cap = state.monthly_cap - if cap is not None and cap.limit_usd is not None: - spent = format_money(cap.spent_this_month_usd) - limit = format_money(cap.limit_usd) - ceiling = " (default ceiling)" if cap.is_default_ceiling else "" - bar, pct = self._billing_spend_bar( - cap.spent_this_month_usd, cap.limit_usd - ) - print(f" {spent} of {limit} used{ceiling} {bar} {pct}%") - - print(f" Balance: {format_money(state.balance_usd)}") + # Two-bar dollar usage view (plan name on the plan bar; top-up below). + pb = getattr(usage, "plan_bar", None) if usage else None + if pb is not None and pb.total_usd > 0: + bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) + _pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else "" + _label = (getattr(usage, "plan_name", None) or "plan").ljust(8)[:8] + print(f" {_label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{_pct_s}") + tb = getattr(usage, "topup_bar", None) if usage else None + if tb is not None and tb.remaining_usd > 0: + print(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") ar = state.auto_reload if ar is not None: @@ -9323,11 +9340,6 @@ def _billing_overview(self, state): ) else: print(" Auto-reload: off") - - if state.org_name: - role = (state.role or "").title() - _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" - _cprint(f" {_d(_org_line)}") print(f" {'─' * 41}") # Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal. @@ -9337,12 +9349,12 @@ def _billing_overview(self, state): return if not state.cli_billing_enabled: _cprint(f" {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal to buy credits here.") + self._billing_portal_hint(state, reason="Enable it on the portal to add funds here.") return # Optimistic funnel: no card on file → a charge will 403 no_payment_method. - # Surface that up front (with the portal link) but DON'T hide Buy — /state.card - # can't fully prove CLI-chargeability, so we advise rather than gate. + # Surface that up front (with the portal link) but DON'T hide Add funds — + # the card field can't fully prove chargeability, so we advise not gate. if state.card is None: _cprint( f" {_d('No saved card for terminal charges yet — set one up on the portal first.')}" @@ -9355,17 +9367,19 @@ def _billing_overview(self, state): self._billing_portal_hint(state) return + # Add funds first, then settings, then the scopeless browser handoff. + # No "Enable terminal billing" item — that's discovered at pay time. choices = [ - ("buy", "Buy credits", "purchase a one-time credit top-up"), - ("auto", "Adjust auto-reload", "configure automatic top-ups"), - ("limit", "Adjust monthly limit", "show the monthly spend cap (read-only)"), + ("buy", "Add funds", "add money to your balance"), + ("auto", "Auto-reload", "configure automatic top-ups"), + ("limit", "Monthly limit", "show the monthly spend cap (read-only)"), ("portal", "Manage on portal", "open the billing page in your browser"), ("cancel", "Cancel", "do nothing"), ] # The overview summary is already printed above; the modal only needs to # present the action menu — repeating the title/balance reads as a dupe. raw = self._prompt_text_input_modal( - title="💳 Choose an action", detail="", + title="💳 Top up your balance", detail="", choices=choices, ) choice = self._normalize_slash_confirm_choice(raw, choices) @@ -9378,7 +9392,7 @@ def _billing_overview(self, state): elif choice == "portal": self._billing_open_portal(state) else: - print(" 🟡 Cancelled.") + print(" Cancelled.") def _billing_spend_bar(self, spent, limit, *, cells: int = 10): """Render a 10-cell `█`/`░` spend bar + integer percent from spent/limit. @@ -9445,7 +9459,7 @@ def _billing_buy_flow(self, state): if not getattr(self, "_app", None): presets = ", ".join(format_money(p) for p in state.charge_presets) print() - _cprint(f" 💳 {_b('Buy usage credits')}") + _cprint(f" 💳 {_b('Add funds')}") print(f" Presets: {presets}") print(" Run this in the interactive CLI to complete a purchase.") self._billing_portal_hint(state) @@ -9460,11 +9474,11 @@ def _billing_buy_flow(self, state): card = state.card detail = f"Payment: {card.masked}" if card else "No saved card on file" raw = self._prompt_text_input_modal( - title="💳 Buy usage credits", detail=detail, choices=preset_choices, + title="💳 Add funds", detail=detail, choices=preset_choices, ) choice = self._normalize_slash_confirm_choice(raw, preset_choices) if not choice or choice == "cancel": - print(" 🟡 Cancelled. No credits added.") + print(" Cancelled. No funds added.") return from decimal import Decimal @@ -9473,7 +9487,7 @@ def _billing_buy_flow(self, state): entered = self._prompt_text_input(" Amount (USD): ") if entered is None: # None = cancelled (e.g. slash-worker can't prompt off-thread). - print(" 🟡 Cancelled. No credits added.") + print(" Cancelled. No funds added.") return v = validate_charge_amount( entered or "", min_usd=state.min_usd, max_usd=state.max_usd @@ -9522,7 +9536,7 @@ def _billing_confirm_and_charge(self, state, amount): ) choice = self._normalize_slash_confirm_choice(raw, confirm_choices) if choice != "pay": - print(" 🟡 Cancelled. No credits added.") + print(" Cancelled. No funds added.") return # Submit the charge with a fresh idempotency key (reused on retry). @@ -9536,7 +9550,9 @@ def _billing_confirm_and_charge(self, state, amount): try: result = post_charge(amount_usd=amount, idempotency_key=key) except BillingScopeRequired: - self._billing_handle_scope_required(state) + # In-flight reauth: enable terminal billing, then resume THIS charge + # (press-Enter beat) — no command re-run. Reuses the same idem key. + self._billing_handle_scope_required(state, amount=amount, idempotency_key=key) return except BillingError as exc: self._billing_render_charge_error(state, exc) @@ -9580,7 +9596,7 @@ def _billing_poll_charge(self, state, charge_id, amount): from agent.billing_view import parse_money shown = format_money(parse_money(amt)) if amt else format_money(amount) - print(f" ✅ {shown} in credits added.") + print(f" ✓ {shown} added to your balance.") return if state_str == "failed": self._billing_render_charge_failed(state, status.get("reason")) @@ -9631,9 +9647,9 @@ def _billing_render_charge_error(self, state, exc): "portal (one-time credit buys don't save a reusable card).") elif code in ("cli_billing_disabled", "remote_spending_disabled") or \ getattr(exc, "code", None) == "remote_spending_disabled": - print(" 🔴 Remote Spending is off for this account — an admin must enable it on the portal.") + print(" Terminal billing is off for this account — an admin must enable it on the portal.") elif code == "role_required": - print(" 🔴 Buying credits needs an org admin/owner. Ask an admin, or manage on the portal.") + print(" Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.") elif code == "idempotency_conflict": print(" 🔴 That charge key was already used for a different amount. Start a fresh top-up.") elif code == "monthly_cap_exceeded": @@ -9651,55 +9667,101 @@ def _billing_render_charge_error(self, state, exc): if portal_url: print(f" Portal: {portal_url}") - def _billing_handle_scope_required(self, state): - """403 insufficient_scope → lazy step-up re-auth (plan D-A).""" + def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key=None): + """403 insufficient_scope → in-flight reauth, then resume the held charge. + + The buy path discovers terminal billing isn't enabled only when the + charge 403s — there is no preflight. We enable it in-flight ("Enable + terminal billing" → browser device-flow), then on return ask the user to + press Enter to resume the held ``amount`` (reusing ``idempotency_key`` so + the resumed charge collapses with the original). Never leaks the raw + billing:manage scope. + """ + from agent.billing_view import format_money + + amount_str = format_money(amount) if amount is not None else "your top-up" print() - print(" 💳 Terminal billing needs an extra permission (billing:manage).") - _scope_msg = ( - "An org admin/owner must tick \"Allow terminal billing\" during " - "login." - ) - _cprint(f" {_d(_scope_msg)}") + print(" ! One-time setup") + _cprint(f" {_d(f'To charge this terminal, enable terminal billing once. It opens your browser to authorize, then {amount_str} picks up right here.')}") if not getattr(self, "_app", None): - print(" Run `hermes portal` and approve terminal billing, then retry.") + print(" Run `hermes portal` and enable terminal billing, then retry.") return confirm_choices = [ - ("yes", "Re-authorize now", "open the portal to grant billing access"), + ("yes", "Enable terminal billing", "open your browser to authorize"), ("no", "Not now", "cancel"), ] raw = self._prompt_text_input_modal( - title="💳 Grant terminal billing access?", - detail="Opens the portal device-authorization page.", + title="💳 Enable terminal billing", + detail="Opens your browser to authorize this terminal.", choices=confirm_choices, ) choice = self._normalize_slash_confirm_choice(raw, confirm_choices) if choice != "yes": - print(" 🟡 Cancelled.") + print(" No charge made. Run /topup when you want to enable terminal billing.") return + print(" Opening your browser to enable terminal billing…") try: from hermes_cli.auth import step_up_nous_billing_scope granted = step_up_nous_billing_scope(open_browser=True) except Exception as exc: - print(f" 🔴 Re-authorization failed: {exc}") - return - if granted: - print(" ✅ Billing permission granted.") - # Step-up only grants the billing:manage TOKEN scope; the ORG - # kill-switch (cli_billing_enabled) is a separate gate. Re-fetch - # /state so we don't over-promise when a charge would still hit - # cli_billing_disabled. - from agent.billing_view import build_billing_state - - fresh = build_billing_state() - if fresh.logged_in and fresh.cli_billing_enabled: - print(" Run /billing buy again to continue.") - else: - print(" 🟡 Permission granted, but terminal billing is still turned " - "off for this org. Enable it in the portal, then run /billing again.") - self._billing_portal_hint(fresh) - else: - print(" 🟡 Terminal billing was not granted (an admin must tick the box).") + print(f" Couldn't enable terminal billing: {exc}") + return + if not granted: + print(" Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.") + return + + # Granted. The token now carries the scope, but the ORG kill-switch + # (cli_billing_enabled) is a separate gate — re-fetch /state so we don't + # over-promise when a charge would still hit cli_billing_disabled. + from agent.billing_view import build_billing_state + + fresh = build_billing_state() + if not (fresh.logged_in and fresh.cli_billing_enabled): + print(" Terminal billing was enabled for this terminal, but it's still turned off for this org. Enable it in the portal, then run /topup again.") + self._billing_portal_hint(fresh) + return + + # Nothing to resume (scope-required hit outside a charge, e.g. auto-reload + # config) → just tell the user it's ready. + if amount is None: + print(" ✓ Terminal billing enabled. Run /topup to continue.") + return + + # Press-Enter beat: the user is back from the browser; resume the held + # purchase on an explicit confirm (reassuring, not silent). + print(" ✓ Terminal billing enabled.") + resume_choices = [ + ("resume", f"Resume {format_money(amount)} top-up", "finish the held purchase"), + ("cancel", "Cancel", "do not charge"), + ] + raw = self._prompt_text_input_modal( + title="💳 Resume your top-up", + detail=f"{format_money(amount)} is ready to finish — press Enter to resume.", + choices=resume_choices, + ) + if self._normalize_slash_confirm_choice(raw, resume_choices) != "resume": + print(" Cancelled. No funds added.") + return + + # Replay the held charge, reusing the original idempotency key so a + # double-submit collapses to one charge. + from hermes_cli.nous_billing import BillingError, post_charge + + from agent.billing_view import new_idempotency_key + + key = idempotency_key or new_idempotency_key() + try: + result = post_charge(amount_usd=amount, idempotency_key=key) + except BillingError as exc: + self._billing_render_charge_error(fresh, exc) + return + charge_id = result.get("chargeId") + if not charge_id: + print(" No charge id returned; please check the portal.") + return + _cprint(f" {_d('Resuming your top-up — confirming settlement…')}") + self._billing_poll_charge(fresh, charge_id, amount) def _billing_auto_reload_flow(self, state): """Screen 4 — auto-reload config: threshold + reload-to → PATCH. @@ -9720,7 +9782,7 @@ def _billing_auto_reload_flow(self, state): print() _cprint(f" 💳 {_b('Auto-reload')}") print(f" {'─' * 41}") - _cprint(f" {_d('Automatically buy more credits when your balance is low.')}") + _cprint(f" {_d('Automatically add funds when your balance is low.')}") if card: print(f" Card on file: {card.masked}") else: diff --git a/tests/hermes_cli/test_billing_cli.py b/tests/hermes_cli/test_billing_cli.py index 31c645760c5d..325bc0c7e8a5 100644 --- a/tests/hermes_cli/test_billing_cli.py +++ b/tests/hermes_cli/test_billing_cli.py @@ -52,11 +52,9 @@ def test_billing_overview_non_interactive_renders_text_not_modal(cli, monkeypatc monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) cli._show_billing("/billing") out = capsys.readouterr().out - assert "Usage credits" in out - assert "$142.50" in out - assert "$180 of $1000 used (default ceiling)" in out - # New design: a spend bar with a percentage on the overview. - assert "%" in out and ("█" in out or "░" in out) + # Balance now leads in the title; dollars, never "credits". + assert "Top up · balance $142.50" in out + assert "credits" not in out.lower() # ZERO sub-commands: no /billing buy|auto-reload|limit advertising. assert "/billing buy" not in out assert "Actions:" not in out @@ -115,8 +113,8 @@ def test_billing_sub_arg_ignored_opens_overview(cli, monkeypatch, capsys): monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) cli._show_billing("/billing buy") # arg is ignored out = capsys.readouterr().out - assert "Usage credits" in out # overview, NOT the buy screen - assert "Buy usage credits" not in out + assert "Top up · balance" in out # overview, NOT the buy screen + assert "Add funds" not in out # the buy screen header isn't shown def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys): @@ -131,6 +129,6 @@ def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys): # Reached via the menu in real use; non-interactively it defers to the portal. cli._billing_buy_flow(state) out = capsys.readouterr().out - assert "Buy usage credits" in out + assert "Add funds" in out assert "$25" in out and "$50" in out and "$100" in out assert "interactive CLI" in out # defers; no charge attempted non-interactively From 35de78c1fa3c60cf455194a2f2a1acb79cb1ea60 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 12:30:45 +0530 Subject: [PATCH 25/62] fix(billing): guard non-JSON 2xx responses in the billing HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/nous_billing.py | 18 +++++- tests/hermes_cli/test_nous_billing_request.py | 64 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_nous_billing_request.py diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index fbab184622a9..e99c959b6d6d 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -360,7 +360,23 @@ def _request( try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8") - return json.loads(raw) if raw.strip() else {} + if not raw.strip(): + return {} + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + # A 2xx with a non-JSON body means the endpoint isn't actually + # serving the billing API here — e.g. a reverse-proxy / SPA + # fallback HTML page when the route isn't deployed on this + # deployment. Surface it as a typed, non-auth error so callers + # degrade gracefully ("unavailable") instead of crashing with a + # raw JSONDecodeError that reads as "not logged in". + raise BillingError( + "Billing endpoint returned a non-JSON response " + "(it may not be available on this deployment).", + error="endpoint_unavailable", + status=getattr(resp, "status", None), + ) from exc except urllib.error.HTTPError as exc: # A 401 on a cached token → drop the cache and retry once with a fresh # (refresh-aware) resolve before surfacing the auth error. diff --git a/tests/hermes_cli/test_nous_billing_request.py b/tests/hermes_cli/test_nous_billing_request.py new file mode 100644 index 000000000000..f89257d3b01c --- /dev/null +++ b/tests/hermes_cli/test_nous_billing_request.py @@ -0,0 +1,64 @@ +"""Tests for the hermes_cli.nous_billing HTTP client's response handling. + +Focus: a 2xx response with a NON-JSON body (e.g. a reverse-proxy / SPA fallback +HTML page when a route isn't actually serving the billing API) must surface as a +typed BillingError, NOT a raw json.JSONDecodeError that escapes the typed-error +contract and reads downstream as "not logged in". +""" + +from __future__ import annotations + +import io +import json +from contextlib import contextmanager + +import pytest + +from hermes_cli import nous_billing as nb + + +class _FakeResp(io.BytesIO): + """Minimal urlopen() context-manager stand-in with a .status attribute.""" + + def __init__(self, body: bytes, status: int = 200): + super().__init__(body) + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *a): + self.close() + + +@contextmanager +def _stub(monkeypatch, body: bytes, status: int = 200): + # Bypass auth/token resolution entirely — we only exercise response parsing. + monkeypatch.setattr(nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example")) + monkeypatch.setattr(nb, "_token_cache", None, raising=False) + monkeypatch.setattr(nb.urllib.request, "urlopen", lambda req, timeout=None: _FakeResp(body, status)) + yield + + +def test_non_json_2xx_body_raises_typed_billing_error(monkeypatch): + # A 200 that returns an HTML page (route not actually mounted) must NOT crash + # with json.JSONDecodeError — it becomes a typed, non-auth BillingError. + html = b"Not Found" + with _stub(monkeypatch, html, status=200): + with pytest.raises(nb.BillingError) as ei: + nb.get_subscription_state() + exc = ei.value + # Not the auth subclass — this is "endpoint unavailable", not "logged out". + assert not isinstance(exc, nb.BillingAuthError) + assert getattr(exc, "error", None) == "endpoint_unavailable" + + +def test_empty_2xx_body_returns_empty_dict(monkeypatch): + with _stub(monkeypatch, b"", status=200): + assert nb.get_billing_state() == {} + + +def test_valid_json_2xx_body_parses(monkeypatch): + payload = {"org": {"name": "Acme"}, "balanceUsd": "10"} + with _stub(monkeypatch, json.dumps(payload).encode(), status=200): + assert nb.get_billing_state() == payload From 9eee09fede7d7f8bbb9c648fb63bf99418771201 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 17:13:08 +0530 Subject: [PATCH 26/62] feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/billing_view.py | 69 ++++++++++++++++++++++++++++++++ tests/agent/test_billing_view.py | 47 ++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/agent/billing_view.py b/agent/billing_view.py index ef97c8d0d645..a1146935c0fa 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -15,6 +15,7 @@ from __future__ import annotations import logging +import os import uuid from dataclasses import dataclass, field from decimal import Decimal, InvalidOperation @@ -202,7 +203,15 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState: Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the surface can show a clear message rather than crashing. + + Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the + card-on-file / admin / scope states are testable offline (mirrors + ``HERMES_DEV_CREDITS_FIXTURE`` for the usage model). """ + fixture = _dev_fixture_billing_state() + if fixture is not None: + return fixture + try: from hermes_cli.nous_billing import ( BillingAuthError, @@ -243,6 +252,66 @@ def _fallback_portal_url(base: str) -> str: return f"{base.rstrip('/')}/billing?topup=open" +# ============================================================================= +# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) +# ============================================================================= + + +def _dev_fixture_billing_state() -> Optional[BillingState]: + """Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX. + + Recognized names:: + + nocard logged in · billing on · admin · NO card on file + card card on file · auto-reload off + card-autoreload card on file · auto-reload on + notadmin logged in · MEMBER role (billing actions disabled) + billing-off logged in · admin · per-org kill-switch OFF + logged-out not logged in + + Returns ``None`` when the env var is unset (the real portal path runs). + Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from + ``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state). + """ + name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower() + if not name: + return None + + portal = "https://portal.staging-nousresearch.com/billing?topup=open" + common: dict[str, Any] = dict( + org_id="org_acme", + org_slug="acme", + org_name="Acme Inc", + role="OWNER", + balance_usd=Decimal("3.40"), + cli_billing_enabled=True, + charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")), + min_usd=Decimal("5"), + max_usd=Decimal("500"), + portal_url=portal, + ) + card = CardInfo(brand="Visa", last4="4242") + autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25")) + + if name in ("logged-out", "logged_out", "loggedout"): + return BillingState(logged_in=False) + if name == "nocard": + return BillingState(logged_in=True, card=None, **common) + if name == "card": + return BillingState(logged_in=True, card=card, **common) + if name in ("card-autoreload", "card_autoreload", "autoreload"): + return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common) + if name in ("notadmin", "not-admin", "member"): + opts = {**common, "role": "MEMBER"} + return BillingState(logged_in=True, card=card, **opts) + if name in ("billing-off", "billing_off", "off"): + opts = {**common, "cli_billing_enabled": False} + return BillingState(logged_in=True, card=None, **opts) + + # Unknown name → logged-out so the misconfiguration is visible. + return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}") + + # ============================================================================= # Idempotency # ============================================================================= diff --git a/tests/agent/test_billing_view.py b/tests/agent/test_billing_view.py index 288c125e4178..be7232a97bb3 100644 --- a/tests/agent/test_billing_view.py +++ b/tests/agent/test_billing_view.py @@ -375,3 +375,50 @@ def test_validate_amount_rejections(raw, err_substr): v = validate_charge_amount(raw, min_usd=Decimal("10"), max_usd=Decimal("10000")) assert not v.ok assert err_substr.lower() in (v.error or "").lower() + + +# --------------------------------------------------------------------------- +# HERMES_DEV_BILLING_FIXTURE — offline card/scope state scaffolding (T0) +# --------------------------------------------------------------------------- + + +def test_billing_fixture_unset_returns_none(monkeypatch): + """No env var → fixture is inert (the real portal path runs).""" + monkeypatch.delenv("HERMES_DEV_BILLING_FIXTURE", raising=False) + assert bv._dev_fixture_billing_state() is None + + +@pytest.mark.parametrize( + "name,has_card,is_admin,billing_on", + [ + ("nocard", False, True, True), + ("card", True, True, True), + ("card-autoreload", True, True, True), + ("notadmin", True, False, True), + ("billing-off", False, True, False), + ], +) +def test_billing_fixture_card_and_gate_invariants(monkeypatch, name, has_card, is_admin, billing_on): + """Each fixture state honors the card/admin/kill-switch contract the gate reads.""" + monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", name) + s = build_billing_state() + assert s.logged_in is True + assert (s.card is not None) is has_card + assert s.is_admin is is_admin + assert s.cli_billing_enabled is billing_on + + +def test_billing_fixture_autoreload_state(monkeypatch): + """card-autoreload pairs a card with an enabled auto-reload (drives that screen).""" + monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "card-autoreload") + s = build_billing_state() + assert s.card is not None + assert s.auto_reload is not None and s.auto_reload.enabled is True + + +def test_billing_fixture_logged_out_and_unknown(monkeypatch): + monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "logged-out") + assert build_billing_state().logged_in is False + monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "bogus-state") + s = build_billing_state() + assert s.logged_in is False and "bogus-state" in (s.error or "") From beb2c5fb3b02d547a23b93aa3f6dbbab40b8eb12 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 17:13:16 +0530 Subject: [PATCH 27/62] refactor(billing): fold /credits into /topup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- agent/account_usage.py | 14 +- agent/credits_tracker.py | 2 +- gateway/run.py | 4 +- gateway/slash_commands.py | 24 +-- hermes_cli/commands.py | 9 +- tests/agent/test_credits_policy.py | 2 +- tests/agent/test_credits_view.py | 85 ++--------- tests/agent/test_nous_credits_snapshot.py | 2 +- tui_gateway/server.py | 35 +---- ui-tui/src/__tests__/creditsCommand.test.ts | 144 ------------------ .../__tests__/turnControllerNotice.test.ts | 2 +- ui-tui/src/app/slash/commands/credits.ts | 57 ------- ui-tui/src/app/slash/commands/topup.ts | 9 +- ui-tui/src/app/slash/registry.ts | 2 - ui-tui/src/gatewayTypes.ts | 10 -- 15 files changed, 52 insertions(+), 349 deletions(-) delete mode 100644 ui-tui/src/__tests__/creditsCommand.test.ts delete mode 100644 ui-tui/src/app/slash/commands/credits.ts diff --git a/agent/account_usage.py b/agent/account_usage.py index da02af3c478c..7ce34877a23e 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: return None details.append(f"Top up: {nous_portal_topup_url(account_info)}") - details.append("(or run /credits)") + details.append("(or run /topup)") plan = getattr(sub, "plan", None) if sub is not None else None return AccountUsageSnapshot( @@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]: @dataclass(frozen=True) class CreditsView: - """Surface-agnostic data for the ``/credits`` command. + """Surface-agnostic data for the ``/topup`` balance view. One portal fetch, one parse — consumed identically by the CLI panel, the gateway button, and any other money surface. Fail-open: when not logged in @@ -356,11 +356,11 @@ class CreditsView: def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView: - """Build the /credits view: balance block + identity line + top-up URL. + """Build the /topup balance view: balance block + identity line + top-up URL. Reuses the same account fetch + snapshot + URL builder as the /usage credits block, so the numbers always match. The balance block is the rendered - snapshot MINUS its trailing top-up/command-hint lines (the /credits surface + snapshot MINUS its trailing top-up/command-hint lines (the /topup surface supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``. """ not_logged_in = CreditsView(logged_in=False) @@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred timeout=timeout ) except Exception: - logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True) + logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True) return not_logged_in if account is None or not getattr(account, "logged_in", False): @@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred snapshot = build_nous_credits_snapshot(account) # Balance lines = the snapshot block minus the two trailing affordance lines - # ("Top up: " + "(or run /credits)") that build_nous_credits_snapshot - # appends for the /usage surface. /credits renders its own button/panel. + # ("Top up: " + "(or run /topup)") that build_nous_credits_snapshot + # appends for the /usage surface. /topup renders its own button/panel. balance_lines: list[str] = [] if snapshot is not None: rendered = render_account_usage_lines(snapshot, markdown=markdown) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index 19e5b1582dae..f2186fb91a56 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -355,7 +355,7 @@ def evaluate_credits_notices( if show_depleted and "credits.depleted" not in active: to_show.append( AgentNotice( - text="✕ Credit access paused · run /credits to top up", + text="✕ Credit access paused · run /topup to top up", level="error", kind=CREDITS_NOTICE_KIND, key="credits.depleted", diff --git a/gateway/run.py b/gateway/run.py index 09b9e1c88f99..cdcf240384db 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8203,8 +8203,8 @@ async def _do_undo(): if canonical == "usage": return await self._handle_usage_command(event) - if canonical == "credits": - return await self._handle_credits_command(event) + if canonical == "topup": + return await self._handle_topup_command(event) if canonical == "insights": return await self._handle_insights_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index ab9ea9759bd6..6620b63df40d 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3300,15 +3300,15 @@ async def _handle_branch_command(self, event: MessageEvent) -> str: key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many" return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id) - async def _handle_credits_command(self, event: MessageEvent) -> str: - """Handle /credits -- show Nous credit balance and the top-up handoff. - - Renders the balance block + identity line + a tappable top-up URL that - opens the portal billing page with the modal open. The terminal does NOT - confirm, poll, or track payment (billing phase 2a) — checkout happens in - the browser and the next /credits shows the new balance. The tappable URL - is the affordance: it works on every platform (button-capable or plain - text like SMS/email). Fetched off the event loop; fail-open. + async def _handle_topup_command(self, event: MessageEvent) -> str: + """Handle /topup -- show the Nous balance and hand off to the portal. + + Renders the balance block + identity line + a tappable portal URL that + opens the billing page. Terminal billing is managed on the portal: the + terminal does NOT charge, confirm, or track payment here — everything + happens in the browser and the next /topup shows the new balance. The + tappable URL is the affordance and works on every platform (button-capable + or plain text like SMS/email). Fetched off the event loop; fail-open. """ from agent.account_usage import build_credits_view @@ -3320,7 +3320,7 @@ async def _handle_credits_command(self, event: MessageEvent) -> str: if view is None or not view.logged_in: return t("gateway.credits.not_logged_in") - lines: list[str] = ["💳 **Nous credits**"] + lines: list[str] = ["💳 **Nous balance**"] for line in view.balance_lines: if line.lstrip().startswith("📈"): continue # drop the helper's header; we print our own @@ -3330,8 +3330,8 @@ async def _handle_credits_command(self, event: MessageEvent) -> str: lines.append(view.identity_line) if view.topup_url: lines.append("") - lines.append(f"Top up: {view.topup_url}") - lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.") + lines.append(f"Manage billing on the portal: {view.topup_url}") + lines.append("Top up and manage billing in the browser — your balance updates here after.") return "\n".join(lines) async def _handle_usage_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 52d817685902..1480474664a8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -217,11 +217,10 @@ class CommandDef: CommandDef("restart", "Gracefully restart the gateway after draining active runs", "Session", gateway_only=True), CommandDef("usage", "Show token usage and rate limits for the current session", "Info"), - CommandDef("credits", "Show Nous credit balance and top up", "Info"), CommandDef("subscription", "View your Nous plan and change it in the browser", "Info", cli_only=True, aliases=("upgrade",)), - CommandDef("topup", "Top up Nous credits — buy credits, auto-reload, limits", "Info", - cli_only=True, aliases=("billing",)), + CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info", + aliases=("billing", "credits")), CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", @@ -1059,9 +1058,9 @@ def discord_skill_commands_by_category( # surface (CLI, TUI, Telegram, Discord). Keep this list TIGHT and intentional — # the telegram-parity test reads it so an entry here is a deliberate # "Slack-via-/hermes" decision, not a silent clamp. -# - credits: the billing/top-up surface; reached via /hermes credits on Slack. +# - topup: the billing/balance surface; reached via /hermes topup on Slack. # - debug: the log/report upload surface; reached via /hermes debug on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"}) +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "debug"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/tests/agent/test_credits_policy.py b/tests/agent/test_credits_policy.py index e73b8fae2f1d..de106dadb90e 100644 --- a/tests/agent/test_credits_policy.py +++ b/tests/agent/test_credits_policy.py @@ -469,7 +469,7 @@ def test_depleted_mentions_credits_command(self): s = CreditsState(paid_access=False) to_show, _ = evaluate_credits_notices(s, latch) depleted_notice = next(n for n in to_show if n.key == "credits.depleted") - assert "/credits" in depleted_notice.text + assert "/topup" in depleted_notice.text # ── Scenario 8: severity order in a single call ────────────────────────────── diff --git a/tests/agent/test_credits_view.py b/tests/agent/test_credits_view.py index 04aab21fe62c..0f75981e4a59 100644 --- a/tests/agent/test_credits_view.py +++ b/tests/agent/test_credits_view.py @@ -131,7 +131,7 @@ def _boom(*a, **kw): assert view.logged_in is False -# ── gateway _handle_credits_command ───────────────────────────────────────── +# ── gateway _handle_topup_command (the messaging billing surface) ──────────── class _FakeEvent: @@ -139,7 +139,7 @@ class _FakeEvent: def _make_gateway_stub(): - """Minimal object exposing the mixin's _handle_credits_command.""" + """Minimal object exposing the mixin's _handle_topup_command.""" from gateway.slash_commands import GatewaySlashCommandsMixin class _Stub(GatewaySlashCommandsMixin): @@ -149,7 +149,7 @@ def __init__(self): return _Stub() -def test_gateway_credits_renders_block_and_url(monkeypatch): +def test_gateway_topup_renders_block_and_url(monkeypatch): view = CreditsView( logged_in=True, balance_lines=("📈 Nous credits", "Total usable: $52.50"), @@ -160,101 +160,48 @@ def test_gateway_credits_renders_block_and_url(monkeypatch): monkeypatch.setattr(account_usage, "build_credits_view", lambda *a, **kw: view) stub = _make_gateway_stub() - out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + out = asyncio.run(stub._handle_topup_command(_FakeEvent())) assert "💳" in out assert "Total usable: $52.50" in out assert "Topping up as alice@example.test / org Acme" in out assert "https://portal.example.test/orgs/acme/billing?topup=open" in out - assert "credits will appear in /credits shortly" in out + assert "Manage billing on the portal" in out # The helper's own 📈 header line is dropped (we render our own 💳 header). assert "📈 Nous credits" not in out -def test_gateway_credits_not_logged_in(monkeypatch): +def test_gateway_topup_not_logged_in(monkeypatch): monkeypatch.setattr( account_usage, "build_credits_view", lambda *a, **kw: CreditsView(logged_in=False) ) stub = _make_gateway_stub() - out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + out = asyncio.run(stub._handle_topup_command(_FakeEvent())) assert "Not logged into Nous Portal" in out -def test_gateway_credits_fetch_exception_is_not_logged_in(monkeypatch): +def test_gateway_topup_fetch_exception_is_not_logged_in(monkeypatch): def _boom(*a, **kw): raise RuntimeError("boom") monkeypatch.setattr(account_usage, "build_credits_view", _boom) stub = _make_gateway_stub() - out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + out = asyncio.run(stub._handle_topup_command(_FakeEvent())) assert "Not logged into Nous Portal" in out # ── command registry ──────────────────────────────────────────────────────── -def test_credits_command_registered(): +def test_credits_folds_into_topup(): + """`/credits` is an alias of `/topup` now — it resolves to the topup command.""" from hermes_cli.commands import resolve_command, COMMAND_REGISTRY cmd = resolve_command("credits") - assert cmd is not None and cmd.name == "credits" - # Available on every surface (not cli_only / gateway_only). - entry = next(c for c in COMMAND_REGISTRY if c.name == "credits") + assert cmd is not None and cmd.name == "topup" + # /topup is available on every surface (not cli_only / gateway_only). + entry = next(c for c in COMMAND_REGISTRY if c.name == "topup") assert entry.cli_only is False assert entry.gateway_only is False - - -# ── CLI _show_credits non-interactive (TUI slash-worker) path ─────────────── - - -def test_cli_show_credits_non_interactive_renders_text_not_modal(monkeypatch, capsys): - """In the TUI slash-worker (no self._app), /credits must render the text - variant — never invoke the prompt_toolkit modal, which would read the - worker's JSON-RPC stdin and crash the command (only the depleted banner - would survive). Regression for that exact failure. - """ - import agent.account_usage as account_usage - from cli import HermesCLI - - monkeypatch.setattr( - account_usage, - "build_credits_view", - lambda *a, **k: CreditsView( - logged_in=True, - balance_lines=("📈 Nous credits", "Total usable: $0.00"), - identity_line="Topping up as a@b.c / org Acme", - topup_url="https://prev.test/orgs/acme/billing?topup=open", - depleted=True, - ), - ) - - cli = HermesCLI.__new__(HermesCLI) - cli._app = None # non-interactive, like the slash worker - - # Must NOT call the modal in this context. - def _boom_modal(*a, **k): - raise AssertionError("modal must not run without a live app") - - monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False) - - cli._show_credits() - - out = capsys.readouterr().out - assert "💳 Nous credits" in out - assert "Total usable: $0.00" in out - assert "Topping up as a@b.c / org Acme" in out - assert "https://prev.test/orgs/acme/billing?topup=open" in out - assert "credits will appear in /credits shortly" in out - - -def test_cli_show_credits_logged_out(monkeypatch, capsys): - import agent.account_usage as account_usage - from cli import HermesCLI - - monkeypatch.setattr( - account_usage, "build_credits_view", lambda *a, **k: CreditsView(logged_in=False) - ) - cli = HermesCLI.__new__(HermesCLI) - cli._app = None - cli._show_credits() - assert "Not logged into Nous Portal" in capsys.readouterr().out + # No standalone `credits` command remains in the registry. + assert not any(c.name == "credits" for c in COMMAND_REGISTRY) diff --git a/tests/agent/test_nous_credits_snapshot.py b/tests/agent/test_nous_credits_snapshot.py index d2c7e1775878..273307c20648 100644 --- a/tests/agent/test_nous_credits_snapshot.py +++ b/tests/agent/test_nous_credits_snapshot.py @@ -143,7 +143,7 @@ def test_topup_line_is_org_pinned_when_slug_present(): blob = "\n".join(_all_lines(snap)) # The /usage top-up link auto-opens the modal and is org-pinned. assert "https://portal.example.test/orgs/acme/billing?topup=open" in blob - assert "/credits" in blob + assert "/topup" in blob def test_topup_line_falls_back_to_legacy_when_slug_null(): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ebe5bd96f3cd..7a294db187ff 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5453,37 +5453,6 @@ def _(rid, params: dict) -> dict: return _ok(rid, usage) -@method("credits.view") -def _(rid, params: dict) -> dict: - """Structured Nous credit view for the TUI /credits command. - - Account-independent (a portal fetch gated on "a Nous account is logged in"), - so it works with no live agent / on a resumed session — same as the /usage - credits block. Returns the surface-agnostic CreditsView fields so the TUI can - render a clickable top-up . Fail-open: a portal hiccup or logged-out - account yields {logged_in: false}, never an error the user has to parse. - """ - try: - from agent.account_usage import build_credits_view - - view = build_credits_view() - return _ok( - rid, - { - "logged_in": bool(view.logged_in), - "balance_lines": [ - line for line in view.balance_lines if not line.lstrip().startswith("📈") - ], - "identity_line": view.identity_line, - "topup_url": view.topup_url, - "depleted": bool(view.depleted), - }, - ) - except Exception: - # Fail-open: TUI treats this as "not logged in" and shows the prompt. - return _ok(rid, {"logged_in": False, "balance_lines": [], "identity_line": None, "topup_url": None, "depleted": False}) - - # =========================================================================== # Phase 2b terminal billing RPC methods # =========================================================================== @@ -5493,7 +5462,7 @@ def _(rid, params: dict) -> dict: # Ink side can branch on the typed billing error code (insufficient_scope, # rate_limited, no_payment_method, …) to render the right affordance instead of # landing in a generic catch. The data-building lives in the shared core -# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /credits. +# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /topup. def _serialize_billing_error(exc) -> dict: @@ -5609,7 +5578,7 @@ def _billing_usage_payload(state) -> dict: def _(rid, params: dict) -> dict: """GET /api/billing/state → serialized BillingState (Screen 1 + 5). - Fail-open like credits.view: a logged-out / unreachable portal yields + Fail-open like the other billing RPCs: a logged-out / unreachable portal yields {ok:true, logged_in:false}. No scope required for this endpoint. """ try: diff --git a/ui-tui/src/__tests__/creditsCommand.test.ts b/ui-tui/src/__tests__/creditsCommand.test.ts deleted file mode 100644 index 6f0f6d59eeca..000000000000 --- a/ui-tui/src/__tests__/creditsCommand.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { creditsCommands } from '../app/slash/commands/credits.js' -import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' -import type { CreditsViewResponse } from '../gatewayTypes.js' - -// The command opens the top-up URL through this helper on confirm. Mock it so -// the test never shells out to a real browser/`xdg-open` and we can assert the -// success/failure messaging deterministically. -vi.mock('../lib/openExternalUrl.js', () => ({ - openExternalUrl: vi.fn(() => true) -})) - -import { openExternalUrl } from '../lib/openExternalUrl.js' - -const openExternalUrlMock = vi.mocked(openExternalUrl) - -const creditsCommand = creditsCommands.find(cmd => cmd.name === 'credits')! - -const buildView = (overrides: Partial = {}): CreditsViewResponse => ({ - balance_lines: ['Grant: $9.50 left', 'Top-up: $25.00'], - depleted: false, - identity_line: 'Signed in as ada@example.com', - logged_in: true, - topup_url: 'https://portal.nousresearch.com/billing/topup', - ...overrides -}) - -// Mirror createSlashHandler's real `guarded` wrapper: skip the handler when the -// command is stale OR the response is falsy. Tests stay non-stale, so this is a -// straightforward "run the handler when we got a response" shim. -const guarded = - (fn: (r: T) => void) => - (r: null | T) => { - if (r) { - fn(r) - } - } - -const buildCtx = (rpcResult: CreditsViewResponse) => { - const sys = vi.fn() - const rpc = vi.fn(() => Promise.resolve(rpcResult)) - const guardedErr = vi.fn() - - const ctx = { - gateway: { rpc }, - guarded, - guardedErr, - sid: 'sid-abc', - stale: () => false, - transcript: { page: vi.fn(), panel: vi.fn(), sys } - } - - // Run the command, then await the rpc promise so the .then() handler has - // flushed before assertions — deterministic, no polling/timeouts. - const run = async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - creditsCommand.run('', ctx as any, 'credits') - await rpc.mock.results[0]?.value - // Allow the chained .then() microtask to settle. - await Promise.resolve() - } - - return { ctx, rpc, run, sys } -} - -describe('/credits slash command', () => { - beforeEach(() => { - resetOverlayState() - openExternalUrlMock.mockClear() - openExternalUrlMock.mockReturnValue(true) - }) - - it('renders the balance (including top-up URL) and arms the confirm overlay', async () => { - const view = buildView() - const { rpc, run, sys } = buildCtx(view) - - await run() - - expect(rpc).toHaveBeenCalledWith('credits.view', { session_id: 'sid-abc' }) - - // (a) sys received the balance text including the topup_url - const printed = sys.mock.calls.map(call => call[0]).join('\n') - expect(printed).toContain('💳 Nous credits') - expect(printed).toContain('Grant: $9.50 left') - expect(printed).toContain('Signed in as ada@example.com') - expect(printed).toContain(view.topup_url) - - // (b) confirm overlay set with the expected label + detail - const confirm = getOverlayState().confirm - expect(confirm).toBeTruthy() - expect(confirm?.confirmLabel).toBe('Open top-up in browser') - expect(confirm?.cancelLabel).toBe('Cancel') - expect(confirm?.title).toBe('Add credits?') - expect(confirm?.detail).toBe(view.topup_url) - - // onConfirm opens the URL and reports success back to the transcript - confirm?.onConfirm() - expect(openExternalUrlMock).toHaveBeenCalledWith(view.topup_url) - expect(sys).toHaveBeenCalledWith( - 'Complete your top-up in the browser — credits will appear in /credits shortly.' - ) - }) - - it('falls back to printing the URL when the browser open is rejected', async () => { - openExternalUrlMock.mockReturnValue(false) - const view = buildView() - const { run, sys } = buildCtx(view) - - await run() - - const confirm = getOverlayState().confirm - expect(confirm).toBeTruthy() - confirm?.onConfirm() - expect(sys).toHaveBeenCalledWith(`Open this URL to top up: ${view.topup_url}`) - }) - - it('does not arm the confirm overlay when there is no top-up URL', async () => { - const view = buildView({ topup_url: null }) - const { run, sys } = buildCtx(view) - - await run() - - const printed = sys.mock.calls.map(call => call[0]).join('\n') - expect(printed).toContain('💳 Nous credits') - expect(getOverlayState().confirm).toBeNull() - }) - - it('shows the not-logged-in message and does NOT arm the confirm overlay', async () => { - const view = buildView({ - balance_lines: [], - identity_line: null, - logged_in: false, - topup_url: null - }) - const { run, sys } = buildCtx(view) - - await run() - - expect(sys).toHaveBeenCalledWith('💳 Not logged into Nous Portal — run /portal to log in.') - expect(getOverlayState().confirm).toBeNull() - expect(openExternalUrlMock).not.toHaveBeenCalled() - }) -}) diff --git a/ui-tui/src/__tests__/turnControllerNotice.test.ts b/ui-tui/src/__tests__/turnControllerNotice.test.ts index 7ef224aee2a1..c3531cb8db58 100644 --- a/ui-tui/src/__tests__/turnControllerNotice.test.ts +++ b/ui-tui/src/__tests__/turnControllerNotice.test.ts @@ -35,7 +35,7 @@ describe('turnController.startMessage — flash-and-yield notices clear on next it('leaves a sticky credits.depleted notice across a new turn', () => { patchUiState({ - notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /credits to top up' } + notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' } }) turnController.startMessage() expect(getUiState().notice?.key).toBe('credits.depleted') diff --git a/ui-tui/src/app/slash/commands/credits.ts b/ui-tui/src/app/slash/commands/credits.ts deleted file mode 100644 index c653916d2dee..000000000000 --- a/ui-tui/src/app/slash/commands/credits.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { CreditsViewResponse } from '../../../gatewayTypes.js' -import { openExternalUrl } from '../../../lib/openExternalUrl.js' -import { patchOverlayState } from '../../overlayStore.js' -import type { SlashCommand } from '../types.js' - -export const creditsCommands: SlashCommand[] = [ - { - help: 'Show Nous credit balance and top up', - name: 'credits', - run: (_arg, ctx) => { - ctx.gateway - .rpc('credits.view', { session_id: ctx.sid }) - .then( - ctx.guarded(view => { - if (!view.logged_in) { - ctx.transcript.sys('💳 Not logged into Nous Portal — run /portal to log in.') - return - } - - const lines = ['💳 Nous credits', ...view.balance_lines] - - if (view.identity_line) { - lines.push('', view.identity_line) - } - - if (view.topup_url) { - lines.push('', `Top up: ${view.topup_url}`) - } - - ctx.transcript.sys(lines.join('\n')) - - const url = view.topup_url - - if (url) { - patchOverlayState({ - confirm: { - cancelLabel: 'Cancel', - confirmLabel: 'Open top-up in browser', - detail: url, - onConfirm: () => { - const ok = openExternalUrl(url) - ctx.transcript.sys( - ok - ? 'Complete your top-up in the browser — credits will appear in /credits shortly.' - : `Open this URL to top up: ${url}` - ) - }, - title: 'Add credits?' - } - }) - } - }) - ) - .catch(ctx.guardedErr) - } - } -] diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 39dcdd197905..2cb954a9d40a 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -186,7 +186,7 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st if (Date.now() - start >= POLL_CAP_MS) { sys( '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + - 'Check /billing or the portal shortly.' + 'Check /topup or the portal shortly.' ) if (portalUrl) { @@ -329,9 +329,10 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B export const topupCommands: SlashCommand[] = [ { - help: 'Top up your balance — add funds, auto-reload, limits', + aliases: ['billing', 'credits'], + help: 'Show your balance and manage billing — add funds, auto-reload, limits', name: 'topup', - // ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/billing` + // ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/topup` // fetches state and opens the interactive overlay (CLI/TUI parity). run: (_arg, ctx) => { const sys: Sys = ctx.transcript.sys @@ -341,7 +342,7 @@ export const topupCommands: SlashCommand[] = [ .then( ctx.guarded(s => { if (!s.logged_in) { - sys('💳 Not logged into Nous Portal — run /portal to log in, then /billing.') + sys('💳 Not logged into Nous Portal — run /portal to log in, then /topup.') return } diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index 4d914bb9f9ac..f87d53f9bfe7 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -1,5 +1,4 @@ import { coreCommands } from './commands/core.js' -import { creditsCommands } from './commands/credits.js' import { debugCommands } from './commands/debug.js' import { opsCommands } from './commands/ops.js' import { sessionCommands } from './commands/session.js' @@ -11,7 +10,6 @@ import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ ...coreCommands, ...topupCommands, - ...creditsCommands, ...sessionCommands, ...subscriptionCommands, ...opsCommands, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 1915e147f573..60a8dfab8859 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -43,16 +43,6 @@ export interface SlashExecResponse { warning?: string } -// ── Credits / top-up ───────────────────────────────────────────────── - -export interface CreditsViewResponse { - balance_lines: string[] - depleted: boolean - identity_line: string | null - logged_in: boolean - topup_url: string | null -} - // ── Terminal billing (Phase 2b) ────────────────────────────────────── export interface BillingCardInfo { From 13a435529d95b958bcd01b38dd609f26abb83435 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 17:13:25 +0530 Subject: [PATCH 28/62] fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- cli.py | 168 +++++++++-------------- ui-tui/src/components/billingOverlay.tsx | 42 +++--- 2 files changed, 93 insertions(+), 117 deletions(-) diff --git a/cli.py b/cli.py index 77085b9a457a..1a70d7fc5e72 100644 --- a/cli.py +++ b/cli.py @@ -8010,8 +8010,6 @@ def process_command(self, command: str) -> bool: self._manual_compress(cmd_original) elif canonical == "usage": self._show_usage() - elif canonical == "credits": - self._show_credits() elif canonical == "subscription": self._show_subscription() elif canonical == "topup": @@ -8940,19 +8938,23 @@ def _print_nous_credits_block(self) -> bool: _cprint(f" {_b(f'Plan: {plan}{renews}')}") printed_any = True + # All lines below go through _cprint (same renderer as the Plan line) so + # ordering is deterministic: raw print() and _cprint() flush to different + # buffers under patch_stdout and interleave nondeterministically (the bar + # would race above/below the Plan line across states). Keep one path. pb = usage.plan_bar if pb is not None and pb.total_usd > 0: bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) _pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else "" _label = (usage.plan_name or "plan").ljust(8)[:8] - print(f" {_label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{_pct_s}") + _cprint(f" {_label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{_pct_s}") printed_any = True tb = usage.topup_bar if tb is not None and tb.remaining_usd > 0: - print(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") + _cprint(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") printed_any = True if usage.has_topup and usage.total_spendable_usd is not None: - print(f" Total spendable: ${usage.total_spendable_usd:,.2f}") + _cprint(f" Total spendable: ${usage.total_spendable_usd:,.2f}") if usage.status == "free": _cprint(f" {_d('> Free · free models only. Run /subscription to reach paid models.')}") @@ -8999,7 +9001,7 @@ def _show_subscription(self): page (NOT the Stripe portal; that page routes upgrade→Checkout / downgrade→scheduled internally). The terminal NEVER charges for a subscription. Fail-open: logged-out / portal hiccup degrades to a clear - message, never a crash. Mirrors ``_show_credits`` / ``_show_billing`` + message, never a crash. Mirrors ``_show_billing`` discipline for the interactive-vs-text split. """ from agent.subscription_view import build_subscription_state, subscription_manage_url @@ -9130,7 +9132,7 @@ def _subscription_overview(self, state, manage_url): # Non-interactive (TUI slash-worker / piped / no live app): the # prompt_toolkit modal can't run here. Render the text hand-off — the - # URL is the affordance, same discipline as _show_credits. + # URL is the affordance, same discipline as _show_billing. if not getattr(self, "_app", None): print() print(f" Manage your subscription: {manage_url}") @@ -9144,7 +9146,7 @@ def _subscription_overview(self, state, manage_url): ("cancel", "Cancel", "do nothing"), ] raw = self._prompt_text_input_modal( - title="⚕ Manage your subscription", + title="Manage your subscription", detail="Pick an option to manage your subscription in the browser.", choices=choices, ) @@ -9171,86 +9173,6 @@ def _subscription_overview(self, state, manage_url): else: print(" 🟡 Cancelled. No plan change.") - def _show_credits(self): - """`/credits` — focused Nous credit balance + top-up handoff. - - Interactive CLI: balance block + identity line + a 3-button panel - (Open top-up / Copy link / Cancel). Non-interactive contexts — the TUI - slash-worker subprocess and any place without a live prompt_toolkit app - (``self._app is None``) — render a text variant (balance + tappable - top-up URL), because the modal would try to read the RPC stdin and crash - the worker. The terminal never confirms or polls payment (billing phase - 2a). Fail-open: a portal hiccup or logged-out account degrades to a clear - message, never a crash. - """ - from agent.account_usage import build_credits_view - - view = build_credits_view() - - if not view.logged_in: - print() - _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") - print(" Run `hermes portal` to log in, then /credits.") - return - - print() - print(" 💳 Nous credits") - print(f" {'─' * 41}") - for line in view.balance_lines: - # Drop the helper's own "📈 Nous credits" header — we print our own. - if line.lstrip().startswith("📈"): - continue - print(f" {line}") - print(f" {'─' * 41}") - if view.identity_line: - print(f" {view.identity_line}") - - if not view.topup_url: - return - - # Non-interactive (TUI slash-worker, piped, no live app): the - # prompt_toolkit modal can't run here — it would read the worker's - # JSON-RPC stdin and crash the command. Render the text variant: the - # tappable URL IS the affordance, same as the messaging surfaces. - if not getattr(self, "_app", None): - print() - print(f" Top up: {view.topup_url}") - print(" Complete your top-up in the browser — credits will appear in /credits shortly.") - return - - choices = [ - ("open", "Open top-up in browser", "launch the portal billing page"), - ("copy", "Copy link", "copy the top-up URL to your clipboard"), - ("cancel", "Cancel", "do nothing"), - ] - raw = self._prompt_text_input_modal( - title="💳 Add credits?", - detail=f"Top-up page:\n{view.topup_url}", - choices=choices, - ) - choice = self._normalize_slash_confirm_choice(raw, choices) - - if choice == "open": - opened = False - try: - import webbrowser - - opened = webbrowser.open(view.topup_url) - except Exception: - opened = False - if not opened: - print(f" Open this URL to top up: {view.topup_url}") - print() - print(" Complete your top-up in the browser — credits will appear in /credits shortly.") - elif choice == "copy": - try: - self._write_osc52_clipboard(view.topup_url) - print(f" 📋 Copied: {view.topup_url}") - except Exception: - print(f" Top-up URL: {view.topup_url}") - else: - print(" 🟡 Cancelled. No credits added.") - # ------------------------------------------------------------------ # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) # ------------------------------------------------------------------ @@ -9266,7 +9188,7 @@ def _show_billing(self, command: str = "/billing"): Interactive CLI uses the prompt_toolkit modal; non-interactive contexts (TUI slash-worker / no live app) render text + the portal deep-link, never - prompting (the URL is the affordance), same discipline as ``_show_credits``. + prompting (the URL is the affordance), same discipline as ``_show_billing``. All money is Decimal end-to-end; the terminal never collects card details. """ from agent.billing_view import build_billing_state @@ -9352,14 +9274,32 @@ def _billing_overview(self, state): self._billing_portal_hint(state, reason="Enable it on the portal to add funds here.") return - # Optimistic funnel: no card on file → a charge will 403 no_payment_method. - # Surface that up front (with the portal link) but DON'T hide Add funds — - # the card field can't fully prove chargeability, so we advise not gate. + # No card on file → a charge can't succeed. Don't offer charge/auto-reload + # actions (they'd 403 no_payment_method or punt). Route to the portal to add + # a REUSABLE card, then the user returns to a refreshed overview. This is a + # half-done state — NOT a success. if state.card is None: _cprint( - f" {_d('No saved card for terminal charges yet — set one up on the portal first.')}" + f" {_d('No card on file — add one on the portal to charge from the terminal.')}" ) - self._billing_portal_hint(state) + if not getattr(self, "_app", None): + self._billing_portal_hint(state) + return + add_card_choices = [ + ("portal", "Add a card on the portal", "opens the billing page to save a reusable card"), + ("cancel", "Cancel", "do nothing"), + ] + raw = self._prompt_text_input_modal( + title="Add a card to continue", + detail="No saved card yet. Add one on the portal, then re-run /topup to add funds.", + choices=add_card_choices, + ) + choice = self._normalize_slash_confirm_choice(raw, add_card_choices) + if choice == "portal": + self._billing_open_portal(state) + print() + _cprint(f" {_d('Added a card? Re-run /topup to add funds.')}") + return # Non-interactive (slash-worker / no live app): no modal, no sub-command # advertising — just the portal funnel (the URL is the affordance). @@ -9369,6 +9309,10 @@ def _billing_overview(self, state): # Add funds first, then settings, then the scopeless browser handoff. # No "Enable terminal billing" item — that's discovered at pay time. + # "Add funds" charges in-terminal against the org's portal-saved card + # (server-held via POST /charge — no card ref leaves the client). No card + # on file → _billing_buy_flow detects no_payment_method and routes to the + # portal to add one. choices = [ ("buy", "Add funds", "add money to your balance"), ("auto", "Auto-reload", "configure automatic top-ups"), @@ -9379,7 +9323,7 @@ def _billing_overview(self, state): # The overview summary is already printed above; the modal only needs to # present the action menu — repeating the title/balance reads as a dupe. raw = self._prompt_text_input_modal( - title="💳 Top up your balance", detail="", + title="Top up your balance", detail="", choices=choices, ) choice = self._normalize_slash_confirm_choice(raw, choices) @@ -9455,6 +9399,14 @@ def _billing_buy_flow(self, state): if not self._billing_require_admin(state): return + # Card gate: no saved card → a charge would 403 no_payment_method. Route to + # the portal to add a reusable card instead of offering doomed presets. + if state.card is None: + print() + _cprint(f" {_d('No card on file — add one on the portal first, then re-run /topup.')}") + self._billing_portal_hint(state) + return + # Screen 3 — preset selection. if not getattr(self, "_app", None): presets = ", ".join(format_money(p) for p in state.charge_presets) @@ -9474,7 +9426,7 @@ def _billing_buy_flow(self, state): card = state.card detail = f"Payment: {card.masked}" if card else "No saved card on file" raw = self._prompt_text_input_modal( - title="💳 Add funds", detail=detail, choices=preset_choices, + title="Add funds", detail=detail, choices=preset_choices, ) choice = self._normalize_slash_confirm_choice(raw, preset_choices) if not choice or choice == "cancel": @@ -9516,6 +9468,7 @@ def _billing_confirm_and_charge(self, state, amount): print(f" Total: {format_money(amount)}") if card: print(f" Payment: {card.masked}") + _cprint(f" {_d('Your card saved on the portal will be charged.')}") print(f" {'─' * 41}") _consent = ( "By confirming, you allow Nous Research to charge your card." @@ -9524,17 +9477,21 @@ def _billing_confirm_and_charge(self, state, amount): confirm_choices = [ ("pay", f"Pay {format_money(amount)} now", "submit the charge"), + ("portal", "Manage on portal", "manage your card / billing in the browser"), ("cancel", "Go back", "do not charge"), ] if not getattr(self, "_app", None): print(" Run in the interactive CLI to confirm a purchase.") return raw = self._prompt_text_input_modal( - title=f"💳 Pay {format_money(amount)}?", + title=f"Pay {format_money(amount)}?", detail=(card.masked if card else "no saved card"), choices=confirm_choices, ) choice = self._normalize_slash_confirm_choice(raw, confirm_choices) + if choice == "portal": + self._billing_open_portal(state) + return if choice != "pay": print(" Cancelled. No funds added.") return @@ -9691,7 +9648,7 @@ def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key= ("no", "Not now", "cancel"), ] raw = self._prompt_text_input_modal( - title="💳 Enable terminal billing", + title="Enable terminal billing", detail="Opens your browser to authorize this terminal.", choices=confirm_choices, ) @@ -9722,6 +9679,15 @@ def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key= self._billing_portal_hint(fresh) return + # Scope granted + org kill-switch on — but a charge still needs a card on + # file. If there's none, this is a half-done state: say so and route to the + # portal to add a card, rather than a bare "✓ enabled" that reads as done. + if fresh.card is None: + print(" ✓ Terminal billing enabled — but there's no card on file yet.") + _cprint(f" {_d('Add a card on the portal, then re-run the action to continue.')}") + self._billing_portal_hint(fresh) + return + # Nothing to resume (scope-required hit outside a charge, e.g. auto-reload # config) → just tell the user it's ready. if amount is None: @@ -9736,7 +9702,7 @@ def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key= ("cancel", "Cancel", "do not charge"), ] raw = self._prompt_text_input_modal( - title="💳 Resume your top-up", + title="Resume your top-up", detail=f"{format_money(amount)} is ready to finish — press Enter to resume.", choices=resume_choices, ) @@ -9808,7 +9774,7 @@ def _billing_auto_reload_flow(self, state): ("cancel", "Cancel", "do nothing"), ] raw = self._prompt_text_input_modal( - title="💳 Auto-reload", + title="Auto-reload", detail=( f"On — below {format_money(ar.threshold_usd)} → " f"reload to {format_money(ar.reload_to_usd)}" @@ -9878,7 +9844,7 @@ def _billing_auto_reload_flow(self, state): ("cancel", "Cancel", "do nothing"), ] raw = self._prompt_text_input_modal( - title="💳 Turn on auto-reload?", + title="Turn on auto-reload?", detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}", choices=confirm_choices, ) diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index dc6a295f1116..afc8b6092d4b 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -85,27 +85,40 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { // do NOT preflight the scope here. const full = s.is_admin && s.cli_billing_enabled + // No card on file → a charge can't succeed. Don't offer charge/auto-reload + // actions (they'd 403 no_payment_method); route to the portal to add a REUSABLE + // card, then the user returns to a refreshed overview. This is a half-done state. + const needsCard = full && !s.card + const note = !s.is_admin ? 'Billing actions need an org admin/owner.' : !s.cli_billing_enabled ? 'Terminal billing is off for this org — enable it on the portal.' - : null - - // Optimistic funnel: admin + kill-switch on but no saved card → a charge will - // 403 no_payment_method. Advise up front (Add funds stays available — the card - // field can't fully prove chargeability, so we hint rather than hide). - const cardHint = full && !s.card ? 'No saved card for terminal charges yet — set one up on the portal first.' : null + : needsCard + ? 'No card on file — add one on the portal to charge from the terminal.' + : null // Add funds first, then settings, then the scopeless browser handoff. Dollars, // never "credits". No "Enable terminal billing" item — see the note above. - const items = full - ? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel'] - : ['Manage on portal', 'Cancel'] + // No card → collapse to the single add-card portal action (charge actions hidden). + // With a card, "Add funds" charges in-terminal against the org's portal-saved + // card (server-held via POST /charge — no card ref leaves the client). + const items = needsCard + ? ['Add a card on the portal', 'Cancel'] + : full + ? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel'] + : ['Manage on portal', 'Cancel'] const [sel, setSel] = useState(0) const choose = (i: number) => { - if (full) { + if (needsCard) { + if (i === 0 && s.portal_url) { + ctx.openPortal(s.portal_url) + } + + onClose() + } else if (full) { if (i === 0) { onPatch({ screen: 'buy' }) } else if (i === 1) { @@ -178,12 +191,6 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { {note} )} - {cardHint && ( - - {cardHint} - - )} - {cardHint && s.portal_url && Portal: {s.portal_url}} {items.map((label, i) => ( @@ -401,6 +408,9 @@ function ConfirmScreen({ Total: ${amount} {payLine} + {s.card && ( + Your card saved on the portal will be charged. + )} By confirming, you allow Nous Research to charge your card. From 6de489909968a38f8d12620231c6b4560288fb87 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 17:29:29 +0530 Subject: [PATCH 29/62] refactor(billing): apply safe simplify-pass fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- agent/billing_view.py | 4 +++- cli.py | 6 ++--- ui-tui/src/components/billingOverlay.tsx | 29 +++++++++++------------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/agent/billing_view.py b/agent/billing_view.py index a1146935c0fa..e40977df0d3c 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -277,7 +277,9 @@ def _dev_fixture_billing_state() -> Optional[BillingState]: if not name: return None - portal = "https://portal.staging-nousresearch.com/billing?topup=open" + # Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL — + # prod host, not staging; the ?topup=open suffix is the /topup deep-link). + portal = "https://portal.nousresearch.com/billing?topup=open" common: dict[str, Any] = dict( org_id="org_acme", org_slug="acme", diff --git a/cli.py b/cli.py index 1a70d7fc5e72..51c850e13907 100644 --- a/cli.py +++ b/cli.py @@ -9310,9 +9310,9 @@ def _billing_overview(self, state): # Add funds first, then settings, then the scopeless browser handoff. # No "Enable terminal billing" item — that's discovered at pay time. # "Add funds" charges in-terminal against the org's portal-saved card - # (server-held via POST /charge — no card ref leaves the client). No card - # on file → _billing_buy_flow detects no_payment_method and routes to the - # portal to add one. + # (server-held via POST /charge — no card ref leaves the client). The + # no-card case is already handled above (the overview routes to the portal + # before this menu shows), so reaching "Add funds" implies a card on file. choices = [ ("buy", "Add funds", "add money to your balance"), ("auto", "Auto-reload", "configure automatic top-ups"), diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index afc8b6092d4b..e953139bf4fd 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -112,35 +112,32 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const [sel, setSel] = useState(0) const choose = (i: number) => { - if (needsCard) { - if (i === 0 && s.portal_url) { - ctx.openPortal(s.portal_url) - } - - onClose() - } else if (full) { + // Only a card-on-file admin/billing-on org (full && !needsCard) gets the + // buy/auto-reload/limit routing. Every other menu (needsCard, or not-full) + // exposes a single portal-or-close action at index 0. + if (full && !needsCard) { if (i === 0) { onPatch({ screen: 'buy' }) } else if (i === 1) { onPatch({ screen: 'autoreload' }) } else if (i === 2) { onPatch({ screen: 'limit' }) - } else if (i === 3) { - if (s.portal_url) { + } else { + if (i === 3 && s.portal_url) { ctx.openPortal(s.portal_url) } onClose() - } else { - onClose() - } - } else { - if (i === 0 && s.portal_url) { - ctx.openPortal(s.portal_url) } - onClose() + return } + + if (i === 0 && s.portal_url) { + ctx.openPortal(s.portal_url) + } + + onClose() } useInput((ch, key) => { From 1cb65a5d8f42d3cb18598e90dcf3f3132bf06e88 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 19:07:07 +0530 Subject: [PATCH 30/62] =?UTF-8?q?fix(billing):=20reactive=20charge=20gatin?= =?UTF-8?q?g=20=E2=80=94=20drop=20card=20preflight,=20react=20to=20403=20(?= =?UTF-8?q?scope=E2=86=92reauth,=20no-card=E2=86=92portal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli.py | 51 ++++++------------------ ui-tui/src/components/billingOverlay.tsx | 38 ++++++------------ 2 files changed, 26 insertions(+), 63 deletions(-) diff --git a/cli.py b/cli.py index 51c850e13907..95cf5c6915b4 100644 --- a/cli.py +++ b/cli.py @@ -9274,32 +9274,10 @@ def _billing_overview(self, state): self._billing_portal_hint(state, reason="Enable it on the portal to add funds here.") return - # No card on file → a charge can't succeed. Don't offer charge/auto-reload - # actions (they'd 403 no_payment_method or punt). Route to the portal to add - # a REUSABLE card, then the user returns to a refreshed overview. This is a - # half-done state — NOT a success. - if state.card is None: - _cprint( - f" {_d('No card on file — add one on the portal to charge from the terminal.')}" - ) - if not getattr(self, "_app", None): - self._billing_portal_hint(state) - return - add_card_choices = [ - ("portal", "Add a card on the portal", "opens the billing page to save a reusable card"), - ("cancel", "Cancel", "do nothing"), - ] - raw = self._prompt_text_input_modal( - title="Add a card to continue", - detail="No saved card yet. Add one on the portal, then re-run /topup to add funds.", - choices=add_card_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, add_card_choices) - if choice == "portal": - self._billing_open_portal(state) - print() - _cprint(f" {_d('Added a card? Re-run /topup to add funds.')}") - return + # A missing card does NOT gate the whole overview — the org may already have + # balance, auto-reload, or a limit to view/manage. The card only matters at + # CHARGE time: "Add funds" -> _billing_buy_flow, which detects no card and + # hands off to the portal there. So always show the full menu below. # Non-interactive (slash-worker / no live app): no modal, no sub-command # advertising — just the portal funnel (the URL is the affordance). @@ -9399,13 +9377,11 @@ def _billing_buy_flow(self, state): if not self._billing_require_admin(state): return - # Card gate: no saved card → a charge would 403 no_payment_method. Route to - # the portal to add a reusable card instead of offering doomed presets. - if state.card is None: - print() - _cprint(f" {_d('No card on file — add one on the portal first, then re-run /topup.')}") - self._billing_portal_hint(state) - return + # No card / scope preflight here — that's the rejected anti-pattern. We let + # the charge fly and react to whatever 403 the server returns: scope first + # (insufficient_scope → in-flight reauth), then card (no_payment_method → + # portal handoff via _billing_render_charge_error). Mirrors the server's gate + # order; the user only hits the flow they actually need. # Screen 3 — preset selection. if not getattr(self, "_app", None): @@ -9600,8 +9576,7 @@ def _billing_render_charge_error(self, state, exc): elif isinstance(exc, BillingSessionRevoked) or code == "session_revoked": print(" 🔴 Your session was logged out. Run `hermes portal` to log in again.") elif code == "no_payment_method": - print(" 💳 No saved card for terminal charges yet. Set one up on the " - "portal (one-time credit buys don't save a reusable card).") + print(" 💳 No card on file — top up and manage billing on the portal.") elif code in ("cli_billing_disabled", "remote_spending_disabled") or \ getattr(exc, "code", None) == "remote_spending_disabled": print(" Terminal billing is off for this account — an admin must enable it on the portal.") @@ -9681,10 +9656,10 @@ def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key= # Scope granted + org kill-switch on — but a charge still needs a card on # file. If there's none, this is a half-done state: say so and route to the - # portal to add a card, rather than a bare "✓ enabled" that reads as done. + # portal to top up / manage billing, rather than a bare "✓ enabled" that reads as done. if fresh.card is None: print(" ✓ Terminal billing enabled — but there's no card on file yet.") - _cprint(f" {_d('Add a card on the portal, then re-run the action to continue.')}") + _cprint(f" {_d('Top up and manage billing on the portal to continue.')}") self._billing_portal_hint(fresh) return @@ -9752,7 +9727,7 @@ def _billing_auto_reload_flow(self, state): if card: print(f" Card on file: {card.masked}") else: - print(" No saved card — set one up on the portal first.") + print(" No saved card — manage billing on the portal.") self._billing_portal_hint(state) return if currently_on: diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index e953139bf4fd..4c698222e1d0 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -85,37 +85,25 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { // do NOT preflight the scope here. const full = s.is_admin && s.cli_billing_enabled - // No card on file → a charge can't succeed. Don't offer charge/auto-reload - // actions (they'd 403 no_payment_method); route to the portal to add a REUSABLE - // card, then the user returns to a refreshed overview. This is a half-done state. - const needsCard = full && !s.card - const note = !s.is_admin ? 'Billing actions need an org admin/owner.' : !s.cli_billing_enabled - ? 'Terminal billing is off for this org — enable it on the portal.' - : needsCard - ? 'No card on file — add one on the portal to charge from the terminal.' - : null - - // Add funds first, then settings, then the scopeless browser handoff. Dollars, - // never "credits". No "Enable terminal billing" item — see the note above. - // No card → collapse to the single add-card portal action (charge actions hidden). - // With a card, "Add funds" charges in-terminal against the org's portal-saved - // card (server-held via POST /charge — no card ref leaves the client). - const items = needsCard - ? ['Add a card on the portal', 'Cancel'] - : full - ? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel'] - : ['Manage on portal', 'Cancel'] + ? 'Terminal billing is off for this org — manage it on the portal.' + : null + + // Always show the full billing menu for an admin/billing-on org — a missing + // card does NOT mean nothing can be done (the org may already have balance, + // auto-reload, a limit). The card only matters at CHARGE time: "Add funds" + // attempts the charge and, if there's no card (no_payment_method), the buy + // flow hands off to the portal to top up / manage billing there. + const items = full + ? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel'] + : ['Manage on portal', 'Cancel'] const [sel, setSel] = useState(0) const choose = (i: number) => { - // Only a card-on-file admin/billing-on org (full && !needsCard) gets the - // buy/auto-reload/limit routing. Every other menu (needsCard, or not-full) - // exposes a single portal-or-close action at index 0. - if (full && !needsCard) { + if (full) { if (i === 0) { onPatch({ screen: 'buy' }) } else if (i === 1) { @@ -638,7 +626,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const turnOn = () => { if (noCard) { - ctx.sys('🔴 No saved card — set one up on the portal first.') + ctx.sys('🔴 No saved card — manage billing on the portal.') if (s.portal_url) { ctx.openPortal(s.portal_url) From 7dae104e40f6106ef778a9401a71981274cfcd53 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 19:24:26 +0530 Subject: [PATCH 31/62] refactor(billing): drop the /credits alias entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- hermes_cli/commands.py | 2 +- tests/agent/test_credits_view.py | 17 ++++++++++------- ui-tui/src/app/slash/commands/topup.ts | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 1480474664a8..cf1fc4fa8c3a 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -220,7 +220,7 @@ class CommandDef: CommandDef("subscription", "View your Nous plan and change it in the browser", "Info", cli_only=True, aliases=("upgrade",)), CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info", - aliases=("billing", "credits")), + aliases=("billing",)), CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", diff --git a/tests/agent/test_credits_view.py b/tests/agent/test_credits_view.py index 0f75981e4a59..d8dcbfc9cf78 100644 --- a/tests/agent/test_credits_view.py +++ b/tests/agent/test_credits_view.py @@ -193,15 +193,18 @@ def _boom(*a, **kw): # ── command registry ──────────────────────────────────────────────────────── -def test_credits_folds_into_topup(): - """`/credits` is an alias of `/topup` now — it resolves to the topup command.""" +def test_credits_command_fully_removed(): + """`/credits` is gone entirely — not a command, not an alias. Billing lives on + /topup, which is the surface on every platform.""" from hermes_cli.commands import resolve_command, COMMAND_REGISTRY - cmd = resolve_command("credits") - assert cmd is not None and cmd.name == "topup" - # /topup is available on every surface (not cli_only / gateway_only). + # /credits resolves to nothing (no command, no alias). + assert resolve_command("credits") is None + # No standalone `credits` command remains in the registry. + assert not any(c.name == "credits" for c in COMMAND_REGISTRY) + # And no command carries `credits` as an alias. + assert not any("credits" in (c.aliases or ()) for c in COMMAND_REGISTRY) + # /topup is the billing surface, available on every surface (not cli/gateway-only). entry = next(c for c in COMMAND_REGISTRY if c.name == "topup") assert entry.cli_only is False assert entry.gateway_only is False - # No standalone `credits` command remains in the registry. - assert not any(c.name == "credits" for c in COMMAND_REGISTRY) diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 2cb954a9d40a..02d52645ceb8 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -329,7 +329,7 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B export const topupCommands: SlashCommand[] = [ { - aliases: ['billing', 'credits'], + aliases: ['billing'], help: 'Show your balance and manage billing — add funds, auto-reload, limits', name: 'topup', // ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/topup` From 90081ba91155515195bde600613f4ad866f98bee Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 19:24:42 +0530 Subject: [PATCH 32/62] =?UTF-8?q?docs(billing):=20fix=20stale=20comment=20?= =?UTF-8?q?in=20=5Fbilling=5Foverview=20=E2=80=94=20describe=20reactive=20?= =?UTF-8?q?no-card=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli.py b/cli.py index 95cf5c6915b4..10563c6205e7 100644 --- a/cli.py +++ b/cli.py @@ -9288,9 +9288,9 @@ def _billing_overview(self, state): # Add funds first, then settings, then the scopeless browser handoff. # No "Enable terminal billing" item — that's discovered at pay time. # "Add funds" charges in-terminal against the org's portal-saved card - # (server-held via POST /charge — no card ref leaves the client). The - # no-card case is already handled above (the overview routes to the portal - # before this menu shows), so reaching "Add funds" implies a card on file. + # (server-held via POST /charge — no card ref leaves the client). A + # missing card is NOT gated here: the buy flow reacts to the server's + # no_payment_method 403 and hands off to the portal at charge time. choices = [ ("buy", "Add funds", "add money to your balance"), ("auto", "Auto-reload", "configure automatic top-ups"), From 5ec5a25eca5b0f19d8d656561341073ef0f1bf70 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 19:34:32 +0530 Subject: [PATCH 33/62] =?UTF-8?q?refactor(billing):=20simplify-pass=20?= =?UTF-8?q?=E2=80=94=20share=20usage-payload=20helper,=20drop=20dead=20bar?= =?UTF-8?q?=20wire=20fields=20+=20redundant=20admin=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli.py | 2 +- tui_gateway/server.py | 27 +++---------------- ui-tui/src/components/subscriptionOverlay.tsx | 2 +- 3 files changed, 6 insertions(+), 25 deletions(-) diff --git a/cli.py b/cli.py index 10563c6205e7..7838ca8ecf4a 100644 --- a/cli.py +++ b/cli.py @@ -9053,7 +9053,7 @@ def _subscription_overview(self, state, manage_url): c = state.current is_free = not (c and c.tier_id) - can_change = state.can_change_plan and state.is_admin + can_change = state.can_change_plan plan_name = (c.tier_name or c.tier_id) if c else (usage.plan_name if usage else None) u_status = getattr(usage, "status", None) if usage else None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7a294db187ff..97c1ae236354 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5554,12 +5554,12 @@ def _s(value): # same plan + top-up bars as /usage and /subscription from its single # fetch. Built from the separate account-info path; fail-open when logged # out or the portal is down. - "usage": _billing_usage_payload(state), + "usage": _usage_payload(state), } -def _billing_usage_payload(state) -> dict: - """Best-effort shared usage model for the /topup overview's bars. +def _usage_payload(state) -> dict: + """Best-effort shared usage model for the /topup + /subscription overlay bars. Only fetched when logged in; fail-open to {available:false} so the overview still renders if the account-info path is down. @@ -5598,11 +5598,8 @@ def _serialize_usage_bar(bar) -> Optional[dict]: return { "kind": bar.kind, - "remaining_usd": bar.remaining_usd, "remaining_display": _fmt_usd(bar.remaining_usd), - "total_usd": bar.total_usd, "total_display": _fmt_usd(bar.total_usd), - "spent_usd": bar.spent_usd, "spent_display": _fmt_usd(bar.spent_usd), "pct_used": bar.pct_used, "fill_fraction": bar.fill_fraction, @@ -5707,26 +5704,10 @@ def _s(value): # separate account-info path (the only source with top-up dollars); # fail-open → {available:false}. Computed lazily so a logged-out state # adds no cost. - "usage": _subscription_usage_payload(state), + "usage": _usage_payload(state), } -def _subscription_usage_payload(state) -> dict: - """Best-effort shared usage model for the /subscription overlay's bars. - - Only fetched when logged in; fail-open to {available:false} so the overview - still renders if the account-info path is down. - """ - if not getattr(state, "logged_in", False): - return {"available": False} - try: - from agent.billing_usage import build_usage_model - - return _serialize_usage_model(build_usage_model()) - except Exception: - return {"available": False} - - @method("subscription.state") def _(rid, params: dict) -> dict: """GET /api/billing/subscription → serialized SubscriptionState. diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 63f2626ec154..4c5d3cfd8bdb 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -73,7 +73,7 @@ function statusLine(s: SubscriptionStateResponse): string { const plan = s.current?.tier_name ?? u?.plan_name ?? null const renewsRaw = u?.renews_display ?? null const renews = renewsRaw ? ` · renews ${renewsRaw}` : '' - const viewOnly = !(s.can_change_plan && s.is_admin) + const viewOnly = !s.can_change_plan if (!plan) { return 'Plan: Free · free models only' From 722299bc5f6c3511db598e6178950741445dcb19 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 20:01:48 +0530 Subject: [PATCH 34/62] =?UTF-8?q?refactor(billing):=20drop=20the=20/billin?= =?UTF-8?q?g=20alias=20too=20=E2=80=94=20/topup=20is=20the=20only=20billin?= =?UTF-8?q?g=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 10 +++++----- hermes_cli/commands.py | 3 +-- tests/agent/test_credits_view.py | 20 ++++++++++++-------- ui-tui/src/app/slash/commands/topup.ts | 1 - 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/cli.py b/cli.py index 7838ca8ecf4a..168209e1fda8 100644 --- a/cli.py +++ b/cli.py @@ -9177,13 +9177,13 @@ def _subscription_overview(self, state, manage_url): # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) # ------------------------------------------------------------------ - def _show_billing(self, command: str = "/billing"): - """`/billing` — terminal billing for Nous (one interactive modal). + def _show_billing(self, command: str = "/topup"): + """`/topup` — terminal billing for Nous (one interactive modal). - ZERO sub-commands: any argument is ignored. Bare ``/billing`` always + ZERO sub-commands: any argument is ignored. Bare ``/topup`` always opens the Overview (Screen 1), whose numbered menu is the *only* way to reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified - UX spec §0.4 — ``/billing buy`` etc. are gone; we don't error on a stray + UX spec §0.4 — ``/topup buy`` etc. are gone; we don't error on a stray arg, we just open the menu.) Interactive CLI uses the prompt_toolkit modal; non-interactive contexts @@ -9201,7 +9201,7 @@ def _show_billing(self, command: str = "/billing"): _cprint(f" 💳 {_d(_msg)}") else: _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") - print(" Run `hermes portal` to log in, then /billing.") + print(" Run `hermes portal` to log in, then /topup.") return # Any sub-arg is intentionally ignored — always open the menu. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index cf1fc4fa8c3a..6d4837941e2f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -219,8 +219,7 @@ class CommandDef: CommandDef("usage", "Show token usage and rate limits for the current session", "Info"), CommandDef("subscription", "View your Nous plan and change it in the browser", "Info", cli_only=True, aliases=("upgrade",)), - CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info", - aliases=("billing",)), + CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info"), CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", diff --git a/tests/agent/test_credits_view.py b/tests/agent/test_credits_view.py index d8dcbfc9cf78..a20bd8f14a68 100644 --- a/tests/agent/test_credits_view.py +++ b/tests/agent/test_credits_view.py @@ -194,17 +194,21 @@ def _boom(*a, **kw): def test_credits_command_fully_removed(): - """`/credits` is gone entirely — not a command, not an alias. Billing lives on - /topup, which is the surface on every platform.""" + """`/credits` and the old `/billing` are gone entirely — not commands, not + aliases. Billing lives only on /topup, with NO aliases, on every platform.""" from hermes_cli.commands import resolve_command, COMMAND_REGISTRY - # /credits resolves to nothing (no command, no alias). + # Both old names resolve to nothing. assert resolve_command("credits") is None - # No standalone `credits` command remains in the registry. - assert not any(c.name == "credits" for c in COMMAND_REGISTRY) - # And no command carries `credits` as an alias. - assert not any("credits" in (c.aliases or ()) for c in COMMAND_REGISTRY) - # /topup is the billing surface, available on every surface (not cli/gateway-only). + assert resolve_command("billing") is None + # No standalone command for either remains in the registry. + assert not any(c.name in ("credits", "billing") for c in COMMAND_REGISTRY) + # And no command carries either as an alias. + for c in COMMAND_REGISTRY: + assert "credits" not in (c.aliases or ()) + assert "billing" not in (c.aliases or ()) + # /topup is the billing surface, on every surface, and carries no aliases. entry = next(c for c in COMMAND_REGISTRY if c.name == "topup") assert entry.cli_only is False assert entry.gateway_only is False + assert not entry.aliases diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 02d52645ceb8..d24697542aa6 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -329,7 +329,6 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B export const topupCommands: SlashCommand[] = [ { - aliases: ['billing'], help: 'Show your balance and manage billing — add funds, auto-reload, limits', name: 'topup', // ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/topup` From 25d7497b06fbad2ef05b800e1baed2cda3ac500d Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 20:28:16 +0530 Subject: [PATCH 35/62] =?UTF-8?q?fix(billing):=20code-review=20fixes=20?= =?UTF-8?q?=E2=80=94=20money-path=20+=20parity=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- agent/subscription_view.py | 17 ++++++- cli.py | 4 ++ tui_gateway/server.py | 15 ++++++ ui-tui/src/app/slash/commands/session.ts | 2 +- ui-tui/src/app/slash/commands/subscription.ts | 14 ++++-- ui-tui/src/app/slash/commands/topup.ts | 46 +++++++++++++------ ui-tui/src/components/billingOverlay.tsx | 45 ++++++++++++------ ui-tui/src/components/subscriptionOverlay.tsx | 27 +++++++---- 8 files changed, 125 insertions(+), 45 deletions(-) diff --git a/agent/subscription_view.py b/agent/subscription_view.py index 3ec17c03313a..c2bf6719b1ed 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -101,6 +101,19 @@ def can_change_plan(self) -> bool: # ============================================================================= +def _coalesce(*vals: Any) -> Any: + """First non-None value (None-coalesce, NOT truthy-coalesce — keeps 0/0.0). + + NAS sends 0 for the free tier's dollarsPerMonth/tierOrder; a bare ``a or b`` + would treat that 0 as missing and fall through to the alias (yielding a '—' + price or a corrupted sort index). + """ + for v in vals: + if v is not None: + return v + return None + + def _parse_tier(raw: Any) -> Optional[SubscriptionTier]: if not isinstance(raw, dict): return None @@ -111,8 +124,8 @@ def _parse_tier(raw: Any) -> Optional[SubscriptionTier]: return SubscriptionTier( tier_id=tier_id, name=name, - tier_order=int(raw.get("tierOrder") or raw.get("order") or 0), - dollars_per_month=parse_money(raw.get("dollarsPerMonth") or raw.get("priceUsd")), + tier_order=int(_coalesce(raw.get("tierOrder"), raw.get("order"), 0)), + dollars_per_month=parse_money(_coalesce(raw.get("dollarsPerMonth"), raw.get("priceUsd"))), monthly_credits=parse_money(raw.get("monthlyCredits")), is_current=bool(raw.get("isCurrent")), is_enabled=bool(raw.get("isEnabled", True)), diff --git a/cli.py b/cli.py index 168209e1fda8..048ea26cd405 100644 --- a/cli.py +++ b/cli.py @@ -9594,6 +9594,10 @@ def _billing_render_charge_error(self, state, exc): wait = getattr(exc, "retry_after", None) mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else "" print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.") + elif code == "insufficient_scope": + # Never leak the raw billing:manage scope (the post-grant replay can + # re-raise it if the grant raced) — the concept is "terminal billing". + print(" 🔴 Terminal billing needs approval — run /topup to enable it, then retry.") else: print(f" 🔴 {exc}") if portal_url: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 97c1ae236354..061c329f7373 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -174,6 +174,13 @@ def _thread_panic_hook(args): # response writes are safe. _LONG_HANDLERS = frozenset( { + # Billing/usage reads each do a blocking portal HTTP fetch (state + usage + # is two serial round-trips); keep them off the main stdin loop so a slow + # portal can't stall approval.respond / session.interrupt / other RPCs. + "billing.state", + "subscription.state", + "usage.bars", + "session.usage", "billing.step_up", "browser.manage", "cli.exec", @@ -5819,6 +5826,7 @@ def _(rid, params: dict) -> dict: sid = params.get("session_id") or "" try: from hermes_cli.auth import step_up_nous_billing_scope + from hermes_cli.nous_billing import BillingError def _on_verification(url: str, code: str) -> None: _emit( @@ -5831,6 +5839,13 @@ def _on_verification(url: str, code: str) -> None: open_browser=False, on_verification=_on_verification ) return _ok(rid, {"ok": True, "granted": bool(granted)}) + except BillingError as exc: + # Route typed billing errors (e.g. session_revoked when the token expires + # mid-device-flow) through the shared spine like the other write handlers, + # so the TUI maps them to the right copy instead of a generic failure. + env = _serialize_billing_error(exc) + env["granted"] = False + return _ok(rid, env) except Exception as exc: return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "granted": False}) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index a74bcd1f8469..513d1a551038 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -567,7 +567,7 @@ export const sessionCommands: SlashCommand[] = [ const plan = usageModel.plan_name ?? (usageModel.status === 'free' ? 'Free' : null) if (plan) { - sections.push({ text: `Plan: ${plan}${usageModel.renews_at ? ` · renews ${usageModel.renews_at}` : ''}` }) + sections.push({ text: `Plan: ${plan}${usageModel.renews_display ? ` · renews ${usageModel.renews_display}` : ''}` }) } if (barLines.length) { diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index 0d3aecbe7dbc..e0cdd6654f87 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -18,9 +18,17 @@ function buildManageUrl(s: SubscriptionStateResponse): string | null { // portal_url is already an absolute URL resolved by resolve_portal_base_url() // on the Python side (e.g. https://portal.nousresearch.com/billing). Strip any // path so we can attach /manage-subscription cleanly. - const base = s.portal_url - ? new URL(s.portal_url).origin - : null + let base: string | null = null + + if (s.portal_url) { + try { + base = new URL(s.portal_url).origin + } catch { + // A malformed portal_url must not throw out of the Ink key handler + // (it would crash the overlay) — treat it as "no manage URL". + return null + } + } if (!base) { return null diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index d24697542aa6..7fa323a59b47 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -46,9 +46,10 @@ const renderBillingError = ( // wait for the token refresh ~15 min away) and tell the user who did it. patchOverlayState({ billing: null }) - const who = env.actor === 'admin' - ? 'An admin turned off terminal billing for this terminal.' - : 'You turned off terminal billing for this terminal.' + const who = + env.actor === 'admin' + ? 'An admin turned off terminal billing for this terminal.' + : 'You turned off terminal billing for this terminal.' sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`) @@ -91,7 +92,11 @@ const renderBillingError = ( case 'monthly_cap_exceeded': { // Surface the remaining headroom the server attaches (parity with the CLI). const remaining = env.payload?.remainingUsd - sys(remaining != null ? `🔴 Monthly spend cap reached — $${remaining} headroom left.` : '🔴 Monthly spend cap reached.') + sys( + remaining != null + ? `🔴 Monthly spend cap reached — $${remaining} headroom left.` + : '🔴 Monthly spend cap reached.' + ) break } @@ -137,6 +142,24 @@ const requestRemoteSpending = (ctx: SlashRunCtx): Promise => const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: string | null): void => { const start = Date.now() + // The 5-min cap, honored on EVERY non-terminal path (pending AND throttled) + // so a sustained 429/503 can't keep the poll alive forever. + const timedOut = (): boolean => { + if (Date.now() - start < POLL_CAP_MS) { + return false + } + + sys( + '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + 'Check /topup or the portal shortly.' + ) + + if (portalUrl) { + sys(`Portal: ${portalUrl}`) + } + + return true + } + const tick = (): void => { if (ctx.stale()) { return @@ -149,6 +172,10 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st if (!r.ok) { // 429/503 while polling = retry-after, NOT a failure. Back off + continue. if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable') { + if (timedOut()) { + return + } + const wait = (r.retry_after ?? 5) * 1000 setTimeout(tick, Math.min(wait, 30000)) @@ -183,16 +210,7 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st } // pending → keep polling until the 5-min cap, then call it a timeout. - if (Date.now() - start >= POLL_CAP_MS) { - sys( - '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + - 'Check /topup or the portal shortly.' - ) - - if (portalUrl) { - sys(`Portal: ${portalUrl}`) - } - + if (timedOut()) { return } diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 4c698222e1d0..982f9fc3f2b7 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -1,5 +1,5 @@ import { Box, Text, useInput } from '@hermes/ink' -import { useState } from 'react' +import { useRef, useState } from 'react' import type { BillingOverlayState } from '../app/interfaces.js' import type { BillingStateResponse } from '../gatewayTypes.js' @@ -55,12 +55,7 @@ export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayP {screen === 'autoreload' && } {screen === 'limit' && } {screen === 'stepup' && ( - + )} ) @@ -332,12 +327,17 @@ function ConfirmScreen({ // rows: Pay $X now / Cancel const [sel, setSel] = useState(0) const [submitting, setSubmitting] = useState(false) + // Synchronous guard: two key events can both observe `submitting === false` + // before React commits the state update, double-firing the charge (and the + // gateway mints a fresh idempotency key per call → two charges). + const submittingRef = useRef(false) const pay = () => { - if (submitting) { + if (submittingRef.current || submitting) { return } + submittingRef.current = true setSubmitting(true) void ctx.charge(amount).then(outcome => { if (outcome === 'needs_remote_spending') { @@ -393,9 +393,7 @@ function ConfirmScreen({ Total: ${amount} {payLine} - {s.card && ( - Your card saved on the portal will be charged. - )} + {s.card && Your card saved on the portal will be charged.} By confirming, you allow Nous Research to charge your card. @@ -439,7 +437,9 @@ function StepUpScreen({ void ctx.requestRemoteSpending().then(granted => { if (!granted) { - ctx.sys("! Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.") + ctx.sys( + "! Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged." + ) onClose() return @@ -458,7 +458,15 @@ function StepUpScreen({ setPhase('resuming') ctx.sys('✓ Terminal billing enabled — resuming your purchase.') - void ctx.charge(amount).then(() => onClose()) + void ctx.charge(amount).then(outcome => { + // If the replay STILL can't spend (grant raced/expired or downscoped), + // say so — don't close on a reassuring line with no charge made. + if (outcome === 'needs_remote_spending') { + ctx.sys('! Terminal billing still needs approval — run /topup to try again. Your card was not charged.') + } + + onClose() + }) } const decline = () => { @@ -567,7 +575,9 @@ function StepUpScreen({ One-time setup To charge this terminal, enable terminal billing once. - It opens your browser to authorize, then your ${amount} top-up picks up right here. + + It opens your browser to authorize, then your ${amount} top-up picks up right here. + @@ -652,7 +662,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { } const turnOff = () => { - void ctx.applyAutoReload(false).then(ok => { + // The PATCH requires threshold/top_up_amount even when disabling (parity + // with the CLI's _billing_auto_reload_disable) — echo the current values, + // else the gateway rejects with invalid_request and auto-reload stays ON. + const thr = Number(prefill(ar?.threshold_usd)) || 0 + const rel = Number(prefill(ar?.reload_to_usd)) || 0 + void ctx.applyAutoReload(false, thr, rel).then(ok => { if (ok) { ctx.sys('✅ Auto-reload turned off.') } diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 4c5d3cfd8bdb..f4ead2a3ed8d 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -104,9 +104,10 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { : 'Cancellation scheduled — your plan stays active until the end of the billing period.' : null - const downgradeNote = !isCancelScheduled && hasPendingDowngrade - ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${c?.pending_downgrade_at}.` - : null + const downgradeNote = + !isCancelScheduled && hasPendingDowngrade + ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${c?.pending_downgrade_at ?? 'the end of the billing period'}.` + : null // State-matched upsell/alert nudge (dollars-only; healthy stays silent). const u = s.usage @@ -130,6 +131,8 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { if (s.portal_url) { ctx.sys('Opening your subscription page in the browser…') void ctx.openManageLink() + } else { + ctx.sys('🔴 No portal URL available — manage your subscription on the Nous portal.') } } @@ -168,12 +171,18 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { {freeNudge && ( - {'> '}{freeNudge} + + {'> '} + {freeNudge} + )} {lowNudge && ( - {'! '}{lowNudge} + + {'! '} + {lowNudge} + )} {s.org_name && ( @@ -232,12 +241,10 @@ function TeamContextScreen({ onClose, s, t }: TeamContextScreenProps) { )} - This terminal is connected to {s.org_name ?? 'a team org'}. Teams run on a shared - balance · use /topup to add funds. - - - Personal subscriptions live on your personal account. + This terminal is connected to {s.org_name ?? 'a team org'}. Teams run on a shared balance · use /topup to add + funds. + Personal subscriptions live on your personal account. {footer('Enter/Esc close', t)} From 9e1ade182034a394c49481985e5c1ab6ccbc9d2b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 21:24:31 +0530 Subject: [PATCH 36/62] fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- cli.py | 20 +++++++++++-------- hermes_cli/commands.py | 4 +++- tui_gateway/server.py | 3 +++ ui-tui/src/components/subscriptionOverlay.tsx | 9 ++++++--- ui-tui/src/gatewayTypes.ts | 2 ++ 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/cli.py b/cli.py index 048ea26cd405..ba81a8526c15 100644 --- a/cli.py +++ b/cli.py @@ -9317,11 +9317,14 @@ def _billing_overview(self, state): print(" Cancelled.") def _billing_spend_bar(self, spent, limit, *, cells: int = 10): - """Render a 10-cell `█`/`░` spend bar + integer percent from spent/limit. - - Returns ``(bar, pct)`` where ``bar`` is like ``[████░░░░░░]`` and ``pct`` - is the spent/limit percentage clamped to 0..100. Box-drawing glyphs are - not SGR codes, so this is leak-safe even without ``_b()``/``_d()``. + """Render a 10-cell `█`/`░` usage bar (filled = REMAINING) + % used. + + Returns ``(bar, pct)`` where ``bar`` is like ``[███████░░░]`` and ``pct`` + is the spent/limit percentage clamped to 0..100. The bar fills by what's + LEFT (fuel-gauge), matching the shared model's ``fill_fraction``, the + top-up bar (full = balance), and the TUI — so the same account renders + identically on CLI and TUI. Box-drawing glyphs are not SGR codes, so this + is leak-safe even without ``_b()``/``_d()``. """ from decimal import Decimal @@ -9332,10 +9335,11 @@ def _billing_spend_bar(self, spent, limit, *, cells: int = 10): s, l = Decimal("0"), Decimal("0") if l <= 0: pct = 0 + filled = 0 else: - pct = int((s / l) * 100) - pct = max(0, min(100, pct)) - filled = int(round(pct / 100 * cells)) + pct = max(0, min(100, int((s / l) * 100))) + remaining = max(Decimal("0"), l - s) + filled = int(round((remaining / l) * cells)) filled = max(0, min(cells, filled)) bar = ("█" * filled) + ("░" * (cells - filled)) return bar, pct diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 6d4837941e2f..f6770fc82531 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1058,8 +1058,10 @@ def discord_skill_commands_by_category( # the telegram-parity test reads it so an entry here is a deliberate # "Slack-via-/hermes" decision, not a silent clamp. # - topup: the billing/balance surface; reached via /hermes topup on Slack. +# - billing: alias of topup — route it via /hermes too, else the alias would +# leak a native Slack slot the canonical command is deliberately denied. # - debug: the log/report upload surface; reached via /hermes debug on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "debug"}) +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "billing", "debug"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 061c329f7373..9d373e813dff 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5664,6 +5664,7 @@ def _(rid, params: dict) -> dict: def _serialize_subscription_state(state) -> dict: """Serialize a SubscriptionState for the wire (Decimals → strings).""" from agent.billing_view import format_money + from agent.billing_usage import format_renews def _s(value): return None if value is None else str(value) @@ -5679,8 +5680,10 @@ def _s(value): "cycle_ends_at": c.cycle_ends_at, "pending_downgrade_tier_name": c.pending_downgrade_tier_name, "pending_downgrade_at": c.pending_downgrade_at, + "pending_downgrade_display": format_renews(c.pending_downgrade_at), "cancel_at_period_end": c.cancel_at_period_end, "cancellation_effective_at": c.cancellation_effective_at, + "cancellation_effective_display": format_renews(c.cancellation_effective_at), } tiers = [] for t in state.tiers: diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index f4ead2a3ed8d..cbd0c79dab6d 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -98,15 +98,18 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { // Headline precedence: cancel-scheduled > downgrade-pending > active. // (Past-due/dunning was removed from the NAS read — a card-failing // subscriber now returns as a normal plan; no special-casing here.) + const cancelOn = c?.cancellation_effective_display ?? c?.cancellation_effective_at const cancellationNote = isCancelScheduled - ? c?.cancellation_effective_at - ? `Cancels on ${c.cancellation_effective_at} — your plan stays active until then.` + ? cancelOn + ? `Cancels on ${cancelOn} — your plan stays active until then.` : 'Cancellation scheduled — your plan stays active until the end of the billing period.' : null + const downgradeOn = + c?.pending_downgrade_display ?? c?.pending_downgrade_at ?? 'the end of the billing period' const downgradeNote = !isCancelScheduled && hasPendingDowngrade - ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${c?.pending_downgrade_at ?? 'the end of the billing period'}.` + ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${downgradeOn}.` : null // State-matched upsell/alert nudge (dollars-only; healthy stays silent). diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 60a8dfab8859..e6dfec1f671c 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -168,8 +168,10 @@ export interface SubscriptionStateResponse { cycle_ends_at: string | null // ISO pending_downgrade_tier_name: string | null pending_downgrade_at: string | null + pending_downgrade_display: string | null // formatted pending_downgrade_at cancel_at_period_end: boolean // subscription scheduled to cancel at period end cancellation_effective_at: string | null // ISO when cancellation takes effect + cancellation_effective_display: string | null // formatted cancellation_effective_at } | null tiers: SubscriptionTierOption[] portal_url: string | null From f8c6da229c88e422b499f8dbadf2eb559f4b8e99 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 22:22:34 +0530 Subject: [PATCH 37/62] 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. --- ui-tui/src/app/interfaces.ts | 8 +++++++- ui-tui/src/app/slash/commands/topup.ts | 7 +++++-- ui-tui/src/components/billingOverlay.tsx | 24 ++++++++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 1422b9e42510..14fea9ea5892 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -116,7 +116,7 @@ export interface BillingOverlayCtx { * `needs_remote_spending` instead of tearing down. Settlement/most errors are * still reported via transcript lines (the poll is non-blocking). */ - charge: (amount: string) => Promise + charge: (amount: string, idempotencyKey?: string) => Promise /** * Run the `billing.step_up` device flow (grant Remote Spending). Resolves * `true` when the grant lands. The browser opens via the gateway's @@ -134,6 +134,12 @@ export interface BillingOverlayCtx { /** Pending confirm built when leaving the buy/autoreload screen. */ export interface BillingPendingCharge { amount: string + /** + * Stable idempotency key for THIS purchase, minted when the amount is chosen. + * Reused across the step-up replay so a re-charge after the grant dedups + * server-side (and a double-submit collapses to one charge). + */ + idempotencyKey?: string } export interface BillingOverlayState { diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 7fa323a59b47..b2410b1d460c 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -304,11 +304,14 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B return false }), - charge: (amount: string): Promise => { + charge: (amount: string, idempotencyKey?: string): Promise => { sys('💳 Charge submitted — confirming settlement…') return ctx.gateway - .rpc('billing.charge', { amount_usd: amount }) + .rpc('billing.charge', { + amount_usd: amount, + ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}) + }) .then((r): BillingChargeOutcome => { if (!r) { return 'error' diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 982f9fc3f2b7..419250509e3e 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto' + import { Box, Text, useInput } from '@hermes/ink' import { useRef, useState } from 'react' @@ -45,6 +47,7 @@ export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayP onPatch({ pendingCharge: null, screen: 'buy' })} onClose={onClose} onPatch={onPatch} @@ -55,7 +58,13 @@ export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayP {screen === 'autoreload' && } {screen === 'limit' && } {screen === 'stepup' && ( - + )} ) @@ -198,7 +207,10 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { const [error, setError] = useState(null) const toConfirm = (amount: string) => { - onPatch({ pendingCharge: { amount }, screen: 'confirm' }) + // Mint the idempotency key here (purchase identity = this amount). It rides + // pendingCharge into Confirm AND the step-up replay, so a retried charge + // dedups server-side; a fresh amount selection gets a fresh key. + onPatch({ pendingCharge: { amount, idempotencyKey: randomUUID() }, screen: 'confirm' }) } const pickPreset = (i: number) => { @@ -310,6 +322,7 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { function ConfirmScreen({ amount, ctx, + idempotencyKey, onBack, onClose, onPatch, @@ -318,6 +331,7 @@ function ConfirmScreen({ }: { amount: string ctx: BillingOverlayState['ctx'] + idempotencyKey?: string onBack: () => void onClose: () => void onPatch: (next: Partial) => void @@ -339,7 +353,7 @@ function ConfirmScreen({ submittingRef.current = true setSubmitting(true) - void ctx.charge(amount).then(outcome => { + void ctx.charge(amount, idempotencyKey).then(outcome => { if (outcome === 'needs_remote_spending') { // Resumable step-up: keep the modal MOUNTED, switch to the stepup // screen (which holds pendingCharge.amount for the post-grant replay). @@ -416,11 +430,13 @@ function ConfirmScreen({ function StepUpScreen({ amount, ctx, + idempotencyKey, onClose, t }: { amount: string ctx: BillingOverlayState['ctx'] + idempotencyKey?: string onClose: () => void t: Theme }) { @@ -458,7 +474,7 @@ function StepUpScreen({ setPhase('resuming') ctx.sys('✓ Terminal billing enabled — resuming your purchase.') - void ctx.charge(amount).then(outcome => { + void ctx.charge(amount, idempotencyKey).then(outcome => { // If the replay STILL can't spend (grant raced/expired or downscoped), // say so — don't close on a reassuring line with no charge made. if (outcome === 'needs_remote_spending') { From 61b5d97f009862f1ce6b3d3ed8b02ed4731d7248 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 22:32:14 +0530 Subject: [PATCH 38/62] refactor(billing): remove dead /subscription tier-picker scaffolding (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- agent/subscription_view.py | 92 +----- tests/agent/test_subscription_view.py | 8 +- tui_gateway/server.py | 13 - .../src/__tests__/subscriptionCommand.test.ts | 4 +- .../__tests__/subscriptionOverlay.test.tsx | 269 ++++-------------- ui-tui/src/app/interfaces.ts | 9 +- ui-tui/src/app/slash/commands/subscription.ts | 1 - ui-tui/src/components/appOverlays.tsx | 5 +- ui-tui/src/components/subscriptionOverlay.tsx | 159 +---------- ui-tui/src/gatewayTypes.ts | 11 - 10 files changed, 85 insertions(+), 486 deletions(-) diff --git a/agent/subscription_view.py b/agent/subscription_view.py index c2bf6719b1ed..f77d759fa007 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -32,19 +32,6 @@ # ============================================================================= -@dataclass(frozen=True) -class SubscriptionTier: - """A plan tier in the catalog.""" - - tier_id: str - name: str - tier_order: int - dollars_per_month: Optional[Decimal] = None - monthly_credits: Optional[Decimal] = None - is_current: bool = False - is_enabled: bool = True - - @dataclass(frozen=True) class CurrentSubscription: """The user's active subscription. ``None`` (not this object) = no plan. @@ -80,7 +67,6 @@ class SubscriptionState: role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER" context: str = "personal" # "personal" | "team" current: Optional[CurrentSubscription] = None - tiers: tuple[SubscriptionTier, ...] = () portal_url: Optional[str] = None # When the fetch failed (vs cleanly not-logged-in), the message for the surface. error: Optional[str] = None @@ -101,37 +87,6 @@ def can_change_plan(self) -> bool: # ============================================================================= -def _coalesce(*vals: Any) -> Any: - """First non-None value (None-coalesce, NOT truthy-coalesce — keeps 0/0.0). - - NAS sends 0 for the free tier's dollarsPerMonth/tierOrder; a bare ``a or b`` - would treat that 0 as missing and fall through to the alias (yielding a '—' - price or a corrupted sort index). - """ - for v in vals: - if v is not None: - return v - return None - - -def _parse_tier(raw: Any) -> Optional[SubscriptionTier]: - if not isinstance(raw, dict): - return None - tier_id = raw.get("tierId") or raw.get("id") - name = raw.get("name") - if not (isinstance(tier_id, str) and isinstance(name, str)): - return None - return SubscriptionTier( - tier_id=tier_id, - name=name, - tier_order=int(_coalesce(raw.get("tierOrder"), raw.get("order"), 0)), - dollars_per_month=parse_money(_coalesce(raw.get("dollarsPerMonth"), raw.get("priceUsd"))), - monthly_credits=parse_money(raw.get("monthlyCredits")), - is_current=bool(raw.get("isCurrent")), - is_enabled=bool(raw.get("isEnabled", True)), - ) - - def _parse_current(raw: Any) -> Optional[CurrentSubscription]: # "No plan" is wire-represented as current:null (free personal OR team) — # the old all-null-object shape is gone. A present current is a real plan, @@ -161,12 +116,6 @@ def subscription_state_from_payload( raw_org = payload.get("org") org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {} - tiers: list[SubscriptionTier] = [] - for item in payload.get("tiers") or (): - parsed = _parse_tier(item) - if parsed is not None: - tiers.append(parsed) - raw_context = payload.get("context") context = raw_context if raw_context in ("personal", "team") else "personal" @@ -177,7 +126,6 @@ def subscription_state_from_payload( role=org.get("role"), context=context, current=_parse_current(payload.get("current")), - tiers=tuple(tiers), portal_url=portal_url, ) @@ -270,27 +218,6 @@ def subscription_manage_url(state: SubscriptionState) -> Optional[str]: _DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing" -def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]: - specs = [ - ("free", "Free", 0, Decimal("0"), Decimal("0")), - ("plus", "Plus", 1, Decimal("20"), Decimal("1000")), - ("super", "Super", 2, Decimal("50"), Decimal("3000")), - ("ultra", "Ultra", 3, Decimal("99"), Decimal("7000")), - ] - return tuple( - SubscriptionTier( - tier_id=tid, - name=name, - tier_order=order, - dollars_per_month=price, - monthly_credits=credits, - is_current=(tid == current_id), - is_enabled=True, - ) - for tid, name, order, price, credits in specs - ) - - def _dev_current(**over: Any) -> CurrentSubscription: base: dict[str, Any] = dict( tier_id="plus", @@ -323,42 +250,31 @@ def dev_fixture_subscription_state() -> Optional[SubscriptionState]: if name in ("logged-out", "logged_out", "loggedout"): return SubscriptionState(logged_in=False) if name == "free": - return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common) + return SubscriptionState(logged_in=True, current=None, **common) if name in ("mid", "mid-tier"): - return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common) + return SubscriptionState(logged_in=True, current=_dev_current(), **common) if name in ("top", "top-tier"): return SubscriptionState( logged_in=True, current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")), - tiers=_dev_tiers("ultra"), **common, ) if name in ("not-admin", "member"): - return SubscriptionState( - logged_in=True, - current=_dev_current(), - tiers=_dev_tiers("plus"), - org_name="Acme Inc", - org_id="org_acme", - role="MEMBER", - portal_url=_DEV_FIXTURE_PORTAL, - ) + return SubscriptionState(logged_in=True, current=_dev_current(), **{**common, "role": "MEMBER"}) if name == "downgrade": return SubscriptionState( logged_in=True, current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"), - tiers=_dev_tiers("super"), **common, ) if name == "cancel": return SubscriptionState( logged_in=True, current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"), - tiers=_dev_tiers("plus"), **common, ) if name == "team": - return SubscriptionState(logged_in=True, context="team", current=None, tiers=(), org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL) + return SubscriptionState(logged_in=True, context="team", current=None, org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL) # Unknown name → behave as logged-out so the misconfiguration is visible. return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}") diff --git a/tests/agent/test_subscription_view.py b/tests/agent/test_subscription_view.py index 30139319e293..37cd651c4863 100644 --- a/tests/agent/test_subscription_view.py +++ b/tests/agent/test_subscription_view.py @@ -66,10 +66,6 @@ def test_parser_maps_camelCase_payload_fields(): "cancelAtPeriodEnd": True, "cancellationEffectiveAt": "2026-07-01", }, - "tiers": [ - {"tierId": "free", "name": "Free", "tierOrder": 0, "monthlyCredits": "0"}, - {"tierId": "plus", "name": "Plus", "tierOrder": 1, "dollarsPerMonth": "20", "monthlyCredits": "1000", "isCurrent": True}, - ], } s = subscription_state_from_payload(payload, portal_url="https://p/billing") @@ -80,8 +76,6 @@ def test_parser_maps_camelCase_payload_fields(): assert s.current.tier_name == "Plus" assert s.current.cancel_at_period_end is True assert s.current.monthly_credits == Decimal("1000") - assert len(s.tiers) == 2 - assert s.tiers[1].is_current is True def test_parser_no_plan_is_none_not_all_null_object(): @@ -113,7 +107,7 @@ def test_no_fixture_when_env_unset(monkeypatch): @pytest.mark.parametrize( "name,checker", [ - ("free", lambda s: s.logged_in and s.current is None and len(s.tiers) == 4), + ("free", lambda s: s.logged_in and s.current is None), ("mid", lambda s: s.current and s.current.tier_id == "plus"), ("top", lambda s: s.current and s.current.tier_id == "ultra"), ("not-admin", lambda s: s.role == "MEMBER" and not s.can_change_plan), diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9d373e813dff..f868971fca38 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5663,7 +5663,6 @@ def _(rid, params: dict) -> dict: def _serialize_subscription_state(state) -> dict: """Serialize a SubscriptionState for the wire (Decimals → strings).""" - from agent.billing_view import format_money from agent.billing_usage import format_renews def _s(value): @@ -5685,17 +5684,6 @@ def _s(value): "cancellation_effective_at": c.cancellation_effective_at, "cancellation_effective_display": format_renews(c.cancellation_effective_at), } - tiers = [] - for t in state.tiers: - tiers.append({ - "tier_id": t.tier_id, - "name": t.name, - "tier_order": t.tier_order, - "dollars_per_month_display": format_money(t.dollars_per_month), - "monthly_credits": _s(t.monthly_credits), - "is_current": t.is_current, - "is_enabled": t.is_enabled, - }) return { "ok": True, "logged_in": state.logged_in, @@ -5706,7 +5694,6 @@ def _s(value): "role": state.role, "context": state.context, "current": current, - "tiers": tiers, "portal_url": state.portal_url, "error": state.error, # Shared dollar usage model (two-bar view) embedded so /subscription diff --git a/ui-tui/src/__tests__/subscriptionCommand.test.ts b/ui-tui/src/__tests__/subscriptionCommand.test.ts index 7c08912d808d..31486c90ec58 100644 --- a/ui-tui/src/__tests__/subscriptionCommand.test.ts +++ b/ui-tui/src/__tests__/subscriptionCommand.test.ts @@ -19,7 +19,6 @@ const loggedInState = (overrides: Partial = {}): Subs org_name: 'Acme', role: 'OWNER', current: null, - tiers: [], portal_url: 'https://portal.nousresearch.com/billing', ...overrides }) @@ -71,7 +70,7 @@ describe('/subscription slash command', () => { it('fetches subscription.state and opens the overlay', async () => { const { run } = buildCtx({ - 'subscription.state': loggedInState({ tiers: [{ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: false, is_enabled: true }] }) + 'subscription.state': loggedInState() }) await run('') @@ -80,7 +79,6 @@ describe('/subscription slash command', () => { expect(overlay).not.toBeNull() expect(overlay?.screen).toBe('overview') - expect(overlay?.state.tiers).toHaveLength(1) }) it('shows portal-login sys line when not logged in', async () => { diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index bf68ac633f07..eccdd2fcda62 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -17,7 +17,7 @@ vi.mock('@hermes/ink', async importOriginal => { import type { SubscriptionOverlayState } from '../app/interfaces.js' import { SubscriptionOverlay } from '../components/subscriptionOverlay.js' -import type { SubscriptionStateResponse, SubscriptionTierOption } from '../gatewayTypes.js' +import type { SubscriptionStateResponse } from '../gatewayTypes.js' import { stripAnsi } from '../lib/text.js' import { DEFAULT_THEME } from '../theme.js' @@ -39,12 +39,7 @@ function render(overlay: SubscriptionOverlayState): string { }) const instance = renderSync( - React.createElement(SubscriptionOverlay, { - onClose: () => {}, - onPatch: () => {}, - overlay, - t - }), + React.createElement(SubscriptionOverlay, { onClose: () => {}, overlay, t }), { patchConsole: false, stderr: stderr as NodeJS.WriteStream, @@ -59,17 +54,6 @@ function render(overlay: SubscriptionOverlayState): string { return stripAnsi(output) } -const tier = (overrides: Partial = {}): SubscriptionTierOption => ({ - tier_id: 'free', - name: 'Free', - tier_order: 0, - dollars_per_month_display: '$0', - monthly_credits: '0', - is_current: false, - is_enabled: true, - ...overrides -}) - const state = (overrides: Partial = {}): SubscriptionStateResponse => ({ ok: true, logged_in: true, @@ -79,7 +63,6 @@ const state = (overrides: Partial = {}): Subscription org_id: 'org_acme', role: 'OWNER', current: null, - tiers: [], portal_url: 'https://portal.nousresearch.com/billing', ...overrides }) @@ -90,225 +73,97 @@ const ctx = { sys: vi.fn() } -const overlay = (s: SubscriptionStateResponse, screen: SubscriptionOverlayState['screen'] = 'overview'): SubscriptionOverlayState => ({ - ctx, - screen, - state: s, - pendingTargetTierId: null -}) +const overlay = (s: SubscriptionStateResponse): SubscriptionOverlayState => ({ ctx, screen: 'overview', state: s }) -describe('SubscriptionOverlay — overview screen', () => { - it('(a) free: shows free upsell + manage, no tier list, no "credits"', () => { - const s = state({ - current: null, - usage: { available: true, status: 'free', plan_name: null }, - tiers: [ - tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }), - tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: false }), - tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000', is_current: false }) - ] - }) - - const out = render(overlay(s)) +// Deep-link only: a single overview screen across every account state. No +// in-terminal tier picker, so there is no confirm/handoff screen to test. +describe('SubscriptionOverlay — overview', () => { + it('free: upsell + "Start a subscription", no tier list, no "credits"', () => { + const out = render(overlay(state({ current: null, usage: { available: true, status: 'free', plan_name: null } }))) expect(out).toContain('Plan: Free · free models only') expect(out).toContain('Paid models need a subscription') expect(out).toContain('Start a subscription') - // No selectable tier menu — tiers are not listed in-terminal anymore. expect(out).not.toContain('$20/mo') expect(out.toLowerCase()).not.toContain('credits') }) - it('(b) subscriber mid-tier: status line + dollar plan bar', () => { - const s = state({ - current: { - tier_id: 'pro', - tier_name: 'Pro', - monthly_credits: '1000', - credits_remaining: '420', - cycle_ends_at: '2026-07-01', - pending_downgrade_tier_name: null, - pending_downgrade_at: null - }, - usage: { - available: true, - status: 'healthy', - plan_name: 'Pro', - renews_at: '2026-07-01', - total_spendable_display: '$14.00', - plan_bar: { - kind: 'plan', - remaining_display: '$14.00', - total_display: '$20.00', - spent_display: '$6.00', - pct_used: 30, - fill_fraction: 0.7 - } - }, - tiers: [ - tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), - tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true }), - tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000' }) - ] - }) - - const out = render(overlay(s)) + it('subscriber: status line + plan bar + top-up bar, no "credits"', () => { + const out = render( + overlay( + state({ + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '700', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { + available: true, + status: 'healthy', + plan_name: 'Pro', + renews_display: 'Jul 1, 2026', + total_spendable_display: '$26.00', + has_topup: true, + plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 }, + topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 } + } + }) + ) + ) expect(out).toContain('Plan: Pro') expect(out).toContain('$14.00 left of $20.00') expect(out).toContain('30% used') - expect(out.toLowerCase()).not.toContain('credits') - }) - - it('(b2) subscriber + top-up: shows both bars', () => { - const s = state({ - current: { - tier_id: 'pro', - tier_name: 'Pro', - monthly_credits: '1000', - credits_remaining: '700', - cycle_ends_at: '2026-07-01', - pending_downgrade_tier_name: null, - pending_downgrade_at: null - }, - usage: { - available: true, - status: 'healthy', - plan_name: 'Pro', - renews_at: '2026-07-01', - total_spendable_display: '$26.00', - has_topup: true, - plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 }, - topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 } - }, - tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] - }) - - const out = render(overlay(s)) - - expect(out).toContain('Pro') expect(out).toContain('top-up') - expect(out).toContain('$12.00') expect(out).toContain('never expires') + expect(out.toLowerCase()).not.toContain('credits') }) - it('(b3) low balance: shows alert nudge', () => { - const s = state({ - current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '170', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, - usage: { - available: true, - status: 'low', - plan_name: 'Pro', - renews_at: '2026-07-01', - total_spendable_display: '$3.40', - plan_bar: { kind: 'plan', remaining_display: '$3.40', total_display: '$20.00', spent_display: '$16.60', pct_used: 83, fill_fraction: 0.17 } - }, - tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] - }) - - const out = render(overlay(s)) + it('low balance: shows alert nudge', () => { + const out = render( + overlay( + state({ + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '170', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { available: true, status: 'low', plan_name: 'Pro', total_spendable_display: '$3.40', plan_bar: { kind: 'plan', remaining_display: '$3.40', total_display: '$20.00', spent_display: '$16.60', pct_used: 83, fill_fraction: 0.17 } } + }) + ) + ) expect(out).toContain('Plan: Pro · $3.40 left') expect(out).toContain('Low balance') }) - it('(c) subscriber top-tier: shows top plan note', () => { - const s = state({ - current: { - tier_id: 'scale', - tier_name: 'Scale', - monthly_credits: '5000', - credits_remaining: '3000', - cycle_ends_at: '2026-07-01', - pending_downgrade_tier_name: null, - pending_downgrade_at: null - }, - usage: { available: true, status: 'healthy', plan_name: 'Scale', renews_at: '2026-07-01' }, - tiers: [ - tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), - tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: false }), - tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000', is_current: true }) - ] - }) - - const out = render(overlay(s)) - - expect(out).toContain('Plan: Scale') - expect(out).toContain('Manage on portal') - }) - - it('(d) not-admin: shows read-only note + no tier list', () => { - const s = state({ - is_admin: false, - can_change_plan: false, - role: 'MEMBER', - current: { - tier_id: 'pro', - tier_name: 'Pro', - monthly_credits: '1000', - credits_remaining: '500', - cycle_ends_at: '2026-07-01', - pending_downgrade_tier_name: null, - pending_downgrade_at: null - }, - usage: { available: true, status: 'healthy', plan_name: 'Pro', renews_at: '2026-07-01' }, - tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })] - }) - - const out = render(overlay(s)) + it('not-admin: shows read-only note', () => { + const out = render( + overlay( + state({ + is_admin: false, + can_change_plan: false, + role: 'MEMBER', + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { available: true, status: 'healthy', plan_name: 'Pro' } + }) + ) + ) expect(out).toContain('view only') expect(out).toContain('Manage on portal') }) - it('(e) downgrade-pending: shows scheduled switch banner', () => { - const s = state({ - current: { - tier_id: 'pro', - tier_name: 'Pro', - monthly_credits: '1000', - credits_remaining: '500', - cycle_ends_at: '2026-07-01', - pending_downgrade_tier_name: 'Free', - pending_downgrade_at: '2026-07-15T00:00:00Z' - }, - usage: { available: true, status: 'healthy', plan_name: 'Pro', renews_at: '2026-07-01' }, - tiers: [ - tier({ tier_id: 'free', name: 'Free', tier_order: 0 }), - tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true }) - ] - }) - - const out = render(overlay(s)) + it('downgrade-pending: shows scheduled-switch banner (formatted date)', () => { + const out = render( + overlay( + state({ + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: 'Free', pending_downgrade_at: '2026-07-15', pending_downgrade_display: 'Jul 15, 2026' }, + usage: { available: true, status: 'healthy', plan_name: 'Pro' } + }) + ) + ) expect(out).toContain('Scheduled to switch to Free') - expect(out).toContain('2026-07-15T00:00:00Z') + expect(out).toContain('Jul 15, 2026') }) -}) - -describe('SubscriptionOverlay — confirm screen', () => { - it('shows tier summary + Stripe disclosure', () => { - const s = state({ - current: null, - tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000' })] - }) - - const out = render({ ...overlay(s, 'confirm'), pendingTargetTierId: 'pro' }) - - expect(out).toContain('Confirm subscription') - expect(out).toContain('Pro') - expect(out).toContain('$20') - expect(out).toContain('securely on your subscription page') - expect(out).toContain('Continue to your subscription page') - }) -}) -describe('SubscriptionOverlay — handoff screen', () => { - it('shows opening-subscription-page copy', () => { - const out = render(overlay(state(), 'handoff')) + it('team context: redirects to /topup, no tier picker', () => { + const out = render(overlay(state({ context: 'team', current: null }))) - expect(out).toContain('Opening your subscription page') - expect(out).toContain('browser') - expect(out).toContain('Re-run /subscription') + expect(out).toContain('shared balance') + expect(out).toContain('/topup') }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 14fea9ea5892..2a9e4af0c65a 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -152,10 +152,9 @@ export interface BillingOverlayState { // ── Subscription overlay (deep-link only, NEVER charges in-terminal) ── -export type SubscriptionScreen = - | 'overview' // shows plan + usage bar + tier list (states a–e collapse into this) - | 'confirm' // y/n confirm before opening the manage-subscription URL - | 'handoff' // transient: "Opening your subscription page in your browser…" +// Deep-link only: the overview hands off to the portal in the browser; there is +// no in-terminal plan picker, so there is exactly one screen. +export type SubscriptionScreen = 'overview' export interface SubscriptionOverlayCtx { /** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */ @@ -170,8 +169,6 @@ export interface SubscriptionOverlayState { ctx: SubscriptionOverlayCtx screen: SubscriptionScreen state: SubscriptionStateResponse - /** The tier the user selected on the overview, summarized on the confirm screen. */ - pendingTargetTierId?: string | null } export interface OverlayState { diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index e0cdd6654f87..8cd808ce431e 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -103,7 +103,6 @@ export const subscriptionCommands: SlashCommand[] = [ patchOverlayState({ subscription: { ctx: buildSubscriptionCtx(ctx, sys, s), - pendingTargetTierId: null, screen: 'overview', state: s } diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 314b5f697a27..9388df4bf650 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -55,14 +55,11 @@ export function PromptZone({ if (overlay.subscription) { const current = overlay.subscription - const onPatch = (next: Partial) => - patchOverlayState(prev => (prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev)) - const onClose = () => patchOverlayState({ subscription: null }) return ( - + ) } diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index cbd0c79dab6d..ca48732b4427 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -5,11 +5,9 @@ import type { SubscriptionOverlayState } from '../app/interfaces.js' import type { SubscriptionStateResponse } from '../gatewayTypes.js' import type { Theme } from '../theme.js' -import { ActionRow, footer, MenuRow, UsageBars } from './overlayPrimitives.js' +import { footer, MenuRow, UsageBars } from './overlayPrimitives.js' interface SubscriptionOverlayProps { - /** Replace the overlay slot (screen transitions + pending data). */ - onPatch: (next: Partial) => void /** Close the overlay entirely. */ onClose: () => void overlay: SubscriptionOverlayState @@ -17,40 +15,21 @@ interface SubscriptionOverlayProps { } /** - * The /subscription modal — deep-link only, NEVER charges in-terminal. - * Mirrors billingOverlay.tsx's structure: a pure-render state machine - * (overview → confirm → handoff) where all RPCs live in subscription.ts and - * are reached through `overlay.ctx`. No step-up here: changing a plan is a - * browser deep-link, which needs no billing scope (the scope gate is on - * /topup's charge, where the resumable step-up lives). + * The /subscription modal — deep-link only, NEVER charges in-terminal. A single + * overview screen (plan + usage) that hands off to the portal in the browser to + * change the plan: no in-terminal picker, no step-up (the scope gate lives on + * /topup's charge). All RPCs live in subscription.ts, reached via `overlay.ctx`. */ -export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) { - const { ctx, screen, state: s } = overlay - - // Team context: no tier picker — teams run on shared credits; redirect to /topup. - if (s.context === 'team') { - return ( - - - - ) - } +export function SubscriptionOverlay({ onClose, overlay, t }: SubscriptionOverlayProps) { + const { ctx, state: s } = overlay return ( - {screen === 'overview' && } - {screen === 'confirm' && ( - onPatch({ screen: 'overview' })} - onClose={onClose} - onPatch={onPatch} - overlay={overlay} - s={s} - t={t} - /> + {s.context === 'team' ? ( + + ) : ( + )} - {screen === 'handoff' && } ) } @@ -60,7 +39,6 @@ export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: Subscripti interface ScreenProps { ctx: SubscriptionOverlayState['ctx'] onClose: () => void - onPatch: (next: Partial) => void s: SubscriptionStateResponse t: Theme } @@ -99,6 +77,7 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { // (Past-due/dunning was removed from the NAS read — a card-failing // subscriber now returns as a normal plan; no special-casing here.) const cancelOn = c?.cancellation_effective_display ?? c?.cancellation_effective_at + const cancellationNote = isCancelScheduled ? cancelOn ? `Cancels on ${cancelOn} — your plan stays active until then.` @@ -107,6 +86,7 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { const downgradeOn = c?.pending_downgrade_display ?? c?.pending_downgrade_at ?? 'the end of the billing period' + const downgradeNote = !isCancelScheduled && hasPendingDowngrade ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${downgradeOn}.` @@ -254,116 +234,3 @@ function TeamContextScreen({ onClose, s, t }: TeamContextScreenProps) { ) } - -// ── Screen: Confirm (y/n deep-link, NO in-terminal charge) ─────────── - -interface ConfirmScreenProps extends ScreenProps { - onBack: () => void - onPatch: (next: Partial) => void - overlay: SubscriptionOverlayState -} - -function ConfirmScreen({ ctx, onBack, onClose, onPatch, overlay, s, t }: ConfirmScreenProps) { - const targetTierId = overlay.pendingTargetTierId ?? undefined - const targetTier = s.tiers.find(tier => tier.tier_id === targetTierId) - const isUpgrade = !s.current?.tier_id - - const [sel, setSel] = useState(0) - const items = ['Continue to your subscription page', 'Cancel'] - const [transitioned, setTransitioned] = useState(false) - - const confirm = () => { - if (transitioned) { - return - } - - setTransitioned(true) - onPatch({ screen: 'handoff' }) - - void ctx.openManageLink().then(ok => { - if (!ok) { - // openManageLink only fails if the portal URL can't be built or the - // browser won't open — return to overview (it sys()'d the reason). - onPatch({ screen: 'overview' }) - } - }) - } - - const choose = (i: number) => { - if (i === 0) { - return confirm() - } - - return onBack() - } - - useInput((ch, key) => { - if (key.escape) { - return onBack() - } - - if (key.upArrow && sel > 0) { - setSel(v => v - 1) - } - - if (key.downArrow && sel < items.length - 1) { - setSel(v => v + 1) - } - - if (key.return) { - return choose(sel) - } - - if (ch === 'y') { - return choose(0) - } - - if (ch === 'n') { - return choose(1) - } - }) - - return ( - - - {isUpgrade ? 'Confirm subscription' : 'Confirm plan change'} - - {targetTier && ( - - {targetTier.name} · {targetTier.dollars_per_month_display}/mo - - )} - You'll finish this change securely on your subscription page in your browser. - - - {items.map((label, i) => ( - - ))} - - - {footer('y/Enter confirm · n/Esc cancel', t)} - - ) -} - -// ── Screen: Handoff (transient) ────────────────────────────────────── - -function HandoffScreen({ onClose, t }: { onClose: () => void; t: Theme }) { - useInput((_ch, key) => { - if (key.escape) { - return onClose() - } - }) - - return ( - - - Opening your subscription page… - - Opening your subscription page in the browser… - Finish on the page that just opened. Re-run /subscription to see the change. - - {footer('Esc close', t)} - - ) -} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index e6dfec1f671c..217221125abb 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -141,16 +141,6 @@ export interface BillingMutationResponse { retry_after?: number | null } -export interface SubscriptionTierOption { - tier_id: string - name: string - tier_order: number - dollars_per_month_display: string - monthly_credits: string - is_current: boolean - is_enabled: boolean // false = grandfathered current tier -} - export interface SubscriptionStateResponse { ok: boolean logged_in: boolean @@ -173,7 +163,6 @@ export interface SubscriptionStateResponse { cancellation_effective_at: string | null // ISO when cancellation takes effect cancellation_effective_display: string | null // formatted cancellation_effective_at } | null - tiers: SubscriptionTierOption[] portal_url: string | null error?: string | null // Shared dollar usage model (two-bar view), embedded by the gateway so the From 0fccd092d9b75c49bb97dcb429d519c9fcf7b989 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 22:36:01 +0530 Subject: [PATCH 39/62] 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). --- agent/billing_usage.py | 8 -- tests/agent/test_billing_usage.py | 215 +++++++++--------------------- 2 files changed, 64 insertions(+), 159 deletions(-) diff --git a/agent/billing_usage.py b/agent/billing_usage.py index 4d3c7af7e87d..2ac762bc2b31 100644 --- a/agent/billing_usage.py +++ b/agent/billing_usage.py @@ -135,14 +135,6 @@ class UsageModel: plan_bar: Optional[UsageBar] = None topup_bar: Optional[UsageBar] = None - @property - def is_low(self) -> bool: - return self.status == "low" - - @property - def is_free(self) -> bool: - return self.status == "free" - @property def has_topup(self) -> bool: return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0) diff --git a/tests/agent/test_billing_usage.py b/tests/agent/test_billing_usage.py index da6390594926..34a502e9d88d 100644 --- a/tests/agent/test_billing_usage.py +++ b/tests/agent/test_billing_usage.py @@ -1,7 +1,7 @@ """Tests for the shared dollar usage model (agent/billing_usage.py). -Behavior contracts, not snapshots: status classification, bar math, fail-open, -and the dollars-only / topup-split invariants the billing UX feedback requires. +Behavior contracts: status classification, bar math, fail-open, and the +dollars-only / topup-split invariants the billing UX requires. """ from __future__ import annotations @@ -11,15 +11,10 @@ import pytest -from agent.billing_usage import ( - LOW_BALANCE_THRESHOLD_USD, - UsageBar, - UsageModel, - usage_model_from_account, -) +from agent.billing_usage import LOW_BALANCE_THRESHOLD_USD, UsageBar, usage_model_from_account -# ── Lightweight stand-ins for NousPortalAccountInfo shape ──────────────────── +# ── Lightweight stand-ins for the NousPortalAccountInfo shape ──────────────── @dataclass @@ -48,185 +43,103 @@ def _acct(**over): return _Account(**over) -# ── Fail-open ──────────────────────────────────────────────────────────────── - - -def test_none_account_is_unavailable(): - assert usage_model_from_account(None).available is False - - -def test_logged_out_is_unavailable(): - assert usage_model_from_account(_acct(logged_in=False)).available is False - - -def test_garbage_account_fails_open(): - class Boom: - @property - def logged_in(self): - raise RuntimeError("kaboom") - - assert usage_model_from_account(Boom()).available is False - - -# ── Status classification ──────────────────────────────────────────────────── - - -def test_no_plan_no_balance_is_free(): - m = usage_model_from_account(_acct(paid_service_access=None, subscription=None, paid_service_access_info=_Access())) +class _Boom: + @property + def logged_in(self): + raise RuntimeError("kaboom") + + +@pytest.mark.parametrize("account", [None, _acct(logged_in=False), _Boom()]) +def test_fails_open_to_unavailable(account): + assert usage_model_from_account(account).available is False + + +@pytest.mark.parametrize( + "account,expected", + [ + # no plan, no balance -> free + (_acct(paid_service_access_info=_Access()), "free"), + # paid access explicitly lost -> depleted + (_acct(paid_service_access=False, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=0.0, total_usable_credits=0.0)), "depleted"), + # above threshold -> healthy + (_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0)), "healthy"), + # under $5 spendable -> low + (_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=3.4, total_usable_credits=3.4)), "low"), + # exactly $5 -> healthy (the threshold boundary is exclusive) + (_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=5.0, total_usable_credits=5.0)), "healthy"), + # top-up only, no plan -> usable (healthy), not free + (_acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=30.0, total_usable_credits=30.0)), "healthy"), + ], +) +def test_status_classification(account, expected): + m = usage_model_from_account(account) assert m.available is True - assert m.status == "free" + assert m.status == expected -def test_paid_access_lost_is_depleted(): - m = usage_model_from_account( - _acct( - paid_service_access=False, - subscription=_Sub(plan="Plus", monthly_credits=20.0), - paid_service_access_info=_Access(subscription_credits_remaining=0.0, total_usable_credits=0.0), - ) - ) - assert m.status == "depleted" - - -def test_healthy_when_above_threshold(): - m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=_Sub(plan="Plus", monthly_credits=20.0, current_period_end="2026-07-01"), - paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0), - ) - ) - assert m.status == "healthy" - assert m.plan_name == "Plus" - assert m.renews_at == "2026-07-01" - - -def test_low_when_total_spendable_under_threshold(): - m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=_Sub(plan="Plus", monthly_credits=20.0), - paid_service_access_info=_Access(subscription_credits_remaining=3.4, total_usable_credits=3.4), - ) - ) - assert m.status == "low" - assert m.is_low is True - # Invariant: the threshold boundary is exclusive — exactly $5 is NOT low. +def test_threshold_constant_is_five(): assert LOW_BALANCE_THRESHOLD_USD == 5.0 -def test_exactly_threshold_is_healthy(): - m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=_Sub(plan="Plus", monthly_credits=20.0), - paid_service_access_info=_Access(subscription_credits_remaining=5.0, total_usable_credits=5.0), - ) - ) - assert m.status == "healthy" - - -def test_topup_only_no_subscription_is_not_free(): - # Purchased balance but no plan -> still a usable (healthy) account, not free. +def test_healthy_carries_plan_name_and_renewal(): m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=None, - paid_service_access_info=_Access(purchased_credits_remaining=30.0, total_usable_credits=30.0), - ) + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0, current_period_end="2026-07-01"), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0)) ) - assert m.status == "healthy" - assert m.has_topup is True - - -# ── Bar math ───────────────────────────────────────────────────────────────── + assert m.plan_name == "Plus" and m.renews_at == "2026-07-01" def test_plan_bar_spent_and_pct(): m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=_Sub(plan="Plus", monthly_credits=20.0), - paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0), - ) + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0)) ) bar = m.plan_bar - assert bar is not None - assert bar.kind == "plan" - assert bar.remaining_usd == 14.0 - assert bar.total_usd == 20.0 + assert bar is not None and bar.kind == "plan" + assert (bar.remaining_usd, bar.total_usd, bar.pct_used) == (14.0, 20.0, 30) assert bar.spent_usd == pytest.approx(6.0) - assert bar.pct_used == 30 # 6/20 -def test_plan_bar_clamps_over_cap_remaining(): - # Rollover/debt: remaining > cap should clamp the bar's remaining to the cap - # and read as zero spent, not a negative. +def test_plan_bar_clamps_over_cap_to_zero_spent(): + # Rollover/debt: remaining > cap clamps to the cap and reads as zero spent. m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=_Sub(plan="Plus", monthly_credits=20.0), - paid_service_access_info=_Access(subscription_credits_remaining=25.0, total_usable_credits=25.0), - ) + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=25.0, total_usable_credits=25.0)) ) - bar = m.plan_bar - assert bar is not None - assert bar.remaining_usd == 20.0 - assert bar.spent_usd == 0.0 + assert m.plan_bar.remaining_usd == 20.0 and m.plan_bar.spent_usd == 0.0 -def test_topup_bar_full_no_denominator(): +def test_topup_bar_is_full_with_no_denominator(): m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=_Sub(plan="Plus", monthly_credits=20.0), - paid_service_access_info=_Access( - subscription_credits_remaining=14.0, purchased_credits_remaining=12.0, total_usable_credits=26.0 - ), - ) + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, purchased_credits_remaining=12.0, total_usable_credits=26.0)) ) tb = m.topup_bar - assert tb is not None - assert tb.kind == "topup" - assert tb.remaining_usd == 12.0 - assert tb.fill_fraction == 1.0 # full bar = balance - assert tb.pct_used is None # no monthly denominator - assert m.total_spendable_usd == 26.0 + assert tb is not None and tb.kind == "topup" + assert tb.remaining_usd == 12.0 and tb.fill_fraction == 1.0 and tb.pct_used is None + assert m.total_spendable_usd == 26.0 and m.has_topup is True -def test_no_plan_bar_without_monthly_denominator(): - # Top-up only, no monthly cap -> no plan bar, but a top-up bar. +def test_no_plan_bar_without_monthly_cap(): m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=None, - paid_service_access_info=_Access(purchased_credits_remaining=8.0, total_usable_credits=8.0), - ) + _acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=8.0, total_usable_credits=8.0)) ) - assert m.plan_bar is None - assert m.topup_bar is not None + assert m.plan_bar is None and m.topup_bar is not None def test_non_finite_values_are_ignored(): m = usage_model_from_account( - _acct( - paid_service_access=True, - subscription=_Sub(plan="Plus", monthly_credits=float("nan")), - paid_service_access_info=_Access(subscription_credits_remaining=float("inf")), - ) + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=float("nan")), + paid_service_access_info=_Access(subscription_credits_remaining=float("inf"))) ) - # NaN cap and Inf remaining must not produce a bar or a bogus total. assert m.plan_bar is None -# ── UsageBar property edge cases ───────────────────────────────────────────── - - def test_usage_bar_fill_fraction_clamped(): assert UsageBar(kind="plan", remaining_usd=30.0, total_usd=20.0).fill_fraction == 1.0 assert UsageBar(kind="plan", remaining_usd=-5.0, total_usd=20.0).fill_fraction == 0.0 assert UsageBar(kind="plan", remaining_usd=0.0, total_usd=0.0).fill_fraction == 0.0 - - -def test_topup_bar_pct_used_is_none(): - assert UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0).pct_used is None From 943389df8971b7fef09e94ef831651507c4c50bf Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 22:36:59 +0530 Subject: [PATCH 40/62] =?UTF-8?q?fix(billing):=20revert=20dead=20'billing'?= =?UTF-8?q?=20Slack-via-hermes=20entry=20=E2=80=94=20the=20alias=20was=20d?= =?UTF-8?q?ropped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- hermes_cli/commands.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f6770fc82531..6d4837941e2f 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -1058,10 +1058,8 @@ def discord_skill_commands_by_category( # the telegram-parity test reads it so an entry here is a deliberate # "Slack-via-/hermes" decision, not a silent clamp. # - topup: the billing/balance surface; reached via /hermes topup on Slack. -# - billing: alias of topup — route it via /hermes too, else the alias would -# leak a native Slack slot the canonical command is deliberately denied. # - debug: the log/report upload surface; reached via /hermes debug on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "billing", "debug"}) +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "debug"}) def _sanitize_slack_name(raw: str) -> str: From 9fa33ef75ad077ffc4dcbbbea252b9a6d1882522 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 22:43:00 +0530 Subject: [PATCH 41/62] 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. --- ui-tui/src/__tests__/billingStepUp.test.tsx | 8 +- ui-tui/src/__tests__/topupCommand.test.ts | 70 +++------------ ui-tui/src/__tests__/usageCommand.test.ts | 97 +++++---------------- 3 files changed, 35 insertions(+), 140 deletions(-) diff --git a/ui-tui/src/__tests__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx index 85ee365ab89d..bfc693d2d58c 100644 --- a/ui-tui/src/__tests__/billingStepUp.test.tsx +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -92,16 +92,12 @@ const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => }) describe('BillingOverlay — step-up screen (Enable terminal billing)', () => { - it('renders the one-time-setup prompt with the held amount', () => { + it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => { const out = render(overlay('stepup')) expect(out).toContain('One-time setup') expect(out).toContain('Enable terminal billing') - expect(out).toContain('$100') // resumes the held purchase + expect(out).toContain('$100') // resumes the held purchase expect(out).toContain('Not now') - }) - - it('NEVER leaks the raw billing:manage scope in copy', () => { - const out = render(overlay('stepup')) expect(out).not.toContain('billing:manage') }) }) diff --git a/ui-tui/src/__tests__/topupCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts index aae340f10cb1..d518521472b1 100644 --- a/ui-tui/src/__tests__/topupCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -245,80 +245,30 @@ describe('/billing slash command (overlay-driven)', () => { expect(getOverlayState().confirm).toBeNull() }) - it('ctx.requestRemoteSpending → billing.step_up, resolves granted', async () => { - const { run, calls } = buildCtx({ - 'billing.state': ownerState(), - 'billing.step_up': { ok: true, granted: true } - }) - - await run('') - const granted = await getOverlayState().billing!.ctx.requestRemoteSpending() - expect(granted).toBe(true) - const su = calls.find(c => c.method === 'billing.step_up') - expect(su).toBeTruthy() - }) - - it('ctx.requestRemoteSpending → not granted resolves false', async () => { - const { run } = buildCtx({ - 'billing.state': ownerState(), - 'billing.step_up': { ok: true, granted: false } - }) + it.each([[true], [false]])('ctx.requestRemoteSpending → billing.step_up resolves %s', async granted => { + const { run, calls } = buildCtx({ 'billing.state': ownerState(), 'billing.step_up': { ok: true, granted } }) await run('') - const granted = await getOverlayState().billing!.ctx.requestRemoteSpending() - expect(granted).toBe(false) - }) - - it('ctx.charge happy path resolves submitted', async () => { - vi.useFakeTimers() - - try { - const { run } = buildCtx({ - 'billing.state': ownerState(), - 'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }, - 'billing.charge_status': { ok: true, status: 'settled', amount_usd: '100' } - }) - - await run('') - const outcome = await getOverlayState().billing!.ctx.charge('100') - expect(outcome).toBe('submitted') - await vi.runAllTimersAsync() - } finally { - vi.useRealTimers() - } + expect(await getOverlayState().billing!.ctx.requestRemoteSpending()).toBe(granted) + expect(calls.find(c => c.method === 'billing.step_up')).toBeTruthy() }) // ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ── - it('ctx.charge remote_spending_revoked (admin) → clears overlay + admin copy', async () => { - const { run, sys } = buildCtx({ - 'billing.state': ownerState(), - 'billing.charge': { ok: false, error: 'remote_spending_revoked', actor: 'admin', recovery: 'reconnect', idempotency_key: 'k' } - }) - - await run('') - expect(getOverlayState().billing).toBeTruthy() - getOverlayState().billing!.ctx.charge('100') - await Promise.resolve() - await Promise.resolve() - const out = printed(sys) - expect(out).toContain('An admin turned off terminal billing for this terminal') - expect(out).toContain('Reconnect to restore') - // Spend UI is killed immediately — no zombie button waiting for refresh. - expect(getOverlayState().billing).toBeNull() - }) - - it('ctx.charge remote_spending_revoked (self) → self copy', async () => { + it.each([ + ['admin', 'An admin turned off terminal billing for this terminal'], + ['self', 'You turned off terminal billing for this terminal'] + ])('ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy', async (actor, copy) => { const { run, sys } = buildCtx({ 'billing.state': ownerState(), - 'billing.charge': { ok: false, error: 'remote_spending_revoked', actor: 'self', recovery: 'reconnect', idempotency_key: 'k' } + 'billing.charge': { ok: false, error: 'remote_spending_revoked', actor, recovery: 'reconnect', idempotency_key: 'k' } }) await run('') getOverlayState().billing!.ctx.charge('100') await Promise.resolve() await Promise.resolve() - expect(printed(sys)).toContain('You turned off terminal billing for this terminal') + expect(printed(sys)).toContain(copy) expect(getOverlayState().billing).toBeNull() }) diff --git a/ui-tui/src/__tests__/usageCommand.test.ts b/ui-tui/src/__tests__/usageCommand.test.ts index f5c99b57498d..312fc28d9c48 100644 --- a/ui-tui/src/__tests__/usageCommand.test.ts +++ b/ui-tui/src/__tests__/usageCommand.test.ts @@ -20,9 +20,7 @@ const buildCtx = (results: Record) => { const sys = vi.fn() const panel = vi.fn() - const rpc = vi.fn((method: string, _params: unknown) => { - return Promise.resolve(results[method]) - }) + const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method])) const ctx = { gateway: { rpc }, @@ -44,97 +42,52 @@ const buildCtx = (results: Record) => { } const baseUsage = (overrides: Partial = {}): SessionUsageResponse => - ({ - calls: 0, - input: 0, - output: 0, - total: 0, - ...overrides - }) as SessionUsageResponse + ({ calls: 0, input: 0, output: 0, total: 0, ...overrides }) as SessionUsageResponse const printed = (sys: ReturnType) => sys.mock.calls.map(c => c[0]).join('\n') +const balancePanel = (panel: ReturnType) => { + const sections = panel.mock.calls.find(c => c[0] === 'Balance')?.[1] as { text?: string }[] | undefined + + return (sections ?? []).map(s => s.text ?? '').join('\n') +} + describe('/usage slash command', () => { beforeEach(() => { vi.clearAllMocks() }) - it('appends the CTA in the with-calls render (healthy path)', async () => { - const { run, sys } = buildCtx({ - 'session.usage': baseUsage({ - calls: 12, - input: 1000, - output: 500, - total: 1500, - model: 'test-model', - credits_lines: ['$50.00 remaining'] - }) - }) - - await run('') + it('always shows the CTA; "no API calls yet" only when there is no balance', async () => { + const empty = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: [] }) }) + await empty.run('') + expect(printed(empty.sys)).toContain('no API calls yet') + expect(printed(empty.sys)).toContain(USAGE_CTA) - expect(printed(sys)).toContain(USAGE_CTA) - }) - - it('appends the CTA in the no-calls render (depleted/empty path)', async () => { - const { run, sys } = buildCtx({ - 'session.usage': baseUsage({ calls: 0, credits_lines: [] }) - }) - - await run('') - - expect(printed(sys)).toContain('no API calls yet') - expect(printed(sys)).toContain(USAGE_CTA) - }) - - it('appends the CTA when credits exist but there are no calls', async () => { - const { run, sys } = buildCtx({ - 'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] }) - }) - - await run('') - - expect(printed(sys)).toContain(USAGE_CTA) - expect(printed(sys)).not.toContain('no API calls yet') + const withBalance = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] }) }) + await withBalance.run('') + expect(printed(withBalance.sys)).not.toContain('no API calls yet') + expect(printed(withBalance.sys)).toContain(USAGE_CTA) }) it('renders the dollar two-bar model (no "credits" wording) when available', async () => { const { panel, run } = buildCtx({ 'session.usage': baseUsage({ - calls: 0, usage: { available: true, status: 'healthy', plan_name: 'Plus', - renews_at: '2026-07-01', + renews_display: 'Jul 1, 2026', total_spendable_display: '$26.00', has_topup: true, - plan_bar: { - kind: 'plan', - remaining_display: '$14.00', - total_display: '$20.00', - spent_display: '$6.00', - pct_used: 30, - fill_fraction: 0.7 - }, - topup_bar: { - kind: 'topup', - remaining_display: '$12.00', - total_display: '$12.00', - spent_display: '$0.00', - pct_used: null, - fill_fraction: 1 - } + plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 }, + topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 } } }) }) await run('') - const titles = panel.mock.calls.map(c => c[0]) - expect(titles).toContain('Balance') - const sections = panel.mock.calls.find(c => c[0] === 'Balance')![1] as { text?: string }[] - const body = sections.map(s => s.text ?? '').join('\n') + const body = balancePanel(panel) expect(body).toContain('Plus') expect(body).toContain('$14.00 left of $20.00') expect(body).toContain('30% used') @@ -145,16 +98,12 @@ describe('/usage slash command', () => { it('shows the free-models upsell for a free account', async () => { const { panel, run } = buildCtx({ - 'session.usage': baseUsage({ - calls: 0, - usage: { available: true, status: 'free', plan_name: null } - }) + 'session.usage': baseUsage({ usage: { available: true, status: 'free', plan_name: null } }) }) await run('') - const sections = panel.mock.calls.find(c => c[0] === 'Balance')![1] as { text?: string }[] - const body = sections.map(s => s.text ?? '').join('\n') + const body = balancePanel(panel) expect(body).toContain('free models only') expect(body).toContain('/subscription') }) From 27a5c647f8bc4e05b7083f70ad3ae7043843190c Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 25 Jun 2026 22:46:11 +0530 Subject: [PATCH 42/62] =?UTF-8?q?refactor(billing):=20extract=20=5Fusage?= =?UTF-8?q?=5Fbar=5Flines=20=E2=80=94=20one=20source=20of=20truth=20for=20?= =?UTF-8?q?the=20CLI=20bars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 56 ++++++++++++++++++++++++++------------------------------ 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/cli.py b/cli.py index ba81a8526c15..b94b023972e2 100644 --- a/cli.py +++ b/cli.py @@ -8942,16 +8942,8 @@ def _print_nous_credits_block(self) -> bool: # ordering is deterministic: raw print() and _cprint() flush to different # buffers under patch_stdout and interleave nondeterministically (the bar # would race above/below the Plan line across states). Keep one path. - pb = usage.plan_bar - if pb is not None and pb.total_usd > 0: - bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) - _pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else "" - _label = (usage.plan_name or "plan").ljust(8)[:8] - _cprint(f" {_label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{_pct_s}") - printed_any = True - tb = usage.topup_bar - if tb is not None and tb.remaining_usd > 0: - _cprint(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") + for _bar_ln in self._usage_bar_lines(usage, usage.plan_name): + _cprint(_bar_ln) printed_any = True if usage.has_topup and usage.total_spendable_usd is not None: _cprint(f" Total spendable: ${usage.total_spendable_usd:,.2f}") @@ -9079,17 +9071,8 @@ def _subscription_overview(self, state, manage_url): print(f" {'─' * 41}") # Two-bar dollar usage view — plan name labels the plan bar. - pb = getattr(usage, "plan_bar", None) if usage else None - if pb is not None and pb.total_usd > 0: - bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) - _pct = pb.pct_used - _pct_s = f" · {_pct}% used" if _pct is not None else "" - _label = (plan_name or "plan").ljust(8)[:8] - print(f" {_label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{_pct_s}") - tb = getattr(usage, "topup_bar", None) if usage else None - if tb is not None and tb.remaining_usd > 0: - full = "█" * 10 - print(f" {'top-up'.ljust(8)}[{full}] ${tb.remaining_usd:,.2f} · never expires") + for _bar_ln in self._usage_bar_lines(usage, plan_name): + print(_bar_ln) if usage and getattr(usage, "has_topup", False) and getattr(usage, "total_spendable_usd", None) is not None: print(f" Total spendable: ${usage.total_spendable_usd:,.2f}") @@ -9243,15 +9226,8 @@ def _billing_overview(self, state): print(f" {'─' * 41}") # Two-bar dollar usage view (plan name on the plan bar; top-up below). - pb = getattr(usage, "plan_bar", None) if usage else None - if pb is not None and pb.total_usd > 0: - bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) - _pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else "" - _label = (getattr(usage, "plan_name", None) or "plan").ljust(8)[:8] - print(f" {_label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{_pct_s}") - tb = getattr(usage, "topup_bar", None) if usage else None - if tb is not None and tb.remaining_usd > 0: - print(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") + for _bar_ln in self._usage_bar_lines(usage, getattr(usage, "plan_name", None)): + print(_bar_ln) ar = state.auto_reload if ar is not None: @@ -9344,6 +9320,26 @@ def _billing_spend_bar(self, spent, limit, *, cells: int = 10): bar = ("█" * filled) + ("░" * (cells - filled)) return bar, pct + def _usage_bar_lines(self, usage, plan_name) -> list: + """The plan + top-up dollar bars as ready-to-print lines (filled = remaining). + + Returns [] when there's nothing to draw. The caller resolves ``plan_name`` + (the plan-bar label) and picks its own print fn — block ordering differs + per surface (``_cprint`` vs ``print`` under patch_stdout). One source of + truth for the bar format across /usage, /subscription, and /topup. + """ + lines: list = [] + pb = getattr(usage, "plan_bar", None) if usage else None + if pb is not None and pb.total_usd > 0: + bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) + pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else "" + label = (plan_name or "plan").ljust(8)[:8] + lines.append(f" {label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{pct_s}") + tb = getattr(usage, "topup_bar", None) if usage else None + if tb is not None and tb.remaining_usd > 0: + lines.append(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") + return lines + def _billing_open_portal(self, state): url = getattr(state, "portal_url", None) if not url: From e636d2bdd73c3ebcc2f1f29869e82c6562876202 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 30 Jun 2026 22:43:49 +0530 Subject: [PATCH 43/62] feat(billing): NAS V3 subscription-change HTTP client wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/nous_billing.py | 120 ++++++++++++++++++ tests/hermes_cli/test_nous_billing_request.py | 88 +++++++++++++ 2 files changed, 208 insertions(+) diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index e99c959b6d6d..4eb5acbd380d 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -496,3 +496,123 @@ def get_subscription_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any return _request("GET", "/api/billing/subscription", timeout=timeout) +# ============================================================================= +# Subscription change (V3) — preview + the pending-change resource + upgrade +# ============================================================================= +# +# Mutating the plan splits into a chargeless lane and the single money route: +# - preview → a quote (no mutation, no charge) of what a change would do. +# - PUT/DELETE pending-change → schedule / clear a downgrade or cancellation +# (chargeless; takes effect at period end). +# - POST upgrade → the ONE route that charges (prorate + charge the card on the +# subscription + flip the plan, in one Stripe op). +# All require the ``billing:manage`` scope (a 403 insufficient_scope raises +# :class:`BillingScopeRequired`, driving the device step-up) — including preview, +# which issues live Stripe calls and reveals charge amounts. + + +def post_subscription_preview( + *, subscription_type_id: str, timeout: float = DEFAULT_TIMEOUT +) -> dict[str, Any]: + """``POST /api/billing/subscription/preview`` — a chargeless effect quote. + + Quotes a change to ``subscription_type_id`` without mutating anything: + ``effect`` is ``charge_now`` (an upgrade → ``amountDueNowCents`` is the prorated + upfront charge), ``scheduled`` (a downgrade → ``effectiveAt`` is period end), + ``no_op`` (already on the tier), or ``blocked`` (``reason`` says why the commit + would be refused). Also returns the current + target tier and the monthly-credit + delta. ``amountDueNowCents`` is ``None`` when not a charge or when the proration + quote is unavailable. Requires ``billing:manage`` (live Stripe calls + amounts). + """ + return _request( + "POST", + "/api/billing/subscription/preview", + body={"subscriptionTypeId": subscription_type_id}, + timeout=timeout, + ) + + +def put_subscription_pending_change( + *, + subscription_type_id: str | None = None, + cancel: bool = False, + timeout: float = DEFAULT_TIMEOUT, +) -> dict[str, Any]: + """``PUT /api/billing/subscription/pending-change`` — set the end-of-period intent. + + A subscription has at most one pending disposition. Pass ``cancel=True`` to + schedule a cancellation, or a ``subscription_type_id`` to schedule a downgrade / + same-price change. UPGRADES are rejected here (they charge immediately — use + :func:`post_subscription_upgrade`). Chargeless; requires ``billing:manage``. + Returns ``{rail, changeType, targetTierName, message}`` for a tier change, or + ``{rail, cancelAtPeriodEnd, message}`` for a cancellation. + """ + if cancel: + body: dict[str, Any] = {"type": "cancellation"} + else: + if not ( + isinstance(subscription_type_id, str) and subscription_type_id.strip() + ): + raise BillingError( + "A subscription tier is required to schedule a plan change.", + error="invalid_subscription_type", + ) + body = { + "type": "tier_change", + "subscriptionTypeId": subscription_type_id.strip(), + } + return _request( + "PUT", + "/api/billing/subscription/pending-change", + body=body, + timeout=timeout, + ) + + +def delete_subscription_pending_change( + *, timeout: float = DEFAULT_TIMEOUT +) -> dict[str, Any]: + """``DELETE /api/billing/subscription/pending-change`` — clear it (resume / undo). + + Removes a scheduled downgrade OR cancellation in one call, restoring the live + active tier and recurring renewal. Chargeless, but it re-enables recurring + spend, so it requires ``billing:manage`` and is honored by the org kill-switch. + Returns ``{rail, cancelAtPeriodEnd: false, message}``. + """ + return _request( + "DELETE", + "/api/billing/subscription/pending-change", + timeout=timeout, + ) + + +def post_subscription_upgrade( + *, + subscription_type_id: str, + idempotency_key: str, + timeout: float = DEFAULT_TIMEOUT, +) -> dict[str, Any]: + """``POST /api/billing/subscription/upgrade`` — immediate paid upgrade. + + The SINGLE money route: one Stripe op prorates, charges the card already on the + subscription, and flips the plan. ``Idempotency-Key`` is MANDATORY (a missing + header is a server 400, not a default) — reuse the same key on retry so a replay + cannot double-charge. Returns ``{status:"upgraded"|"already_on_tier", ...}`` on + success, or ``{status:"requires_action"|"payment_failed", reason, recoveryUrl}`` + when the charge needs 3DS / was declined and must be finished in the portal at + ``recoveryUrl``. Requires ``billing:manage``. + """ + if not (isinstance(idempotency_key, str) and idempotency_key.strip()): + raise BillingError( + "Idempotency-Key is required for an upgrade.", + error="idempotency_key_required", + ) + return _request( + "POST", + "/api/billing/subscription/upgrade", + body={"subscriptionTypeId": subscription_type_id}, + extra_headers={"Idempotency-Key": idempotency_key.strip()}, + timeout=timeout, + ) + + diff --git a/tests/hermes_cli/test_nous_billing_request.py b/tests/hermes_cli/test_nous_billing_request.py index f89257d3b01c..861a34b0e20a 100644 --- a/tests/hermes_cli/test_nous_billing_request.py +++ b/tests/hermes_cli/test_nous_billing_request.py @@ -62,3 +62,91 @@ def test_valid_json_2xx_body_parses(monkeypatch): payload = {"org": {"name": "Acme"}, "balanceUsd": "10"} with _stub(monkeypatch, json.dumps(payload).encode(), status=200): assert nb.get_billing_state() == payload + + +# --------------------------------------------------------------------------- +# Subscription change (V3): the request the client actually puts on the wire. +# --------------------------------------------------------------------------- + + +@contextmanager +def _capture(monkeypatch, body: bytes = b"{}", status: int = 200): + """Stub urlopen, recording the urllib.request.Request the client built.""" + seen: dict[str, object] = {} + monkeypatch.setattr( + nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example") + ) + + def _fake_urlopen(req, timeout=None): + seen["method"] = req.get_method() + seen["url"] = req.full_url + seen["data"] = json.loads(req.data.decode()) if req.data else None + seen["headers"] = {k.lower(): v for k, v in req.header_items()} + return _FakeResp(body, status) + + monkeypatch.setattr(nb.urllib.request, "urlopen", _fake_urlopen) + yield seen + + +def test_post_subscription_preview_request(monkeypatch): + with _capture(monkeypatch) as seen: + nb.post_subscription_preview(subscription_type_id="nous-chat-plan-40") + assert seen["method"] == "POST" + assert seen["url"] == "https://portal.example/api/billing/subscription/preview" + assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"} + + +def test_put_pending_change_tier_change_request(monkeypatch): + with _capture(monkeypatch) as seen: + nb.put_subscription_pending_change(subscription_type_id="nous-chat-plan-10") + assert seen["method"] == "PUT" + assert ( + seen["url"] == "https://portal.example/api/billing/subscription/pending-change" + ) + assert seen["data"] == { + "type": "tier_change", + "subscriptionTypeId": "nous-chat-plan-10", + } + + +def test_put_pending_change_cancellation_request(monkeypatch): + with _capture(monkeypatch) as seen: + nb.put_subscription_pending_change(cancel=True) + assert seen["method"] == "PUT" + assert seen["data"] == {"type": "cancellation"} + + +def test_put_pending_change_without_tier_or_cancel_raises(): + # No urlopen stub: a bad call must fail BEFORE any network I/O. + with pytest.raises(nb.BillingError) as ei: + nb.put_subscription_pending_change() + assert getattr(ei.value, "error", None) == "invalid_subscription_type" + + +def test_delete_pending_change_request(monkeypatch): + with _capture(monkeypatch) as seen: + nb.delete_subscription_pending_change() + assert seen["method"] == "DELETE" + assert ( + seen["url"] == "https://portal.example/api/billing/subscription/pending-change" + ) + assert seen["data"] is None + + +def test_post_subscription_upgrade_sends_idempotency_key(monkeypatch): + with _capture(monkeypatch) as seen: + nb.post_subscription_upgrade( + subscription_type_id="nous-chat-plan-40", idempotency_key="abc-123" + ) + assert seen["method"] == "POST" + assert seen["url"] == "https://portal.example/api/billing/subscription/upgrade" + assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"} + assert seen["headers"].get("idempotency-key") == "abc-123" + + +def test_post_subscription_upgrade_blank_key_raises(): + with pytest.raises(nb.BillingError) as ei: + nb.post_subscription_upgrade( + subscription_type_id="nous-chat-plan-40", idempotency_key=" " + ) + assert getattr(ei.value, "error", None) == "idempotency_key_required" From 3d385cee6176de7db4e3bdd332088be1ce9a8af4 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 30 Jun 2026 22:47:28 +0530 Subject: [PATCH 44/62] 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. --- agent/subscription_view.py | 140 ++++++++++++++++++++++++-- tests/agent/test_subscription_view.py | 97 ++++++++++++++++++ 2 files changed, 231 insertions(+), 6 deletions(-) diff --git a/agent/subscription_view.py b/agent/subscription_view.py index f77d759fa007..829eff840546 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -5,9 +5,11 @@ and let the surface degrade gracefully (never crash). Money is decimal end-to-end (server emits decimal strings); we only format for display. -The TUI ``SubscriptionOverlay`` is **deep-link only** — it never charges -in-terminal. The manage URL is built locally on the TUI side from the -``portal_url`` and ``org_id`` fields in the subscription state. +The TUI ``SubscriptionOverlay`` drives the plan change in-terminal (V3): it +previews the effect, then schedules a downgrade / cancellation / resume +(chargeless) or applies an upgrade (charges the card on the subscription). The +portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the +fallback for an upgrade that needs 3DS / was declined. WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A). Until it ships, the fail-open contract handles 404s — the builder returns @@ -53,6 +55,47 @@ class CurrentSubscription: cancellation_effective_at: Optional[str] = None # ISO +@dataclass(frozen=True) +class SubscriptionTier: + """A selectable plan in the catalog — one row of the in-terminal tier picker. + + Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan + (shown but not selectable); ``is_enabled=False`` is a grandfathered tier the + user is on but that can no longer be selected. ``tier_order`` sorts the picker + and drives the upgrade-vs-downgrade direction hint. + """ + + tier_id: str + name: str + tier_order: int = 0 + dollars_per_month: Optional[Decimal] = None + monthly_credits: Optional[Decimal] = None + is_current: bool = False + is_enabled: bool = True + + +@dataclass(frozen=True) +class SubscriptionChangePreview: + """Parsed ``POST /api/billing/subscription/preview`` — what a change would do. + + ``effect`` is the disposition the commit would take: + - ``charge_now`` → an upgrade; ``amount_due_now_cents`` is the prorated charge. + - ``scheduled`` → a downgrade / same-price change at ``effective_at`` (period end). + - ``no_op`` → already on the target tier. + - ``blocked`` → the commit would be refused; ``reason`` says why. + """ + + effect: str + reason: Optional[str] = None + current_tier_id: Optional[str] = None + current_tier_name: Optional[str] = None + target_tier_id: Optional[str] = None + target_tier_name: Optional[str] = None + monthly_credits_delta: Optional[Decimal] = None + amount_due_now_cents: Optional[int] = None + effective_at: Optional[str] = None # ISO + + @dataclass(frozen=True) class SubscriptionState: """Parsed ``GET /api/billing/subscription`` — the overview screen's data. @@ -67,6 +110,7 @@ class SubscriptionState: role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER" context: str = "personal" # "personal" | "team" current: Optional[CurrentSubscription] = None + tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker) portal_url: Optional[str] = None # When the fetch failed (vs cleanly not-logged-in), the message for the surface. error: Optional[str] = None @@ -109,6 +153,57 @@ def _parse_current(raw: Any) -> Optional[CurrentSubscription]: ) +def _coalesce(*vals: Any) -> Any: + """First non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``). + + NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain + ``x or default`` would drop those, so coalesce on ``None`` specifically. + """ + for v in vals: + if v is not None: + return v + return None + + +def _parse_tier(raw: Any) -> Optional[SubscriptionTier]: + """Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`.""" + if not isinstance(raw, dict): + return None + tier_id = raw.get("tierId") or raw.get("id") + if not tier_id: + return None + return SubscriptionTier( + tier_id=tier_id, + name=raw.get("name") or "", + tier_order=int(_coalesce(raw.get("tierOrder"), 0)), + dollars_per_month=parse_money(raw.get("dollarsPerMonthDisplay")), + monthly_credits=parse_money(raw.get("monthlyCredits")), + is_current=bool(raw.get("isCurrent")), + is_enabled=bool(_coalesce(raw.get("isEnabled"), True)), + ) + + +def subscription_change_preview_from_payload( + payload: dict[str, Any], +) -> SubscriptionChangePreview: + """Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`.""" + effect = payload.get("effect") + cents = payload.get("amountDueNowCents") + return SubscriptionChangePreview( + # An unrecognized/missing effect is treated as ``blocked`` — fail safe, never + # charge on a malformed quote. + effect=effect if isinstance(effect, str) else "blocked", + reason=payload.get("reason") or None, + current_tier_id=payload.get("currentTierId"), + current_tier_name=payload.get("currentTierName"), + target_tier_id=payload.get("targetTierId"), + target_tier_name=payload.get("targetTierName"), + monthly_credits_delta=parse_money(payload.get("monthlyCreditsDelta")), + amount_due_now_cents=int(cents) if isinstance(cents, (int, float)) else None, + effective_at=payload.get("effectiveAt") or None, + ) + + def subscription_state_from_payload( payload: dict[str, Any], *, portal_url: Optional[str] = None ) -> SubscriptionState: @@ -119,6 +214,13 @@ def subscription_state_from_payload( raw_context = payload.get("context") context = raw_context if raw_context in ("personal", "team") else "personal" + raw_tiers = payload.get("tiers") + tiers = ( + tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None) + if isinstance(raw_tiers, list) + else () + ) + return SubscriptionState( logged_in=True, org_name=org.get("name"), @@ -126,6 +228,7 @@ def subscription_state_from_payload( role=org.get("role"), context=context, current=_parse_current(payload.get("current")), + tiers=tiers, portal_url=portal_url, ) @@ -230,6 +333,28 @@ def _dev_current(**over: Any) -> CurrentSubscription: return CurrentSubscription(**base) +def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]: + """A sample plan catalog for fixtures (marks ``current_id`` as the active tier).""" + specs = ( + ("free", "Free", 0, "0", "0"), + ("plus", "Plus", 1, "20", "1000"), + ("super", "Super", 2, "40", "3000"), + ("ultra", "Ultra", 3, "80", "7000"), + ) + return tuple( + SubscriptionTier( + tier_id=tid, + name=name, + tier_order=order, + dollars_per_month=parse_money(dpm), + monthly_credits=parse_money(mc), + is_current=(tid == current_id), + is_enabled=True, + ) + for tid, name, order, dpm, mc in specs + ) + + def dev_fixture_subscription_state() -> Optional[SubscriptionState]: """Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``. @@ -250,27 +375,30 @@ def dev_fixture_subscription_state() -> Optional[SubscriptionState]: if name in ("logged-out", "logged_out", "loggedout"): return SubscriptionState(logged_in=False) if name == "free": - return SubscriptionState(logged_in=True, current=None, **common) + return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common) if name in ("mid", "mid-tier"): - return SubscriptionState(logged_in=True, current=_dev_current(), **common) + return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common) if name in ("top", "top-tier"): return SubscriptionState( logged_in=True, current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")), + tiers=_dev_tiers("ultra"), **common, ) if name in ("not-admin", "member"): - return SubscriptionState(logged_in=True, current=_dev_current(), **{**common, "role": "MEMBER"}) + return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **{**common, "role": "MEMBER"}) if name == "downgrade": return SubscriptionState( logged_in=True, current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"), + tiers=_dev_tiers("super"), **common, ) if name == "cancel": return SubscriptionState( logged_in=True, current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"), + tiers=_dev_tiers("plus"), **common, ) if name == "team": diff --git a/tests/agent/test_subscription_view.py b/tests/agent/test_subscription_view.py index 37cd651c4863..496409009237 100644 --- a/tests/agent/test_subscription_view.py +++ b/tests/agent/test_subscription_view.py @@ -13,6 +13,7 @@ SubscriptionState, build_subscription_state, dev_fixture_subscription_state, + subscription_change_preview_from_payload, subscription_manage_url, subscription_state_from_payload, ) @@ -96,6 +97,93 @@ def test_parser_defaults_unknown_context_to_personal(): assert s.context == "personal" +# ── tier catalog parsing (the picker) ──────────────────────────────── + + +def test_parser_maps_tiers_catalog(): + payload = { + "tiers": [ + { + "tierId": "free", + "name": "Free", + "tierOrder": 0, + "dollarsPerMonthDisplay": "0", + "monthlyCredits": "0", + "isCurrent": False, + "isEnabled": True, + }, + { + "tierId": "plus", + "name": "Plus", + "tierOrder": 1, + "dollarsPerMonthDisplay": "20", + "monthlyCredits": "1000", + "isCurrent": True, + "isEnabled": True, + }, + ], + } + s = subscription_state_from_payload(payload, portal_url=None) + assert len(s.tiers) == 2 + free, plus = s.tiers + # The free tier's 0s must survive (coalesce-on-None, not falsy `or`). + assert free.tier_id == "free" and free.tier_order == 0 + assert free.dollars_per_month == Decimal("0") + assert plus.is_current is True + assert plus.dollars_per_month == Decimal("20") and plus.monthly_credits == Decimal("1000") + + +def test_parser_tiers_absent_is_empty_tuple(): + assert subscription_state_from_payload({}, portal_url=None).tiers == () + + +# ── preview parser (POST /preview) ─────────────────────────────────── + + +def test_preview_parser_charge_now(): + p = subscription_change_preview_from_payload( + { + "effect": "charge_now", + "reason": None, + "currentTierId": "plus", + "currentTierName": "Plus", + "targetTierId": "ultra", + "targetTierName": "Ultra", + "monthlyCreditsDelta": "6000", + "amountDueNowCents": 1234, + "effectiveAt": None, + } + ) + assert p.effect == "charge_now" + assert p.amount_due_now_cents == 1234 + assert p.target_tier_name == "Ultra" + assert p.monthly_credits_delta == Decimal("6000") + + +def test_preview_parser_scheduled_has_effective_at_and_no_charge(): + p = subscription_change_preview_from_payload( + {"effect": "scheduled", "amountDueNowCents": None, "effectiveAt": "2026-08-01"} + ) + assert p.effect == "scheduled" + assert p.amount_due_now_cents is None + assert p.effective_at == "2026-08-01" + + +def test_preview_parser_blocked_carries_reason(): + p = subscription_change_preview_from_payload( + {"effect": "blocked", "reason": "Retract the cancellation before upgrading."} + ) + assert p.effect == "blocked" + assert p.reason and "Retract" in p.reason + + +def test_preview_parser_missing_effect_fails_safe_to_blocked(): + # A malformed quote must never read as a charge — default to blocked. + p = subscription_change_preview_from_payload({}) + assert p.effect == "blocked" + assert p.amount_due_now_cents is None + + # ── dev fixtures (env-driven, no live portal) ──────────────────────── @@ -124,6 +212,15 @@ def test_dev_fixture_states(monkeypatch, name, checker): assert checker(s) +def test_dev_fixture_exposes_tier_catalog(monkeypatch): + # A picker needs a catalog: the mid fixture lists tiers with the active one flagged. + monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid") + s = dev_fixture_subscription_state() + assert s is not None and len(s.tiers) >= 2 + current = [t for t in s.tiers if t.is_current] + assert len(current) == 1 and current[0].tier_id == "plus" + + def test_dev_fixture_unknown_name_fails_safe(monkeypatch): monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "bogus") s = dev_fixture_subscription_state() From 311eee611d5e80ba5ab9e142d7d11325c2fe31f3 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 30 Jun 2026 22:52:46 +0530 Subject: [PATCH 45/62] feat(billing): gateway RPCs for the V3 subscription change flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_tui_gateway_server.py | 143 ++++++++++++++++++++++++++++++ tui_gateway/server.py | 145 +++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 0c70557ce3a2..228bf8f66b72 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -7946,3 +7946,146 @@ def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs): assert session["agent"].model == "claude-sonnet-4.6" finally: server._sessions.clear() + + +# ── subscription change RPCs (V3): preview + pending-change + upgrade ── + + +def _sub_rpc(method, params): + # These RPCs are in _LONG_HANDLERS (pool-routed → dispatch returns None and the + # worker writes via the transport), so drive the inline handler directly. + return server.handle_request({"id": "1", "method": method, "params": params})["result"] + + +def test_subscription_preview_serializes_quote(monkeypatch): + import hermes_cli.nous_billing as nb + + monkeypatch.setattr( + nb, + "post_subscription_preview", + lambda subscription_type_id: { + "effect": "charge_now", + "reason": None, + "currentTierId": "plus", + "currentTierName": "Plus", + "targetTierId": "ultra", + "targetTierName": "Ultra", + "monthlyCreditsDelta": "6000", + "amountDueNowCents": 1234, + "effectiveAt": None, + }, + ) + res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"}) + assert res["ok"] is True + assert res["effect"] == "charge_now" + assert res["amount_due_now_cents"] == 1234 + assert res["target_tier_name"] == "Ultra" + assert res["monthly_credits_delta"] == "6000" + + +def test_subscription_preview_requires_tier(): + res = _sub_rpc("subscription.preview", {}) + assert res["ok"] is False + assert res["error"] == "invalid_request" + + +def test_subscription_preview_scope_error_maps_to_step_up(monkeypatch): + import hermes_cli.nous_billing as nb + + def _raise(subscription_type_id): + raise nb.BillingScopeRequired("billing:manage required") + + monkeypatch.setattr(nb, "post_subscription_preview", _raise) + res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"}) + assert res["ok"] is False + assert res["error"] == "insufficient_scope" + + +def test_subscription_change_cancellation(monkeypatch): + import hermes_cli.nous_billing as nb + + seen = {} + + def _put(*, subscription_type_id=None, cancel=False): + seen["tier"] = subscription_type_id + seen["cancel"] = cancel + return {"rail": "stripe", "cancelAtPeriodEnd": True, "message": "Scheduled to cancel."} + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + res = _sub_rpc("subscription.change", {"cancel": True}) + assert res["ok"] is True + assert seen == {"tier": None, "cancel": True} + assert res["message"] == "Scheduled to cancel." + + +def test_subscription_change_tier_downgrade(monkeypatch): + import hermes_cli.nous_billing as nb + + seen = {} + + def _put(*, subscription_type_id=None, cancel=False): + seen["tier"] = subscription_type_id + seen["cancel"] = cancel + return {"rail": "stripe", "changeType": "downgrade", "targetTierName": "Plus", "message": "Scheduled."} + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + res = _sub_rpc("subscription.change", {"subscription_type_id": "plus"}) + assert res["ok"] is True + assert seen == {"tier": "plus", "cancel": False} + + +def test_subscription_change_requires_tier_or_cancel(): + res = _sub_rpc("subscription.change", {}) + assert res["ok"] is False + assert res["error"] == "invalid_request" + + +def test_subscription_resume(monkeypatch): + import hermes_cli.nous_billing as nb + + monkeypatch.setattr( + nb, + "delete_subscription_pending_change", + lambda: {"rail": "stripe", "cancelAtPeriodEnd": False, "message": "Resumed."}, + ) + res = _sub_rpc("subscription.resume", {}) + assert res["ok"] is True + assert res["message"] == "Resumed." + + +def test_subscription_upgrade_echoes_status_and_idempotency(monkeypatch): + import hermes_cli.nous_billing as nb + + seen = {} + + def _upgrade(*, subscription_type_id, idempotency_key): + seen["key"] = idempotency_key + return {"status": "upgraded", "targetTierId": "ultra", "targetTierName": "Ultra"} + + monkeypatch.setattr(nb, "post_subscription_upgrade", _upgrade) + res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra", "idempotency_key": "k-1"}) + assert res["ok"] is True + assert res["status"] == "upgraded" + assert res["target_tier_name"] == "Ultra" + assert res["idempotency_key"] == "k-1" + assert seen["key"] == "k-1" + + +def test_subscription_upgrade_requires_action_surfaces_recovery(monkeypatch): + import hermes_cli.nous_billing as nb + + monkeypatch.setattr( + nb, + "post_subscription_upgrade", + lambda *, subscription_type_id, idempotency_key: { + "status": "requires_action", + "reason": "authentication_required", + "recoveryUrl": "https://portal.example/subscription?org_id=o", + }, + ) + res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra"}) + # The RPC succeeds; the CHARGE needs 3DS → status + recovery_url for the portal. + assert res["ok"] is True + assert res["status"] == "requires_action" + assert res["recovery_url"].startswith("https://portal.example") + assert res["idempotency_key"] # minted when the caller omits one diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f868971fca38..1570a029f9a9 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -179,6 +179,13 @@ def _thread_panic_hook(args): # portal can't stall approval.respond / session.interrupt / other RPCs. "billing.state", "subscription.state", + # Subscription change (V3): preview + the pending-change mutations + upgrade + # each do a blocking portal round-trip (preview + upgrade also hit Stripe, + # which can take seconds) — keep them off the main stdin loop. + "subscription.preview", + "subscription.change", + "subscription.resume", + "subscription.upgrade", "usage.bars", "session.usage", "billing.step_up", @@ -5664,6 +5671,7 @@ def _(rid, params: dict) -> dict: def _serialize_subscription_state(state) -> dict: """Serialize a SubscriptionState for the wire (Decimals → strings).""" from agent.billing_usage import format_renews + from agent.billing_view import format_money def _s(value): return None if value is None else str(value) @@ -5684,6 +5692,20 @@ def _s(value): "cancellation_effective_at": c.cancellation_effective_at, "cancellation_effective_display": format_renews(c.cancellation_effective_at), } + # Selectable catalog for the in-terminal tier picker; price is pre-formatted + # ($X / $X.YY) so the TUI renders it directly. + tiers = [ + { + "tier_id": t.tier_id, + "name": t.name, + "tier_order": t.tier_order, + "dollars_per_month_display": format_money(t.dollars_per_month), + "monthly_credits": _s(t.monthly_credits), + "is_current": t.is_current, + "is_enabled": t.is_enabled, + } + for t in state.tiers + ] return { "ok": True, "logged_in": state.logged_in, @@ -5694,6 +5716,7 @@ def _s(value): "role": state.role, "context": state.context, "current": current, + "tiers": tiers, "portal_url": state.portal_url, "error": state.error, # Shared dollar usage model (two-bar view) embedded so /subscription @@ -5721,6 +5744,128 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"}) +def _serialize_subscription_preview(p) -> dict: + """Serialize a SubscriptionChangePreview for the wire (Decimal → string).""" + return { + "ok": True, + "effect": p.effect, + "reason": p.reason, + "current_tier_id": p.current_tier_id, + "current_tier_name": p.current_tier_name, + "target_tier_id": p.target_tier_id, + "target_tier_name": p.target_tier_name, + "monthly_credits_delta": ( + None if p.monthly_credits_delta is None else str(p.monthly_credits_delta) + ), + "amount_due_now_cents": p.amount_due_now_cents, + "effective_at": p.effective_at, + } + + +@method("subscription.preview") +def _(rid, params: dict) -> dict: + """POST /api/billing/subscription/preview → serialized quote or typed error. + + params: {subscription_type_id: str}. Chargeless effect quote. Requires + billing:manage (live Stripe calls + amounts), so a 403 → insufficient_scope + drives the device step-up exactly like the mutations. + """ + from agent.subscription_view import subscription_change_preview_from_payload + from hermes_cli.nous_billing import BillingError, post_subscription_preview + + tier_id = params.get("subscription_type_id") + if not tier_id: + return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"}) + try: + preview = subscription_change_preview_from_payload( + post_subscription_preview(subscription_type_id=tier_id) + ) + return _ok(rid, _serialize_subscription_preview(preview)) + except BillingError as exc: + return _ok(rid, _serialize_billing_error(exc)) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + + +@method("subscription.change") +def _(rid, params: dict) -> dict: + """PUT /api/billing/subscription/pending-change → {ok, message} or typed error. + + params: {subscription_type_id?: str, cancel?: bool}. Schedules a downgrade / + same-price change OR a cancellation at period end (chargeless). Requires + billing:manage. + """ + from hermes_cli.nous_billing import BillingError, put_subscription_pending_change + + cancel = bool(params.get("cancel")) + tier_id = params.get("subscription_type_id") + if not cancel and not tier_id: + return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id or cancel is required"}) + try: + result = put_subscription_pending_change(subscription_type_id=tier_id, cancel=cancel) + return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result}) + except BillingError as exc: + return _ok(rid, _serialize_billing_error(exc)) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + + +@method("subscription.resume") +def _(rid, params: dict) -> dict: + """DELETE /api/billing/subscription/pending-change → {ok, message} or typed error. + + Clears a scheduled downgrade or cancellation (resume / undo). Chargeless, but it + re-enables recurring spend → requires billing:manage and honors the kill-switch. + """ + from hermes_cli.nous_billing import BillingError, delete_subscription_pending_change + + try: + result = delete_subscription_pending_change() + return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result}) + except BillingError as exc: + return _ok(rid, _serialize_billing_error(exc)) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + + +@method("subscription.upgrade") +def _(rid, params: dict) -> dict: + """POST /api/billing/subscription/upgrade → {ok, status, ...} or typed error. + + params: {subscription_type_id: str, idempotency_key?: str}. The single money + route: prorate + charge the card on the subscription + flip the plan. SCA / + decline come back as status requires_action / payment_failed with a recovery_url + to finish in the portal. The idempotency key is minted if absent and echoed so + the TUI reuses it on retry of the SAME upgrade. Requires billing:manage. + """ + from agent.billing_view import new_idempotency_key + from hermes_cli.nous_billing import BillingError, post_subscription_upgrade + + tier_id = params.get("subscription_type_id") + if not tier_id: + return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"}) + key = params.get("idempotency_key") or new_idempotency_key() + try: + result = post_subscription_upgrade(subscription_type_id=tier_id, idempotency_key=key) + return _ok( + rid, + { + "ok": True, + "status": result.get("status"), + "target_tier_name": result.get("targetTierName"), + "recovery_url": result.get("recoveryUrl"), + "reason": result.get("reason"), + "idempotency_key": key, + }, + ) + except BillingError as exc: + env = _serialize_billing_error(exc) + env["idempotency_key"] = key # so the TUI can reuse on retry + return _ok(rid, env) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key}) + + @method("billing.charge") def _(rid, params: dict) -> dict: From e2422ceabb765b6f54e44266aab4cc8dbc78f275 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Tue, 30 Jun 2026 23:04:41 +0530 Subject: [PATCH 46/62] feat(billing): in-terminal subscription change flow (TUI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- .../__tests__/subscriptionOverlay.test.tsx | 163 +++++- ui-tui/src/app/interfaces.ts | 55 +- ui-tui/src/app/slash/commands/subscription.ts | 46 +- ui-tui/src/components/appOverlays.tsx | 5 +- ui-tui/src/components/subscriptionOverlay.tsx | 498 +++++++++++++++--- ui-tui/src/gatewayTypes.ts | 57 ++ 6 files changed, 747 insertions(+), 77 deletions(-) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index eccdd2fcda62..a97004b1a2ce 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -39,7 +39,7 @@ function render(overlay: SubscriptionOverlayState): string { }) const instance = renderSync( - React.createElement(SubscriptionOverlay, { onClose: () => {}, overlay, t }), + React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay, t }), { patchConsole: false, stderr: stderr as NodeJS.WriteStream, @@ -54,6 +54,12 @@ function render(overlay: SubscriptionOverlayState): string { return stripAnsi(output) } +const TIERS = [ + { tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0', is_current: false, is_enabled: true }, + { tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true, is_enabled: true }, + { tier_id: 'ultra', name: 'Ultra', tier_order: 2, dollars_per_month_display: '$40', monthly_credits: '3000', is_current: false, is_enabled: true } +] + const state = (overrides: Partial = {}): SubscriptionStateResponse => ({ ok: true, logged_in: true, @@ -63,20 +69,27 @@ const state = (overrides: Partial = {}): Subscription org_id: 'org_acme', role: 'OWNER', current: null, + tiers: [], portal_url: 'https://portal.nousresearch.com/billing', ...overrides }) const ctx = { openManageLink: vi.fn(() => Promise.resolve(true)), + openPortal: vi.fn(), + preview: vi.fn(() => Promise.resolve(null)), refreshState: vi.fn(() => Promise.resolve(null)), - sys: vi.fn() + resume: vi.fn(() => Promise.resolve(null)), + scheduleCancellation: vi.fn(() => Promise.resolve(null)), + scheduleChange: vi.fn(() => Promise.resolve(null)), + sys: vi.fn(), + upgrade: vi.fn(() => Promise.resolve(null)) } const overlay = (s: SubscriptionStateResponse): SubscriptionOverlayState => ({ ctx, screen: 'overview', state: s }) -// Deep-link only: a single overview screen across every account state. No -// in-terminal tier picker, so there is no confirm/handoff screen to test. +// Overview: the entry screen across every account state (plan + usage + the +// actions that enter the in-terminal change flow). describe('SubscriptionOverlay — overview', () => { it('free: upsell + "Start a subscription", no tier list, no "credits"', () => { const out = render(overlay(state({ current: null, usage: { available: true, status: 'free', plan_name: null } }))) @@ -167,3 +180,145 @@ describe('SubscriptionOverlay — overview', () => { expect(out).toContain('/topup') }) }) + +// In-terminal change flow (V3): picker → confirm → result. useInput is mocked +// (no key simulation), so these assert each screen's rendered content. + +const subscriber = (overrides: Partial = {}): SubscriptionStateResponse => + state({ + current: { + tier_id: 'plus', + tier_name: 'Plus', + monthly_credits: '1000', + credits_remaining: '500', + cycle_ends_at: '2026-07-01', + pending_downgrade_tier_name: null, + pending_downgrade_at: null + }, + tiers: TIERS, + usage: { available: true, status: 'healthy', plan_name: 'Plus' }, + ...overrides + }) + +const at = ( + screen: SubscriptionOverlayState['screen'], + s: SubscriptionStateResponse, + extra: Partial = {} +): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra }) + +describe('SubscriptionOverlay — overview actions', () => { + it('admin subscriber: offers Change plan + Cancel subscription', () => { + const out = render(overlay(subscriber())) + + expect(out).toContain('Change plan') + expect(out).toContain('Cancel subscription') + }) + + it('pending change: offers undo instead of cancel', () => { + const out = render( + overlay( + subscriber({ + current: { + tier_id: 'plus', + tier_name: 'Plus', + monthly_credits: '1000', + credits_remaining: '500', + cycle_ends_at: '2026-07-01', + cancel_at_period_end: true, + cancellation_effective_at: '2026-07-01', + pending_downgrade_tier_name: null, + pending_downgrade_at: null + } + }) + ) + ) + + expect(out).toContain('Keep current plan') + expect(out).not.toContain('Cancel subscription') + }) +}) + +describe('SubscriptionOverlay — picker', () => { + it('lists other paid tiers with direction hints; hides current + free', () => { + const out = render(at('picker', subscriber())) + + expect(out).toContain('Ultra') + expect(out).toContain('$40/mo') + expect(out).toContain('upgrade') // ultra (order 2) > plus (order 1) + expect(out).not.toContain('Plus · $20/mo') // current tier is not selectable + expect(out).not.toContain('$0/mo') // free tier excluded — use Cancel instead + }) +}) + +describe('SubscriptionOverlay — confirm', () => { + it('charge_now: shows the prorated charge + upgrade copy', () => { + const out = render( + at('confirm', subscriber(), { + pending: { + kind: 'upgrade', + targetTierId: 'ultra', + preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234, monthly_credits_delta: '2000' } + } + }) + ) + + expect(out).toContain('Pay $12.34 & upgrade now') + expect(out).toContain('Upgrade to Ultra') + }) + + it('scheduled: shows effective date + no charge now', () => { + const out = render( + at('confirm', subscriber(), { + pending: { + kind: 'tier_change', + targetTierId: 'plus', + preview: { ok: true, effect: 'scheduled', target_tier_name: 'Plus', effective_at: '2026-08-01T00:00:00Z', amount_due_now_cents: null } + } + }) + ) + + expect(out).toContain('Schedule change to Plus') + expect(out).toContain('2026-08-01') + expect(out).toContain('No charge now') + }) + + it('cancellation: shows cancel-at-period-end copy', () => { + const out = render(at('confirm', subscriber(), { pending: { kind: 'cancellation', targetTierId: null, preview: null } })) + + expect(out).toContain('Confirm cancellation') + expect(out).toContain('will not renew') + }) + + it('blocked: shows the reason + Manage on portal', () => { + const out = render( + at('confirm', subscriber(), { + pending: { kind: 'tier_change', targetTierId: 'ultra', preview: { ok: true, effect: 'blocked', reason: 'Retract the cancellation before upgrading.' } } + }) + ) + + expect(out).toContain('Retract the cancellation') + expect(out).toContain('Manage on portal') + }) +}) + +describe('SubscriptionOverlay — result', () => { + it('ok: shows Done + the re-run hint', () => { + const out = render(at('result', subscriber(), { result: { ok: true, message: 'Upgraded to Ultra.' } })) + + expect(out).toContain('Done') + expect(out).toContain('Upgraded to Ultra.') + expect(out).toContain('Re-run /subscription') + }) + + it('error with recovery: shows the message + Open the portal', () => { + const out = render( + at('result', subscriber(), { + result: { ok: false, message: 'This upgrade needs extra verification (3DS).', recoveryUrl: 'https://portal.example/x' } + }) + ) + + expect(out).toContain('Could not complete') + expect(out).toContain('3DS') + expect(out).toContain('Open the portal to finish') + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 2a9e4af0c65a..9d34a3eed96b 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -3,7 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea import type { PasteEvent } from '../components/textInput.js' import type { GatewayClient } from '../gatewayClient.js' -import type { BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionStateResponse } from '../gatewayTypes.js' +import type { BillingMutationResponse, BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../gatewayTypes.js' import type { ParsedVoiceRecordKey } from '../lib/platform.js' import type { RpcResult } from '../lib/rpc.js' import type { Theme } from '../theme.js' @@ -150,23 +150,68 @@ export interface BillingOverlayState { state: BillingStateResponse } -// ── Subscription overlay (deep-link only, NEVER charges in-terminal) ── +// ── Subscription overlay (in-terminal plan change, V3) ── -// Deep-link only: the overview hands off to the portal in the browser; there is -// no in-terminal plan picker, so there is exactly one screen. -export type SubscriptionScreen = 'overview' +// A small state machine: overview → picker → confirm → result. +// overview — plan + status, entry to the picker / resume / manage-on-portal. +// picker — the tier catalog (up/down direction hints; current tier shown, +// not selectable). +// confirm — the previewed effect of the chosen change (charge $X now / +// scheduled at date / no-op / blocked) + the apply action. +// result — the outcome, including an SCA/decline upgrade handed off to the +// portal. +export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' export interface SubscriptionOverlayCtx { /** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */ openManageLink: () => Promise + /** Open an arbitrary portal recovery URL (e.g. an upgrade's SCA handoff). */ + openPortal: (url: string) => void /** Re-fetch subscription.state. */ refreshState: () => Promise + /** POST /preview a change to `tierId` → the chargeless effect quote (or typed error). */ + preview: (tierId: string) => Promise + /** PUT pending-change: schedule a downgrade / same-price change to `tierId`. */ + scheduleChange: (tierId: string) => Promise + /** PUT pending-change: schedule a cancellation at period end. */ + scheduleCancellation: () => Promise + /** DELETE pending-change: clear a scheduled downgrade / cancellation (resume). */ + resume: () => Promise + /** POST /upgrade: charge the card on the subscription + flip the plan now. */ + upgrade: (tierId: string, idempotencyKey?: string) => Promise /** Emit a transcript system line. */ sys: (text: string) => void } +/** What the confirm screen is about to apply, plus its preview quote. */ +export interface SubscriptionPendingChange { + /** The target tier (null for a cancellation). */ + targetTierId: string | null + /** How it will be applied — drives which ctx call confirm makes. */ + kind: 'cancellation' | 'tier_change' | 'upgrade' + /** The preview quote shown on confirm (null = the quote call failed). */ + preview?: null | SubscriptionPreviewResponse + /** + * Stable idempotency key for an upgrade charge, minted when confirm opens. + * Reused on retry so a re-submit dedups server-side. + */ + idempotencyKey?: string +} + +/** The outcome rendered on the result screen. */ +export interface SubscriptionResult { + message: string + ok: boolean + /** A portal URL to finish an SCA/declined upgrade, when present. */ + recoveryUrl?: null | string +} + export interface SubscriptionOverlayState { ctx: SubscriptionOverlayCtx + /** Set on the 'confirm' screen: the change being confirmed + its preview. */ + pending?: null | SubscriptionPendingChange + /** Set on the 'result' screen: the outcome to render. */ + result?: null | SubscriptionResult screen: SubscriptionScreen state: SubscriptionStateResponse } diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index 8cd808ce431e..451d4a02852c 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -1,4 +1,9 @@ -import type { SubscriptionStateResponse } from '../../../gatewayTypes.js' +import type { + BillingMutationResponse, + SubscriptionPreviewResponse, + SubscriptionStateResponse, + SubscriptionUpgradeResponse +} from '../../../gatewayTypes.js' import { openExternalUrl } from '../../../lib/openExternalUrl.js' import type { SubscriptionOverlayCtx } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' @@ -65,19 +70,54 @@ const buildSubscriptionCtx = ( const opened = openExternalUrl(url) if (opened) { - sys('Opening your subscription page in the browser — finish the change there; re-run /subscription to confirm.') + sys('Opening your subscription page in the browser — finish there, then re-run /subscription.') } else { sys('Could not open browser — visit your subscription page manually at ' + url) } return Promise.resolve(opened) }, + openPortal: (url: string) => { + if (openExternalUrl(url)) { + sys('Opening the portal in your browser — finish there, then re-run /subscription.') + } else { + sys('Could not open browser — visit ' + url + ' to finish.') + } + }, + preview: tierId => + ctx.gateway + .rpc('subscription.preview', { subscription_type_id: tierId }) + .then(r => r ?? null) + .catch(() => null), refreshState: () => ctx.gateway .rpc('subscription.state', {}) .then(r => r ?? null) .catch(() => null), - sys + resume: () => + ctx.gateway + .rpc('subscription.resume', {}) + .then(r => r ?? null) + .catch(() => null), + scheduleCancellation: () => + ctx.gateway + .rpc('subscription.change', { cancel: true }) + .then(r => r ?? null) + .catch(() => null), + scheduleChange: tierId => + ctx.gateway + .rpc('subscription.change', { subscription_type_id: tierId }) + .then(r => r ?? null) + .catch(() => null), + sys, + upgrade: (tierId, idempotencyKey) => + ctx.gateway + .rpc('subscription.upgrade', { + subscription_type_id: tierId, + ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}) + }) + .then(r => r ?? null) + .catch(() => null) }) export const subscriptionCommands: SlashCommand[] = [ diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 9388df4bf650..314b5f697a27 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -55,11 +55,14 @@ export function PromptZone({ if (overlay.subscription) { const current = overlay.subscription + const onPatch = (next: Partial) => + patchOverlayState(prev => (prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev)) + const onClose = () => patchOverlayState({ subscription: null }) return ( - + ) } diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index ca48732b4427..17fe1ec89907 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -1,51 +1,181 @@ import { Box, Text, useInput } from '@hermes/ink' -import { useState } from 'react' - -import type { SubscriptionOverlayState } from '../app/interfaces.js' -import type { SubscriptionStateResponse } from '../gatewayTypes.js' +import { useRef, useState } from 'react' + +import type { + SubscriptionOverlayState, + SubscriptionPendingChange, + SubscriptionResult +} from '../app/interfaces.js' +import type { + BillingMutationResponse, + SubscriptionStateResponse, + SubscriptionTierOption, + SubscriptionUpgradeResponse +} from '../gatewayTypes.js' import type { Theme } from '../theme.js' -import { footer, MenuRow, UsageBars } from './overlayPrimitives.js' +import { ActionRow, footer, MenuRow, UsageBars } from './overlayPrimitives.js' interface SubscriptionOverlayProps { /** Close the overlay entirely. */ onClose: () => void + /** Merge a partial into the overlay state (screen transitions + pending/result). */ + onPatch: (next: Partial) => void overlay: SubscriptionOverlayState t: Theme } /** - * The /subscription modal — deep-link only, NEVER charges in-terminal. A single - * overview screen (plan + usage) that hands off to the portal in the browser to - * change the plan: no in-terminal picker, no step-up (the scope gate lives on - * /topup's charge). All RPCs live in subscription.ts, reached via `overlay.ctx`. + * The /subscription modal — an in-terminal plan-change flow (V3). A small state + * machine: overview → picker → confirm → result. Downgrades / cancellations / + * resume are chargeless; an upgrade charges the card on the subscription via the + * upgrade RPC, and an SCA/decline is handed off to the portal. Starting a NEW + * subscription still deep-links to the portal (needs a fresh card — out of scope + * here). All RPCs live in subscription.ts, reached via `overlay.ctx`. */ -export function SubscriptionOverlay({ onClose, overlay, t }: SubscriptionOverlayProps) { - const { ctx, state: s } = overlay +export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) { + const { screen, state: s } = overlay + + // Teams have no personal subscription — dead-end to /topup, no picker. + if (s.context === 'team') { + return ( + + + + ) + } return ( - {s.context === 'team' ? ( - - ) : ( - - )} + {screen === 'picker' && } + {screen === 'confirm' && } + {screen === 'result' && } + {screen === 'overview' && } ) } -// ── Screen: Overview (covers states a–e: free/mid/top/not-admin/downgrade) ── +// ── Shared helpers ─────────────────────────────────────────────────── interface ScreenProps { - ctx: SubscriptionOverlayState['ctx'] onClose: () => void - s: SubscriptionStateResponse + onPatch: (next: Partial) => void + overlay: SubscriptionOverlayState t: Theme } -/** Status line — dollars-only, state-matched. The allowance detail ($X of $Y · - * % used) lives on the bar line, so this line shows only the spendable total + - * renewal — no duplicated "of $Y left". */ +/** A selectable menu row with its action. */ +interface Row { + color?: string + label: string + run: () => void +} + +/** ↑/↓ + Enter + number-key selection over `rows`; Esc runs `onEscape`. */ +function useMenu(rows: Row[], onEscape: () => void): number { + const [sel, setSel] = useState(0) + + useInput((ch, key) => { + if (key.escape) { + return onEscape() + } + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + } + + if (key.downArrow && sel < rows.length - 1) { + setSel(v => v + 1) + } + + if (key.return) { + return rows[sel]?.run() + } + + const n = parseInt(ch, 10) + + if (n >= 1 && n <= rows.length) { + return rows[n - 1]?.run() + } + }) + + return Math.min(sel, Math.max(0, rows.length - 1)) +} + +/** ISO datetime → YYYY-MM-DD for display, or a soft fallback. */ +function shortDate(iso?: null | string): string { + return iso && iso.length >= 10 ? iso.slice(0, 10) : 'the end of the billing period' +} + +/** Integer cents → "$X.YY", or null when no amount is quoted. */ +function centsDisplay(cents?: null | number): null | string { + return typeof cents === 'number' ? `$${(cents / 100).toFixed(2)}` : null +} + +/** + * Map a failed RPC envelope to a result. insufficient_scope is special-cased: + * /subscription has no in-terminal step-up (that lives on /topup), so route the + * user there to enable terminal billing, then retry. + */ +function errorResult(r: { error?: string; message?: string; portal_url?: null | string } | null): SubscriptionResult { + if (r?.error === 'insufficient_scope') { + return { + message: 'Terminal billing is not enabled for this account. Run /topup to enable it, then try again.', + ok: false, + recoveryUrl: r.portal_url ?? null + } + } + + return { + message: r?.message || r?.error || 'Something went wrong. Try again, or manage on the portal.', + ok: false, + recoveryUrl: r?.portal_url ?? null + } +} + +/** Map a chargeless pending-change mutation (schedule / cancel / resume). */ +function mutationResult(r: BillingMutationResponse | null, okMessage: string): SubscriptionResult { + return r?.ok ? { message: r.message || okMessage, ok: true } : errorResult(r) +} + +/** Map an upgrade response, routing SCA / decline to a portal recovery. */ +function upgradeResult(r: null | SubscriptionUpgradeResponse): SubscriptionResult { + if (!r) { + return { message: 'The upgrade could not be completed.', ok: false } + } + + if (r.ok && (r.status === 'already_on_tier' || r.status === 'upgraded')) { + return { + message: + r.status === 'already_on_tier' + ? `You are already on ${r.target_tier_name ?? 'this plan'}.` + : `Upgraded to ${r.target_tier_name ?? 'your new plan'}. Your new monthly credits land in a moment.`, + ok: true + } + } + + if (r.status === 'requires_action') { + return { + message: 'This upgrade needs extra verification (3DS). Finish it on the portal.', + ok: false, + recoveryUrl: r.recovery_url ?? null + } + } + + if (r.status === 'payment_failed') { + return { + message: 'Your card was declined. Update your payment method on the portal and try again.', + ok: false, + recoveryUrl: r.recovery_url ?? null + } + } + + return errorResult(r) +} + +// ── Screen: Overview (plan + usage + entry to the change flow) ──────── + +/** Status line — dollars-only, state-matched (the bar carries the breakdown). */ function statusLine(s: SubscriptionStateResponse): string { const u = s.usage const plan = s.current?.tier_name ?? u?.plan_name ?? null @@ -61,21 +191,25 @@ function statusLine(s: SubscriptionStateResponse): string { return `Plan: ${plan} · ${u.total_spendable_display} left` } - // Healthy/top: show the spendable total once; the bar carries the breakdown. const left = u?.total_spendable_display ? ` · ${u.total_spendable_display} left` : '' return `Plan: ${plan}${left}${viewOnly ? ' · view only' : renews}` } -function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { +function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { + const { ctx, state: s } = overlay const c = s.current const isFree = !c?.tier_id const isCancelScheduled = !!c?.cancel_at_period_end const hasPendingDowngrade = !!c?.pending_downgrade_tier_name + const hasPendingChange = isCancelScheduled || hasPendingDowngrade + // Admin/owner on a personal paid plan can change it in-terminal; otherwise the + // portal enforces who can act (members) / starting a new sub needs a card. + const canChange = s.can_change_plan && !isFree + + // Guard the async resume so a double-press cannot fire two DELETEs mid-await. + const busyRef = useRef(false) - // Headline precedence: cancel-scheduled > downgrade-pending > active. - // (Past-due/dunning was removed from the NAS read — a card-failing - // subscriber now returns as a normal plan; no special-casing here.) const cancelOn = c?.cancellation_effective_display ?? c?.cancellation_effective_at const cancellationNote = isCancelScheduled @@ -84,15 +218,13 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { : 'Cancellation scheduled — your plan stays active until the end of the billing period.' : null - const downgradeOn = - c?.pending_downgrade_display ?? c?.pending_downgrade_at ?? 'the end of the billing period' + const downgradeOn = c?.pending_downgrade_display ?? c?.pending_downgrade_at ?? 'the end of the billing period' const downgradeNote = !isCancelScheduled && hasPendingDowngrade ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${downgradeOn}.` : null - // State-matched upsell/alert nudge (dollars-only; healthy stays silent). const u = s.usage const freeNudge = isFree ? 'Paid models need a subscription. Start one to reach them.' : null @@ -101,50 +233,46 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { ? `Low balance · ${u.total_spendable_display ?? 'under $5'} left. Top up or upgrade before a mid-run cutoff.` : null - // No in-terminal plan picker (decided with the user): just show usage + the - // current plan, then hand off to the portal to manage it. Members (non-admin) - // get the same two actions — the portal enforces who can actually change it. - // Free users have nothing to "manage" yet, so the verb matches their job. - const manageLabel = isFree ? 'Start a subscription' : 'Manage on portal' - const items = [manageLabel, 'Close'] - const [sel, setSel] = useState(0) - - const choose = (i: number) => { - if (i === 0) { - if (s.portal_url) { - ctx.sys('Opening your subscription page in the browser…') - void ctx.openManageLink() - } else { - ctx.sys('🔴 No portal URL available — manage your subscription on the Nous portal.') - } + const doManage = () => { + if (s.portal_url) { + void ctx.openManageLink() + } else { + ctx.sys('🔴 No portal URL available — manage your subscription on the Nous portal.') } return onClose() } - useInput((ch, key) => { - if (key.escape) { - return onClose() + const doResume = () => { + if (busyRef.current) { + return } - if (key.upArrow && sel > 0) { - setSel(v => v - 1) - } + busyRef.current = true + void ctx + .resume() + .then(r => onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' })) + } - if (key.downArrow && sel < items.length - 1) { - setSel(v => v + 1) - } + const rows: Row[] = [] - if (key.return) { - return choose(sel) + if (canChange) { + rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) }) + + if (hasPendingChange) { + rows.push({ color: t.color.ok, label: 'Keep current plan (undo pending change)', run: doResume }) + } else { + rows.push({ + label: 'Cancel subscription', + run: () => onPatch({ pending: { kind: 'cancellation', preview: null, targetTierId: null }, screen: 'confirm' }) + }) } + } - const n = parseInt(ch, 10) + rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage }) + rows.push({ label: 'Close', run: onClose }) - if (n >= 1 && n <= items.length) { - return choose(n - 1) - } - }) + const sel = useMenu(rows, onClose) return ( @@ -186,8 +314,8 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { )} - {items.map((label, i) => ( - + {rows.map((row, i) => ( + ))} @@ -196,6 +324,248 @@ function OverviewScreen({ ctx, onClose, s, t }: ScreenProps) { ) } +// ── Screen: Picker (choose a tier → preview → confirm) ─────────────── + +function PickerScreen({ onClose, onPatch, overlay, t }: ScreenProps) { + const { ctx, state: s } = overlay + const currentOrder = s.tiers.find(tier => tier.is_current)?.tier_order ?? 0 + + // Selectable = enabled, not the current plan, and not the free/no-sub tier + // (going to free is a cancellation, offered on the overview). Sorted by price. + const choices: SubscriptionTierOption[] = s.tiers + .filter(tier => tier.is_enabled && !tier.is_current && tier.tier_order > 0) + .sort((a, b) => a.tier_order - b.tier_order) + + // Guard the async preview so a double-press cannot fire two quotes. + const busyRef = useRef(false) + + const pick = (tier: SubscriptionTierOption) => { + if (busyRef.current) { + return + } + + busyRef.current = true + void ctx.preview(tier.tier_id).then(p => { + if (!p) { + return onPatch({ result: { message: 'Could not preview that change.', ok: false }, screen: 'result' }) + } + + if (!p.ok) { + return onPatch({ result: errorResult(p), screen: 'result' }) + } + + // charge_now ⇒ an upgrade (charges now); everything else schedules at + // period end. blocked/no_op still go to confirm, which shows why + no apply. + const kind = p.effect === 'charge_now' ? 'upgrade' : 'tier_change' + onPatch({ pending: { kind, preview: p, targetTierId: tier.tier_id }, screen: 'confirm' }) + }) + } + + const back = () => onPatch({ screen: 'overview' }) + + const rows: Row[] = choices.map(tier => { + const direction = tier.tier_order > currentOrder ? 'upgrade' : 'downgrade' + + return { + label: `${tier.name} · ${tier.dollars_per_month_display}/mo · ${direction}`, + run: () => pick(tier) + } + }) + + rows.push({ label: 'Back', run: back }) + + const sel = useMenu(rows, back) + + return ( + + + Change plan + + + Current: {s.current?.tier_name ?? 'Free'}. Pick a plan to see the effect before confirming. + + + {choices.length === 0 && No other plans are available to switch to right now.} + {rows.map((row, i) => ( + + ))} + + {footer('↑/↓ select · Enter preview · Esc back', t)} + + ) +} + +// ── Screen: Confirm (show the previewed effect, then apply) ────────── + +function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { + const { ctx, state: s } = overlay + const pending: null | SubscriptionPendingChange = overlay.pending ?? null + const preview = pending?.preview ?? null + const isCancellation = pending?.kind === 'cancellation' + // Cancellation is always a scheduled (chargeless) effect; otherwise trust the + // quote (default to blocked so a missing quote never offers an apply). + const effect = isCancellation ? 'scheduled' : (preview?.effect ?? 'blocked') + + const [submitting, setSubmitting] = useState(false) + // Synchronous guard: two key events can both see submitting===false before + // React commits, double-firing the mutation/charge. + const submittingRef = useRef(false) + + const back = () => onPatch({ pending: null, screen: isCancellation ? 'overview' : 'picker' }) + + const apply = () => { + if (submittingRef.current || !pending) { + return + } + + submittingRef.current = true + setSubmitting(true) + + const finish = (result: SubscriptionResult) => onPatch({ result, screen: 'result' }) + + if (pending.kind === 'cancellation') { + void ctx + .scheduleCancellation() + .then(r => finish(mutationResult(r, 'Your subscription is scheduled to cancel at the end of the billing period.'))) + + return + } + + if (pending.kind === 'upgrade') { + void ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r => finish(upgradeResult(r))) + + return + } + + void ctx + .scheduleChange(pending.targetTierId ?? '') + .then(r => finish(mutationResult(r, 'Your plan change is scheduled for the end of the billing period.'))) + } + + const manage = () => { + void ctx.openManageLink() + + return onClose() + } + + // Build the rows. blocked/no_op have nothing to apply; blocked offers the + // portal as the escape hatch. + const amount = centsDisplay(preview?.amount_due_now_cents) + const targetName = isCancellation ? null : (preview?.target_tier_name ?? 'the selected plan') + + let primary: null | Row = null + + if (isCancellation) { + primary = { color: t.color.warn, label: 'Cancel subscription', run: apply } + } else if (effect === 'charge_now') { + primary = { color: t.color.ok, label: amount ? `Pay ${amount} & upgrade now` : 'Upgrade now (prorated charge)', run: apply } + } else if (effect === 'scheduled') { + primary = { color: t.color.ok, label: `Schedule change to ${targetName}`, run: apply } + } else if (effect === 'blocked') { + primary = { label: 'Manage on portal', run: manage } + } + + const rows: Row[] = primary ? [primary, { label: 'Back', run: back }] : [{ label: 'Back', run: back }] + const sel = useMenu(rows, back) + + return ( + + + {isCancellation ? 'Confirm cancellation' : 'Confirm plan change'} + + {submitting && Working…} + + {isCancellation && ( + <> + + Cancel {s.current?.tier_name ?? 'your plan'} — it stays active until {shortDate(s.current?.cycle_ends_at)}, then + will not renew. + + You keep your remaining credits for this period. You can resume before it ends. + + )} + + {effect === 'charge_now' && !isCancellation && ( + <> + + Upgrade to {targetName}.{' '} + {amount ? `You will be charged ${amount} now (prorated).` : 'You will be charged the prorated amount now.'} + + {preview?.monthly_credits_delta && ( + Monthly credits change: {preview.monthly_credits_delta}. + )} + The card on your subscription will be charged. + + )} + + {effect === 'scheduled' && !isCancellation && ( + <> + + Change to {targetName} — takes effect {shortDate(preview?.effective_at)}. No charge now; you keep your current + plan until then. + + {preview?.monthly_credits_delta && ( + Monthly credits change: {preview.monthly_credits_delta}. + )} + + )} + + {effect === 'no_op' && !isCancellation && ( + You are already on {targetName} — nothing to change. + )} + + {effect === 'blocked' && !isCancellation && ( + {preview?.reason ?? 'That change cannot be made here — manage it on the portal.'} + )} + + + {rows.map((row, i) => ( + + ))} + + {footer('↑/↓ select · Enter confirm · Esc back', t)} + + ) +} + +// ── Screen: Result (outcome + optional portal recovery) ────────────── + +function ResultScreen({ onClose, overlay, t }: Omit) { + const { ctx } = overlay + const result: null | SubscriptionResult = overlay.result ?? null + const recoveryUrl = result?.recoveryUrl ?? null + + const openRecovery = () => { + if (recoveryUrl) { + ctx.openPortal(recoveryUrl) + } + + return onClose() + } + + const rows: Row[] = recoveryUrl + ? [{ color: t.color.accent, label: 'Open the portal to finish', run: openRecovery }, { label: 'Close', run: onClose }] + : [{ label: 'Close', run: onClose }] + + const sel = useMenu(rows, onClose) + + return ( + + + {result?.ok ? 'Done' : 'Could not complete'} + + {result?.message ?? ''} + {result?.ok && Re-run /subscription to see the updated plan.} + + {rows.map((row, i) => ( + + ))} + + {footer('↑/↓ select · Enter · Esc close', t)} + + ) +} + // ── Screen: Team context (no tier picker — teams use shared credits) ── interface TeamContextScreenProps { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 217221125abb..a034fe9764b5 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -141,6 +141,16 @@ export interface BillingMutationResponse { retry_after?: number | null } +export interface SubscriptionTierOption { + tier_id: string + name: string + tier_order: number // sorts the picker + upgrade/downgrade hint + dollars_per_month_display: string // pre-formatted ($X / $X.YY) + monthly_credits: string | null + is_current: boolean // the active plan: shown, not selectable + is_enabled: boolean // false = grandfathered current tier +} + export interface SubscriptionStateResponse { ok: boolean logged_in: boolean @@ -163,6 +173,7 @@ export interface SubscriptionStateResponse { cancellation_effective_at: string | null // ISO when cancellation takes effect cancellation_effective_display: string | null // formatted cancellation_effective_at } | null + tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker portal_url: string | null error?: string | null // Shared dollar usage model (two-bar view), embedded by the gateway so the @@ -170,6 +181,52 @@ export interface SubscriptionStateResponse { usage?: UsageModelData } +// A chargeless quote (POST /subscription/preview) of what a change would do. +// `effect` drives the confirm copy; a failed preview reuses the typed-error +// envelope fields (same as the mutations) so a 403 still triggers the step-up. +export interface SubscriptionPreviewResponse { + ok: boolean + effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked' + reason?: string | null + current_tier_id?: string | null + current_tier_name?: string | null + target_tier_id?: string | null + target_tier_name?: string | null + monthly_credits_delta?: string | null + amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade + effective_at?: string | null // ISO, when a scheduled change lands + // typed-error envelope (present when ok=false) + error?: string + message?: string + portal_url?: string | null + retry_after?: number | null + payload?: BillingErrorPayload + actor?: string + code?: string + recovery?: string +} + +// The single money route (POST /subscription/upgrade). `status` distinguishes a +// completed upgrade from an SCA/decline that must finish in the portal at +// `recovery_url`. `idempotency_key` is echoed so a retry reuses it. +export interface SubscriptionUpgradeResponse { + ok: boolean + status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed' + target_tier_name?: string | null + recovery_url?: string | null + reason?: string | null + idempotency_key?: string + // typed-error envelope (present when ok=false) + error?: string + message?: string + portal_url?: string | null + retry_after?: number | null + payload?: BillingErrorPayload + actor?: string + code?: string + recovery?: string +} + export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } From 30067b44b6988114972f9ec9bf21ffa12f884735 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 1 Jul 2026 22:46:22 +0530 Subject: [PATCH 47/62] feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/subscriptionOverlay.test.tsx | 23 +- ui-tui/src/app/interfaces.ts | 22 +- ui-tui/src/app/slash/commands/subscription.ts | 5 + ui-tui/src/components/subscriptionOverlay.tsx | 330 ++++++++++++------ 4 files changed, 277 insertions(+), 103 deletions(-) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index a97004b1a2ce..7bb9590029b8 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -79,6 +79,7 @@ const ctx = { openPortal: vi.fn(), preview: vi.fn(() => Promise.resolve(null)), refreshState: vi.fn(() => Promise.resolve(null)), + requestRemoteSpending: vi.fn(() => Promise.resolve(true)), resume: vi.fn(() => Promise.resolve(null)), scheduleCancellation: vi.fn(() => Promise.resolve(null)), scheduleChange: vi.fn(() => Promise.resolve(null)), @@ -159,7 +160,7 @@ describe('SubscriptionOverlay — overview', () => { expect(out).toContain('Manage on portal') }) - it('downgrade-pending: shows scheduled-switch banner (formatted date)', () => { + it('downgrade-pending: leads with a Pro ──▶ Free banner + status echo', () => { const out = render( overlay( state({ @@ -169,8 +170,12 @@ describe('SubscriptionOverlay — overview', () => { ) ) - expect(out).toContain('Scheduled to switch to Free') + expect(out).toContain('Scheduled change') + expect(out).toContain('──▶') + expect(out).toContain('Free') expect(out).toContain('Jul 15, 2026') + // the status line itself echoes the transition + expect(out).toContain('Plan: Pro → Free') }) it('team context: redirects to /topup, no tier picker', () => { @@ -233,11 +238,23 @@ describe('SubscriptionOverlay — overview actions', () => { ) ) - expect(out).toContain('Keep current plan') + // undo is promoted to the first action; the banner shows the pending cancel + expect(out).toContain('Keep Plus (undo this change)') + expect(out).toContain('cancels') expect(out).not.toContain('Cancel subscription') }) }) +describe('SubscriptionOverlay — step-up', () => { + it('prompts to enable terminal billing (never leaks the raw scope)', () => { + const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } })) + + expect(out).toContain('Terminal billing') + expect(out).toContain('Enable terminal billing') + expect(out).not.toContain('billing:manage') + }) +}) + describe('SubscriptionOverlay — picker', () => { it('lists other paid tiers with direction hints; hides current + free', () => { const out = render(at('picker', subscriber())) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 11ec06d7fb47..4cb689c7c3e9 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -152,7 +152,8 @@ export interface BillingOverlayState { // ── Subscription overlay (in-terminal plan change, V3) ── -// A small state machine: overview → picker → confirm → result. +// A small state machine: overview → picker → confirm → result, with a stepup +// screen spliced in on demand. // overview — plan + status, entry to the picker / resume / manage-on-portal. // picker — the tier catalog (up/down direction hints; current tier shown, // not selectable). @@ -160,7 +161,16 @@ export interface BillingOverlayState { // scheduled at date / no-op / blocked) + the apply action. // result — the outcome, including an SCA/decline upgrade handed off to the // portal. -export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' +// stepup — reached when a mutation returns insufficient_scope: grants the +// terminal-billing scope in place, then auto-replays the held action. +export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' | 'stepup' + +// The action held while the stepup screen grants terminal billing, replayed on +// grant: re-preview a tier, re-apply the confirmed pending change, or re-resume. +export type SubscriptionStepUpRetry = + | { kind: 'apply' } + | { kind: 'preview'; tierId: string } + | { kind: 'resume' } export interface SubscriptionOverlayCtx { /** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */ @@ -179,6 +189,12 @@ export interface SubscriptionOverlayCtx { resume: () => Promise /** POST /upgrade: charge the card on the subscription + flip the plan now. */ upgrade: (tierId: string, idempotencyKey?: string) => Promise + /** + * Run the `billing.step_up` device flow (grant terminal billing / "Remote + * Spending"). Resolves `true` when the grant lands. The browser opens via the + * gateway's out-of-band verification event — the stepup screen just awaits. + */ + requestRemoteSpending: () => Promise /** Emit a transcript system line. */ sys: (text: string) => void } @@ -214,6 +230,8 @@ export interface SubscriptionOverlayState { result?: null | SubscriptionResult screen: SubscriptionScreen state: SubscriptionStateResponse + /** Held while on the 'stepup' screen: the action to replay once the grant lands. */ + stepUpRetry?: null | SubscriptionStepUpRetry } export interface OverlayState { diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index 451d4a02852c..10987d0d4ed2 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -94,6 +94,11 @@ const buildSubscriptionCtx = ( .rpc('subscription.state', {}) .then(r => r ?? null) .catch(() => null), + requestRemoteSpending: () => + ctx.gateway + .rpc('billing.step_up', { session_id: ctx.sid ?? undefined }) + .then(r => !!(r && r.ok && r.granted)) + .catch(() => false), resume: () => ctx.gateway .rpc('subscription.resume', {}) diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 17fe1ec89907..f85dec2d5bfc 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -4,10 +4,10 @@ import { useRef, useState } from 'react' import type { SubscriptionOverlayState, SubscriptionPendingChange, - SubscriptionResult + SubscriptionResult, + SubscriptionStepUpRetry } from '../app/interfaces.js' import type { - BillingMutationResponse, SubscriptionStateResponse, SubscriptionTierOption, SubscriptionUpgradeResponse @@ -27,11 +27,11 @@ interface SubscriptionOverlayProps { /** * The /subscription modal — an in-terminal plan-change flow (V3). A small state - * machine: overview → picker → confirm → result. Downgrades / cancellations / - * resume are chargeless; an upgrade charges the card on the subscription via the - * upgrade RPC, and an SCA/decline is handed off to the portal. Starting a NEW - * subscription still deep-links to the portal (needs a fresh card — out of scope - * here). All RPCs live in subscription.ts, reached via `overlay.ctx`. + * machine: overview → picker → confirm → result, with a stepup screen spliced in + * when a mutation needs terminal billing. Downgrades / cancellations / resume are + * chargeless; an upgrade charges the card on the subscription, and an SCA/decline + * is handed off to the portal. Starting a NEW subscription still deep-links (needs + * a fresh card). All RPCs live in subscription.ts, reached via `overlay.ctx`. */ export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) { const { screen, state: s } = overlay @@ -50,6 +50,7 @@ export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: Subscripti {screen === 'picker' && } {screen === 'confirm' && } {screen === 'result' && } + {screen === 'stepup' && } {screen === 'overview' && } ) @@ -112,20 +113,16 @@ function centsDisplay(cents?: null | number): null | string { return typeof cents === 'number' ? `$${(cents / 100).toFixed(2)}` : null } +/** True when a response is the insufficient_scope denial (route to step-up). */ +function isScopeDenial(r: { error?: string; ok?: boolean } | null): boolean { + return !!r && !r.ok && r.error === 'insufficient_scope' +} + /** - * Map a failed RPC envelope to a result. insufficient_scope is special-cased: - * /subscription has no in-terminal step-up (that lives on /topup), so route the - * user there to enable terminal billing, then retry. + * Map a failed RPC envelope to a result. (insufficient_scope is intercepted + * earlier and routed to the step-up screen, so it should not reach here.) */ function errorResult(r: { error?: string; message?: string; portal_url?: null | string } | null): SubscriptionResult { - if (r?.error === 'insufficient_scope') { - return { - message: 'Terminal billing is not enabled for this account. Run /topup to enable it, then try again.', - ok: false, - recoveryUrl: r.portal_url ?? null - } - } - return { message: r?.message || r?.error || 'Something went wrong. Try again, or manage on the portal.', ok: false, @@ -134,7 +131,7 @@ function errorResult(r: { error?: string; message?: string; portal_url?: null | } /** Map a chargeless pending-change mutation (schedule / cancel / resume). */ -function mutationResult(r: BillingMutationResponse | null, okMessage: string): SubscriptionResult { +function mutationResult(r: null | { message?: string; ok?: boolean }, okMessage: string): SubscriptionResult { return r?.ok ? { message: r.message || okMessage, ok: true } : errorResult(r) } @@ -173,12 +170,108 @@ function upgradeResult(r: null | SubscriptionUpgradeResponse): SubscriptionResul return errorResult(r) } +// ── Scope-aware routing (shared by the picker, confirm, overview + step-up) ── + +/** Preview a tier and route: confirm (ok), stepup (scope), or result (other error). */ +function previewAndRoute( + ctx: SubscriptionOverlayState['ctx'], + tierId: string, + onPatch: ScreenProps['onPatch'] +): Promise { + return ctx.preview(tierId).then(p => { + if (!p) { + return onPatch({ result: { message: 'Could not preview that change.', ok: false }, screen: 'result' }) + } + + if (!p.ok) { + if (isScopeDenial(p)) { + return onPatch({ screen: 'stepup', stepUpRetry: { kind: 'preview', tierId } }) + } + + return onPatch({ result: errorResult(p), screen: 'result' }) + } + + // charge_now ⇒ an upgrade (charges now); everything else schedules at period + // end. blocked/no_op still go to confirm, which shows why + no apply. + const kind = p.effect === 'charge_now' ? 'upgrade' : 'tier_change' + onPatch({ pending: { kind, preview: p, targetTierId: tierId }, screen: 'confirm' }) + }) +} + +/** Apply the confirmed pending change and route: result (ok/err) or stepup (scope). */ +function applyPendingAndRoute( + ctx: SubscriptionOverlayState['ctx'], + pending: null | SubscriptionPendingChange, + onPatch: ScreenProps['onPatch'] +): Promise { + if (!pending) { + return Promise.resolve() + } + + const toStepUp = () => onPatch({ screen: 'stepup', stepUpRetry: { kind: 'apply' } }) + const finish = (result: SubscriptionResult) => onPatch({ result, screen: 'result' }) + + if (pending.kind === 'cancellation') { + return ctx.scheduleCancellation().then(r => + isScopeDenial(r) + ? toStepUp() + : finish(mutationResult(r, 'Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.')) + ) + } + + if (pending.kind === 'upgrade') { + return ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r => (isScopeDenial(r) ? toStepUp() : finish(upgradeResult(r)))) + } + + return ctx.scheduleChange(pending.targetTierId ?? '').then(r => + isScopeDenial(r) + ? toStepUp() + : finish(mutationResult(r, 'Scheduled — your plan doesn’t change today. You keep your current plan until the end of the billing period, then it switches.')) + ) +} + +/** Resume (undo the pending change) and route: result (ok/err) or stepup (scope). */ +function resumeAndRoute(ctx: SubscriptionOverlayState['ctx'], onPatch: ScreenProps['onPatch']): Promise { + return ctx.resume().then(r => + isScopeDenial(r) + ? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'resume' } }) + : onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' }) + ) +} + +// ── The pending scheduled change (drives the banner + status echo) ── + +interface PendingTransition { + to: string + when: string +} + +/** The scheduled downgrade/cancel as a from→to transition, or null. */ +function pendingTransition(c: SubscriptionStateResponse['current']): null | PendingTransition { + if (!c) { + return null + } + + if (c.cancel_at_period_end) { + return { to: 'cancels', when: c.cancellation_effective_display ?? shortDate(c.cancellation_effective_at) } + } + + if (c.pending_downgrade_tier_name) { + return { to: c.pending_downgrade_tier_name, when: c.pending_downgrade_display ?? shortDate(c.pending_downgrade_at) } + } + + return null +} + // ── Screen: Overview (plan + usage + entry to the change flow) ──────── -/** Status line — dollars-only, state-matched (the bar carries the breakdown). */ +/** Status line — dollars-only, and echoes a pending "Ultra → Plus" transition. */ function statusLine(s: SubscriptionStateResponse): string { const u = s.usage - const plan = s.current?.tier_name ?? u?.plan_name ?? null + const c = s.current + const plan = c?.tier_name ?? u?.plan_name ?? null + const trans = pendingTransition(c) + const flip = plan && trans ? ` → ${trans.to}` : '' const renewsRaw = u?.renews_display ?? null const renews = renewsRaw ? ` · renews ${renewsRaw}` : '' const viewOnly = !s.can_change_plan @@ -188,21 +281,21 @@ function statusLine(s: SubscriptionStateResponse): string { } if (u?.status === 'low' && u.total_spendable_display) { - return `Plan: ${plan} · ${u.total_spendable_display} left` + return `Plan: ${plan}${flip} · ${u.total_spendable_display} left` } const left = u?.total_spendable_display ? ` · ${u.total_spendable_display} left` : '' - return `Plan: ${plan}${left}${viewOnly ? ' · view only' : renews}` + return `Plan: ${plan}${flip}${left}${viewOnly ? ' · view only' : renews}` } function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { const { ctx, state: s } = overlay const c = s.current const isFree = !c?.tier_id - const isCancelScheduled = !!c?.cancel_at_period_end - const hasPendingDowngrade = !!c?.pending_downgrade_tier_name - const hasPendingChange = isCancelScheduled || hasPendingDowngrade + const currentName = c?.tier_name ?? 'your plan' + const trans = pendingTransition(c) + const hasPendingChange = !!trans // Admin/owner on a personal paid plan can change it in-terminal; otherwise the // portal enforces who can act (members) / starting a new sub needs a card. const canChange = s.can_change_plan && !isFree @@ -210,21 +303,6 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { // Guard the async resume so a double-press cannot fire two DELETEs mid-await. const busyRef = useRef(false) - const cancelOn = c?.cancellation_effective_display ?? c?.cancellation_effective_at - - const cancellationNote = isCancelScheduled - ? cancelOn - ? `Cancels on ${cancelOn} — your plan stays active until then.` - : 'Cancellation scheduled — your plan stays active until the end of the billing period.' - : null - - const downgradeOn = c?.pending_downgrade_display ?? c?.pending_downgrade_at ?? 'the end of the billing period' - - const downgradeNote = - !isCancelScheduled && hasPendingDowngrade - ? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${downgradeOn}.` - : null - const u = s.usage const freeNudge = isFree ? 'Paid models need a subscription. Start one to reach them.' : null @@ -249,19 +327,19 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { } busyRef.current = true - void ctx - .resume() - .then(r => onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' })) + void resumeAndRoute(ctx, onPatch) } const rows: Row[] = [] if (canChange) { - rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) }) - + // When a change is already scheduled, undo is the most likely next intent — + // promote it to the first, highlighted action. if (hasPendingChange) { - rows.push({ color: t.color.ok, label: 'Keep current plan (undo pending change)', run: doResume }) + rows.push({ color: t.color.ok, label: `Keep ${currentName} (undo this change)`, run: doResume }) + rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) }) } else { + rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) }) rows.push({ label: 'Cancel subscription', run: () => onPatch({ pending: { kind: 'cancellation', preview: null, targetTierId: null }, screen: 'confirm' }) @@ -276,6 +354,22 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { return ( + {/* Lead with the scheduled change so it can't read as "nothing happened". */} + {trans && ( + + + ⏳ Scheduled change + + + {currentName} + ──▶ + {trans.to} + · {trans.when} + + You keep {currentName} (and its credits) until then. + + )} + {statusLine(s)} @@ -302,16 +396,6 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { {s.role ? ` · ${s.role}` : ''} )} - {cancellationNote && ( - - {cancellationNote} - - )} - {downgradeNote && ( - - {downgradeNote} - - )} {rows.map((row, i) => ( @@ -326,7 +410,7 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { // ── Screen: Picker (choose a tier → preview → confirm) ─────────────── -function PickerScreen({ onClose, onPatch, overlay, t }: ScreenProps) { +function PickerScreen({ onPatch, overlay, t }: ScreenProps) { const { ctx, state: s } = overlay const currentOrder = s.tiers.find(tier => tier.is_current)?.tier_order ?? 0 @@ -345,20 +429,7 @@ function PickerScreen({ onClose, onPatch, overlay, t }: ScreenProps) { } busyRef.current = true - void ctx.preview(tier.tier_id).then(p => { - if (!p) { - return onPatch({ result: { message: 'Could not preview that change.', ok: false }, screen: 'result' }) - } - - if (!p.ok) { - return onPatch({ result: errorResult(p), screen: 'result' }) - } - - // charge_now ⇒ an upgrade (charges now); everything else schedules at - // period end. blocked/no_op still go to confirm, which shows why + no apply. - const kind = p.effect === 'charge_now' ? 'upgrade' : 'tier_change' - onPatch({ pending: { kind, preview: p, targetTierId: tier.tier_id }, screen: 'confirm' }) - }) + void previewAndRoute(ctx, tier.tier_id, onPatch) } const back = () => onPatch({ screen: 'overview' }) @@ -420,26 +491,7 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { submittingRef.current = true setSubmitting(true) - - const finish = (result: SubscriptionResult) => onPatch({ result, screen: 'result' }) - - if (pending.kind === 'cancellation') { - void ctx - .scheduleCancellation() - .then(r => finish(mutationResult(r, 'Your subscription is scheduled to cancel at the end of the billing period.'))) - - return - } - - if (pending.kind === 'upgrade') { - void ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r => finish(upgradeResult(r))) - - return - } - - void ctx - .scheduleChange(pending.targetTierId ?? '') - .then(r => finish(mutationResult(r, 'Your plan change is scheduled for the end of the billing period.'))) + void applyPendingAndRoute(ctx, pending, onPatch) } const manage = () => { @@ -448,8 +500,6 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { return onClose() } - // Build the rows. blocked/no_op have nothing to apply; blocked offers the - // portal as the escape hatch. const amount = centsDisplay(preview?.amount_due_now_cents) const targetName = isCancellation ? null : (preview?.target_tier_name ?? 'the selected plan') @@ -467,12 +517,17 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { const rows: Row[] = primary ? [primary, { label: 'Back', run: back }] : [{ label: 'Back', run: back }] const sel = useMenu(rows, back) + // Chip contrasts an immediate charge vs a period-end schedule at a glance. + const chip = effect === 'charge_now' ? { color: t.color.ok, label: 'charged now' } : effect === 'scheduled' ? { color: t.color.warn, label: 'scheduled · not today' } : null return ( - - {isCancellation ? 'Confirm cancellation' : 'Confirm plan change'} - + + + {isCancellation ? 'Confirm cancellation' : 'Confirm plan change'} + + {chip && · {chip.label}} + {submitting && Working…} {isCancellation && ( @@ -555,7 +610,7 @@ function ResultScreen({ onClose, overlay, t }: Omit) { {result?.ok ? 'Done' : 'Could not complete'} {result?.message ?? ''} - {result?.ok && Re-run /subscription to see the updated plan.} + {result?.ok && Re-run /subscription anytime to review it.} {rows.map((row, i) => ( @@ -566,6 +621,85 @@ function ResultScreen({ onClose, overlay, t }: Omit) { ) } +// ── Screen: Step-up (grant terminal billing inline, then replay) ────── + +function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { + const { ctx } = overlay + const retry: null | SubscriptionStepUpRetry = overlay.stepUpRetry ?? null + const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt') + const startedRef = useRef(false) + + const enable = () => { + if (startedRef.current) { + return + } + + startedRef.current = true + setPhase('waiting') + void ctx.requestRemoteSpending().then(granted => { + if (!granted) { + return onPatch({ + result: { + message: + 'Terminal billing was not enabled — an org admin/owner must allow it for this org. You can also make this change on the portal.', + ok: false + }, + screen: 'result', + stepUpRetry: null + }) + } + + // Granted → replay the held action. + onPatch({ stepUpRetry: null }) + + if (!retry) { + return onPatch({ screen: 'overview' }) + } + + if (retry.kind === 'preview') { + return void previewAndRoute(ctx, retry.tierId, onPatch) + } + + if (retry.kind === 'resume') { + return void resumeAndRoute(ctx, onPatch) + } + + return void applyPendingAndRoute(ctx, overlay.pending ?? null, onPatch) + }) + } + + const back = () => onPatch({ screen: retry?.kind === 'apply' ? 'confirm' : 'overview', stepUpRetry: null }) + + const rows: Row[] = phase === 'prompt' ? [{ color: t.color.ok, label: 'Enable terminal billing', run: enable }, { label: 'Cancel', run: back }] : [] + const sel = useMenu(rows, back) + + return ( + + + Terminal billing + + {phase === 'prompt' ? ( + <> + Changing your plan needs terminal billing enabled for this org. Enable it here, then continue. + An org admin/owner approves it once; the change resumes automatically. + + {rows.map((row, i) => ( + + ))} + + {footer('↑/↓ select · Enter · Esc back', t)} + + ) : ( + <> + Opening your browser to approve… finish there; your change resumes automatically. + + {footer('Waiting for approval… · Esc to cancel', t)} + + )} + + ) +} + // ── Screen: Team context (no tier picker — teams use shared credits) ── interface TeamContextScreenProps { From 06fa84c253faffcd0a659d6b65608baaf6a13cb9 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 1 Jul 2026 22:55:18 +0530 Subject: [PATCH 48/62] feat(billing): full in-terminal subscription change flow in the classic CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 335 +++++++++++++++++++--- tests/hermes_cli/test_subscription_cli.py | 197 +++++++++++++ 2 files changed, 499 insertions(+), 33 deletions(-) create mode 100644 tests/hermes_cli/test_subscription_cli.py diff --git a/cli.py b/cli.py index 91b945eb66ee..a46c1a802076 100644 --- a/cli.py +++ b/cli.py @@ -9430,19 +9430,42 @@ def _subscription_overview(self, state, manage_url): if not renews_display and c and c.cycle_ends_at: renews_display = format_renews(c.cycle_ends_at) - # Status line — dollars-only, no duplicated "of $Y" (the bar carries that). + # Status line — dollars-only, with a "→ Plus" echo of a pending change so + # the headline itself carries a scheduled downgrade/cancellation. + _flip = "" + if c and c.cancel_at_period_end: + _flip = " → cancels" + elif c and c.pending_downgrade_tier_name: + _flip = f" → {c.pending_downgrade_tier_name}" if not plan_name: status = "Plan: Free · free models only" elif usage is not None and u_status == "low" and usage.total_spendable_usd is not None: _tot = f"${usage.total_spendable_usd:,.2f}" - status = f"Plan: {plan_name} · {_tot} left" + status = f"Plan: {plan_name}{_flip} · {_tot} left" else: _spend = getattr(usage, "total_spendable_usd", None) if usage else None _left = f" · ${_spend:,.2f} left" if _spend is not None else "" _tail = " · view only" if view_only else (f" · renews {renews_display}" if renews_display else "") - status = f"Plan: {plan_name}{_left}{_tail}" + status = f"Plan: {plan_name}{_flip}{_left}{_tail}" + + # Lead with the scheduled change (cancel > downgrade) so it can't read as + # "nothing happened" — mirrors the TUI banner. All-`_cprint` (blanks + # included) so the block orders deterministically even when piped. + _trans = None + if c and c.cancel_at_period_end: + _when = format_renews(c.cancellation_effective_at) or "the end of the billing period" + _trans = ((c.tier_name or "your plan"), "cancels", _when) + elif c and c.pending_downgrade_tier_name: + _when = format_renews(c.pending_downgrade_at) or "the end of the cycle" + _trans = ((c.tier_name or "your plan"), c.pending_downgrade_tier_name, _when) + _cprint("") + if _trans: + _from, _to, _when = _trans + _cprint(f" ⏳ {_b('Scheduled change')}") + _cprint(f" {_from} ──▶ {_to} {_d('· ' + _when)}") + _cprint(f" {_d(f'You keep {_from} (and its credits) until then.')}") + _cprint("") - print() _cprint(f" ⚕ {_b(status)}") print(f" {'─' * 41}") @@ -9466,17 +9489,9 @@ def _subscription_overview(self, state, manage_url): _cprint(f" {_d(_org_line)}") print(f" {'─' * 41}") - # Headline precedence: cancel-scheduled > downgrade-pending. - if c and c.cancel_at_period_end: - _eff = format_renews(c.cancellation_effective_at) if c.cancellation_effective_at else None - if _eff: - _cprint(f" {_d(f'Cancels on {_eff} — your plan stays active until then.')}") - else: - _cprint(f" {_d('Cancellation scheduled — your plan stays active until the end of the billing period.')}") - elif c and c.pending_downgrade_tier_name: - _when = format_renews(c.pending_downgrade_at) or "the end of the cycle" - _cprint(f" {_d(f'Scheduled to switch to {c.pending_downgrade_tier_name} on {_when}.')}") - + # ── Actions ── Members (non-admin) and non-interactive contexts fall back + # to the portal hand-off; a paid admin/owner gets the full in-terminal + # change flow (parity with the TUI overlay). if not can_change: print() _cprint(f" {_d('Plan changes need an org admin/owner.')}") @@ -9484,33 +9499,36 @@ def _subscription_overview(self, state, manage_url): print(f" Manage on portal: {manage_url}") return - if not manage_url: + if not getattr(self, "_app", None): + # Non-interactive (TUI slash-worker / piped): the modal can't run. print() - _cprint(f" {_d('No manage URL available — is your portal configured?')}") + if manage_url: + print(f" Manage your subscription: {manage_url}") + print(" Open it in your browser, then re-run /subscription.") return - # Non-interactive (TUI slash-worker / piped / no live app): the - # prompt_toolkit modal can't run here. Render the text hand-off — the - # URL is the affordance, same discipline as _show_billing. - if not getattr(self, "_app", None): - print() - print(f" Manage your subscription: {manage_url}") - print(" Open it in your browser, then re-run /subscription.") + if is_free: + # Starting a NEW subscription needs a fresh card — deep-link only. + self._subscription_open_portal(state, manage_url, verb="Start a subscription") return + # Paid + admin/owner + interactive → the in-terminal change flow. + self._subscription_change_menu(state, manage_url) + + def _subscription_open_portal(self, state, manage_url, *, verb="Manage your subscription"): + """Open / copy the manage-subscription URL — the portal hand-off.""" + if not manage_url: + print() + _cprint(f" {_d('No manage URL available — is your portal configured?')}") + return print() choices = [ - ("open", "Open subscription page", "manage your subscription in the browser"), + ("open", verb, "open the subscription page in your browser"), ("copy", "Copy link", "copy the manage-subscription URL to your clipboard"), ("cancel", "Cancel", "do nothing"), ] - raw = self._prompt_text_input_modal( - title="Manage your subscription", - detail="Pick an option to manage your subscription in the browser.", - choices=choices, - ) + raw = self._prompt_text_input_modal(title=verb, detail="", choices=choices) choice = self._normalize_slash_confirm_choice(raw, choices) - if choice == "open": opened = False try: @@ -9520,9 +9538,9 @@ def _subscription_overview(self, state, manage_url): except Exception: opened = False if not opened: - print(f" Open this URL to change your plan: {manage_url}") + print(f" Open this URL: {manage_url}") print() - print(" Finish the change in your browser, then re-run /subscription.") + print(" Finish in your browser, then re-run /subscription.") elif choice == "copy": try: self._write_osc52_clipboard(manage_url) @@ -9530,7 +9548,258 @@ def _subscription_overview(self, state, manage_url): except Exception: print(f" Manage URL: {manage_url}") else: + print(" 🟡 Cancelled.") + + def _subscription_change_menu(self, state, manage_url): + """The in-terminal change menu for a paid admin/owner (interactive).""" + c = state.current + has_pending = bool(c and (c.cancel_at_period_end or c.pending_downgrade_tier_name)) + keep_name = (c.tier_name if c else None) or "your plan" + choices = [("change", "Change plan", "upgrade or downgrade in the terminal")] + if has_pending: + choices.append(("keep", f"Keep {keep_name} (undo the scheduled change)", "cancel the pending change")) + else: + choices.append(("cancel_sub", "Cancel subscription", "schedule cancellation at period end")) + choices.append(("portal", "Manage on portal", "open the billing page in your browser")) + choices.append(("cancel", "Close", "do nothing")) + raw = self._prompt_text_input_modal(title="Manage your subscription", detail="", choices=choices) + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice == "change": + self._subscription_pick_tier(state) + elif choice == "keep": + self._subscription_apply(state, ("resume", None)) + elif choice == "cancel_sub": + self._subscription_confirm_cancel(state) + elif choice == "portal": + self._subscription_open_portal(state, manage_url) + else: + print(" 🟡 Cancelled. No plan change.") + + def _subscription_pick_tier(self, state): + """Tier picker → preview → confirm (mirrors the TUI picker screen).""" + from agent.billing_view import format_money + + c = state.current + tiers = tuple(state.tiers or ()) + cur_order = next((t.tier_order for t in tiers if t.is_current), 0) + # Selectable = enabled paid tiers other than current (free/no-sub excluded; + # dropping to free is a cancellation, on the change menu). Sorted by price. + selectable = sorted( + [t for t in tiers if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0], + key=lambda t: t.tier_order or 0, + ) + if not selectable: + print(" No other plans are available to switch to right now.") + return + choices = [] + for t in selectable: + direction = "upgrade" if (t.tier_order or 0) > cur_order else "downgrade" + choices.append((t.tier_id, f"{t.name} · {format_money(t.dollars_per_month)}/mo · {direction}", f"switch to {t.name}")) + choices.append(("cancel", "Back", "do nothing")) + raw = self._prompt_text_input_modal( + title="Change plan", + detail=f"Current: {c.tier_name if c else 'Free'}. Pick a plan to preview the effect.", + choices=choices, + ) + choice = self._normalize_slash_confirm_choice(raw, choices) + if not choice or choice == "cancel": + print(" 🟡 Cancelled. No plan change.") + return + self._subscription_preview_and_confirm(state, choice) + + def _subscription_preview_and_confirm(self, state, tier_id): + """Preview the change (chargeless quote), show the effect, then confirm+apply.""" + from agent.subscription_view import subscription_change_preview_from_payload + from hermes_cli.nous_billing import BillingError, BillingScopeRequired, post_subscription_preview + + _cprint(f" {_d('Checking the change…')}") + try: + payload = post_subscription_preview(subscription_type_id=tier_id) + except BillingScopeRequired: + self._subscription_handle_scope_required(state, retry=("preview", tier_id)) + return + except BillingError as exc: + self._subscription_render_error(state, exc) + return + p = subscription_change_preview_from_payload(payload) + effect = p.effect + target = p.target_tier_name or "the selected plan" + print() + if effect == "no_op": + _cprint(f" {_d(f'You are already on {target} — nothing to change.')}") + return + if effect == "blocked": + _cprint(f" 🟡 {p.reason or 'That change cannot be made here — manage it on the portal.'}") + return + if effect == "charge_now": + _amt = f"${p.amount_due_now_cents / 100:.2f}" if p.amount_due_now_cents is not None else None + _cprint(f" {_b('Confirm plan change')} {_d('· charged now')}") + if _amt: + _cprint(f" Upgrade to {target}. You will be charged {_amt} now (prorated).") + else: + _cprint(f" Upgrade to {target}. You will be charged the prorated amount now.") + _cprint(f" {_d('The card on your subscription will be charged.')}") + pay_label = f"Pay {_amt} & upgrade now" if _amt else "Upgrade now (prorated charge)" + action = ("upgrade", tier_id) + else: # scheduled + _when = p.effective_at[:10] if (p.effective_at and len(p.effective_at) >= 10) else "the end of the billing period" + _cprint(f" {_b('Confirm plan change')} {_d('· scheduled · not today')}") + _cprint(f" Change to {target} — takes effect {_when}. No charge now; you keep your current plan until then.") + pay_label = f"Schedule change to {target}" + action = ("schedule", tier_id) + if p.monthly_credits_delta: + _cprint(f" {_d(f'Monthly credits change: {p.monthly_credits_delta}.')}") + confirm_choices = [ + ("yes", pay_label, "apply this change"), + ("cancel", "Go back", "do not change"), + ] + raw = self._prompt_text_input_modal(title=pay_label, detail="", choices=confirm_choices) + if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": print(" 🟡 Cancelled. No plan change.") + return + self._subscription_apply(state, action) + + def _subscription_confirm_cancel(self, state): + """Confirm, then schedule a cancellation at period end.""" + from agent.billing_usage import format_renews + + c = state.current + _end = (format_renews(c.cycle_ends_at) if (c and c.cycle_ends_at) else None) or "the end of the billing period" + print() + _cprint(f" {_b('Confirm cancellation')} {_d('· scheduled · not today')}") + _cprint(f" Cancel {(c.tier_name if c else 'your plan')} — it stays active until {_end}, then won't renew.") + _cprint(f" {_d('You keep your remaining credits for this period. You can resume before it ends.')}") + confirm_choices = [ + ("yes", "Cancel subscription", "schedule cancellation at period end"), + ("cancel", "Go back", "keep your plan"), + ] + raw = self._prompt_text_input_modal(title="Cancel subscription?", detail="", choices=confirm_choices) + if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": + print(" 🟡 Cancelled. Your plan is unchanged.") + return + self._subscription_apply(state, ("cancel", None)) + + def _subscription_apply(self, state, action, idempotency_key=None): + """Run the mutation for `action`, handling the scope step-up + the result. + + `action` is one of ("upgrade", tier_id) / ("schedule", tier_id) / + ("cancel", None) / ("resume", None). insufficient_scope routes to the + step-up and replays; the upgrade idempotency key is reused across the replay. + """ + from hermes_cli.nous_billing import ( + BillingError, + BillingScopeRequired, + delete_subscription_pending_change, + post_subscription_upgrade, + put_subscription_pending_change, + ) + + kind, arg = action + key = None + if kind == "upgrade": + from agent.billing_view import new_idempotency_key + + key = idempotency_key or new_idempotency_key() + try: + if kind == "upgrade": + res = post_subscription_upgrade(subscription_type_id=arg, idempotency_key=key) or {} + status = res.get("status") + name = res.get("targetTierName") or "your new plan" + _url = res.get("recoveryUrl") + if status == "already_on_tier": + _cprint(f" {_DIM}✓ You are already on {name}.{_RST}") + elif status == "upgraded": + _cprint(f" {_DIM}✓ Upgraded to {name}. Your new monthly credits land in a moment.{_RST}") + elif status == "requires_action": + _cprint(" 🟡 This upgrade needs extra verification (3DS). Finish it on the portal.") + if _url: + print(f" Portal: {_url}") + elif status == "payment_failed": + _cprint(" 🔴 Your card was declined. Update your payment method on the portal and try again.") + if _url: + print(f" Portal: {_url}") + else: + _cprint(" 🟡 The upgrade could not be completed.") + return + if kind == "schedule": + put_subscription_pending_change(subscription_type_id=arg) + _cprint(f" {_DIM}✓ Scheduled — your plan doesn't change today. You keep it until the end of the billing period, then it switches.{_RST}") + elif kind == "cancel": + put_subscription_pending_change(cancel=True) + _cprint(f" {_DIM}✓ Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.{_RST}") + elif kind == "resume": + delete_subscription_pending_change() + _cprint(f" {_DIM}✓ Undone — you stay on your current plan.{_RST}") + _cprint(f" {_d('Re-run /subscription anytime to review it.')}") + except BillingScopeRequired: + self._subscription_handle_scope_required(state, retry=action, idempotency_key=key) + except BillingError as exc: + self._subscription_render_error(state, exc) + + def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=None): + """insufficient_scope → grant terminal billing (step-up), then replay `retry`. + + Mirrors _billing_handle_scope_required: the classic CLI calls + step_up_nous_billing_scope directly (it opens the browser + blocks), then + replays the held preview/mutation so the user never re-runs the command. + """ + print() + print(" ! One-time setup") + _cprint(f" {_d('To change your plan from the terminal, enable terminal billing once. It opens your browser to authorize, then your change picks up right here.')}") + if not getattr(self, "_app", None): + print(" Run `hermes portal` and enable terminal billing, then re-run /subscription.") + return + confirm_choices = [ + ("yes", "Enable terminal billing", "open your browser to authorize"), + ("no", "Not now", "cancel"), + ] + raw = self._prompt_text_input_modal( + title="Enable terminal billing", + detail="Opens your browser to authorize this terminal.", + choices=confirm_choices, + ) + if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": + print(" No change made. Enable terminal billing when you're ready.") + return + print(" Opening your browser to enable terminal billing…") + try: + from hermes_cli.auth import step_up_nous_billing_scope + + granted = step_up_nous_billing_scope(open_browser=True) + except Exception as exc: + print(f" Couldn't enable terminal billing: {exc}") + return + if not granted: + print(" Couldn't enable terminal billing — an org admin or owner has to approve it for this org.") + return + _cprint(f" {_DIM}✓ Terminal billing enabled.{_RST}") + # Re-fetch fresh state, then replay the held action. + from agent.subscription_view import build_subscription_state + + try: + fresh = build_subscription_state() + except Exception: + fresh = state + rkind, rarg = retry + if rkind == "preview": + self._subscription_preview_and_confirm(fresh, rarg) + else: + self._subscription_apply(fresh, retry, idempotency_key=idempotency_key) + + def _subscription_render_error(self, state, exc): + """Render a subscription BillingError (a lighter _billing_render_charge_error).""" + code = getattr(exc, "error", None) + msg = str(exc) or "Something went wrong." + if code == "insufficient_scope": + # Defensive: the flow routes scope to the step-up before reaching here. + _cprint(" 🟡 Terminal billing isn't enabled. Enable it, then retry.") + elif code in ("subscription_mutation_rejected", "preview_rejected"): + _cprint(f" 🟡 {msg}") + else: + _cprint(f" 🔴 {msg}") + _url = getattr(exc, "portal_url", None) + if _url: + print(f" Portal: {_url}") # ------------------------------------------------------------------ # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py new file mode 100644 index 000000000000..852b1f782888 --- /dev/null +++ b/tests/hermes_cli/test_subscription_cli.py @@ -0,0 +1,197 @@ +"""Tests for the /subscription CLI change flow (cli.py::_show_subscription). + +Parity with the TUI overlay: the classic CLI now previews + applies a plan change +in-terminal (picker → preview → confirm → apply), grants terminal billing inline on +insufficient_scope, and leads a scheduled downgrade/cancel with a prominent banner. +Interactive screens are driven by mocking `_prompt_text_input_modal`. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +import agent.billing_usage as bu +import agent.subscription_view as sv +import hermes_cli.nous_billing as nb +from agent.subscription_view import CurrentSubscription, SubscriptionState, SubscriptionTier +from cli import HermesCLI + + +@pytest.fixture +def cli(): + obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed) + obj._app = None # non-interactive by default; tests flip it on + return obj + + +_TIERS = ( + SubscriptionTier(tier_id="free", name="Free", tier_order=0, dollars_per_month=Decimal("0"), monthly_credits=Decimal("0"), is_current=False, is_enabled=True), + SubscriptionTier(tier_id="plus", name="Plus", tier_order=1, dollars_per_month=Decimal("20"), monthly_credits=Decimal("22"), is_current=False, is_enabled=True), + SubscriptionTier(tier_id="ultra", name="Ultra", tier_order=3, dollars_per_month=Decimal("200"), monthly_credits=Decimal("220"), is_current=True, is_enabled=True), +) + + +def _sub_state(**current_over) -> SubscriptionState: + current_fields = dict(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("220"), cycle_ends_at="2026-07-28") + current_fields.update(current_over) + current = CurrentSubscription(**current_fields) + return SubscriptionState( + logged_in=True, + org_name="Acme", + org_id="org_1", + role="OWNER", + context="personal", + current=current, + tiers=_TIERS, + portal_url="https://portal.example/billing", + ) + + +def _scripted_modal(*responses): + it = iter(responses) + + def _modal(self, **kw): + return next(it) + + return _modal + + +@pytest.fixture(autouse=True) +def _no_usage_model(monkeypatch): + # The overview's usage model needs a live portal; None → the plan-field fallback. + monkeypatch.setattr(bu, "build_usage_model", lambda *a, **kw: None, raising=False) + + +def test_overview_leads_with_scheduled_downgrade_banner(cli, monkeypatch, capsys): + st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28") + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "Scheduled change" in out + assert "──▶" in out + assert "Plus" in out + # the status line itself echoes the transition + assert "Plan: Ultra → Plus" in out + + +def test_change_flow_schedules_a_downgrade(cli, monkeypatch, capsys): + cli._app = object() # interactive + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + # change menu → "change"; picker → "plus" (only selectable); confirm → "yes" + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes"), raising=False) + monkeypatch.setattr( + nb, "post_subscription_preview", + lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28T00:00:00Z", "monthlyCreditsDelta": "-198"}, + ) + seen = {} + monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: seen.update(kw) or {"message": "Scheduled."}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert seen.get("subscription_type_id") == "plus" + assert "doesn't change today" in out + + +def test_change_flow_upgrade_charges_now(cli, monkeypatch, capsys): + cli._app = object() + # Current = Plus so Ultra is a selectable upgrade. + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple(SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) for t in _TIERS) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + seen = {} + monkeypatch.setattr(nb, "post_subscription_upgrade", lambda **kw: seen.update(kw) or {"status": "upgraded", "targetTierName": "Ultra"}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert seen.get("subscription_type_id") == "ultra" + assert seen.get("idempotency_key") # minted + assert "$46.30" in out + assert "Upgraded to Ultra" in out + + +def test_change_menu_cancel_schedules_cancellation(cli, monkeypatch, capsys): + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("cancel_sub", "yes"), raising=False) + seen = {} + monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: seen.update(kw) or {"message": "Cancelled."}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert seen.get("cancel") is True + assert "cancels" in out.lower() + + +def test_pending_change_menu_offers_undo(cli, monkeypatch, capsys): + cli._app = object() + st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28") + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("keep"), raising=False) + called = {"n": 0} + monkeypatch.setattr(nb, "delete_subscription_pending_change", lambda **kw: called.update(n=called["n"] + 1) or {"message": "Resumed."}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert called["n"] == 1 + assert "Undone" in out + + +def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsys): + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + # menu → change; picker → plus; confirm → yes; step-up → yes + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"}) + calls = {"n": 0} + + def _put(**kw): + calls["n"] += 1 + if calls["n"] == 1: + raise nb.BillingScopeRequired("terminal billing required") + return {"message": "Scheduled."} + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + import hermes_cli.auth as auth + + monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True, raising=False) + + cli._show_subscription() + out = capsys.readouterr().out + + # applied once (scope-denied), granted, replayed → applied again + assert calls["n"] == 2 + assert "Terminal billing enabled" in out + + +def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys): + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"}) + calls = {"n": 0} + + def _put(**kw): + calls["n"] += 1 + raise nb.BillingScopeRequired("terminal billing required") + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + import hermes_cli.auth as auth + + monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: False, raising=False) + + cli._show_subscription() + out = capsys.readouterr().out + + assert calls["n"] == 1 # applied once, grant denied, no replay + assert "Couldn't enable terminal billing" in out From 199ebee6bd1563b1310ab27fb2219bcd6de6737d Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 1 Jul 2026 23:34:31 +0530 Subject: [PATCH 49/62] fix(billing): close TUI subscription money-path holes (ultracode review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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'. --- .../__tests__/subscriptionOverlay.test.tsx | 2 +- ui-tui/src/app/interfaces.ts | 12 +- ui-tui/src/app/slash/commands/subscription.ts | 6 +- ui-tui/src/components/subscriptionOverlay.tsx | 154 +++++++++++++----- 4 files changed, 125 insertions(+), 49 deletions(-) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index 7bb9590029b8..ca8302d2990c 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -79,7 +79,7 @@ const ctx = { openPortal: vi.fn(), preview: vi.fn(() => Promise.resolve(null)), refreshState: vi.fn(() => Promise.resolve(null)), - requestRemoteSpending: vi.fn(() => Promise.resolve(true)), + requestRemoteSpending: vi.fn(() => Promise.resolve({ granted: true })), resume: vi.fn(() => Promise.resolve(null)), scheduleCancellation: vi.fn(() => Promise.resolve(null)), scheduleChange: vi.fn(() => Promise.resolve(null)), diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index c49984c57936..d8a333dae56c 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -172,6 +172,13 @@ export type SubscriptionStepUpRetry = | { kind: 'preview'; tierId: string } | { kind: 'resume' } +/** Outcome of a terminal-billing step-up: granted, plus the typed denial (for copy). */ +export interface StepUpResult { + granted: boolean + error?: string + message?: string +} + export interface SubscriptionOverlayCtx { /** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */ openManageLink: () => Promise @@ -191,10 +198,11 @@ export interface SubscriptionOverlayCtx { upgrade: (tierId: string, idempotencyKey?: string) => Promise /** * Run the `billing.step_up` device flow (grant terminal billing / "Remote - * Spending"). Resolves `true` when the grant lands. The browser opens via the + * Spending"). Resolves `{granted}` plus the typed denial (`error`/`message`) so + * the stepup screen shows the right recovery. The browser opens via the * gateway's out-of-band verification event — the stepup screen just awaits. */ - requestRemoteSpending: () => Promise + requestRemoteSpending: () => Promise /** Emit a transcript system line. */ sys: (text: string) => void } diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index 10987d0d4ed2..ba533d425d5b 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -97,8 +97,10 @@ const buildSubscriptionCtx = ( requestRemoteSpending: () => ctx.gateway .rpc('billing.step_up', { session_id: ctx.sid ?? undefined }) - .then(r => !!(r && r.ok && r.granted)) - .catch(() => false), + // Carry the typed denial (session_revoked / remote_spending_revoked / + // rate_limited / …) so the stepup screen shows the right recovery. + .then(r => ({ error: r?.error, granted: !!(r && r.ok && r.granted), message: r?.message })) + .catch(() => ({ granted: false, message: 'Could not reach the billing service — check your connection, then retry.' })), resume: () => ctx.gateway .rpc('subscription.resume', {}) diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index f85dec2d5bfc..6fb748c3efbd 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto' + import { Box, Text, useInput } from '@hermes/ink' import { useRef, useState } from 'react' @@ -138,7 +140,15 @@ function mutationResult(r: null | { message?: string; ok?: boolean }, okMessage: /** Map an upgrade response, routing SCA / decline to a portal recovery. */ function upgradeResult(r: null | SubscriptionUpgradeResponse): SubscriptionResult { if (!r) { - return { message: 'The upgrade could not be completed.', ok: false } + // null = a transport failure (WS drop / request timeout) on the CHARGING + // route — NAS may have already prorated + charged. Report it as ambiguous and + // steer to a safe re-check, never a blind retry (which #2's dedup can't cover + // once the key is lost). + return { + message: + 'Couldn’t confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.', + ok: false + } } if (r.ok && (r.status === 'already_on_tier' || r.status === 'upgraded')) { @@ -170,6 +180,27 @@ function upgradeResult(r: null | SubscriptionUpgradeResponse): SubscriptionResul return errorResult(r) } +/** Map a failed terminal-billing step-up to the right recovery copy (typed). */ +function stepUpDenialResult(res: { error?: string; message?: string }): SubscriptionResult { + if (res.error === 'session_revoked') { + return { message: 'Your session expired — run /portal to log in again, then retry the change.', ok: false } + } + + if (res.error === 'remote_spending_revoked') { + return { message: res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.', ok: false } + } + + if (res.error === 'rate_limited') { + return { message: 'Too many attempts — wait a moment, then try again.', ok: false } + } + + return { + message: + res.message || 'Terminal billing was not enabled — an org admin/owner must allow it for this org. You can also make this change on the portal.', + ok: false + } +} + // ── Scope-aware routing (shared by the picker, confirm, overview + step-up) ── /** Preview a tier and route: confirm (ok), stepup (scope), or result (other error). */ @@ -194,7 +225,16 @@ function previewAndRoute( // charge_now ⇒ an upgrade (charges now); everything else schedules at period // end. blocked/no_op still go to confirm, which shows why + no apply. const kind = p.effect === 'charge_now' ? 'upgrade' : 'tier_change' - onPatch({ pending: { kind, preview: p, targetTierId: tierId }, screen: 'confirm' }) + + // Mint the upgrade idempotency key HERE so it rides `pending` into confirm AND + // the step-up replay — a re-submit / post-grant replay dedups server-side + // (mirrors billingOverlay's pendingCharge.idempotencyKey). + const pending: SubscriptionPendingChange = + kind === 'upgrade' + ? { idempotencyKey: randomUUID(), kind, preview: p, targetTierId: tierId } + : { kind, preview: p, targetTierId: tierId } + + onPatch({ pending, screen: 'confirm' }) }) } @@ -482,7 +522,16 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { // React commits, double-firing the mutation/charge. const submittingRef = useRef(false) - const back = () => onPatch({ pending: null, screen: isCancellation ? 'overview' : 'picker' }) + const back = () => { + // Don't navigate away while an apply is in flight: the screen hasn't changed + // yet (applyPendingAndRoute patches only after the RPC resolves), so a fresh + // re-mount would re-fire the mutation — a second charge on the upgrade path. + if (submittingRef.current) { + return + } + + onPatch({ pending: null, screen: isCancellation ? 'overview' : 'picker' }) + } const apply = () => { if (submittingRef.current || !pending) { @@ -626,8 +675,12 @@ function ResultScreen({ onClose, overlay, t }: Omit) { function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { const { ctx } = overlay const retry: null | SubscriptionStepUpRetry = overlay.stepUpRetry ?? null - const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt') + const [phase, setPhase] = useState<'granted' | 'prompt' | 'waiting'>('prompt') const startedRef = useRef(false) + // Set when the user cancels while the browser grant is still in flight. The + // grant's late `.then` MUST NOT fire the held change after a cancel — otherwise + // a cancel-then-approve charges the card the user just declined. + const abortedRef = useRef(false) const enable = () => { if (startedRef.current) { @@ -636,41 +689,56 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { startedRef.current = true setPhase('waiting') - void ctx.requestRemoteSpending().then(granted => { - if (!granted) { - return onPatch({ - result: { - message: - 'Terminal billing was not enabled — an org admin/owner must allow it for this org. You can also make this change on the portal.', - ok: false - }, - screen: 'result', - stepUpRetry: null - }) + void ctx.requestRemoteSpending().then(res => { + if (abortedRef.current) { + return } - // Granted → replay the held action. - onPatch({ stepUpRetry: null }) - - if (!retry) { - return onPatch({ screen: 'overview' }) + if (res.granted) { + // HOLD — do not auto-fire the held change. Require an explicit Continue so + // a cancelled/late grant can never charge (mirrors billingOverlay's + // 'granted' phase). The user already consented at confirm; this reconfirms. + return setPhase('granted') } - if (retry.kind === 'preview') { - return void previewAndRoute(ctx, retry.tierId, onPatch) - } + // Typed denial (session_revoked / remote_spending_revoked / rate_limited / + // admin-approval) → the right recovery copy, not a flat "admin must allow". + onPatch({ result: stepUpDenialResult(res), screen: 'result', stepUpRetry: null }) + }) + } - if (retry.kind === 'resume') { - return void resumeAndRoute(ctx, onPatch) - } + const resume = () => { + onPatch({ stepUpRetry: null }) - return void applyPendingAndRoute(ctx, overlay.pending ?? null, onPatch) - }) + if (!retry) { + return onPatch({ screen: 'overview' }) + } + + if (retry.kind === 'preview') { + return void previewAndRoute(ctx, retry.tierId, onPatch) + } + + if (retry.kind === 'resume') { + return void resumeAndRoute(ctx, onPatch) + } + + return void applyPendingAndRoute(ctx, overlay.pending ?? null, onPatch) } - const back = () => onPatch({ screen: retry?.kind === 'apply' ? 'confirm' : 'overview', stepUpRetry: null }) + const back = () => { + // Abandon. If a grant is in flight, mark it aborted so its .then no-ops (no + // un-consented charge); if already granted, just leave without replaying. + abortedRef.current = true + onPatch({ screen: retry?.kind === 'apply' ? 'confirm' : 'overview', stepUpRetry: null }) + } + + const rows: Row[] = + phase === 'granted' + ? [{ color: t.color.ok, label: retry?.kind === 'apply' ? 'Continue the change' : 'Continue', run: resume }, { label: 'Cancel', run: back }] + : phase === 'prompt' + ? [{ color: t.color.ok, label: 'Enable terminal billing', run: enable }, { label: 'Cancel', run: back }] + : [] - const rows: Row[] = phase === 'prompt' ? [{ color: t.color.ok, label: 'Enable terminal billing', run: enable }, { label: 'Cancel', run: back }] : [] const sel = useMenu(rows, back) return ( @@ -678,24 +746,22 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { Terminal billing - {phase === 'prompt' ? ( + {phase === 'prompt' && ( <> Changing your plan needs terminal billing enabled for this org. Enable it here, then continue. - An org admin/owner approves it once; the change resumes automatically. - - {rows.map((row, i) => ( - - ))} - - {footer('↑/↓ select · Enter · Esc back', t)} - - ) : ( - <> - Opening your browser to approve… finish there; your change resumes automatically. - - {footer('Waiting for approval… · Esc to cancel', t)} + An org admin/owner approves it once in the browser. )} + {phase === 'waiting' && ( + Opening your browser to approve… finish there, then come back — nothing is charged until you continue. + )} + {phase === 'granted' && Terminal billing enabled. Continue to finish your change.} + + {rows.map((row, i) => ( + + ))} + + {footer(phase === 'waiting' ? 'Waiting for approval… · Esc to cancel' : '↑/↓ select · Enter · Esc back', t)} ) } From ee93fc8e4709aab1b3b3c2309d57c859217f58a1 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 1 Jul 2026 23:34:31 +0530 Subject: [PATCH 50/62] fix(billing): close CLI subscription money-path holes (ultracode review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- cli.py | 87 +++++++++++++++++------ tests/hermes_cli/test_subscription_cli.py | 41 +++++++++++ 2 files changed, 108 insertions(+), 20 deletions(-) diff --git a/cli.py b/cli.py index dec4a4421c56..c9cf145e45b2 100644 --- a/cli.py +++ b/cli.py @@ -9757,13 +9757,22 @@ def _subscription_change_menu(self, state, manage_url): c = state.current has_pending = bool(c and (c.cancel_at_period_end or c.pending_downgrade_tier_name)) keep_name = (c.tier_name if c else None) or "your plan" - choices = [("change", "Change plan", "upgrade or downgrade in the terminal")] + # When a change is already scheduled, undo is the most likely next intent → + # promote it first (parity with the TUI). The Close row uses value "close" + # (not "cancel") so typing the word "cancel" — which the alias table would + # map to a Close row — can't be confused with "Cancel subscription". if has_pending: - choices.append(("keep", f"Keep {keep_name} (undo the scheduled change)", "cancel the pending change")) + choices = [ + ("keep", f"Keep {keep_name} (undo the scheduled change)", "cancel the pending change"), + ("change", "Change plan", "upgrade or downgrade in the terminal"), + ] else: - choices.append(("cancel_sub", "Cancel subscription", "schedule cancellation at period end")) + choices = [ + ("change", "Change plan", "upgrade or downgrade in the terminal"), + ("cancel_sub", "Cancel subscription", "schedule cancellation at period end"), + ] choices.append(("portal", "Manage on portal", "open the billing page in your browser")) - choices.append(("cancel", "Close", "do nothing")) + choices.append(("close", "Close", "do nothing")) raw = self._prompt_text_input_modal(title="Manage your subscription", detail="", choices=choices) choice = self._normalize_slash_confirm_choice(raw, choices) if choice == "change": @@ -9775,7 +9784,7 @@ def _subscription_change_menu(self, state, manage_url): elif choice == "portal": self._subscription_open_portal(state, manage_url) else: - print(" 🟡 Cancelled. No plan change.") + print(" 🟡 Closed. No plan change.") def _subscription_pick_tier(self, state): """Tier picker → preview → confirm (mirrors the TUI picker screen).""" @@ -9809,8 +9818,13 @@ def _subscription_pick_tier(self, state): return self._subscription_preview_and_confirm(state, choice) - def _subscription_preview_and_confirm(self, state, tier_id): - """Preview the change (chargeless quote), show the effect, then confirm+apply.""" + def _subscription_preview_and_confirm(self, state, tier_id, *, allow_stepup=True): + """Preview the change (chargeless quote), show the effect, then confirm+apply. + + ``allow_stepup=False`` (a post-grant replay) declines a second step-up on a + repeated scope denial so the flow can't re-prompt/re-open the browser in a + loop. + """ from agent.subscription_view import subscription_change_preview_from_payload from hermes_cli.nous_billing import BillingError, BillingScopeRequired, post_subscription_preview @@ -9818,7 +9832,10 @@ def _subscription_preview_and_confirm(self, state, tier_id): try: payload = post_subscription_preview(subscription_type_id=tier_id) except BillingScopeRequired: - self._subscription_handle_scope_required(state, retry=("preview", tier_id)) + if allow_stepup: + self._subscription_handle_scope_required(state, retry=("preview", tier_id)) + else: + print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") return except BillingError as exc: self._subscription_render_error(state, exc) @@ -9830,8 +9847,16 @@ def _subscription_preview_and_confirm(self, state, tier_id): if effect == "no_op": _cprint(f" {_d(f'You are already on {target} — nothing to change.')}") return - if effect == "blocked": - _cprint(f" 🟡 {p.reason or 'That change cannot be made here — manage it on the portal.'}") + if effect not in ("charge_now", "scheduled"): + # blocked OR an unknown/unexpected effect → fail SAFE (never schedule a + # real change on an unrecognized string, unlike a bare `else`), and + # re-offer the portal hand-off like the TUI's blocked branch. + from agent.subscription_view import subscription_manage_url + + _cprint(f" 🟡 {p.reason or 'This change cannot be confirmed here — manage it on the portal.'}") + _mu = subscription_manage_url(state) + if _mu: + print(f" Manage on portal: {_mu}") return if effect == "charge_now": _amt = f"${p.amount_due_now_cents / 100:.2f}" if p.amount_due_now_cents is not None else None @@ -9843,18 +9868,24 @@ def _subscription_preview_and_confirm(self, state, tier_id): _cprint(f" {_d('The card on your subscription will be charged.')}") pay_label = f"Pay {_amt} & upgrade now" if _amt else "Upgrade now (prorated charge)" action = ("upgrade", tier_id) - else: # scheduled + # The money-moving row is NOT the default — a bare Enter hits "Go back", + # so a single stray keystroke can't charge the card. + confirm_choices = [ + ("cancel", "Go back", "do not charge"), + ("yes", pay_label, "charge + upgrade now"), + ] + else: # scheduled (whitelisted above) _when = p.effective_at[:10] if (p.effective_at and len(p.effective_at) >= 10) else "the end of the billing period" _cprint(f" {_b('Confirm plan change')} {_d('· scheduled · not today')}") _cprint(f" Change to {target} — takes effect {_when}. No charge now; you keep your current plan until then.") pay_label = f"Schedule change to {target}" action = ("schedule", tier_id) + confirm_choices = [ + ("yes", pay_label, "apply this change"), + ("cancel", "Go back", "do not change"), + ] if p.monthly_credits_delta: _cprint(f" {_d(f'Monthly credits change: {p.monthly_credits_delta}.')}") - confirm_choices = [ - ("yes", pay_label, "apply this change"), - ("cancel", "Go back", "do not change"), - ] raw = self._prompt_text_input_modal(title=pay_label, detail="", choices=confirm_choices) if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": print(" 🟡 Cancelled. No plan change.") @@ -9881,12 +9912,14 @@ def _subscription_confirm_cancel(self, state): return self._subscription_apply(state, ("cancel", None)) - def _subscription_apply(self, state, action, idempotency_key=None): + def _subscription_apply(self, state, action, idempotency_key=None, *, allow_stepup=True): """Run the mutation for `action`, handling the scope step-up + the result. `action` is one of ("upgrade", tier_id) / ("schedule", tier_id) / ("cancel", None) / ("resume", None). insufficient_scope routes to the step-up and replays; the upgrade idempotency key is reused across the replay. + ``allow_stepup=False`` (a post-grant replay) declines a second step-up on a + repeated scope denial so the flow can't re-prompt/re-open the browser in a loop. """ from hermes_cli.nous_billing import ( BillingError, @@ -9934,7 +9967,10 @@ def _subscription_apply(self, state, action, idempotency_key=None): _cprint(f" {_DIM}✓ Undone — you stay on your current plan.{_RST}") _cprint(f" {_d('Re-run /subscription anytime to review it.')}") except BillingScopeRequired: - self._subscription_handle_scope_required(state, retry=action, idempotency_key=key) + if allow_stepup: + self._subscription_handle_scope_required(state, retry=action, idempotency_key=key) + else: + print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") except BillingError as exc: self._subscription_render_error(state, exc) @@ -9975,7 +10011,18 @@ def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=N print(" Couldn't enable terminal billing — an org admin or owner has to approve it for this org.") return _cprint(f" {_DIM}✓ Terminal billing enabled.{_RST}") - # Re-fetch fresh state, then replay the held action. + # Bust the 30s token cache so the replay uses the freshly-scoped token. The + # cache still holds the pre-grant unscoped token, and _request only busts it + # on a 401 (not a 403 scope denial) — without this, the replay would 403 + # again and (before the allow_stepup guard) re-prompt in a loop. + try: + from hermes_cli import nous_billing as _nb + + _nb._token_cache = None + except Exception: + pass + # Re-fetch fresh state, then replay the held action ONCE (allow_stepup=False + # so a repeated scope denial can't re-enter the step-up). from agent.subscription_view import build_subscription_state try: @@ -9984,9 +10031,9 @@ def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=N fresh = state rkind, rarg = retry if rkind == "preview": - self._subscription_preview_and_confirm(fresh, rarg) + self._subscription_preview_and_confirm(fresh, rarg, allow_stepup=False) else: - self._subscription_apply(fresh, retry, idempotency_key=idempotency_key) + self._subscription_apply(fresh, retry, idempotency_key=idempotency_key, allow_stepup=False) def _subscription_render_error(self, state, exc): """Render a subscription BillingError (a lighter _billing_render_charge_error).""" diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py index 852b1f782888..3cabdac3871b 100644 --- a/tests/hermes_cli/test_subscription_cli.py +++ b/tests/hermes_cli/test_subscription_cli.py @@ -195,3 +195,44 @@ def _put(**kw): assert calls["n"] == 1 # applied once, grant denied, no replay assert "Couldn't enable terminal billing" in out + + +def test_unknown_preview_effect_fails_safe(cli, monkeypatch, capsys): + # An unrecognized effect string must NOT schedule a real change (fail safe). + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "weird_unknown", "targetTierName": "Plus"}) + put = {"n": 0} + monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: put.update(n=put["n"] + 1) or {}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert put["n"] == 0 # no mutation on an unknown effect + assert "portal" in out.lower() + + +def test_bounded_stepup_does_not_loop_on_repeat_denial(cli, monkeypatch, capsys): + # Grant "succeeds" but the scope stays denied → replay ONCE (allow_stepup=False), + # then stop — no re-prompt / re-open-browser loop. + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"}) + calls = {"n": 0} + + def _put(**kw): + calls["n"] += 1 + raise nb.BillingScopeRequired("still no scope") + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + import hermes_cli.auth as auth + + monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True, raising=False) + + cli._show_subscription() + out = capsys.readouterr().out + + assert calls["n"] == 2 # applied, granted, replayed once — no third attempt + assert "still isn't enabled" in out From 96ff097a640eac3c034fe3f7890733b3d7eafc00 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 2 Jul 2026 00:07:24 +0530 Subject: [PATCH 51/62] fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui-tui/src/components/subscriptionOverlay.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 6fb748c3efbd..cafa327e9ac2 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -675,12 +675,15 @@ function ResultScreen({ onClose, overlay, t }: Omit) { function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { const { ctx } = overlay const retry: null | SubscriptionStepUpRetry = overlay.stepUpRetry ?? null - const [phase, setPhase] = useState<'granted' | 'prompt' | 'waiting'>('prompt') + const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt') const startedRef = useRef(false) // Set when the user cancels while the browser grant is still in flight. The // grant's late `.then` MUST NOT fire the held change after a cancel — otherwise // a cancel-then-approve charges the card the user just declined. const abortedRef = useRef(false) + // Guards the post-grant replay from double-firing (double-Enter on the default + // 'Continue' row) — mirrors billingOverlay.resume()'s phase flip. + const resumingRef = useRef(false) const enable = () => { if (startedRef.current) { @@ -708,6 +711,15 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { } const resume = () => { + // Fire the held replay at most once. Without this, a double-Enter on the + // default 'Continue' row sends two mutations (the upgrade dedups on the shared + // idempotency key, but schedule/cancel/resume replays carry none). + if (resumingRef.current || phase !== 'granted') { + return + } + + resumingRef.current = true + setPhase('resuming') onPatch({ stepUpRetry: null }) if (!retry) { @@ -726,6 +738,12 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { } const back = () => { + // Once a replay is firing, block abandon — the mutation/charge is in flight and + // re-mounting confirm would let a second submit through. + if (resumingRef.current) { + return + } + // Abandon. If a grant is in flight, mark it aborted so its .then no-ops (no // un-consented charge); if already granted, just leave without replaying. abortedRef.current = true @@ -756,12 +774,13 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { Opening your browser to approve… finish there, then come back — nothing is charged until you continue. )} {phase === 'granted' && Terminal billing enabled. Continue to finish your change.} + {phase === 'resuming' && Applying your change…} {rows.map((row, i) => ( ))} - {footer(phase === 'waiting' ? 'Waiting for approval… · Esc to cancel' : '↑/↓ select · Enter · Esc back', t)} + {footer(phase === 'waiting' ? 'Waiting for approval… · Esc to cancel' : phase === 'resuming' ? 'Working…' : '↑/↓ select · Enter · Esc back', t)} ) } From 3a5cd02a3f350ae168730c314771204b134ec59a Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 2 Jul 2026 00:07:24 +0530 Subject: [PATCH 52/62] fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 35 +++++++++++++++++++---- tests/hermes_cli/test_subscription_cli.py | 28 ++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/cli.py b/cli.py index c9cf145e45b2..141b9366b871 100644 --- a/cli.py +++ b/cli.py @@ -9890,7 +9890,7 @@ def _subscription_preview_and_confirm(self, state, tier_id, *, allow_stepup=True if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": print(" 🟡 Cancelled. No plan change.") return - self._subscription_apply(state, action) + self._subscription_apply(state, action, allow_stepup=allow_stepup) def _subscription_confirm_cancel(self, state): """Confirm, then schedule a cancellation at period end.""" @@ -9937,7 +9937,17 @@ def _subscription_apply(self, state, action, idempotency_key=None, *, allow_step key = idempotency_key or new_idempotency_key() try: if kind == "upgrade": - res = post_subscription_upgrade(subscription_type_id=arg, idempotency_key=key) or {} + try: + res = post_subscription_upgrade(subscription_type_id=arg, idempotency_key=key) or {} + except BillingScopeRequired: + raise # a scope denial rejects BEFORE charging → route to the step-up + except BillingError as exc: + # Any OTHER error on the charging route (transport / timeout / 500) + # is AMBIGUOUS — NAS may have already prorated + charged. Steer to a + # re-check; never a flat failure that invites a blind retry (which + # mints a fresh key the server can't dedup → a real second charge). + self._subscription_render_upgrade_ambiguous(exc) + return status = res.get("status") name = res.get("targetTierName") or "your new plan" _url = res.get("recoveryUrl") @@ -9948,13 +9958,14 @@ def _subscription_apply(self, state, action, idempotency_key=None, *, allow_step elif status == "requires_action": _cprint(" 🟡 This upgrade needs extra verification (3DS). Finish it on the portal.") if _url: - print(f" Portal: {_url}") + _cprint(f" Portal: {_url}") elif status == "payment_failed": _cprint(" 🔴 Your card was declined. Update your payment method on the portal and try again.") if _url: - print(f" Portal: {_url}") + _cprint(f" Portal: {_url}") else: - _cprint(" 🟡 The upgrade could not be completed.") + # Unknown / absent 2xx status → also ambiguous, not a flat failure. + self._subscription_render_upgrade_ambiguous(None) return if kind == "schedule": put_subscription_pending_change(subscription_type_id=arg) @@ -10048,7 +10059,19 @@ def _subscription_render_error(self, state, exc): _cprint(f" 🔴 {msg}") _url = getattr(exc, "portal_url", None) if _url: - print(f" Portal: {_url}") + _cprint(f" Portal: {_url}") + + def _subscription_render_upgrade_ambiguous(self, exc): + """A charge-route failure (transport / timeout / 500 / unknown status) is + AMBIGUOUS — NAS may have already prorated + charged. Steer to a re-check, + never a flat failure that invites a blind retry (mirrors the TUI's + upgradeResult(null) — the CLI can't persist the key across a command re-run, + so a re-check is the safe path).""" + _cprint(" 🟡 Couldn't confirm the upgrade — your card may or may not have been charged.") + _cprint(f" {_d('Re-run /subscription to check your plan before trying again.')}") + _url = getattr(exc, "portal_url", None) if exc is not None else None + if _url: + _cprint(f" Portal: {_url}") # ------------------------------------------------------------------ # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py index 3cabdac3871b..bb6554e7da86 100644 --- a/tests/hermes_cli/test_subscription_cli.py +++ b/tests/hermes_cli/test_subscription_cli.py @@ -236,3 +236,31 @@ def _put(**kw): assert calls["n"] == 2 # applied, granted, replayed once — no third attempt assert "still isn't enabled" in out + + +def test_upgrade_transport_failure_is_ambiguous_not_flat_failure(cli, monkeypatch, capsys): + # BUG B: a charge-route failure must warn "may or may not have been charged" + # (steer to a re-check), never a flat failure that invites a blind retry (which + # would mint a fresh idempotency key the server can't dedup → a real 2nd charge). + cli._app = object() + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple( + SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) + for t in _TIERS + ) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + + def _boom(**kw): + raise nb.BillingError("Could not reach Nous Portal", error="endpoint_unavailable") + + monkeypatch.setattr(nb, "post_subscription_upgrade", _boom) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "may or may not have been charged" in out + assert "Re-run /subscription" in out + assert "could not be completed" not in out # not the old flat failure From 3b77f4361ed7c505e408a5d8cff1915dd4fee30f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 2 Jul 2026 00:32:35 +0530 Subject: [PATCH 53/62] fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(). --- ui-tui/src/components/subscriptionOverlay.tsx | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index cafa327e9ac2..5f7bf9cfdd66 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -203,11 +203,22 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip // ── Scope-aware routing (shared by the picker, confirm, overview + step-up) ── +// A REPEAT scope denial during a post-grant replay must NOT route back to the +// stepup screen: we're already mounted there (in the 'resuming' phase), so an +// onPatch({screen:'stepup'}) is a no-op that never remounts → the screen freezes. +// Post-grant replays pass allowStepUp=false and surface this instead (mirrors the +// CLI's allow_stepup=False cap). +const scopeStillDeniedResult: SubscriptionResult = { + message: 'Terminal billing still isn’t enabled for this org — enable it on the portal, then retry.', + ok: false +} + /** Preview a tier and route: confirm (ok), stepup (scope), or result (other error). */ function previewAndRoute( ctx: SubscriptionOverlayState['ctx'], tierId: string, - onPatch: ScreenProps['onPatch'] + onPatch: ScreenProps['onPatch'], + allowStepUp = true ): Promise { return ctx.preview(tierId).then(p => { if (!p) { @@ -216,7 +227,9 @@ function previewAndRoute( if (!p.ok) { if (isScopeDenial(p)) { - return onPatch({ screen: 'stepup', stepUpRetry: { kind: 'preview', tierId } }) + return allowStepUp + ? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'preview', tierId } }) + : onPatch({ result: scopeStillDeniedResult, screen: 'result' }) } return onPatch({ result: errorResult(p), screen: 'result' }) @@ -242,13 +255,21 @@ function previewAndRoute( function applyPendingAndRoute( ctx: SubscriptionOverlayState['ctx'], pending: null | SubscriptionPendingChange, - onPatch: ScreenProps['onPatch'] + onPatch: ScreenProps['onPatch'], + allowStepUp = true ): Promise { if (!pending) { + // Nothing to apply (defensive) — return to the overview rather than stranding. + onPatch({ screen: 'overview' }) + return Promise.resolve() } - const toStepUp = () => onPatch({ screen: 'stepup', stepUpRetry: { kind: 'apply' } }) + const toStepUp = () => + allowStepUp + ? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'apply' } }) + : onPatch({ result: scopeStillDeniedResult, screen: 'result' }) + const finish = (result: SubscriptionResult) => onPatch({ result, screen: 'result' }) if (pending.kind === 'cancellation') { @@ -271,12 +292,16 @@ function applyPendingAndRoute( } /** Resume (undo the pending change) and route: result (ok/err) or stepup (scope). */ -function resumeAndRoute(ctx: SubscriptionOverlayState['ctx'], onPatch: ScreenProps['onPatch']): Promise { - return ctx.resume().then(r => - isScopeDenial(r) - ? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'resume' } }) - : onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' }) - ) +function resumeAndRoute(ctx: SubscriptionOverlayState['ctx'], onPatch: ScreenProps['onPatch'], allowStepUp = true): Promise { + return ctx.resume().then(r => { + if (isScopeDenial(r)) { + return allowStepUp + ? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'resume' } }) + : onPatch({ result: scopeStillDeniedResult, screen: 'result' }) + } + + return onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' }) + }) } // ── The pending scheduled change (drives the banner + status echo) ── @@ -726,15 +751,17 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { return onPatch({ screen: 'overview' }) } + // allowStepUp=false: a repeat scope denial surfaces a result, never a frozen + // re-entry into this (already-mounted) stepup screen. if (retry.kind === 'preview') { - return void previewAndRoute(ctx, retry.tierId, onPatch) + return void previewAndRoute(ctx, retry.tierId, onPatch, false) } if (retry.kind === 'resume') { - return void resumeAndRoute(ctx, onPatch) + return void resumeAndRoute(ctx, onPatch, false) } - return void applyPendingAndRoute(ctx, overlay.pending ?? null, onPatch) + return void applyPendingAndRoute(ctx, overlay.pending ?? null, onPatch, false) } const back = () => { From d0e5a90ef1838c4c22918429ad827a5e7a189f25 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 2 Jul 2026 00:32:35 +0530 Subject: [PATCH 54/62] fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cli.py | 26 +++++++++--- tests/hermes_cli/test_subscription_cli.py | 52 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index 141b9366b871..b925777b0332 100644 --- a/cli.py +++ b/cli.py @@ -9923,7 +9923,10 @@ def _subscription_apply(self, state, action, idempotency_key=None, *, allow_step """ from hermes_cli.nous_billing import ( BillingError, + BillingRateLimited, + BillingRemoteSpendingRevoked, BillingScopeRequired, + BillingSessionRevoked, delete_subscription_pending_change, post_subscription_upgrade, put_subscription_pending_change, @@ -9941,12 +9944,25 @@ def _subscription_apply(self, state, action, idempotency_key=None, *, allow_step res = post_subscription_upgrade(subscription_type_id=arg, idempotency_key=key) or {} except BillingScopeRequired: raise # a scope denial rejects BEFORE charging → route to the step-up + except (BillingRateLimited, BillingSessionRevoked, BillingRemoteSpendingRevoked) as exc: + # Deterministic PRE-charge typed rejections (429 / 401 / 403) never + # reached Stripe → surface the CORRECT recovery (retry_after / re-login / + # reconnect), NOT the "maybe charged" ambiguity copy. + self._subscription_render_error(state, exc) + return except BillingError as exc: - # Any OTHER error on the charging route (transport / timeout / 500) - # is AMBIGUOUS — NAS may have already prorated + charged. Steer to a - # re-check; never a flat failure that invites a blind retry (which - # mints a fresh key the server can't dedup → a real second charge). - self._subscription_render_upgrade_ambiguous(exc) + _status = getattr(exc, "status", None) + _code = getattr(exc, "error", None) + if _code in ("network_error", "endpoint_unavailable") or _status is None or _status >= 500: + # Genuinely INDETERMINATE — transport / unparseable 2xx / a 5xx the + # server hit mid-request: NAS may have already prorated + charged. + # Steer to a re-check, never a blind retry (a fresh key can't dedup → + # a real second charge). + self._subscription_render_upgrade_ambiguous(exc) + else: + # A deterministic 4xx (role_required / no_payment_method / …) → the + # normal error copy, not "maybe charged". + self._subscription_render_error(state, exc) return status = res.get("status") name = res.get("targetTierName") or "your new plan" diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py index bb6554e7da86..e5b4064f90b4 100644 --- a/tests/hermes_cli/test_subscription_cli.py +++ b/tests/hermes_cli/test_subscription_cli.py @@ -264,3 +264,55 @@ def _boom(**kw): assert "may or may not have been charged" in out assert "Re-run /subscription" in out assert "could not be completed" not in out # not the old flat failure + + +def test_upgrade_rate_limit_is_deterministic_not_ambiguous(cli, monkeypatch, capsys): + # R2: a typed PRE-charge rejection (429 rate-limit) must NOT be mislabeled + # "may or may not have been charged" — it never reached Stripe. It gets the + # normal error copy, not the ambiguous one. + cli._app = object() + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple( + SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) + for t in _TIERS + ) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + + def _rl(**kw): + raise nb.BillingRateLimited("Slow down — too many requests.", error="rate_limited", status=429, retry_after=30) + + monkeypatch.setattr(nb, "post_subscription_upgrade", _rl) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "may or may not have been charged" not in out # NOT the ambiguous copy + assert "Slow down" in out # the real, deterministic error + + +def test_upgrade_transport_failure_still_ambiguous_after_narrowing(cli, monkeypatch, capsys): + # Regression floor for R2: a genuine transport failure (network_error, no status) + # must STILL be ambiguous after the narrowing. + cli._app = object() + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple( + SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) + for t in _TIERS + ) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + + def _net(**kw): + raise nb.BillingError("Could not reach Nous Portal: timeout", error="network_error") + + monkeypatch.setattr(nb, "post_subscription_upgrade", _net) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "may or may not have been charged" in out From 752ccf1d488986255ce04ca1c7e297e5a5d4c5c0 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 2 Jul 2026 02:06:37 +0530 Subject: [PATCH 55/62] feat(billing): card visibility + guided add-card path in /topup and /subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/billing_view.py | 60 ++++++++- cli.py | 91 ++++++++++++- tests/hermes_cli/test_billing_cli.py | 105 ++++++++++++++- tests/hermes_cli/test_subscription_cli.py | 39 ++++++ tui_gateway/server.py | 14 +- ui-tui/src/__tests__/billingStepUp.test.tsx | 1 + .../__tests__/subscriptionOverlay.test.tsx | 1 + ui-tui/src/app/interfaces.ts | 14 +- ui-tui/src/app/slash/commands/subscription.ts | 9 +- ui-tui/src/app/slash/commands/topup.ts | 5 + ui-tui/src/components/billingOverlay.tsx | 120 ++++++++++++++++-- ui-tui/src/components/subscriptionOverlay.tsx | 30 ++++- ui-tui/src/gatewayTypes.ts | 6 + 13 files changed, 471 insertions(+), 24 deletions(-) diff --git a/agent/billing_view.py b/agent/billing_view.py index e40977df0d3c..fa3054f5226e 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -65,15 +65,49 @@ def format_money(value: Optional[Decimal]) -> str: # ============================================================================= +# resolvedVia → the human answer to "why THIS card?". Keys are the server's card +# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label +# so the display degrades cleanly on servers that don't send resolvedVia yet. +_CARD_PROVENANCE_LABELS = { + "subPin": "the card on your subscription", + "customerDefault": "your default card saved on the portal", + "autoRefill": "your auto-reload card", +} + + @dataclass(frozen=True) class CardInfo: brand: str last4: str + # NAS card-on-file fields (post card-resolver): which ladder rung found the + # card, and whether it is known-failing (auto-reload payment failures). + # Both default off so pre-resolver payloads parse unchanged. + resolved_via: Optional[str] = None + needs_repair: bool = False @property def masked(self) -> str: + # A Link payment method has no card number (last4 = "") — render the + # brand alone, not "Link ····". + if not self.last4: + return self.brand return f"{self.brand} ····{self.last4}" + @property + def provenance(self) -> Optional[str]: + """Human label for why this card was picked, or None (unknown rung / + server too old to say).""" + if self.resolved_via is None: + return None + return _CARD_PROVENANCE_LABELS.get(self.resolved_via) + + @property + def display(self) -> str: + """The one-line card display: ``Visa ····4242 — the card on your + subscription`` (or just the masked card when provenance is unknown).""" + label = self.provenance + return f"{self.masked} — {label}" if label else self.masked + @dataclass(frozen=True) class MonthlyCap: @@ -134,9 +168,19 @@ def _parse_card(raw: Any) -> Optional[CardInfo]: return None brand = raw.get("brand") last4 = raw.get("last4") - if isinstance(brand, str) and isinstance(last4, str): - return CardInfo(brand=brand, last4=last4) - return None + if not (isinstance(brand, str) and isinstance(last4, str)): + return None + # Post-resolver fields — all optional so both payload generations parse. + resolved_via = raw.get("resolvedVia") + if not isinstance(resolved_via, str): + resolved_via = None + chargeability = raw.get("chargeability") + needs_repair = ( + isinstance(chargeability, dict) and chargeability.get("kind") == "needs_repair" + ) + return CardInfo( + brand=brand, last4=last4, resolved_via=resolved_via, needs_repair=needs_repair + ) def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]: @@ -301,6 +345,16 @@ def _dev_fixture_billing_state() -> Optional[BillingState]: return BillingState(logged_in=True, card=None, **common) if name == "card": return BillingState(logged_in=True, card=card, **common) + if name in ("card-sub", "card_sub"): + # Post-resolver: the card came from the subscription (provenance label). + _sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin") + return BillingState(logged_in=True, card=_sub_card, **common) + if name in ("card-repair", "card_repair"): + # Post-resolver: auto-reload card with failing payments (warn, don't block). + _bad_card = CardInfo( + brand="Mastercard", last4="9911", resolved_via="autoRefill", needs_repair=True + ) + return BillingState(logged_in=True, card=_bad_card, auto_reload=autoreload_on, **common) if name in ("card-autoreload", "card_autoreload", "autoreload"): return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common) if name in ("notadmin", "not-admin", "member"): diff --git a/cli.py b/cli.py index b925777b0332..e6d43f573c0b 100644 --- a/cli.py +++ b/cli.py @@ -9865,7 +9865,21 @@ def _subscription_preview_and_confirm(self, state, tier_id, *, allow_stepup=True _cprint(f" Upgrade to {target}. You will be charged {_amt} now (prorated).") else: _cprint(f" Upgrade to {target}. You will be charged the prorated amount now.") - _cprint(f" {_d('The card on your subscription will be charged.')}") + # Best-effort: name the exact card (billing.state), but only when the + # resolver rung matches what a subscription charge actually uses + # (subPin / customerDefault — Stripe's own precedence). Any failure or + # older NAS → the generic line stands. + _card_line = "The card on your subscription will be charged." + try: + from agent.billing_view import build_billing_state + + _bs = build_billing_state(timeout=6.0) + _c = _bs.card if _bs.logged_in else None + if _c is not None and _c.resolved_via in ("subPin", "customerDefault"): + _card_line = f"{_c.masked} — the card on your subscription — will be charged." + except Exception: + pass + _cprint(f" {_d(_card_line)}") pay_label = f"Pay {_amt} & upgrade now" if _amt else "Upgrade now (prorated charge)" action = ("upgrade", tier_id) # The money-moving row is NOT the default — a bare Enter hits "Go back", @@ -10171,6 +10185,16 @@ def _billing_overview(self, state): ) else: print(" Auto-reload: off") + # Card presence at a glance: which card a charge would use (with why — + # "the card on your subscription"), or that none is saved. Only for the + # full-menu case (admin + billing on) — others get the portal note below. + if state.is_admin and state.cli_billing_enabled: + if state.card is not None: + print(f" Card: {state.card.display}") + if state.card.needs_repair: + _cprint(" ⚠️ This card has been failing automatic top-ups — update it on the portal.") + else: + _cprint(f" {_d('No saved card on file — “Add funds” walks you through adding one.')}") print(f" {'─' * 41}") # Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal. @@ -10303,6 +10327,48 @@ def _billing_require_admin(self, state) -> bool: return False return True + def _billing_add_card_flow(self, state): + """No saved card → guide adding one on the portal, with a re-check loop. + + Cards are added on the portal (never in-terminal). "I've added it" re-fetches + billing state so the purchase continues right here once the card is saved — + this also recovers a transient miss (the card display is best-effort + server-side). Returns the refreshed state (card present), or None to abandon. + """ + print() + _cprint(f" 💳 {_b('Add a card first')}") + _cprint(" No saved card on file.") + _cprint(f" {_d('Add a card once on the portal billing page — after that you can top up right from the terminal.')}") + choices = [ + ("portal", "Add a card on the portal", "opens the billing page in your browser"), + ("recheck", "I've added it — check again", "re-check for the card and continue"), + ("cancel", "Back", "do nothing"), + ] + for _ in range(8): # bounded: portal-open plus a handful of re-checks + raw = self._prompt_text_input_modal(title="Add a card", detail="", choices=choices) + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice == "portal": + self._billing_open_portal(state) + _cprint(f" {_d('Add the card on the billing page, then pick “check again” here.')}") + continue + if choice == "recheck": + from agent.billing_view import build_billing_state + + try: + fresh = build_billing_state() + except Exception: + fresh = None + if fresh is not None and fresh.logged_in: + state = fresh + if state.card is not None: + _cprint(f" {_DIM}✓ Card found: {state.card.display} — continuing.{_RST}") + return state + print(" Still no card on file — finish adding it on the portal, then check again.") + continue + break + print(" Cancelled. No funds added.") + return None + def _billing_buy_flow(self, state): """Screen 2 (preset select) → Screen 3 (confirm + charge + poll).""" from agent.billing_view import format_money, validate_charge_amount @@ -10326,6 +10392,14 @@ def _billing_buy_flow(self, state): self._billing_portal_hint(state) return + # No card on file → the guided ADD-CARD path first (portal + re-check), + # so the user isn't walked through picking an amount that will 403. + # Returns refreshed state with a card, or None (abandoned). + if state.card is None: + state = self._billing_add_card_flow(state) + if state is None or state.card is None: + return + preset_choices = [] for p in state.charge_presets: preset_choices.append((str(p), format_money(p), "one-time credit purchase")) @@ -10333,7 +10407,9 @@ def _billing_buy_flow(self, state): preset_choices.append(("cancel", "Cancel", "do nothing")) card = state.card - detail = f"Payment: {card.masked}" if card else "No saved card on file" + detail = f"Payment: {card.display}" if card else "No saved card on file" + if card is not None and card.needs_repair: + _cprint(" ⚠️ This card has been failing automatic top-ups — it may decline. Update it on the portal.") raw = self._prompt_text_input_modal( title="Add funds", detail=detail, choices=preset_choices, ) @@ -10376,8 +10452,13 @@ def _billing_confirm_and_charge(self, state, amount): print(f" {'─' * 41}") print(f" Total: {format_money(amount)}") if card: - print(f" Payment: {card.masked}") - _cprint(f" {_d('Your card saved on the portal will be charged.')}") + print(f" Payment: {card.display}") + # Provenance-less payloads (older NAS) keep the generic line; when + # the resolver says WHY this card, the Payment line carries it. + if card.provenance is None: + _cprint(f" {_d('Your card saved on the portal will be charged.')}") + if card.needs_repair: + _cprint(" ⚠️ This card has been failing automatic top-ups — it may decline. Update it on the portal.") print(f" {'─' * 41}") _consent = ( "By confirming, you allow Nous Research to charge your card." @@ -10394,7 +10475,7 @@ def _billing_confirm_and_charge(self, state, amount): return raw = self._prompt_text_input_modal( title=f"Pay {format_money(amount)}?", - detail=(card.masked if card else "no saved card"), + detail=(card.display if card else "no saved card"), choices=confirm_choices, ) choice = self._normalize_slash_confirm_choice(raw, confirm_choices) diff --git a/tests/hermes_cli/test_billing_cli.py b/tests/hermes_cli/test_billing_cli.py index 325bc0c7e8a5..f0eabae95913 100644 --- a/tests/hermes_cli/test_billing_cli.py +++ b/tests/hermes_cli/test_billing_cli.py @@ -114,7 +114,9 @@ def test_billing_sub_arg_ignored_opens_overview(cli, monkeypatch, capsys): cli._show_billing("/billing buy") # arg is ignored out = capsys.readouterr().out assert "Top up · balance" in out # overview, NOT the buy screen - assert "Add funds" not in out # the buy screen header isn't shown + # The buy screen's preset list isn't shown. (The overview's no-card hint may + # legitimately mention the "Add funds" menu item, so key on presets instead.) + assert "Presets:" not in out def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys): @@ -132,3 +134,104 @@ def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys): assert "Add funds" in out assert "$25" in out and "$50" in out and "$100" in out assert "interactive CLI" in out # defers; no charge attempted non-interactively + + +# ── Card visibility + the add-card path (inline w/ NAS card-resolver) ── + + +def _scripted(*responses): + it = iter(responses) + + def _modal(self, **kw): + return next(it) + + return _modal + + +def test_overview_shows_card_with_provenance_and_repair_warning(cli, monkeypatch, capsys): + state = BillingState( + logged_in=True, role="OWNER", balance_usd=Decimal("10"), + cli_billing_enabled=True, charge_presets=(Decimal("25"),), + card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin", needs_repair=True), + portal_url="https://portal/billing", + ) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) + cli._show_billing("/topup") + out = capsys.readouterr().out + assert "Card: Visa ····4242 — the card on your subscription" in out + assert "failing automatic top-ups" in out + + +def test_overview_shows_no_card_hint(cli, monkeypatch, capsys): + state = BillingState( + logged_in=True, role="OWNER", balance_usd=Decimal("10"), + cli_billing_enabled=True, charge_presets=(Decimal("25"),), + card=None, portal_url="https://portal/billing", + ) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) + cli._show_billing("/topup") + out = capsys.readouterr().out + assert "No saved card on file" in out + assert "Add funds" in out # the hint names the path + + +def test_link_card_renders_brand_alone(cli, monkeypatch, capsys): + # A Link payment method has no card number (last4 = "") — never "Link ····". + state = BillingState( + logged_in=True, role="OWNER", balance_usd=Decimal("10"), + cli_billing_enabled=True, charge_presets=(Decimal("25"),), + card=CardInfo(brand="Link", last4=""), portal_url="https://portal/billing", + ) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) + cli._show_billing("/topup") + out = capsys.readouterr().out + assert "Card: Link" in out + assert "Link ····" not in out + + +def test_buy_flow_no_card_guides_then_continues_after_recheck(cli, monkeypatch, capsys): + # No card → the guided add-card path; "check again" re-fetches state and, + # once the card exists, continues straight into the preset menu. + cli._app = object() + common = dict( + logged_in=True, role="OWNER", cli_billing_enabled=True, + charge_presets=(Decimal("25"), Decimal("50")), + min_usd=Decimal("5"), max_usd=Decimal("500"), + portal_url="https://portal/billing", + ) + nocard = BillingState(card=None, **common) + withcard = BillingState(card=CardInfo(brand="Visa", last4="4242", resolved_via="customerDefault"), **common) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: withcard) + # add-card modal → "recheck"; preset modal → "cancel" (we only test the routing) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("recheck", "cancel"), raising=False) + + cli._billing_buy_flow(nocard) + out = capsys.readouterr().out + + assert "Add a card first" in out + assert "Card found: Visa ····4242 — your default card saved on the portal" in out + assert "Cancelled. No funds added." in out # reached the preset menu, then bailed + + +def test_buy_flow_no_card_back_abandons(cli, monkeypatch, capsys): + cli._app = object() + nocard = BillingState( + logged_in=True, role="OWNER", cli_billing_enabled=True, + charge_presets=(Decimal("25"),), card=None, + portal_url="https://portal/billing", + ) + calls = {"n": 0} + + def _no_fetch(*a, **kw): + calls["n"] += 1 + return nocard + + monkeypatch.setattr(bv, "build_billing_state", _no_fetch) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False) + + cli._billing_buy_flow(nocard) + out = capsys.readouterr().out + + assert "Add a card first" in out + assert "Cancelled. No funds added." in out + assert calls["n"] == 0 # backed out before any re-check diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py index e5b4064f90b4..7a5b2babb59b 100644 --- a/tests/hermes_cli/test_subscription_cli.py +++ b/tests/hermes_cli/test_subscription_cli.py @@ -316,3 +316,42 @@ def _net(**kw): out = capsys.readouterr().out assert "may or may not have been charged" in out + + +@pytest.fixture(autouse=True) +def _no_card_lookup(monkeypatch): + # The charge_now confirm best-effort-fetches billing state to NAME the card + # being charged; keep unit tests offline (generic line) unless overridden. + import agent.billing_view as bv + + def _offline(*a, **kw): + raise RuntimeError("offline") + + monkeypatch.setattr(bv, "build_billing_state", _offline, raising=False) + + +def test_upgrade_confirm_names_the_subscription_card(cli, monkeypatch, capsys): + # Post-card-resolver NAS: the confirm names the exact card when it resolved + # via the subscription rung (what the upgrade actually charges). + cli._app = object() + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple( + SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) + for t in _TIERS + ) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + # picker → ultra; charge confirm → back out (we only assert the card line) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "cancel"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + import agent.billing_view as bv + from agent.billing_view import BillingState as _BS + from agent.billing_view import CardInfo as _CI + + _state = _BS(logged_in=True, card=_CI(brand="Visa", last4="4242", resolved_via="subPin")) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: _state, raising=False) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "Visa ····4242 — the card on your subscription — will be charged." in out diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d7e664f45246..6c26656b47e3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7311,7 +7311,19 @@ def _s(value): card = None if state.card is not None: - card = {"brand": state.card.brand, "last4": state.card.last4, "masked": state.card.masked} + card = { + "brand": state.card.brand, + "last4": state.card.last4, + "masked": state.card.masked, + # Post-card-resolver fields (None/False on older NAS payloads): + # display = "Visa ····4242 — the card on your subscription"; + # resolved_via = the raw resolution rung, for rung-gated surfaces + # (the /subscription confirm only shows the card when the rung + # matches what a subscription charge would use). + "display": state.card.display, + "resolved_via": state.card.resolved_via, + "needs_repair": state.card.needs_repair, + } monthly_cap = None if state.monthly_cap is not None: mc = state.monthly_cap diff --git a/ui-tui/src/__tests__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx index bfc693d2d58c..44168865f41f 100644 --- a/ui-tui/src/__tests__/billingStepUp.test.tsx +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -79,6 +79,7 @@ const ctx = { applyAutoReload: vi.fn(() => Promise.resolve(true)), charge: vi.fn(() => Promise.resolve('submitted' as const)), openPortal: vi.fn(), + refreshState: vi.fn(() => Promise.resolve(null)), requestRemoteSpending: vi.fn(() => Promise.resolve(true)), sys: vi.fn(), validate: vi.fn((raw: string) => ({ amount: raw })) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index ca8302d2990c..a07b16220e91 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -75,6 +75,7 @@ const state = (overrides: Partial = {}): Subscription }) const ctx = { + fetchCard: vi.fn(() => Promise.resolve(null)), openManageLink: vi.fn(() => Promise.resolve(true)), openPortal: vi.fn(), preview: vi.fn(() => Promise.resolve(null)), diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index d8a333dae56c..d68285afe439 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -3,7 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea import type { PasteEvent } from '../components/textInput.js' import type { GatewayClient } from '../gatewayClient.js' -import type { BillingMutationResponse, BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../gatewayTypes.js' +import type { BillingCardInfo, BillingMutationResponse, BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../gatewayTypes.js' import type { ParsedVoiceRecordKey } from '../lib/platform.js' import type { RpcResult } from '../lib/rpc.js' import type { Theme } from '../theme.js' @@ -125,6 +125,12 @@ export interface BillingOverlayCtx { requestRemoteSpending: () => Promise /** Open the portal in the browser + echo a transcript line. */ openPortal: (url: string) => void + /** + * Re-fetch billing state (`billing.state`) — used by the add-card path's + * "I've added it — check again" so a card saved on the portal appears without + * re-running /topup. Resolves null on failure (caller keeps the old state). + */ + refreshState: () => Promise /** Emit a transcript system line. */ sys: (text: string) => void /** Validate a custom amount against state bounds + 2dp (mirrors the server). */ @@ -180,6 +186,12 @@ export interface StepUpResult { } export interface SubscriptionOverlayCtx { + /** + * Best-effort card lookup (`billing.state`) for the upgrade confirm — shows + * WHICH card the upgrade will charge. Resolves null on any failure or when + * the server doesn't say (older NAS): the confirm keeps its generic line. + */ + fetchCard: () => Promise /** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */ openManageLink: () => Promise /** Open an arbitrary portal recovery URL (e.g. an upgrade's SCA handoff). */ diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index ba533d425d5b..98c8bdb0d2f3 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -1,5 +1,6 @@ import type { BillingMutationResponse, + BillingStateResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse @@ -58,6 +59,11 @@ const buildSubscriptionCtx = ( sys: Sys, initialState: SubscriptionStateResponse ): SubscriptionOverlayCtx => ({ + fetchCard: () => + ctx.gateway + .rpc('billing.state', {}) + .then(r => (r?.ok ? (r.card ?? null) : null)) + .catch(() => null), openManageLink: () => { const url = buildManageUrl(initialState) @@ -133,7 +139,8 @@ export const subscriptionCommands: SlashCommand[] = [ name: 'subscription', aliases: ['upgrade'], // ZERO sub-commands: bare `/subscription` fetches state and opens the - // overlay (deep-link only — NEVER charges in-terminal). + // overlay's in-terminal change flow (only /upgrade's charge_now confirm + // moves money, via the V3 upgrade route). run: (_arg, ctx) => { const sys: Sys = ctx.transcript.sys diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index b2410b1d460c..24dba4b671ce 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -344,6 +344,11 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B openExternalUrl(url) sys(`Opening portal: ${url}`) }, + refreshState: () => + ctx.gateway + .rpc('billing.state', {}) + .then(r => (r?.ok ? r : null)) + .catch(() => null), sys, validate: (raw: string) => validateAmount(raw, s) }) diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 419250509e3e..8b5ccafe5996 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -97,9 +97,9 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { // Always show the full billing menu for an admin/billing-on org — a missing // card does NOT mean nothing can be done (the org may already have balance, - // auto-reload, a limit). The card only matters at CHARGE time: "Add funds" - // attempts the charge and, if there's no card (no_payment_method), the buy - // flow hands off to the portal to top up / manage billing there. + // auto-reload, a limit). The card only matters at CHARGE time: with no card + // on file, "Add funds" opens the guided add-card path (portal + check-again) + // instead of an amount picker that would 403 no_payment_method. const items = full ? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel'] : ['Manage on portal', 'Cancel'] @@ -175,6 +175,17 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { /subscription. Renders nothing when no usage model is available. */} {auto && {auto}} + {/* Card presence at a glance: which card a charge would use (with why — + "the card on your subscription"), or that none is saved. Only for the + full menu — members/billing-off get the portal note instead. */} + {full && ( + + {s.card ? `Card: ${s.card.display ?? s.card.masked}` : 'No saved card on file — “Add funds” walks you through adding one.'} + + )} + {full && s.card?.needs_repair && ( + ⚠ This card has been failing automatic top-ups — update it on the portal. + )} {note && ( {note} @@ -197,14 +208,54 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { const presets = s.charge_presets_display const rawPresets = s.charge_presets - // rows: [...presets, 'Custom amount…', 'Cancel'] - const rows = [...presets, 'Custom amount…', 'Cancel'] + // No card on file → the buy screen becomes the ADD-CARD path: cards are added + // on the portal (never in-terminal), and "check again" re-fetches state so the + // flow continues right here once the card is saved. Card present → the normal + // preset menu. (The card display is best-effort server-side, so "check again" + // also recovers a transient miss.) + const noCard = !s.card + + const rows = noCard + ? ['Add a card on the portal', 'I’ve added it — check again', 'Back'] + : [...presets, 'Custom amount…', 'Cancel'] + const customIdx = presets.length const [sel, setSel] = useState(0) const [typing, setTyping] = useState(false) const [custom, setCustom] = useState('') const [error, setError] = useState(null) + const [checking, setChecking] = useState(false) + // Synchronous guard: double-Enter on "check again" must not stack re-fetches. + const checkingRef = useRef(false) + + const recheck = () => { + if (checkingRef.current) { + return + } + + checkingRef.current = true + setChecking(true) + void ctx.refreshState().then(fresh => { + checkingRef.current = false + setChecking(false) + + if (!fresh) { + return setError('Could not refresh billing state — try again in a moment.') + } + + setError(null) + // Re-render with the fresh state: if the card is now on file, this same + // screen flips into the preset menu and the purchase continues here. + onPatch({ state: fresh }) + + if (fresh.card) { + ctx.sys(`✓ Card found: ${fresh.card.display ?? fresh.card.masked} — pick an amount.`) + } else { + ctx.sys('Still no card on file — finish adding it on the portal, then check again.') + } + }) + } const toConfirm = (amount: string) => { // Mint the idempotency key here (purchase identity = this amount). It rides @@ -240,6 +291,25 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { } const choose = (i: number) => { + if (noCard) { + if (i === 0) { + if (s.portal_url) { + ctx.openPortal(s.portal_url) + ctx.sys('Add a card on the billing page, then come back and pick “check again”.') + } else { + setError('Could not build the portal link — is your portal configured?') + } + + return + } + + if (i === 1) { + return recheck() + } + + return onPatch({ screen: 'overview' }) + } + if (i < presets.length) { pickPreset(i) } else if (i === customIdx) { @@ -268,7 +338,7 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { } if (key.return) { - return choose(sel) + return choose(Math.min(sel, rows.length - 1)) } const n = parseInt(ch, 10) @@ -278,7 +348,10 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { } }) - const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file' + // sel can go stale when a refresh flips the row set (3 add-card rows ↔ N + // preset rows) — clamp for render + Enter. + const cSel = Math.min(sel, rows.length - 1) + const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file' if (typing) { return ( @@ -300,15 +373,37 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { ) } + if (noCard) { + return ( + + + Add funds + + No saved card on file. + Add a card once on the portal billing page — after that you can top up right from the terminal. + + {rows.map((label, i) => ( + + ))} + {error && {error}} + + {footer(checking ? 'Checking for a card…' : `↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`, t)} + + ) + } + return ( Add funds {payLine} + {s.card?.needs_repair && ( + ⚠ This card has been failing automatic top-ups — it may decline. Update it on the portal. + )} {rows.map((label, i) => ( - + ))} {error && {error}} @@ -398,7 +493,7 @@ function ConfirmScreen({ } }) - const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file' + const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file' return ( @@ -407,7 +502,12 @@ function ConfirmScreen({ Total: ${amount} {payLine} - {s.card && Your card saved on the portal will be charged.} + {/* Provenance-less payloads (older NAS) keep the generic line; when the + resolver says WHY this card, payLine already carries it. */} + {s.card && !s.card.resolved_via && Your card saved on the portal will be charged.} + {s.card?.needs_repair && ( + ⚠ This card has been failing automatic top-ups — it may decline. Update it on the portal. + )} By confirming, you allow Nous Research to charge your card. diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 5f7bf9cfdd66..fad87f7bf26a 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto' import { Box, Text, useInput } from '@hermes/ink' -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { SubscriptionOverlayState, @@ -574,6 +574,30 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { return onClose() } + // WHICH card the upgrade will charge (brand + last4) — best-effort via + // billing.state, shown only when the resolver rung matches what a + // subscription charge actually uses (subPin / customerDefault, mirroring + // Stripe's own precedence). Anything else → the generic line stands. + const [chargeCard, setChargeCard] = useState(null) + + useEffect(() => { + if (isCancellation || effect !== 'charge_now') { + return + } + + let cancelled = false + + void ctx.fetchCard().then(card => { + if (!cancelled && card && (card.resolved_via === 'subPin' || card.resolved_via === 'customerDefault')) { + setChargeCard(card.masked) + } + }) + + return () => { + cancelled = true + } + }, [ctx, effect, isCancellation]) + const amount = centsDisplay(preview?.amount_due_now_cents) const targetName = isCancellation ? null : (preview?.target_tier_name ?? 'the selected plan') @@ -623,7 +647,9 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { {preview?.monthly_credits_delta && ( Monthly credits change: {preview.monthly_credits_delta}. )} - The card on your subscription will be charged. + + {chargeCard ? `${chargeCard} — the card on your subscription — will be charged.` : 'The card on your subscription will be charged.'} + )} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 781f79102a0f..ad3817b2df4e 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -49,6 +49,12 @@ export interface BillingCardInfo { brand: string last4: string masked: string + /** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */ + display?: string + /** The card has been failing automatic top-ups — warn before charging. */ + needs_repair?: boolean + /** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */ + resolved_via?: null | string } export interface BillingMonthlyCap { From 5661a3f8b059f895c6956d9b9e830be57ab6d908 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 17 Jul 2026 22:18:24 +0530 Subject: [PATCH 56/62] 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. --- agent/billing_view.py | 71 +++++++--- agent/subscription_view.py | 19 ++- cli.py | 6 - hermes_cli/nous_billing.py | 30 ++++- tests/agent/test_billing_view.py | 125 ++++++++++++++++++ tests/agent/test_subscription_view.py | 49 +++++++ tests/hermes_cli/test_billing_cli.py | 5 +- .../test_remote_spending_gate_contract.py | 4 +- tests/test_tui_gateway_server.py | 85 ++++++++++++ tui_gateway/server.py | 16 ++- 10 files changed, 372 insertions(+), 38 deletions(-) diff --git a/agent/billing_view.py b/agent/billing_view.py index fa3054f5226e..849601dc524b 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -79,11 +79,9 @@ def format_money(value: Optional[Decimal]) -> str: class CardInfo: brand: str last4: str - # NAS card-on-file fields (post card-resolver): which ladder rung found the - # card, and whether it is known-failing (auto-reload payment failures). - # Both default off so pre-resolver payloads parse unchanged. + # NAS card-on-file field (post card-resolver): which ladder rung found the + # card. Defaults off so pre-resolver payloads parse unchanged. resolved_via: Optional[str] = None - needs_repair: bool = False @property def masked(self) -> str: @@ -116,11 +114,20 @@ class MonthlyCap: is_default_ceiling: bool = False +@dataclass(frozen=True) +class AutoReloadCard: + kind: str # "canonical" | "distinct" | "none" + payment_method_id: Optional[str] = None + brand: Optional[str] = None + last4: Optional[str] = None + + @dataclass(frozen=True) class AutoReload: enabled: bool = False threshold_usd: Optional[Decimal] = None reload_to_usd: Optional[Decimal] = None + card: Optional[AutoReloadCard] = None @dataclass(frozen=True) @@ -135,7 +142,8 @@ class BillingState: org_id: Optional[str] = None org_slug: Optional[str] = None org_name: Optional[str] = None - role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER" + role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER" + can_change_plan_raw: Optional[bool] = None balance_usd: Optional[Decimal] = None cli_billing_enabled: bool = False charge_presets: tuple[Decimal, ...] = () @@ -150,9 +158,20 @@ class BillingState: @property def is_admin(self) -> bool: - """True for OWNER/ADMIN — the roles that can manage billing.""" + """Deprecated/display only — a legacy OWNER/ADMIN check. + + NOT a capability check; use :attr:`can_change_plan` for gating billing + plan-change actions. + """ return (self.role or "").upper() in ("OWNER", "ADMIN") + @property + def can_change_plan(self) -> bool: + """Server capability when supplied; otherwise the legacy role fallback.""" + if self.can_change_plan_raw is not None: + return self.can_change_plan_raw + return self.is_admin + @property def can_charge(self) -> bool: """True when the UI should offer charge/auto-reload actions. @@ -174,13 +193,7 @@ def _parse_card(raw: Any) -> Optional[CardInfo]: resolved_via = raw.get("resolvedVia") if not isinstance(resolved_via, str): resolved_via = None - chargeability = raw.get("chargeability") - needs_repair = ( - isinstance(chargeability, dict) and chargeability.get("kind") == "needs_repair" - ) - return CardInfo( - brand=brand, last4=last4, resolved_via=resolved_via, needs_repair=needs_repair - ) + return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via) def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]: @@ -200,6 +213,27 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]: enabled=bool(raw.get("enabled")), threshold_usd=parse_money(raw.get("thresholdUsd")), reload_to_usd=parse_money(raw.get("reloadToUsd")), + card=_parse_auto_reload_card(raw.get("card")), + ) + + +def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]: + if not isinstance(raw, dict): + return None + kind = raw.get("kind") + if kind not in ("canonical", "distinct", "none"): + return None + if kind in ("canonical", "none"): + return AutoReloadCard(kind=kind) + + payment_method_id = raw.get("paymentMethodId") + brand = raw.get("brand") + last4 = raw.get("last4") + return AutoReloadCard( + kind=kind, + payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None, + brand=brand if isinstance(brand, str) else None, + last4=last4 if isinstance(last4, str) else None, ) @@ -224,6 +258,11 @@ def billing_state_from_payload( org_slug=org.get("slug"), org_name=org.get("name"), role=org.get("role"), + can_change_plan_raw=( + payload.get("canChangePlan") + if isinstance(payload.get("canChangePlan"), bool) + else None + ), balance_usd=parse_money(payload.get("balanceUsd")), cli_billing_enabled=bool(payload.get("cliBillingEnabled")), charge_presets=tuple(presets), @@ -349,12 +388,6 @@ def _dev_fixture_billing_state() -> Optional[BillingState]: # Post-resolver: the card came from the subscription (provenance label). _sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin") return BillingState(logged_in=True, card=_sub_card, **common) - if name in ("card-repair", "card_repair"): - # Post-resolver: auto-reload card with failing payments (warn, don't block). - _bad_card = CardInfo( - brand="Mastercard", last4="9911", resolved_via="autoRefill", needs_repair=True - ) - return BillingState(logged_in=True, card=_bad_card, auto_reload=autoreload_on, **common) if name in ("card-autoreload", "card_autoreload", "autoreload"): return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common) if name in ("notadmin", "not-admin", "member"): diff --git a/agent/subscription_view.py b/agent/subscription_view.py index 829eff840546..f78fcb283d1c 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -107,7 +107,8 @@ class SubscriptionState: logged_in: bool org_name: Optional[str] = None org_id: Optional[str] = None # org.id from the NAS response - role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER" + role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER" + can_change_plan_raw: Optional[bool] = None context: str = "personal" # "personal" | "team" current: Optional[CurrentSubscription] = None tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker) @@ -117,12 +118,18 @@ class SubscriptionState: @property def is_admin(self) -> bool: - """True for OWNER/ADMIN — the roles that can change plans.""" + """Deprecated/display only — a legacy OWNER/ADMIN check. + + NOT a capability check; use :attr:`can_change_plan` for gating billing + plan-change actions. + """ return (self.role or "").upper() in ("OWNER", "ADMIN") @property def can_change_plan(self) -> bool: - """True when the UI should offer plan-change actions (role gate from NAS).""" + """Server capability when supplied; otherwise the legacy role fallback.""" + if self.can_change_plan_raw is not None: + return self.can_change_plan_raw return self.is_admin @@ -226,6 +233,11 @@ def subscription_state_from_payload( org_name=org.get("name"), org_id=org.get("id") or None, role=org.get("role"), + can_change_plan_raw=( + payload.get("canChangePlan") + if isinstance(payload.get("canChangePlan"), bool) + else None + ), context=context, current=_parse_current(payload.get("current")), tiers=tiers, @@ -407,4 +419,3 @@ def dev_fixture_subscription_state() -> Optional[SubscriptionState]: # Unknown name → behave as logged-out so the misconfiguration is visible. return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}") - diff --git a/cli.py b/cli.py index 265bad532ebd..bf2b8ebbce34 100644 --- a/cli.py +++ b/cli.py @@ -10564,8 +10564,6 @@ def _billing_overview(self, state): if state.is_admin and state.cli_billing_enabled: if state.card is not None: print(f" Card: {state.card.display}") - if state.card.needs_repair: - _cprint(" ⚠️ This card has been failing automatic top-ups — update it on the portal.") else: _cprint(f" {_d('No saved card on file — “Add funds” walks you through adding one.')}") print(f" {'─' * 41}") @@ -10781,8 +10779,6 @@ def _billing_buy_flow(self, state): card = state.card detail = f"Payment: {card.display}" if card else "No saved card on file" - if card is not None and card.needs_repair: - _cprint(" ⚠️ This card has been failing automatic top-ups — it may decline. Update it on the portal.") raw = self._prompt_text_input_modal( title="Add funds", detail=detail, choices=preset_choices, ) @@ -10830,8 +10826,6 @@ def _billing_confirm_and_charge(self, state, amount): # the resolver says WHY this card, the Payment line carries it. if card.provenance is None: _cprint(f" {_d('Your card saved on the portal will be charged.')}") - if card.needs_repair: - _cprint(" ⚠️ This card has been failing automatic top-ups — it may decline. Update it on the portal.") print(f" {'─' * 41}") _consent = ( "By confirming, you allow Nous Research to charge your card." diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index 4eb5acbd380d..cf645353dabb 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -132,6 +132,25 @@ class BillingRateLimited(BillingError): """ +class BillingStripeUnavailable(BillingRateLimited): + """``503 stripe_unavailable`` — Stripe itself is down. + + TRANSIENT: back off and retry using Retry-After; this is NOT the same as + being throttled by our own rate limiter, so surfaces must not render "rate + limited" copy for it — they should read ``.error`` to tell the two apart. + Subclasses BillingRateLimited so existing back-off call sites still catch it. + """ + + +class BillingUpgradeCapExceeded(BillingRateLimited): + """``429 upgrade_cap_exceeded`` — the org hit its 5-upgrades/day cap. + + Distinct from the hourly ``rate_limited`` charge cap (same HTTP status, + different meaning + no useful short-Retry-After backoff). Subclasses + BillingRateLimited for the same reason as BillingStripeUnavailable. + """ + + # ============================================================================= # Base-URL + auth resolution # ============================================================================= @@ -298,6 +317,15 @@ def _raise_for_error( "recovery": recovery, } + if error == "stripe_unavailable": + raise BillingStripeUnavailable( + message or "Stripe is temporarily unavailable — try again shortly.", **common + ) + if error == "upgrade_cap_exceeded": + raise BillingUpgradeCapExceeded( + message or "Daily plan-change limit reached — try again tomorrow.", **common + ) + if status == 401: # session_revoked is a full logout (→ re-login), stronger than a 401 # expired-token. Both stay BillingAuthError-compatible for legacy callers. @@ -614,5 +642,3 @@ def post_subscription_upgrade( extra_headers={"Idempotency-Key": idempotency_key.strip()}, timeout=timeout, ) - - diff --git a/tests/agent/test_billing_view.py b/tests/agent/test_billing_view.py index be7232a97bb3..dbe16e6c09e2 100644 --- a/tests/agent/test_billing_view.py +++ b/tests/agent/test_billing_view.py @@ -21,6 +21,7 @@ import agent.billing_view as bv from agent.billing_view import ( AutoReload, + AutoReloadCard, BillingState, CardInfo, MonthlyCap, @@ -37,6 +38,8 @@ BillingError, BillingRateLimited, BillingScopeRequired, + BillingStripeUnavailable, + BillingUpgradeCapExceeded, _raise_for_error, resolve_portal_base_url, ) @@ -130,6 +133,58 @@ def test_state_member_tier_parse(): assert s.can_charge is False # not admin +@pytest.mark.parametrize( + "role,can_change_plan_raw,is_admin,can_change_plan", + [ + ("OWNER", None, True, True), + ("ADMIN", None, True, True), + ("FINANCE_ADMIN", True, False, True), + ("SECURITY_ADMIN", None, False, False), + ("MEMBER", None, False, False), + ], +) +def test_state_five_roles( + role, can_change_plan_raw, is_admin, can_change_plan +): + payload = _member_payload() + payload["org"]["role"] = role + if can_change_plan_raw is not None: + payload["canChangePlan"] = can_change_plan_raw + + state = billing_state_from_payload(payload) + + assert state.is_admin is is_admin + assert state.can_change_plan_raw is can_change_plan_raw + assert state.can_change_plan is can_change_plan + if role == "SECURITY_ADMIN": + assert state.card is None + assert state.monthly_cap is None + assert state.auto_reload is None + + +@pytest.mark.parametrize( + "role,server_capability", + [("MEMBER", True), ("OWNER", False)], +) +def test_state_can_change_plan_prefers_server_capability(role, server_capability): + payload = _member_payload() + payload["org"]["role"] = role + payload["canChangePlan"] = server_capability + + state = billing_state_from_payload(payload) + + assert state.can_change_plan is server_capability + + +def test_state_can_change_plan_falls_back_to_legacy_role_check(): + owner = _member_payload() + owner["org"]["role"] = "OWNER" + member = _member_payload() + + assert billing_state_from_payload(owner).can_change_plan is True + assert billing_state_from_payload(member).can_change_plan is False + + def test_state_owner_tier_parse(): s = billing_state_from_payload(_owner_payload()) assert s.is_admin is True @@ -146,6 +201,54 @@ def test_state_owner_tier_parse(): ) +@pytest.mark.parametrize( + "raw_card,expected", + [ + ( + {"kind": "canonical", "paymentMethodId": "ignored", "brand": "ignored"}, + AutoReloadCard(kind="canonical"), + ), + ( + { + "kind": "distinct", + "paymentMethodId": "pm_auto", + "brand": None, + "last4": None, + }, + AutoReloadCard( + kind="distinct", + payment_method_id="pm_auto", + brand=None, + last4=None, + ), + ), + ( + {"kind": "none", "last4": "ignored"}, + AutoReloadCard(kind="none"), + ), + ], +) +def test_state_parses_auto_reload_card_variants(raw_card, expected): + payload = _owner_payload() + payload["autoReload"]["card"] = raw_card + + state = billing_state_from_payload(payload) + + assert state.auto_reload is not None + assert state.auto_reload.card == expected + + +@pytest.mark.parametrize("raw_card", [None, "canonical", {}, {"kind": "future"}]) +def test_state_ignores_unrecognized_auto_reload_card(raw_card): + payload = _owner_payload() + payload["autoReload"]["card"] = raw_card + + state = billing_state_from_payload(payload) + + assert state.auto_reload is not None + assert state.auto_reload.card is None + + def test_state_can_charge_false_when_killswitch_off(): p = _owner_payload() p["cliBillingEnabled"] = False @@ -205,6 +308,28 @@ def test_rate_limited_maps_with_retry_after(status): assert isinstance(ei.value, BillingRateLimited) +@pytest.mark.parametrize( + "status,error,expected_type", + [ + (503, "stripe_unavailable", BillingStripeUnavailable), + (429, "upgrade_cap_exceeded", BillingUpgradeCapExceeded), + ], +) +def test_specific_billing_throttle_errors_remain_distinguishable( + status, error, expected_type +): + with pytest.raises(expected_type) as ei: + _raise_for_error( + status, + {"error": error}, + _Headers({"Retry-After": "90"}), + ) + + assert ei.value.error == error + assert ei.value.retry_after == 90 + assert isinstance(ei.value, BillingRateLimited) + + @pytest.mark.parametrize( "error", [ diff --git a/tests/agent/test_subscription_view.py b/tests/agent/test_subscription_view.py index 496409009237..6c80c1ef52fa 100644 --- a/tests/agent/test_subscription_view.py +++ b/tests/agent/test_subscription_view.py @@ -92,6 +92,55 @@ def test_parser_member_role_cannot_change_plan(): assert s.can_change_plan is False +@pytest.mark.parametrize( + "role,can_change_plan_raw,is_admin,can_change_plan", + [ + ("OWNER", None, True, True), + ("ADMIN", None, True, True), + ("FINANCE_ADMIN", True, False, True), + ("SECURITY_ADMIN", None, False, False), + ("MEMBER", None, False, False), + ], +) +def test_parser_five_roles( + role, can_change_plan_raw, is_admin, can_change_plan +): + payload = {"org": {"role": role}} + if can_change_plan_raw is not None: + payload["canChangePlan"] = can_change_plan_raw + + state = subscription_state_from_payload(payload, portal_url=None) + + assert state.is_admin is is_admin + assert state.can_change_plan_raw is can_change_plan_raw + assert state.can_change_plan is can_change_plan + + +@pytest.mark.parametrize( + "role,server_capability", + [("MEMBER", True), ("OWNER", False)], +) +def test_parser_can_change_plan_prefers_server_capability(role, server_capability): + state = subscription_state_from_payload( + {"org": {"role": role}, "canChangePlan": server_capability}, + portal_url=None, + ) + + assert state.can_change_plan is server_capability + + +def test_parser_can_change_plan_falls_back_to_legacy_role_check(): + owner = subscription_state_from_payload( + {"org": {"role": "OWNER"}}, portal_url=None + ) + member = subscription_state_from_payload( + {"org": {"role": "MEMBER"}}, portal_url=None + ) + + assert owner.can_change_plan is True + assert member.can_change_plan is False + + def test_parser_defaults_unknown_context_to_personal(): s = subscription_state_from_payload({"context": "wat"}, portal_url=None) assert s.context == "personal" diff --git a/tests/hermes_cli/test_billing_cli.py b/tests/hermes_cli/test_billing_cli.py index f0eabae95913..6c1a7c3b6b08 100644 --- a/tests/hermes_cli/test_billing_cli.py +++ b/tests/hermes_cli/test_billing_cli.py @@ -148,18 +148,17 @@ def _modal(self, **kw): return _modal -def test_overview_shows_card_with_provenance_and_repair_warning(cli, monkeypatch, capsys): +def test_overview_shows_card_with_provenance(cli, monkeypatch, capsys): state = BillingState( logged_in=True, role="OWNER", balance_usd=Decimal("10"), cli_billing_enabled=True, charge_presets=(Decimal("25"),), - card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin", needs_repair=True), + card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin"), portal_url="https://portal/billing", ) monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) cli._show_billing("/topup") out = capsys.readouterr().out assert "Card: Visa ····4242 — the card on your subscription" in out - assert "failing automatic top-ups" in out def test_overview_shows_no_card_hint(cli, monkeypatch, capsys): diff --git a/tests/hermes_cli/test_remote_spending_gate_contract.py b/tests/hermes_cli/test_remote_spending_gate_contract.py index dfd0daeba15b..308f970f28e3 100644 --- a/tests/hermes_cli/test_remote_spending_gate_contract.py +++ b/tests/hermes_cli/test_remote_spending_gate_contract.py @@ -107,9 +107,9 @@ def test_envelope_session_revoked_kind(): assert env["recovery"] == "login" -def test_envelope_503_is_rate_limited_with_retry(): +def test_envelope_503_preserves_server_code_with_retry(): env = _serialize(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"}) - assert env["error"] == "rate_limited" + assert env["error"] == "temporarily_unavailable" assert env["retry_after"] == 30 diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index fe5802568eeb..1e9535b20ec3 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9880,6 +9880,91 @@ def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs): server._sessions.clear() +# ── billing/subscription state + error serialization ───────────────── + + +@pytest.mark.parametrize( + "card,expected", + [ + ("canonical", {"kind": "canonical"}), + ( + "distinct", + { + "kind": "distinct", + "payment_method_id": "pm_auto", + "brand": None, + "last4": None, + }, + ), + ("none", {"kind": "none"}), + ], +) +def test_billing_state_serializes_auto_reload_card_union(monkeypatch, card, expected): + from agent.billing_view import AutoReload, AutoReloadCard, BillingState + + monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False}) + auto_reload_card = AutoReloadCard( + kind=card, + payment_method_id="pm_auto" if card == "distinct" else None, + ) + state = BillingState( + logged_in=True, + auto_reload=AutoReload(enabled=True, card=auto_reload_card), + ) + + result = server._serialize_billing_state(state) + + assert result["auto_reload"]["card"] == expected + + +def test_billing_state_serializes_server_plan_capability(monkeypatch): + from agent.billing_view import BillingState + + monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False}) + state = BillingState( + logged_in=True, + role="MEMBER", + can_change_plan_raw=True, + ) + + result = server._serialize_billing_state(state) + + assert result["is_admin"] is False + assert result["can_change_plan"] is True + + +class _BillingHeaders: + def __init__(self, values): + self._values = values + + def get(self, key): + return self._values.get(key) + + +@pytest.mark.parametrize( + "status,error,retry_after", + [ + (503, "stripe_unavailable", 75), + (429, "upgrade_cap_exceeded", None), + (429, "rate_limited", None), + ], +) +def test_billing_error_serialization_preserves_server_code( + status, error, retry_after +): + import hermes_cli.nous_billing as nb + + headers = _BillingHeaders({"Retry-After": str(retry_after)}) if retry_after else None + with pytest.raises(nb.BillingRateLimited) as ei: + nb._raise_for_error(status, {"error": error}, headers) + + result = server._serialize_billing_error(ei.value) + + assert result["error"] == error + assert ei.value.error == error + assert result["retry_after"] == retry_after + + # ── subscription change RPCs (V3): preview + pending-change + upgrade ── diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e90f87b26b1d..d00c4cfe45eb 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7938,7 +7938,7 @@ def _serialize_billing_error(exc) -> dict: elif isinstance(exc, BillingScopeRequired): kind = "insufficient_scope" elif isinstance(exc, BillingRateLimited): - kind = "rate_limited" + kind = str(exc.error) if getattr(exc, "error", None) else "rate_limited" elif getattr(exc, "error", None): kind = str(exc.error) return { @@ -7976,7 +7976,6 @@ def _s(value): # matches what a subscription charge would use). "display": state.card.display, "resolved_via": state.card.resolved_via, - "needs_repair": state.card.needs_repair, } monthly_cap = None if state.monthly_cap is not None: @@ -7991,12 +7990,24 @@ def _s(value): auto_reload = None if state.auto_reload is not None: ar = state.auto_reload + card_out = None + if ar.card is not None: + if ar.card.kind == "distinct": + card_out = { + "kind": "distinct", + "payment_method_id": ar.card.payment_method_id, + "brand": ar.card.brand, + "last4": ar.card.last4, + } + else: + card_out = {"kind": ar.card.kind} auto_reload = { "enabled": ar.enabled, "threshold_usd": _s(ar.threshold_usd), "threshold_display": format_money(ar.threshold_usd), "reload_to_usd": _s(ar.reload_to_usd), "reload_to_display": format_money(ar.reload_to_usd), + "card": card_out, } return { "ok": True, @@ -8005,6 +8016,7 @@ def _s(value): "org_slug": state.org_slug, "role": state.role, "is_admin": state.is_admin, + "can_change_plan": state.can_change_plan, "can_charge": state.can_charge, "balance_usd": _s(state.balance_usd), "balance_display": format_money(state.balance_usd), From 442919d8a37886baa04fe773880b966ee653c473 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 17 Jul 2026 22:18:34 +0530 Subject: [PATCH 57/62] 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. --- ui-tui/src/__tests__/billingStepUp.test.tsx | 50 +++++- .../__tests__/subscriptionOverlay.test.tsx | 153 +++++++++++++++++- ui-tui/src/__tests__/topupCommand.test.ts | 96 +++++++++++ ui-tui/src/app/slash/commands/topup.ts | 47 +++++- ui-tui/src/components/billingOverlay.tsx | 44 +++-- ui-tui/src/components/subscriptionOverlay.tsx | 120 ++++++++++++-- ui-tui/src/gatewayTypes.ts | 11 +- 7 files changed, 478 insertions(+), 43 deletions(-) diff --git a/ui-tui/src/__tests__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx index 44168865f41f..f59b1ef2ac88 100644 --- a/ui-tui/src/__tests__/billingStepUp.test.tsx +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -54,7 +54,7 @@ function render(overlay: BillingOverlayState): string { return stripAnsi(output) } -const billState = (): BillingStateResponse => +const billState = (overrides: Partial = {}): BillingStateResponse => ({ auto_reload: null, balance_display: '$12.00', @@ -72,7 +72,8 @@ const billState = (): BillingStateResponse => ok: true, org_name: 'Acme', portal_url: 'https://portal/billing', - role: 'OWNER' + role: 'OWNER', + ...overrides }) as BillingStateResponse const ctx = { @@ -137,3 +138,48 @@ describe('BillingOverlay — overview (reordered, dollars)', () => { expect(out).toContain('never expires') }) }) + +describe('BillingOverlay — auto-reload card divergence', () => { + const autoReload = (card: NonNullable['card']) => ({ + card, + enabled: true, + reload_to_display: '$100', + reload_to_usd: '100', + threshold_display: '$20', + threshold_usd: '20' + }) + + it('warns when auto-reload charges a distinct card and offers the portal hand-off', () => { + const out = render({ + ...overlay('autoreload'), + state: billState({ + auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: 'Visa', last4: '9999' }) + }) + }) + + expect(out).toContain('Auto-refill is charging Visa ••9999 — not your card on file') + expect(out).toContain('authorize Nous Research to charge Visa ••9999') + expect(out).toContain('Use your card on file — manage on portal') + }) + + it('uses generic distinct-card copy when metadata is unresolved', () => { + const out = render({ + ...overlay('autoreload'), + state: billState({ + auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: null, last4: null }) + }) + }) + + expect(out).toContain('Auto-refill is charging a different card — not your card on file') + }) + + it.each(['canonical', 'none'] as const)('does not warn for a %s auto-reload card', kind => { + const out = render({ + ...overlay('autoreload'), + state: billState({ auto_reload: autoReload({ kind }) }) + }) + + expect(out).not.toContain('not your card on file') + expect(out).not.toContain('Use your card on file — manage on portal') + }) +}) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index a07b16220e91..22cfec4e77e9 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -4,6 +4,10 @@ import { renderSync } from '@hermes/ink' import React from 'react' import { describe, expect, it, vi } from 'vitest' +const inputHarness = vi.hoisted(() => ({ + handler: undefined as undefined | ((input: string, key: Record) => void) +})) + // Stub useInput so the overlay doesn't try to enter raw mode under renderSync // (PassThrough stdin doesn't support it). Box/Text pass through to real Ink. vi.mock('@hermes/ink', async importOriginal => { @@ -11,7 +15,9 @@ vi.mock('@hermes/ink', async importOriginal => { return { ...mod, - useInput: () => {} + useInput: (handler: (input: string, key: Record) => void) => { + inputHarness.handler = handler + } } }) @@ -23,8 +29,8 @@ import { DEFAULT_THEME } from '../theme.js' const t = DEFAULT_THEME -/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */ -function render(overlay: SubscriptionOverlayState): string { +/** Mount a SubscriptionOverlay via renderSync + PassThrough. */ +function mount(overlay: SubscriptionOverlayState, onPatch: (next: Partial) => void = () => {}) { const stdout = new PassThrough() const stdin = new PassThrough() const stderr = new PassThrough() @@ -38,8 +44,10 @@ function render(overlay: SubscriptionOverlayState): string { output += chunk.toString() }) + inputHarness.handler = undefined + const element = React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch, overlay, t }) const instance = renderSync( - React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay, t }), + element, { patchConsole: false, stderr: stderr as NodeJS.WriteStream, @@ -48,10 +56,23 @@ function render(overlay: SubscriptionOverlayState): string { } ) - instance.unmount() - instance.cleanup() + return { + cleanup: () => { + instance.unmount() + instance.cleanup() + }, + output: () => stripAnsi(output), + rerender: () => instance.rerender(element) + } +} + +/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */ +function render(overlay: SubscriptionOverlayState): string { + const mounted = mount(overlay) + const output = mounted.output() + mounted.cleanup() - return stripAnsi(output) + return output } const TIERS = [ @@ -340,3 +361,121 @@ describe('SubscriptionOverlay — result', () => { expect(out).toContain('Open the portal to finish') }) }) + +describe('SubscriptionOverlay — upgrade response mapping', () => { + const applyUpgrade = async (response: unknown) => { + const onPatch = vi.fn() + const upgrade = vi.fn(() => Promise.resolve(response)) + const mounted = mount( + at('confirm', subscriber(), { + ctx: { ...ctx, upgrade } as SubscriptionOverlayState['ctx'], + pending: { + idempotencyKey: 'upgrade-key', + kind: 'upgrade', + preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234 }, + targetTierId: 'ultra' + } + }), + onPatch + ) + + inputHarness.handler?.('', { return: true }) + await vi.waitFor(() => expect(onPatch).toHaveBeenCalled()) + mounted.cleanup() + + return onPatch.mock.calls.at(-1)?.[0] as Partial + } + + it.each([ + ['authentication_required', 'upgraded'], + ['subscription_payment_intent_requires_action', 'payment_failed'] + ])('reason %s routes to card verification regardless of status %s', async (reason, status) => { + const patch = await applyUpgrade({ + ok: status === 'upgraded', + reason, + recovery_url: 'https://portal.example/verify', + status, + target_tier_name: 'Ultra' + }) + + expect(patch.screen).toBe('result') + expect(patch.result?.ok).toBe(false) + expect(patch.result?.message).toContain('verify your card in the portal') + expect(patch.result?.recoveryUrl).toBe('https://portal.example/verify') + }) + + it('card_declined reason routes to a different-card recovery', async () => { + const patch = await applyUpgrade({ + ok: false, + reason: 'card_declined', + recovery_url: 'https://portal.example/card', + status: 'requires_action' + }) + + expect(patch.result?.message).toContain('try a different card on the portal') + expect(patch.result?.recoveryUrl).toBe('https://portal.example/card') + }) + + it('already_on_tier remains an immediate success', async () => { + const patch = await applyUpgrade({ ok: true, status: 'already_on_tier', target_tier_name: 'Ultra' }) + + expect(patch.result).toMatchObject({ message: 'You are already on Ultra.', ok: true }) + expect(patch.result).not.toHaveProperty('pendingTierId') + }) + + it('upgraded marks the result as applying to the target tier', async () => { + const patch = await applyUpgrade({ ok: true, status: 'upgraded', target_tier_name: 'Ultra' }) + + expect(patch.result).toMatchObject({ ok: true, pendingTierId: 'ultra' }) + }) + + it('shows Applying, then Done once refreshed state reaches the upgraded tier', async () => { + vi.useFakeTimers() + + try { + const refreshState = vi.fn(() => Promise.resolve(subscriber({ + current: { + tier_id: 'ultra', + tier_name: 'Ultra', + monthly_credits: '3000', + credits_remaining: '3000', + cycle_ends_at: '2026-08-01', + pending_downgrade_tier_name: null, + pending_downgrade_at: null + } + }))) + const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' } + const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result })) + + expect(mounted.output()).toContain('Applying…') + await vi.advanceTimersByTimeAsync(2000) + mounted.rerender() + expect(refreshState).toHaveBeenCalledTimes(1) + expect(mounted.output()).toContain('Done') + expect(mounted.output()).toContain('Upgraded to Ultra.') + mounted.cleanup() + } finally { + vi.useRealTimers() + } + }) + + it('keeps a successful upgrade soft-pending after the bounded confirmation window', async () => { + vi.useFakeTimers() + + try { + const refreshState = vi.fn(() => Promise.resolve(subscriber())) + const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' } + const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result })) + + await vi.advanceTimersByTimeAsync(30_000) + mounted.rerender() + expect(refreshState).toHaveBeenCalledTimes(15) + expect(mounted.output()).toContain('Still applying') + expect(mounted.output()).toContain('refresh in a moment') + expect(mounted.output()).not.toContain('Could not complete') + mounted.cleanup() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/ui-tui/src/__tests__/topupCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts index d518521472b1..63b967b26d1f 100644 --- a/ui-tui/src/__tests__/topupCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -12,6 +12,7 @@ const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')! const ownerState = (overrides: Partial = {}): BillingStateResponse => ({ auto_reload: { + card: { kind: 'canonical' }, enabled: false, reload_to_display: '—', reload_to_usd: null, @@ -231,6 +232,60 @@ describe('/billing slash command (overlay-driven)', () => { expect(out).toContain('Portal: /billing?topup=open') }) + it('ctx.charge consent_required → one-time portal confirmation copy + portal funnel', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { + ok: false, + error: 'consent_required', + portal_url: '/billing/consent', + idempotency_key: 'k' + } + }) + + await run('') + await getOverlayState().billing!.ctx.charge('100') + const out = printed(sys) + expect(out).toContain('one-time card confirmation') + expect(out).toContain('Portal: /billing/consent') + }) + + it.each([ + ['org_access_denied', "This token isn't bound to an org you can manage"], + ['upgrade_cap_exceeded', 'Daily plan-change limit reached'], + ['auto_top_up_disabled_failures', 'Auto-reload was turned off after repeated charge failures'] + ])('ctx.charge %s → typed recovery copy', async (error, copy) => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: false, error, idempotency_key: 'k' } + }) + + await run('') + await getOverlayState().billing!.ctx.charge('100') + expect(printed(sys)).toContain(copy) + }) + + it.each([ + [undefined, 'Stripe is having trouble right now — try again shortly.'], + [120, 'Stripe is having trouble right now — try again shortly (try again in ~2 min).'] + ])('ctx.charge stripe_unavailable (retry_after=%s) → transient Stripe copy', async (retryAfter, copy) => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { + ok: false, + error: 'stripe_unavailable', + idempotency_key: 'k', + ...(retryAfter == null ? {} : { retry_after: retryAfter }) + } + }) + + await run('') + await getOverlayState().billing!.ctx.charge('100') + const out = printed(sys) + expect(out).toContain(copy) + expect(out).not.toContain('Too many charges') + }) + it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => { const { run } = buildCtx({ 'billing.state': ownerState(), @@ -286,6 +341,27 @@ describe('/billing slash command (overlay-driven)', () => { expect(getOverlayState().billing).toBeNull() }) + it('ctx.charge → poll transport loss reports an unconfirmed outcome', async () => { + const { ctx, rpc, run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } + }) + + await run('') + rpc.mockImplementation((method: string) => { + if (method === 'billing.charge_status') { + return Promise.reject(new Error('socket closed')) + } + + return Promise.resolve( + method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null + ) + }) + await getOverlayState().billing!.ctx.charge('100') + await vi.waitFor(() => expect(printed(sys)).toContain('outcome is unconfirmed')) + expect(ctx.guardedErr).toHaveBeenCalled() + }) + it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => { const { run, sys } = buildCtx({ 'billing.state': ownerState(), @@ -326,6 +402,7 @@ describe('/billing slash command (overlay-driven)', () => { const { run, calls } = buildCtx({ 'billing.state': ownerState({ auto_reload: { + card: { kind: 'canonical' }, enabled: true, reload_to_display: '$100', reload_to_usd: '100', @@ -355,6 +432,25 @@ describe('/billing slash command (overlay-driven)', () => { expect(printed(sys)).toContain('Monthly spend cap reached.') }) + it('ctx.charge → poll → processing_error has intentional failure copy', async () => { + vi.useFakeTimers() + + try { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }, + 'billing.charge_status': { ok: true, status: 'failed', reason: 'processing_error' } + }) + + await run('') + getOverlayState().billing!.ctx.charge('100') + await vi.runAllTimersAsync() + expect(printed(sys)).toContain("The charge didn't go through (processing_error).") + } finally { + vi.useRealTimers() + } + }) + it('ctx.openPortal opens the URL + echoes a transcript line', async () => { const { run, sys } = buildCtx({ 'billing.state': ownerState() }) await run('') diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 24dba4b671ce..ae0b7b9f3130 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -13,6 +13,7 @@ import type { SlashCommand, SlashRunCtx } from '../types.js' // Poll cadence (plan §5, frozen): 2s interval, 5-minute cap. const POLL_INTERVAL_MS = 2000 const POLL_CAP_MS = 5 * 60 * 1000 +const UNCONFIRMED_CHARGE_MESSAGE = '🟡 Your last charge’s outcome is unconfirmed — check your balance/history before retrying.' type Sys = (text: string) => void @@ -73,7 +74,27 @@ const renderBillingError = ( break case 'role_required': - sys('Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.') + sys('Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.') + + break + + case 'consent_required': + sys('This action needs a one-time card confirmation and consent step on the portal before it can proceed.') + + break + + case 'org_access_denied': + sys("This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.") + + break + + case 'upgrade_cap_exceeded': + sys('🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.') + + break + + case 'auto_top_up_disabled_failures': + sys('Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.') break @@ -111,6 +132,13 @@ const renderBillingError = ( break } + case 'stripe_unavailable': { + const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : '' + sys(`🟡 Stripe is having trouble right now — try again shortly${mins}.`) + + break + } + default: sys(`🔴 ${env.message || env.error || 'Billing request failed.'}`) } @@ -171,7 +199,7 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st ctx.guarded(r => { if (!r.ok) { // 429/503 while polling = retry-after, NOT a failure. Back off + continue. - if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable') { + if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable' || r.error === 'stripe_unavailable') { if (timedOut()) { return } @@ -187,7 +215,7 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st // call it failed; surface the revoke + tell the user to verify balance. if (r.error === 'remote_spending_revoked' || r.error === 'session_revoked') { renderBillingError(sys, ctx, r) - sys('🟡 Your last charge’s outcome is unconfirmed — check your balance/history before retrying.') + sys(UNCONFIRMED_CHARGE_MESSAGE) return } @@ -217,7 +245,13 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st setTimeout(tick, POLL_INTERVAL_MS) }) ) - .catch(ctx.guardedErr) + .catch(e => { + ctx.guardedErr(e) + + if (!ctx.stale()) { + sys(UNCONFIRMED_CHARGE_MESSAGE) + } + }) } tick() @@ -240,6 +274,11 @@ const renderChargeFailed = (sys: Sys, reason?: string | null, portalUrl?: string break + case 'processing_error': + sys("🔴 The charge didn't go through (processing_error).") + + break + default: sys(`🔴 The charge didn't go through (${reason || 'processing_error'}).`) } diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 8b5ccafe5996..b75d88e2ca12 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -90,7 +90,7 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const full = s.is_admin && s.cli_billing_enabled const note = !s.is_admin - ? 'Billing actions need an org admin/owner.' + ? 'Billing actions need someone with billing permissions (owner, admin, or finance admin).' : !s.cli_billing_enabled ? 'Terminal billing is off for this org — manage it on the portal.' : null @@ -183,9 +183,6 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { {s.card ? `Card: ${s.card.display ?? s.card.masked}` : 'No saved card on file — “Add funds” walks you through adding one.'} )} - {full && s.card?.needs_repair && ( - ⚠ This card has been failing automatic top-ups — update it on the portal. - )} {note && ( {note} @@ -398,9 +395,6 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { Add funds {payLine} - {s.card?.needs_repair && ( - ⚠ This card has been failing automatic top-ups — it may decline. Update it on the portal. - )} {rows.map((label, i) => ( @@ -505,9 +499,6 @@ function ConfirmScreen({ {/* Provenance-less payloads (older NAS) keep the generic line; when the resolver says WHY this card, payLine already carries it. */} {s.card && !s.card.resolved_via && Your card saved on the portal will be charged.} - {s.card?.needs_repair && ( - ⚠ This card has been failing automatic top-ups — it may decline. Update it on the portal. - )} By confirming, you allow Nous Research to charge your card. @@ -554,7 +545,7 @@ function StepUpScreen({ void ctx.requestRemoteSpending().then(granted => { if (!granted) { ctx.sys( - "! Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged." + "! Couldn't enable terminal billing — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged." ) onClose() @@ -708,6 +699,11 @@ function StepUpScreen({ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const ar = s.auto_reload const enabled = Boolean(ar?.enabled) + const distinctCard = ar?.card.kind === 'distinct' ? ar.card : null + const distinctCardName = distinctCard + ? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') || 'a different card' + : null + const manageCardLabel = 'Use your card on file — manage on portal' // Prefill from state (strip the $ from the *_usd raw fields if present). const prefill = (raw?: null | string) => (raw == null ? '' : String(raw).replace(/^\$/, '').trim()) @@ -716,7 +712,15 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const [field, setField] = useState<'reloadTo' | 'threshold'>('threshold') const [error, setError] = useState(null) // focusRow: 0=threshold field, 1=reloadTo field, 2=Agree, 3=Turn off (if enabled), last=Cancel - const actionRows = enabled ? ['Agree and turn on', 'Turn off', 'Cancel'] : ['Agree and turn on', 'Cancel'] + const manageCardRows = distinctCard && s.portal_url ? [manageCardLabel] : [] + const actionRows = enabled + ? ['Agree and turn on', 'Turn off', ...manageCardRows, 'Cancel'] + : ['Agree and turn on', ...manageCardRows, 'Cancel'] + const actionColors: Record = { + 'Agree and turn on': t.color.ok, + 'Turn off': t.color.warn, + [manageCardLabel]: t.color.accent + } const FIELD_ROWS = 2 const [row, setRow] = useState(0) @@ -796,6 +800,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { turnOn() } else if (label === 'Turn off') { turnOff() + } else if (label === manageCardLabel) { + if (s.portal_url) { + ctx.openPortal(s.portal_url) + } + + onClose() } else { onPatch({ screen: 'overview' }) } @@ -842,6 +852,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { }) const cardLine = s.card ? `Card on file: ${s.card.masked}` : 'No saved card on file' + const chargeCardName = distinctCardName ?? (s.card ? s.card.masked : 'your card') const fieldBox = (label: string, value: string, onChange: (v: string) => void, focused: boolean, key: string) => ( @@ -875,20 +886,23 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { Automatically add funds when your balance is low. {cardLine} + {distinctCardName && ( + ⚠ Auto-refill is charging {distinctCardName} — not your card on file. + )} {fieldBox('When balance falls below:', threshold, setThreshold, row === 0, 'threshold')} {fieldBox('Reload balance to:', reloadTo, setReloadTo, row === 1, 'reloadTo')} - By confirming, you authorize Nous Research to charge {s.card ? s.card.masked : 'your card'} whenever your - balance falls below the threshold. Turn off any time here or on the portal. + By confirming, you authorize Nous Research to charge {chargeCardName} whenever your balance falls below the + threshold. Turn off any time here or on the portal. {error && {error}} {actionRows.map((label, i) => ( void @@ -74,6 +77,10 @@ interface Row { run: () => void } +interface SubscriptionResultWithPending extends SubscriptionResult { + pendingTierId?: null | string +} + /** ↑/↓ + Enter + number-key selection over `rows`; Esc runs `onEscape`. */ function useMenu(rows: Row[], onEscape: () => void): number { const [sel, setSel] = useState(0) @@ -138,7 +145,7 @@ function mutationResult(r: null | { message?: string; ok?: boolean }, okMessage: } /** Map an upgrade response, routing SCA / decline to a portal recovery. */ -function upgradeResult(r: null | SubscriptionUpgradeResponse): SubscriptionResult { +function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: null | string): SubscriptionResultWithPending { if (!r) { // null = a transport failure (WS drop / request timeout) on the CHARGING // route — NAS may have already prorated + charged. Report it as ambiguous and @@ -151,16 +158,37 @@ function upgradeResult(r: null | SubscriptionUpgradeResponse): SubscriptionResul } } - if (r.ok && (r.status === 'already_on_tier' || r.status === 'upgraded')) { + if (r.reason === 'authentication_required' || r.reason === 'subscription_payment_intent_requires_action') { return { - message: - r.status === 'already_on_tier' - ? `You are already on ${r.target_tier_name ?? 'this plan'}.` - : `Upgraded to ${r.target_tier_name ?? 'your new plan'}. Your new monthly credits land in a moment.`, + message: 'Please verify your card in the portal to finish this upgrade.', + ok: false, + recoveryUrl: r.recovery_url ?? null + } + } + + if (r.reason === 'card_declined') { + return { + message: 'Your card was declined — try a different card on the portal.', + ok: false, + recoveryUrl: r.recovery_url ?? null + } + } + + if (r.ok && r.status === 'already_on_tier') { + return { + message: `You are already on ${r.target_tier_name ?? 'this plan'}.`, ok: true } } + if (r.ok && r.status === 'upgraded') { + return { + message: `Upgraded to ${r.target_tier_name ?? 'your new plan'}. Your new monthly credits land in a moment.`, + ok: true, + pendingTierId: pendingTierId ?? null + } + } + if (r.status === 'requires_action') { return { message: 'This upgrade needs extra verification (3DS). Finish it on the portal.', @@ -196,7 +224,7 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip return { message: - res.message || 'Terminal billing was not enabled — an org admin/owner must allow it for this org. You can also make this change on the portal.', + res.message || 'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.', ok: false } } @@ -281,7 +309,9 @@ function applyPendingAndRoute( } if (pending.kind === 'upgrade') { - return ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r => (isScopeDenial(r) ? toStepUp() : finish(upgradeResult(r)))) + return ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r => + isScopeDenial(r) ? toStepUp() : finish(upgradeResult(r, pending.targetTierId)) + ) } return ctx.scheduleChange(pending.targetTierId ?? '').then(r => @@ -687,8 +717,72 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { function ResultScreen({ onClose, overlay, t }: Omit) { const { ctx } = overlay - const result: null | SubscriptionResult = overlay.result ?? null + const result = (overlay.result ?? null) as null | SubscriptionResultWithPending const recoveryUrl = result?.recoveryUrl ?? null + const pendingTierId = result?.pendingTierId ?? null + const [applyState, setApplyState] = useState<'applying' | 'confirmed' | 'timed_out'>( + pendingTierId ? 'applying' : 'confirmed' + ) + + useEffect(() => { + if (!pendingTierId) { + return + } + + let attempts = 0 + let cancelled = false + let timer: ReturnType | undefined + + const scheduleOrFinish = () => { + if (cancelled) { + return + } + + if (attempts >= UPGRADE_CONFIRM_ATTEMPTS) { + setApplyState('timed_out') + + return + } + + timer = setTimeout(tick, UPGRADE_CONFIRM_INTERVAL_MS) + } + + const tick = () => { + attempts += 1 + void ctx + .refreshState() + .then(fresh => { + if (cancelled) { + return + } + + if (fresh?.current?.tier_id === pendingTierId) { + setApplyState('confirmed') + + return + } + + scheduleOrFinish() + }) + .catch(scheduleOrFinish) + } + + timer = setTimeout(tick, UPGRADE_CONFIRM_INTERVAL_MS) + + return () => { + cancelled = true + + if (timer) { + clearTimeout(timer) + } + } + }, [ctx, pendingTierId]) + + const applying = result?.ok && applyState === 'applying' + const timedOut = result?.ok && applyState === 'timed_out' + const message = timedOut + ? 'Your upgrade succeeded and is still applying — refresh in a moment.' + : (result?.message ?? '') const openRecovery = () => { if (recoveryUrl) { @@ -707,10 +801,10 @@ function ResultScreen({ onClose, overlay, t }: Omit) { return ( - {result?.ok ? 'Done' : 'Could not complete'} + {applying ? 'Applying…' : timedOut ? 'Still applying' : result?.ok ? 'Done' : 'Could not complete'} - {result?.message ?? ''} - {result?.ok && Re-run /subscription anytime to review it.} + {message} + {result?.ok && !applying && !timedOut && Re-run /subscription anytime to review it.} {rows.map((row, i) => ( @@ -820,7 +914,7 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { {phase === 'prompt' && ( <> Changing your plan needs terminal billing enabled for this org. Enable it here, then continue. - An org admin/owner approves it once in the browser. + Someone with billing permissions (owner, admin, or finance admin) approves it once in the browser. )} {phase === 'waiting' && ( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 112694a5821d..312385b7c6eb 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -51,8 +51,6 @@ export interface BillingCardInfo { masked: string /** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */ display?: string - /** The card has been failing automatic top-ups — warn before charging. */ - needs_repair?: boolean /** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */ resolved_via?: null | string } @@ -66,6 +64,15 @@ export interface BillingMonthlyCap { } export interface BillingAutoReload { + card: + | { kind: 'canonical' } + | { + kind: 'distinct' + payment_method_id: string + brand: string | null + last4: string | null + } + | { kind: 'none' } enabled: boolean reload_to_display: string reload_to_usd: string | null From 8ce6d91a28e5576879047e95fd26e3d5a284c7e8 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 17 Jul 2026 22:48:21 +0530 Subject: [PATCH 58/62] 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. --- docs/billing-lifecycle.md | 170 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/billing-lifecycle.md diff --git a/docs/billing-lifecycle.md b/docs/billing-lifecycle.md new file mode 100644 index 000000000000..6b482846cd94 --- /dev/null +++ b/docs/billing-lifecycle.md @@ -0,0 +1,170 @@ +# Billing lifecycle: client-side state, errors, and recovery + +This is the map from every `billing.*`/`subscription.*` state shape the gateway +serves (from NAS) to what the terminal actually renders, and from every typed +refusal/error code to its exact user-facing copy and recovery action. The +guarantee: no NAS billing state and no typed refusal falls through to a +generic toast — every case below is an explicit branch in +`ui-tui/src/app/slash/commands/topup.ts`, `ui-tui/src/components/billingOverlay.tsx`, +or `ui-tui/src/components/subscriptionOverlay.tsx`. An **unknown** code still +degrades gracefully: it hits the `default` branch (a generic-but-real message +pulled from the server payload, never a blank toast) rather than crashing or +silently dropping the refusal. + +## 1. `billing.state` shapes → render + +Source: `ui-tui/src/components/billingOverlay.tsx` (`OverviewScreen`, +`BuyScreen`, `AutoReloadScreen`), `ui-tui/src/app/slash/commands/topup.ts` (`/topup` run). + +| State shape | Render | +|---|---| +| Logged out (`s.logged_in === false`) | Overlay never opens. `sys`: `💳 Not logged into Nous Portal — run /portal to log in, then /topup.` | +| `billing.state` RPC fetch fails (transport/timeout) | **Fail-closed**: `.catch(ctx.guardedErr)` — overlay never opens, no state is assumed. `sys`: `error: `. Never renders "no card" or any other guessed state; user must retry `/topup`. | +| `card: null` (no saved card), full menu (`is_admin && cli_billing_enabled`) | Overview shows `No saved card on file — "Add funds" walks you through adding one.` "Add funds" opens the **add-card path**: `Add a card on the portal` / `I've added it — check again` / `Back` (never an amount picker, which would 403 `no_payment_method`). | +| `card` present, `resolved_via` set | `Card: {display}` (e.g. `Visa ····4242 — the card on your subscription`) using the provenance-aware `display` field. | +| `card` present, `resolved_via` absent (older NAS) | Falls back to the generic `Card: {masked}`; Confirm screen adds `Your card saved on the portal will be charged.` | +| `auto_reload: null` | No auto-reload line at all (`autoReloadLine` returns `null`) — the feature isn't surfaced. | +| `auto_reload.card.kind: 'canonical'` | No distinct-card warning; card line falls back to the card on file. | +| `auto_reload.card.kind: 'distinct'` | `⚠ Auto-refill is charging {brand} ••{last4} — not your card on file.` in the Auto-reload screen (the divergence notice). | +| `auto_reload.card.kind: 'none'` | Same as `canonical` rendering-wise — no distinct-card warning shown. | +| `monthly_cap` present, `limit_usd != null` | `{spent_display} of {limit_display} used this month` (+ ` (default ceiling)` iff `is_default_ceiling`). | +| `monthly_cap` absent or `limit_usd == null` | `No monthly cap visible (managed on the portal).` | +| Role without billing capability (`!is_admin`, menu collapses) | Note: `Billing actions need someone with billing permissions (owner, admin, or finance admin).` Menu collapses to `Manage on portal` / `Cancel`. | +| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Terminal billing is off for this org — manage it on the portal.` Same collapsed menu. | + +Note: `full = s.is_admin && s.cli_billing_enabled` gates the **org-level** +switch, not the per-terminal `billing:manage` scope — that's discovered +reactively (a charge 403s `insufficient_scope`) and routes to the resumable +step-up screen instead of a preflight check. + +## 2. Refusal codes (`renderBillingError`, in code order) + +Source: `renderBillingError` in `ui-tui/src/app/slash/commands/topup.ts:37-149`. +"Portal" row = `sys('Portal: {portal_url}')` is appended whenever `portal_url` is present, for every code (including default). + +| `error` code | Copy | Portal URL | `retry_after` | +|---|---|:-:|:-:| +| `insufficient_scope` | `This needs terminal billing enabled. Start a top-up to enable it, then retry.` | if present | — | +| `remote_spending_revoked` (CF-4) | `{An admin turned off terminal billing for this terminal. \| You turned off terminal billing for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — | +| `session_revoked` | `Your session was logged out. Run /portal to log in again.` Also clears `billing` overlay state. | if present | — | +| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Terminal billing is off for this account — an admin must enable it on the portal.` | if present | — | +| `role_required` | `Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.` | if present | — | +| `consent_required` | `This action needs a one-time card confirmation and consent step on the portal before it can proceed.` | if present | — | +| `org_access_denied` | `This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.` | if present | — | +| `upgrade_cap_exceeded` | `🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.` | if present | — | +| `auto_top_up_disabled_failures` | `Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.` | if present | — | +| `idempotency_conflict` | `🔴 That charge key was already used for a different amount. Start a fresh top-up.` | if present | — | +| `no_payment_method` | `💳 No saved card for terminal charges yet. Set one up on the portal (one-time credit buys don't save a reusable card).` | if present | — | +| `monthly_cap_exceeded` | `🔴 Monthly spend cap reached — ${remainingUsd} headroom left.` if `payload.remainingUsd` present, else `🔴 Monthly spend cap reached.` | if present | — | +| `rate_limited` / `temporarily_unavailable` | `🟡 Too many charges right now{ (try again in ~N min)}. This isn't a payment failure.` | if present | **yes** — minutes computed as `max(1, round(retry_after/60))` | +| `stripe_unavailable` | `🟡 Stripe is having trouble right now — try again shortly{ (try again in ~N min)}.` | if present | **yes** (same formula) | +| *default (unknown/other)* | `🔴 {message \|\| error \|\| 'Billing request failed.'}` — still surfaces whatever the server said, never a blank toast. | if present | — | + +## 3. Charge settlement outcomes (`pollCharge` / `renderChargeFailed`) + +Source: `pollCharge` (`ui-tui/src/app/slash/commands/topup.ts:170-258`) and +`renderChargeFailed` (`:260-290`). Poll cadence: 2s interval, 5-minute cap +(`POLL_INTERVAL_MS=2000`, `POLL_CAP_MS=5*60*1000`), applied on **every** +non-terminal path (pending *and* throttled), so a sustained 429/503 can't +keep the poll alive forever. + +| Outcome | Copy | Notes | +|---|---|---| +| `status: 'settled'` | `✅ ${amount_usd} added.` (or `✅ Credits added.` if no amount) | Terminal success. | +| `status: 'failed'`, `reason: 'authentication_required'` | `🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase.` | + `Portal:` line if `portalUrl`. | +| `status: 'failed'`, `reason: 'payment_method_expired'` | `🔴 Your card has expired. Update it on the portal.` | + `Portal:` line. | +| `status: 'failed'`, `reason: 'card_declined'` | `🔴 Your card was declined. Try another card on the portal.` | + `Portal:` line. | +| `status: 'failed'`, `reason: 'processing_error'` | `🔴 The charge didn't go through (processing_error).` | + `Portal:` line. | +| `status: 'failed'`, unrecognized/missing `reason` | `🔴 The charge didn't go through ({reason \|\| 'processing_error'}).` | Same portal funnel — parity with `cli.py`'s `_billing_portal_hint`. | +| Poll timeout (still `pending` past the 5-min cap) | `🟡 Still processing after 5 minutes — this is a timeout, not a failure. Check /topup or the portal shortly.` | + `Portal:` line if `portalUrl`. Explicitly NOT called a failure. | +| Revocation mid-poll (`remote_spending_revoked` / `session_revoked` while polling) | Renders the matching §2 copy, **then** appends: `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` | CF-7 rule 4: a post-revoke 403 while polling is ambiguous (the charge may have already settled) — never call it "failed". | +| 429/503 while polling (`rate_limited`/`temporarily_unavailable`/`stripe_unavailable`) | No error shown; backs off using `retry_after` (default 5s, capped at 30s) and keeps polling until the 5-min cap, then reads as timeout. | Not a payment failure. | +| Other `!ok` status-check error | `🔴 Could not check the charge: {message \|\| error \|\| 'error'}` | | +| Transport loss (poll RPC throws/rejects) | `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` (`UNCONFIRMED_CHARGE_MESSAGE`) | Same "unconfirmed, check balance" framing as revocation mid-poll — a dropped connection can never be read as "failed". | + +## 4. Subscription preview / pending-change / upgrade outcomes + +Source: `previewAndRoute`, `applyPendingAndRoute`, `upgradeResult`, +`stepUpDenialResult` in `ui-tui/src/components/subscriptionOverlay.tsx`. + +**Preview `effect` values** (drive the Confirm screen): + +| `effect` | Confirm screen copy | Primary action | +|---|---|---| +| `charge_now` | `Upgrade to {target}. You will be charged {amount} now (prorated).` (+ monthly-credits delta, + which card if resolver confidently knows) | `Pay {amount} & upgrade now` | +| `scheduled` | `Change to {target} — takes effect {date}. No charge now; you keep your current plan until then.` | `Schedule change to {target}` | +| `no_op` | `You are already on {target} — nothing to change.` | none (Back only) | +| `blocked` | `{preview.reason}` or fallback `That change cannot be made here — manage it on the portal.` | `Manage on portal` | +| Preview RPC returns `null`/transport failure | routes straight to Result: `Could not preview that change.` | — | +| Preview `!ok`, `insufficient_scope` | routes to `stepup` screen (`{kind:'preview', tierId}`) | — | +| Preview `!ok`, other error | routes to Result with `errorResult(p)` (`message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'`) | — | + +**Pending-change apply outcomes** (`applyPendingAndRoute`): + +| `pending.kind` | Success copy | +|---|---| +| `cancellation` | `Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.` | +| `tier_change` (downgrade/schedule) | `Scheduled — your plan doesn't change today. You keep your current plan until the end of the billing period, then it switches.` | +| `upgrade` | routed through `upgradeResult` (below) | +| any kind, mutation `insufficient_scope` | routes to stepup (`{kind:'apply'}`) | + +**Upgrade `status` × `reason` matrix** (`upgradeResult`, checked in this +order — `reason` is checked *before* `status`): + +| Condition | Result | +|---|---| +| `r === null` (transport failure on the charging route) | `Couldn't confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.` — ambiguous, never a blind retry. | +| `reason: 'authentication_required'` **or** `reason: 'subscription_payment_intent_requires_action'` | `Please verify your card in the portal to finish this upgrade.` → `recovery_url`. **Both reasons map to the same SCA copy** — the client branches on `reason`, not `status`, specifically so an SCA case that pre-#711 NAS mislabels with `status: 'payment_failed'` (no distinguishing reason yet) still routes to the correct "verify your card" copy instead of reading as a hard decline. | +| `reason: 'card_declined'` | `Your card was declined — try a different card on the portal.` → `recovery_url`. | +| `ok && status: 'already_on_tier'` | `You are already on {target_tier_name}.` (success) | +| `ok && status: 'upgraded'` | `Upgraded to {target_tier_name}. Your new monthly credits land in a moment.` — starts the eventual-consistency apply-poll (below). | +| `status: 'requires_action'` (no distinguishing reason) | `This upgrade needs extra verification (3DS). Finish it on the portal.` → `recovery_url`. | +| `status: 'payment_failed'` (no distinguishing reason) | `Your card was declined. Update your payment method on the portal and try again.` → `recovery_url`. | +| anything else | `errorResult(r)`: `message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'` | + +**Eventual-consistency apply-poll** (`ResultScreen`, only after `status: +'upgraded'`): polls `billing`/subscription state every 2s +(`UPGRADE_CONFIRM_INTERVAL_MS`) up to 15 attempts +(`UPGRADE_CONFIRM_ATTEMPTS`, i.e. ~30s) until `current.tier_id` flips to the +target. While waiting the screen reads `Applying…`; if it never flips inside +the budget it reads `Still applying` / `Your upgrade succeeded and is still +applying — refresh in a moment.` — the upgrade is never re-reported as failed +just because NAS hasn't caught up yet. + +**Step-up denial copy** (`stepUpDenialResult`, subscription flow): + +| `error` | Copy | +|---|---| +| `session_revoked` | `Your session expired — run /portal to log in again, then retry the change.` | +| `remote_spending_revoked` | `{message}` or `Terminal spending was turned off for this session — reconnect from the portal, then retry.` | +| `rate_limited` | `Too many attempts — wait a moment, then try again.` | +| other/unknown | `{message}` or `Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.` | + +A **repeat** scope denial during a post-grant replay never re-enters the +step-up screen (it's already mounted there — re-patching would freeze it); +`allowStepUp=false` instead surfaces a terminal result: `Terminal billing +still isn't enabled for this org — enable it on the portal, then retry.` + +## Text-mode (CLI) parity + +`cli.py`'s `_show_billing` / `_billing_overview` and `_show_subscription` / +`_subscription_overview` are read-mostly mirrors of the same state shapes +(balance title, two-bar dollar usage, auto-reload line, card line, monthly +cap) and share the "fail-open on logged-out/portal-hiccup, never crash" +discipline. The CLI's `/subscription` has **no in-terminal tier picker or +upgrade flow** — its only action is `_billing_portal_hint`'s deep-link to +`subscription_manage_url`; all plan changes happen on the portal. `/topup`'s +interactive modal (prompt_toolkit) is closer to parity with the TUI overlay, +but non-interactive contexts (TUI slash-worker, no live app) fall back to the +same text + portal-link rendering as `/subscription`, never prompting. + +## Forward compatibility + +Any `error`/`status`/`reason` code not in the tables above lands on the +`default` branch in `renderBillingError` (§2) or `errorResult`/`upgradeResult`'s +fallthrough (§4): it still renders the server's own `message` (never blank, +never a crash), just without bespoke copy or a typed recovery affordance. +NAS W3 introduces card-health codes (`card_paused`, `card_expired`, +`card_mismatch`) that are not yet typed here — until a client update adds +explicit branches, they will arrive as unknown codes and degrade to this +default path. From fb0f4a5956878ddef65af4629044c2b5b1ad9fcf Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sat, 18 Jul 2026 00:51:20 +0530 Subject: [PATCH 59/62] 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. --- hermes_cli/nous_billing.py | 40 ++++++++++++++++--- tests/hermes_cli/test_nous_billing_request.py | 9 +++++ tests/test_tui_gateway_server.py | 12 +++++- tui_gateway/server.py | 23 +---------- 4 files changed, 56 insertions(+), 28 deletions(-) diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index cf645353dabb..83c65f70de74 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -122,7 +122,19 @@ class BillingSessionRevoked(BillingAuthError): """ -class BillingRateLimited(BillingError): +class BillingTransient(BillingError): + """A deterministic non-charge outcome: the request definitely did NOT + reach/complete at Stripe, so it's always safe to retry after backoff — + never the "maybe charged" ambiguity of a real 5xx/timeout. Covers + 429 rate limiting, 503 gate-unavailable, Stripe being down, and the + daily upgrade cap — distinct failure modes that share this one + contract property. Catch this (not the old ad-hoc subclass hierarchy) + wherever the intent is "any transient, definitely-not-charged billing + failure, back off and retry/poll". + """ + + +class BillingRateLimited(BillingTransient): """``429 rate_limited`` or ``503 temporarily_unavailable``. NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell @@ -132,22 +144,24 @@ class BillingRateLimited(BillingError): """ -class BillingStripeUnavailable(BillingRateLimited): +class BillingStripeUnavailable(BillingTransient): """``503 stripe_unavailable`` — Stripe itself is down. TRANSIENT: back off and retry using Retry-After; this is NOT the same as being throttled by our own rate limiter, so surfaces must not render "rate limited" copy for it — they should read ``.error`` to tell the two apart. - Subclasses BillingRateLimited so existing back-off call sites still catch it. + A BillingTransient sibling of BillingRateLimited (not a subclass) — surfaces + must not render "rate limited" copy for it; read ``.error`` to distinguish it. """ -class BillingUpgradeCapExceeded(BillingRateLimited): +class BillingUpgradeCapExceeded(BillingTransient): """``429 upgrade_cap_exceeded`` — the org hit its 5-upgrades/day cap. Distinct from the hourly ``rate_limited`` charge cap (same HTTP status, - different meaning + no useful short-Retry-After backoff). Subclasses - BillingRateLimited for the same reason as BillingStripeUnavailable. + different meaning + no useful short-Retry-After backoff). A BillingTransient + sibling of BillingRateLimited (not a subclass) — surfaces must read ``.error`` + to distinguish the failure mode. """ @@ -200,6 +214,20 @@ def _absolutize_portal_url(portal_url: Optional[str]) -> Optional[str]: _token_cache: tuple[float, str, str] | None = None # (cached_at, token, base) +def invalidate_cached_token() -> None: + """Bust the 30s token cache so post-step-up replays use the freshly-scoped token. + + ``_request`` only self-busts the cache on a 401 (an expired/invalid + token), not on a 403 scope denial — so after a step-up grant, the + cache would otherwise still hold the pre-grant unscoped token and + the immediate replay would 403 again. Callers outside this module + (e.g. the CLI's scope step-up flow) call this instead of poking + the private ``_token_cache`` global directly. + """ + global _token_cache + _token_cache = None + + def _billing_not_logged_in(exc: Optional[BaseException] = None) -> "BillingAuthError": """Build the canonical 'not logged in' BillingAuthError (single source).""" err = BillingAuthError( diff --git a/tests/hermes_cli/test_nous_billing_request.py b/tests/hermes_cli/test_nous_billing_request.py index 861a34b0e20a..92471ee2b131 100644 --- a/tests/hermes_cli/test_nous_billing_request.py +++ b/tests/hermes_cli/test_nous_billing_request.py @@ -64,6 +64,15 @@ def test_valid_json_2xx_body_parses(monkeypatch): assert nb.get_billing_state() == payload +def test_transient_siblings_not_parent_child(): + assert issubclass(nb.BillingRateLimited, nb.BillingTransient) + assert issubclass(nb.BillingStripeUnavailable, nb.BillingTransient) + assert issubclass(nb.BillingUpgradeCapExceeded, nb.BillingTransient) + assert not issubclass(nb.BillingStripeUnavailable, nb.BillingRateLimited) + assert not issubclass(nb.BillingUpgradeCapExceeded, nb.BillingRateLimited) + assert not issubclass(nb.BillingRateLimited, nb.BillingStripeUnavailable) + + # --------------------------------------------------------------------------- # Subscription change (V3): the request the client actually puts on the wire. # --------------------------------------------------------------------------- diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 1e9535b20ec3..6c56924cce3a 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9955,7 +9955,7 @@ def test_billing_error_serialization_preserves_server_code( import hermes_cli.nous_billing as nb headers = _BillingHeaders({"Retry-After": str(retry_after)}) if retry_after else None - with pytest.raises(nb.BillingRateLimited) as ei: + with pytest.raises(nb.BillingTransient) as ei: nb._raise_for_error(status, {"error": error}, headers) result = server._serialize_billing_error(ei.value) @@ -9965,6 +9965,16 @@ def test_billing_error_serialization_preserves_server_code( assert result["retry_after"] == retry_after +def test_billing_rate_limit_without_error_defaults_wire_code(): + import hermes_cli.nous_billing as nb + + exc = nb.BillingRateLimited("slow down", status=429, retry_after=10) + + result = server._serialize_billing_error(exc) + + assert result["error"] == "rate_limited" + + # ── subscription change RPCs (V3): preview + pending-change + upgrade ── diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d00c4cfe45eb..002c2a78d77e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7890,25 +7890,6 @@ def _on_progress(event: str, detail: str) -> None: return _err(rid, 5031, f"pet.hatch failed: {exc}") -@method("credits.view") -def _(rid, params: dict) -> dict: - """Structured Nous credit view for the TUI /credits command. - - Account-independent (a portal fetch gated on "a Nous account is logged in"), - so it works with no live agent / on a resumed session — same as the /usage - credits block. Returns the surface-agnostic CreditsView fields so the TUI can - render a clickable top-up . Fail-open: a portal hiccup or logged-out - account yields {logged_in: false}, never an error the user has to parse. - """ - try: - from agent.billing_usage import build_usage_model - - usage["usage"] = _serialize_usage_model(build_usage_model()) - except Exception: - pass - return _ok(rid, usage) - - # =========================================================================== # Phase 2b terminal billing RPC methods # =========================================================================== @@ -7924,10 +7905,10 @@ def _(rid, params: dict) -> dict: def _serialize_billing_error(exc) -> dict: """Map a BillingError into the result.error envelope the TUI branches on.""" from hermes_cli.nous_billing import ( - BillingRateLimited, BillingRemoteSpendingRevoked, BillingScopeRequired, BillingSessionRevoked, + BillingTransient, ) kind = "error" @@ -7937,7 +7918,7 @@ def _serialize_billing_error(exc) -> dict: kind = "session_revoked" elif isinstance(exc, BillingScopeRequired): kind = "insufficient_scope" - elif isinstance(exc, BillingRateLimited): + elif isinstance(exc, BillingTransient): kind = str(exc.error) if getattr(exc, "error", None) else "rate_limited" elif getattr(exc, "error", None): kind = str(exc.error) From ce6f14df2b4121d21bc56ba5b325b98f93d9f29b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sat, 18 Jul 2026 00:51:20 +0530 Subject: [PATCH 60/62] refactor(cli): extract CLIBillingMixin; charge gates follow the server capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- agent/billing_view.py | 11 +- cli.py | 1423 +---------------------------- hermes_cli/cli_billing_mixin.py | 1455 ++++++++++++++++++++++++++++++ tests/agent/test_billing_view.py | 17 +- 4 files changed, 1481 insertions(+), 1425 deletions(-) create mode 100644 hermes_cli/cli_billing_mixin.py diff --git a/agent/billing_view.py b/agent/billing_view.py index 849601dc524b..0e9930fd3ea6 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -176,10 +176,15 @@ def can_change_plan(self) -> bool: def can_charge(self) -> bool: """True when the UI should offer charge/auto-reload actions. - Admin role AND the per-org kill-switch on. (The server still enforces; - this is just for graying out actions the user can't take.) + Uses the server-granted plan-change capability (``can_change_plan``, + which itself falls back to the legacy OWNER/ADMIN role check when the + server omits ``canChangePlan``) AND the per-org kill-switch. This lets + the server grant charge capability to non-OWNER/ADMIN roles (e.g. + FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the + deprecated 3-role admin check. (The server still enforces; this is + just for graying out actions the user can't take.) """ - return self.is_admin and self.cli_billing_enabled + return self.can_change_plan and self.cli_billing_enabled def _parse_card(raw: Any) -> Optional[CardInfo]: diff --git a/cli.py b/cli.py index bf2b8ebbce34..dd68c009533d 100644 --- a/cli.py +++ b/cli.py @@ -54,6 +54,7 @@ from hermes_cli.fallback_config import get_fallback_chain from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin from hermes_cli.cli_commands_mixin import CLICommandsMixin +from hermes_cli.cli_billing_mixin import CLIBillingMixin # prompt_toolkit for fixed input area TUI from prompt_toolkit.history import FileHistory @@ -3698,7 +3699,7 @@ def save_config_value(key_path: str, value: any) -> bool: # HermesCLI Class # ============================================================================ -class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): +class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): """ Interactive CLI for the Hermes Agent. @@ -9859,1426 +9860,6 @@ def _show_usage(self): # Console quietness is enforced by hermes_logging not # installing a console StreamHandler in non-verbose mode. - def _print_nous_credits_block(self) -> bool: - """Print the Nous dollar balance block (two-bar view) when a Nous account - is logged in. Returns True if it printed anything. - - Prefers the shared dollar usage model (``agent.billing_usage`` — two-bar - plan/top-up view, dollars-only, the /usage + /subscription source of - truth). Falls back to the legacy ``nous_credits_lines`` text only when the - model is unavailable. Agent-independent (a portal fetch gated on "a Nous - account is logged in"), so /usage shows the block even in the TUI - slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and - wall-clock-bounded; honors HERMES_DEV_CREDITS_FIXTURE for offline testing. - """ - try: - from agent.billing_usage import build_usage_model, format_renews - - usage = build_usage_model() - except Exception: - usage = None - format_renews = None # type: ignore - - if usage is not None and usage.available and format_renews is not None: - printed_any = False - plan = usage.plan_name or ("Free" if usage.status == "free" else None) - renews_display = getattr(usage, "renews_display", None) or format_renews(usage.renews_at) - renews = f" · renews {renews_display}" if renews_display else "" - if plan: - print() - _cprint(f" {_b(f'Plan: {plan}{renews}')}") - printed_any = True - - # All lines below go through _cprint (same renderer as the Plan line) so - # ordering is deterministic: raw print() and _cprint() flush to different - # buffers under patch_stdout and interleave nondeterministically (the bar - # would race above/below the Plan line across states). Keep one path. - for _bar_ln in self._usage_bar_lines(usage, usage.plan_name): - _cprint(_bar_ln) - printed_any = True - if usage.has_topup and usage.total_spendable_usd is not None: - _cprint(f" Total spendable: ${usage.total_spendable_usd:,.2f}") - - if usage.status == "free": - _cprint(f" {_d('> Free · free models only. Run /subscription to reach paid models.')}") - printed_any = True - elif usage.status == "low": - _amt = f"${usage.total_spendable_usd:,.2f}" if usage.total_spendable_usd is not None else "under $5" - _low = f"! Low balance · {_amt} left. Run /topup or /subscription." - _cprint(f" {_low}") - printed_any = True - - if printed_any: - return True - - # Fallback: legacy text lines (only when the model is unavailable). - from agent.account_usage import nous_credits_lines - - lines = nous_credits_lines() - if not lines: - return False - print() - for line in lines: - print(f" {line}") - return True - - def _print_usage_cta(self) -> None: - """Print the `/usage` call-to-action pointing at /subscription + /topup. - - Mirrors the TUI's ``USAGE_CTA`` (``session.ts``) so every surface ends a - usage read with the same nudge. Only called when a Nous account is logged - in (the balance block printed), since both commands are Nous-account only. - """ - _cprint(f" {_d('Run /subscription to change plan · /topup to add to your balance')}") - - # ------------------------------------------------------------------ - # /subscription — view plan + change it in the browser (CLI surface) - # ------------------------------------------------------------------ - - def _show_subscription(self): - """`/subscription` (alias `/upgrade`) — view the Nous plan + browser hand-off. - - The CLI mirror of the TUI ``SubscriptionOverlay``: a read of the current - plan, this cycle's subscription credits, renewal date, and the plans you - could switch to — then a deep-link to NAS's own ``/manage-subscription`` - page (NOT the Stripe portal; that page routes upgrade→Checkout / - downgrade→scheduled internally). The terminal NEVER charges for a - subscription. Fail-open: logged-out / portal hiccup degrades to a clear - message, never a crash. Mirrors ``_show_billing`` - discipline for the interactive-vs-text split. - """ - from agent.subscription_view import build_subscription_state, subscription_manage_url - - state = build_subscription_state() - - if not state.logged_in: - print() - if state.error: - _cprint(f" 💳 {_d(f'Could not load subscription: {state.error}')}") - else: - _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") - print(" Run `hermes portal` to log in, then /subscription.") - return - - # Team context: no personal plan — teams run on a shared balance. - if state.context == "team": - print() - _cprint(f" ⚕ {_b('Team subscription')}") - print(f" {'─' * 41}") - if state.org_name: - role = (state.role or "").title() - _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" - _cprint(f" {_d(_org_line)}") - org = state.org_name or "a team org" - print(f" This terminal is connected to {org}. Teams run on a shared") - print(" balance · use /topup to add funds.") - _cprint(f" {_d('Personal subscriptions live on your personal account.')}") - return - - self._subscription_overview(state, subscription_manage_url(state)) - - def _subscription_overview(self, state, manage_url): - """Print the plan read block, then the browser hand-off to manage it. - - Dollars-only (no "credits") — mirrors the TUI overlay: a status line, the - shared two-bar dollar usage view (plan + top-up) with the plan name on - the bar, and state-matched free/low nudges. No in-terminal tier picker — - the only action is managing the subscription on the portal. - """ - # Shared dollar usage model (the only source with top-up dollars). - from agent.billing_usage import format_renews - try: - from agent.billing_usage import build_usage_model - - usage = build_usage_model() - except Exception: - usage = None - - c = state.current - is_free = not (c and c.tier_id) - can_change = state.can_change_plan - - plan_name = (c.tier_name or c.tier_id) if c else (usage.plan_name if usage else None) - u_status = getattr(usage, "status", None) if usage else None - view_only = not can_change - renews_display = getattr(usage, "renews_display", None) if usage else None - if not renews_display and c and c.cycle_ends_at: - renews_display = format_renews(c.cycle_ends_at) - - # Status line — dollars-only, with a "→ Plus" echo of a pending change so - # the headline itself carries a scheduled downgrade/cancellation. - _flip = "" - if c and c.cancel_at_period_end: - _flip = " → cancels" - elif c and c.pending_downgrade_tier_name: - _flip = f" → {c.pending_downgrade_tier_name}" - if not plan_name: - status = "Plan: Free · free models only" - elif usage is not None and u_status == "low" and usage.total_spendable_usd is not None: - _tot = f"${usage.total_spendable_usd:,.2f}" - status = f"Plan: {plan_name}{_flip} · {_tot} left" - else: - _spend = getattr(usage, "total_spendable_usd", None) if usage else None - _left = f" · ${_spend:,.2f} left" if _spend is not None else "" - _tail = " · view only" if view_only else (f" · renews {renews_display}" if renews_display else "") - status = f"Plan: {plan_name}{_flip}{_left}{_tail}" - - # Lead with the scheduled change (cancel > downgrade) so it can't read as - # "nothing happened" — mirrors the TUI banner. All-`_cprint` (blanks - # included) so the block orders deterministically even when piped. - _trans = None - if c and c.cancel_at_period_end: - _when = format_renews(c.cancellation_effective_at) or "the end of the billing period" - _trans = ((c.tier_name or "your plan"), "cancels", _when) - elif c and c.pending_downgrade_tier_name: - _when = format_renews(c.pending_downgrade_at) or "the end of the cycle" - _trans = ((c.tier_name or "your plan"), c.pending_downgrade_tier_name, _when) - _cprint("") - if _trans: - _from, _to, _when = _trans - _cprint(f" ⏳ {_b('Scheduled change')}") - _cprint(f" {_from} ──▶ {_to} {_d('· ' + _when)}") - _cprint(f" {_d(f'You keep {_from} (and its credits) until then.')}") - _cprint("") - - _cprint(f" ⚕ {_b(status)}") - print(f" {'─' * 41}") - - # Two-bar dollar usage view — plan name labels the plan bar. - for _bar_ln in self._usage_bar_lines(usage, plan_name): - print(_bar_ln) - if usage and getattr(usage, "has_topup", False) and getattr(usage, "total_spendable_usd", None) is not None: - print(f" Total spendable: ${usage.total_spendable_usd:,.2f}") - - # State-matched nudge (free upsell / low alert; healthy stays silent). - if is_free: - _cprint(f" {_d('> Paid models need a subscription. Start one to reach them.')}") - elif u_status == "low": - _amt = f"${usage.total_spendable_usd:,.2f}" if usage is not None and usage.total_spendable_usd is not None else "under $5" - _low = f"! Low balance · {_amt} left. Top up or upgrade before a mid-run cutoff." - _cprint(f" {_low}") - - if state.org_name: - role = (state.role or "").title() - _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" - _cprint(f" {_d(_org_line)}") - print(f" {'─' * 41}") - - # ── Actions ── Members (non-admin) and non-interactive contexts fall back - # to the portal hand-off; a paid admin/owner gets the full in-terminal - # change flow (parity with the TUI overlay). - if not can_change: - print() - _cprint(f" {_d('Plan changes need an org admin/owner.')}") - if manage_url: - print(f" Manage on portal: {manage_url}") - return - - if not getattr(self, "_app", None): - # Non-interactive (TUI slash-worker / piped): the modal can't run. - print() - if manage_url: - print(f" Manage your subscription: {manage_url}") - print(" Open it in your browser, then re-run /subscription.") - return - - if is_free: - # Starting a NEW subscription needs a fresh card — deep-link only. - self._subscription_open_portal(state, manage_url, verb="Start a subscription") - return - - # Paid + admin/owner + interactive → the in-terminal change flow. - self._subscription_change_menu(state, manage_url) - - def _subscription_open_portal(self, state, manage_url, *, verb="Manage your subscription"): - """Open / copy the manage-subscription URL — the portal hand-off.""" - if not manage_url: - print() - _cprint(f" {_d('No manage URL available — is your portal configured?')}") - return - print() - choices = [ - ("open", verb, "open the subscription page in your browser"), - ("copy", "Copy link", "copy the manage-subscription URL to your clipboard"), - ("cancel", "Cancel", "do nothing"), - ] - raw = self._prompt_text_input_modal(title=verb, detail="", choices=choices) - choice = self._normalize_slash_confirm_choice(raw, choices) - if choice == "open": - opened = False - try: - import webbrowser - - opened = webbrowser.open(manage_url) - except Exception: - opened = False - if not opened: - print(f" Open this URL: {manage_url}") - print() - print(" Finish in your browser, then re-run /subscription.") - elif choice == "copy": - try: - self._write_osc52_clipboard(manage_url) - print(f" 📋 Copied: {manage_url}") - except Exception: - print(f" Manage URL: {manage_url}") - else: - print(" 🟡 Cancelled.") - - def _subscription_change_menu(self, state, manage_url): - """The in-terminal change menu for a paid admin/owner (interactive).""" - c = state.current - has_pending = bool(c and (c.cancel_at_period_end or c.pending_downgrade_tier_name)) - keep_name = (c.tier_name if c else None) or "your plan" - # When a change is already scheduled, undo is the most likely next intent → - # promote it first (parity with the TUI). The Close row uses value "close" - # (not "cancel") so typing the word "cancel" — which the alias table would - # map to a Close row — can't be confused with "Cancel subscription". - if has_pending: - choices = [ - ("keep", f"Keep {keep_name} (undo the scheduled change)", "cancel the pending change"), - ("change", "Change plan", "upgrade or downgrade in the terminal"), - ] - else: - choices = [ - ("change", "Change plan", "upgrade or downgrade in the terminal"), - ("cancel_sub", "Cancel subscription", "schedule cancellation at period end"), - ] - choices.append(("portal", "Manage on portal", "open the billing page in your browser")) - choices.append(("close", "Close", "do nothing")) - raw = self._prompt_text_input_modal(title="Manage your subscription", detail="", choices=choices) - choice = self._normalize_slash_confirm_choice(raw, choices) - if choice == "change": - self._subscription_pick_tier(state) - elif choice == "keep": - self._subscription_apply(state, ("resume", None)) - elif choice == "cancel_sub": - self._subscription_confirm_cancel(state) - elif choice == "portal": - self._subscription_open_portal(state, manage_url) - else: - print(" 🟡 Closed. No plan change.") - - def _subscription_pick_tier(self, state): - """Tier picker → preview → confirm (mirrors the TUI picker screen).""" - from agent.billing_view import format_money - - c = state.current - tiers = tuple(state.tiers or ()) - cur_order = next((t.tier_order for t in tiers if t.is_current), 0) - # Selectable = enabled paid tiers other than current (free/no-sub excluded; - # dropping to free is a cancellation, on the change menu). Sorted by price. - selectable = sorted( - [t for t in tiers if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0], - key=lambda t: t.tier_order or 0, - ) - if not selectable: - print(" No other plans are available to switch to right now.") - return - choices = [] - for t in selectable: - direction = "upgrade" if (t.tier_order or 0) > cur_order else "downgrade" - choices.append((t.tier_id, f"{t.name} · {format_money(t.dollars_per_month)}/mo · {direction}", f"switch to {t.name}")) - choices.append(("cancel", "Back", "do nothing")) - raw = self._prompt_text_input_modal( - title="Change plan", - detail=f"Current: {c.tier_name if c else 'Free'}. Pick a plan to preview the effect.", - choices=choices, - ) - choice = self._normalize_slash_confirm_choice(raw, choices) - if not choice or choice == "cancel": - print(" 🟡 Cancelled. No plan change.") - return - self._subscription_preview_and_confirm(state, choice) - - def _subscription_preview_and_confirm(self, state, tier_id, *, allow_stepup=True): - """Preview the change (chargeless quote), show the effect, then confirm+apply. - - ``allow_stepup=False`` (a post-grant replay) declines a second step-up on a - repeated scope denial so the flow can't re-prompt/re-open the browser in a - loop. - """ - from agent.subscription_view import subscription_change_preview_from_payload - from hermes_cli.nous_billing import BillingError, BillingScopeRequired, post_subscription_preview - - _cprint(f" {_d('Checking the change…')}") - try: - payload = post_subscription_preview(subscription_type_id=tier_id) - except BillingScopeRequired: - if allow_stepup: - self._subscription_handle_scope_required(state, retry=("preview", tier_id)) - else: - print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") - return - except BillingError as exc: - self._subscription_render_error(state, exc) - return - p = subscription_change_preview_from_payload(payload) - effect = p.effect - target = p.target_tier_name or "the selected plan" - print() - if effect == "no_op": - _cprint(f" {_d(f'You are already on {target} — nothing to change.')}") - return - if effect not in ("charge_now", "scheduled"): - # blocked OR an unknown/unexpected effect → fail SAFE (never schedule a - # real change on an unrecognized string, unlike a bare `else`), and - # re-offer the portal hand-off like the TUI's blocked branch. - from agent.subscription_view import subscription_manage_url - - _cprint(f" 🟡 {p.reason or 'This change cannot be confirmed here — manage it on the portal.'}") - _mu = subscription_manage_url(state) - if _mu: - print(f" Manage on portal: {_mu}") - return - if effect == "charge_now": - _amt = f"${p.amount_due_now_cents / 100:.2f}" if p.amount_due_now_cents is not None else None - _cprint(f" {_b('Confirm plan change')} {_d('· charged now')}") - if _amt: - _cprint(f" Upgrade to {target}. You will be charged {_amt} now (prorated).") - else: - _cprint(f" Upgrade to {target}. You will be charged the prorated amount now.") - # Best-effort: name the exact card (billing.state), but only when the - # resolver rung matches what a subscription charge actually uses - # (subPin / customerDefault — Stripe's own precedence). Any failure or - # older NAS → the generic line stands. - _card_line = "The card on your subscription will be charged." - try: - from agent.billing_view import build_billing_state - - _bs = build_billing_state(timeout=6.0) - _c = _bs.card if _bs.logged_in else None - if _c is not None and _c.resolved_via in ("subPin", "customerDefault"): - _card_line = f"{_c.masked} — the card on your subscription — will be charged." - except Exception: - pass - _cprint(f" {_d(_card_line)}") - pay_label = f"Pay {_amt} & upgrade now" if _amt else "Upgrade now (prorated charge)" - action = ("upgrade", tier_id) - # The money-moving row is NOT the default — a bare Enter hits "Go back", - # so a single stray keystroke can't charge the card. - confirm_choices = [ - ("cancel", "Go back", "do not charge"), - ("yes", pay_label, "charge + upgrade now"), - ] - else: # scheduled (whitelisted above) - _when = p.effective_at[:10] if (p.effective_at and len(p.effective_at) >= 10) else "the end of the billing period" - _cprint(f" {_b('Confirm plan change')} {_d('· scheduled · not today')}") - _cprint(f" Change to {target} — takes effect {_when}. No charge now; you keep your current plan until then.") - pay_label = f"Schedule change to {target}" - action = ("schedule", tier_id) - confirm_choices = [ - ("yes", pay_label, "apply this change"), - ("cancel", "Go back", "do not change"), - ] - if p.monthly_credits_delta: - _cprint(f" {_d(f'Monthly credits change: {p.monthly_credits_delta}.')}") - raw = self._prompt_text_input_modal(title=pay_label, detail="", choices=confirm_choices) - if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": - print(" 🟡 Cancelled. No plan change.") - return - self._subscription_apply(state, action, allow_stepup=allow_stepup) - - def _subscription_confirm_cancel(self, state): - """Confirm, then schedule a cancellation at period end.""" - from agent.billing_usage import format_renews - - c = state.current - _end = (format_renews(c.cycle_ends_at) if (c and c.cycle_ends_at) else None) or "the end of the billing period" - print() - _cprint(f" {_b('Confirm cancellation')} {_d('· scheduled · not today')}") - _cprint(f" Cancel {(c.tier_name if c else 'your plan')} — it stays active until {_end}, then won't renew.") - _cprint(f" {_d('You keep your remaining credits for this period. You can resume before it ends.')}") - confirm_choices = [ - ("yes", "Cancel subscription", "schedule cancellation at period end"), - ("cancel", "Go back", "keep your plan"), - ] - raw = self._prompt_text_input_modal(title="Cancel subscription?", detail="", choices=confirm_choices) - if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": - print(" 🟡 Cancelled. Your plan is unchanged.") - return - self._subscription_apply(state, ("cancel", None)) - - def _subscription_apply(self, state, action, idempotency_key=None, *, allow_stepup=True): - """Run the mutation for `action`, handling the scope step-up + the result. - - `action` is one of ("upgrade", tier_id) / ("schedule", tier_id) / - ("cancel", None) / ("resume", None). insufficient_scope routes to the - step-up and replays; the upgrade idempotency key is reused across the replay. - ``allow_stepup=False`` (a post-grant replay) declines a second step-up on a - repeated scope denial so the flow can't re-prompt/re-open the browser in a loop. - """ - from hermes_cli.nous_billing import ( - BillingError, - BillingRateLimited, - BillingRemoteSpendingRevoked, - BillingScopeRequired, - BillingSessionRevoked, - delete_subscription_pending_change, - post_subscription_upgrade, - put_subscription_pending_change, - ) - - kind, arg = action - key = None - if kind == "upgrade": - from agent.billing_view import new_idempotency_key - - key = idempotency_key or new_idempotency_key() - try: - if kind == "upgrade": - try: - res = post_subscription_upgrade(subscription_type_id=arg, idempotency_key=key) or {} - except BillingScopeRequired: - raise # a scope denial rejects BEFORE charging → route to the step-up - except (BillingRateLimited, BillingSessionRevoked, BillingRemoteSpendingRevoked) as exc: - # Deterministic PRE-charge typed rejections (429 / 401 / 403) never - # reached Stripe → surface the CORRECT recovery (retry_after / re-login / - # reconnect), NOT the "maybe charged" ambiguity copy. - self._subscription_render_error(state, exc) - return - except BillingError as exc: - _status = getattr(exc, "status", None) - _code = getattr(exc, "error", None) - if _code in ("network_error", "endpoint_unavailable") or _status is None or _status >= 500: - # Genuinely INDETERMINATE — transport / unparseable 2xx / a 5xx the - # server hit mid-request: NAS may have already prorated + charged. - # Steer to a re-check, never a blind retry (a fresh key can't dedup → - # a real second charge). - self._subscription_render_upgrade_ambiguous(exc) - else: - # A deterministic 4xx (role_required / no_payment_method / …) → the - # normal error copy, not "maybe charged". - self._subscription_render_error(state, exc) - return - status = res.get("status") - name = res.get("targetTierName") or "your new plan" - _url = res.get("recoveryUrl") - if status == "already_on_tier": - _cprint(f" {_DIM}✓ You are already on {name}.{_RST}") - elif status == "upgraded": - _cprint(f" {_DIM}✓ Upgraded to {name}. Your new monthly credits land in a moment.{_RST}") - elif status == "requires_action": - _cprint(" 🟡 This upgrade needs extra verification (3DS). Finish it on the portal.") - if _url: - _cprint(f" Portal: {_url}") - elif status == "payment_failed": - _cprint(" 🔴 Your card was declined. Update your payment method on the portal and try again.") - if _url: - _cprint(f" Portal: {_url}") - else: - # Unknown / absent 2xx status → also ambiguous, not a flat failure. - self._subscription_render_upgrade_ambiguous(None) - return - if kind == "schedule": - put_subscription_pending_change(subscription_type_id=arg) - _cprint(f" {_DIM}✓ Scheduled — your plan doesn't change today. You keep it until the end of the billing period, then it switches.{_RST}") - elif kind == "cancel": - put_subscription_pending_change(cancel=True) - _cprint(f" {_DIM}✓ Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.{_RST}") - elif kind == "resume": - delete_subscription_pending_change() - _cprint(f" {_DIM}✓ Undone — you stay on your current plan.{_RST}") - _cprint(f" {_d('Re-run /subscription anytime to review it.')}") - except BillingScopeRequired: - if allow_stepup: - self._subscription_handle_scope_required(state, retry=action, idempotency_key=key) - else: - print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") - except BillingError as exc: - self._subscription_render_error(state, exc) - - def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=None): - """insufficient_scope → grant terminal billing (step-up), then replay `retry`. - - Mirrors _billing_handle_scope_required: the classic CLI calls - step_up_nous_billing_scope directly (it opens the browser + blocks), then - replays the held preview/mutation so the user never re-runs the command. - """ - print() - print(" ! One-time setup") - _cprint(f" {_d('To change your plan from the terminal, enable terminal billing once. It opens your browser to authorize, then your change picks up right here.')}") - if not getattr(self, "_app", None): - print(" Run `hermes portal` and enable terminal billing, then re-run /subscription.") - return - confirm_choices = [ - ("yes", "Enable terminal billing", "open your browser to authorize"), - ("no", "Not now", "cancel"), - ] - raw = self._prompt_text_input_modal( - title="Enable terminal billing", - detail="Opens your browser to authorize this terminal.", - choices=confirm_choices, - ) - if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": - print(" No change made. Enable terminal billing when you're ready.") - return - print(" Opening your browser to enable terminal billing…") - try: - from hermes_cli.auth import step_up_nous_billing_scope - - granted = step_up_nous_billing_scope(open_browser=True) - except Exception as exc: - print(f" Couldn't enable terminal billing: {exc}") - return - if not granted: - print(" Couldn't enable terminal billing — an org admin or owner has to approve it for this org.") - return - _cprint(f" {_DIM}✓ Terminal billing enabled.{_RST}") - # Bust the 30s token cache so the replay uses the freshly-scoped token. The - # cache still holds the pre-grant unscoped token, and _request only busts it - # on a 401 (not a 403 scope denial) — without this, the replay would 403 - # again and (before the allow_stepup guard) re-prompt in a loop. - try: - from hermes_cli import nous_billing as _nb - - _nb._token_cache = None - except Exception: - pass - # Re-fetch fresh state, then replay the held action ONCE (allow_stepup=False - # so a repeated scope denial can't re-enter the step-up). - from agent.subscription_view import build_subscription_state - - try: - fresh = build_subscription_state() - except Exception: - fresh = state - rkind, rarg = retry - if rkind == "preview": - self._subscription_preview_and_confirm(fresh, rarg, allow_stepup=False) - else: - self._subscription_apply(fresh, retry, idempotency_key=idempotency_key, allow_stepup=False) - - def _subscription_render_error(self, state, exc): - """Render a subscription BillingError (a lighter _billing_render_charge_error).""" - code = getattr(exc, "error", None) - msg = str(exc) or "Something went wrong." - if code == "insufficient_scope": - # Defensive: the flow routes scope to the step-up before reaching here. - _cprint(" 🟡 Terminal billing isn't enabled. Enable it, then retry.") - elif code in ("subscription_mutation_rejected", "preview_rejected"): - _cprint(f" 🟡 {msg}") - else: - _cprint(f" 🔴 {msg}") - _url = getattr(exc, "portal_url", None) - if _url: - _cprint(f" Portal: {_url}") - - def _subscription_render_upgrade_ambiguous(self, exc): - """A charge-route failure (transport / timeout / 500 / unknown status) is - AMBIGUOUS — NAS may have already prorated + charged. Steer to a re-check, - never a flat failure that invites a blind retry (mirrors the TUI's - upgradeResult(null) — the CLI can't persist the key across a command re-run, - so a re-check is the safe path).""" - _cprint(" 🟡 Couldn't confirm the upgrade — your card may or may not have been charged.") - _cprint(f" {_d('Re-run /subscription to check your plan before trying again.')}") - _url = getattr(exc, "portal_url", None) if exc is not None else None - if _url: - _cprint(f" Portal: {_url}") - - # ------------------------------------------------------------------ - # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) - # ------------------------------------------------------------------ - - def _show_billing(self, command: str = "/topup"): - """`/topup` — terminal billing for Nous (one interactive modal). - - ZERO sub-commands: any argument is ignored. Bare ``/topup`` always - opens the Overview (Screen 1), whose numbered menu is the *only* way to - reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified - UX spec §0.4 — ``/topup buy`` etc. are gone; we don't error on a stray - arg, we just open the menu.) - - Interactive CLI uses the prompt_toolkit modal; non-interactive contexts - (TUI slash-worker / no live app) render text + the portal deep-link, never - prompting (the URL is the affordance), same discipline as ``_show_billing``. - All money is Decimal end-to-end; the terminal never collects card details. - """ - from agent.billing_view import build_billing_state - - state = build_billing_state() - if not state.logged_in: - print() - if state.error: - _msg = f"Couldn't load billing: {state.error}" - _cprint(f" 💳 {_d(_msg)}") - else: - _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") - print(" Run `hermes portal` to log in, then /topup.") - return - - # Any sub-arg is intentionally ignored — always open the menu. - self._billing_overview(state) - - def _billing_portal_hint(self, state, *, reason: str = "") -> None: - """Print a portal deep-link line (the funnel for portal-only actions).""" - url = getattr(state, "portal_url", None) - if not url: - return - if reason: - print(f" {reason}") - print(f" Manage on portal: {url}") - - def _billing_overview(self, state): - """Screen 1 — overview: balance in title, two-bar dollar usage, action menu. - - Dollars-only (no "credits") — mirrors the TUI /topup overlay: balance - leads in the title, the shared plan + top-up bars render below, then the - reordered menu (Add funds first). No scope preflight — terminal billing - is discovered reactively when a charge 403s insufficient_scope. - """ - from agent.billing_view import format_money - - # Shared dollar usage model (plan + top-up bars), same source as /usage. - try: - from agent.billing_usage import build_usage_model - - usage = build_usage_model() - except Exception: - usage = None - - print() - _cprint(f" 💳 {_b(f'Top up · balance {format_money(state.balance_usd)}')}") - if state.org_name: - role = (state.role or "").title() - _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" - _cprint(f" {_d(_org_line)}") - print(f" {'─' * 41}") - - # Two-bar dollar usage view (plan name on the plan bar; top-up below). - for _bar_ln in self._usage_bar_lines(usage, getattr(usage, "plan_name", None)): - print(_bar_ln) - - ar = state.auto_reload - if ar is not None: - if ar.enabled: - print( - f" Auto-reload: on — below {format_money(ar.threshold_usd)} " - f"→ reload to {format_money(ar.reload_to_usd)}" - ) - else: - print(" Auto-reload: off") - # Card presence at a glance: which card a charge would use (with why — - # "the card on your subscription"), or that none is saved. Only for the - # full-menu case (admin + billing on) — others get the portal note below. - if state.is_admin and state.cli_billing_enabled: - if state.card is not None: - print(f" Card: {state.card.display}") - else: - _cprint(f" {_d('No saved card on file — “Add funds” walks you through adding one.')}") - print(f" {'─' * 41}") - - # Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal. - if not state.is_admin: - _cprint(f" {_d('Billing actions require an org admin/owner.')}") - self._billing_portal_hint(state) - return - if not state.cli_billing_enabled: - _cprint(f" {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal to add funds here.") - return - - # A missing card does NOT gate the whole overview — the org may already have - # balance, auto-reload, or a limit to view/manage. The card only matters at - # CHARGE time: "Add funds" -> _billing_buy_flow, which detects no card and - # hands off to the portal there. So always show the full menu below. - - # Non-interactive (slash-worker / no live app): no modal, no sub-command - # advertising — just the portal funnel (the URL is the affordance). - if not getattr(self, "_app", None): - self._billing_portal_hint(state) - return - - # Add funds first, then settings, then the scopeless browser handoff. - # No "Enable terminal billing" item — that's discovered at pay time. - # "Add funds" charges in-terminal against the org's portal-saved card - # (server-held via POST /charge — no card ref leaves the client). A - # missing card is NOT gated here: the buy flow reacts to the server's - # no_payment_method 403 and hands off to the portal at charge time. - choices = [ - ("buy", "Add funds", "add money to your balance"), - ("auto", "Auto-reload", "configure automatic top-ups"), - ("limit", "Monthly limit", "show the monthly spend cap (read-only)"), - ("portal", "Manage on portal", "open the billing page in your browser"), - ("cancel", "Cancel", "do nothing"), - ] - # The overview summary is already printed above; the modal only needs to - # present the action menu — repeating the title/balance reads as a dupe. - raw = self._prompt_text_input_modal( - title="Top up your balance", detail="", - choices=choices, - ) - choice = self._normalize_slash_confirm_choice(raw, choices) - if choice == "buy": - self._billing_buy_flow(state) - elif choice == "auto": - self._billing_auto_reload_flow(state) - elif choice == "limit": - self._billing_limit_screen(state) - elif choice == "portal": - self._billing_open_portal(state) - else: - print(" Cancelled.") - - def _billing_spend_bar(self, spent, limit, *, cells: int = 10): - """Render a 10-cell `█`/`░` usage bar (filled = REMAINING) + % used. - - Returns ``(bar, pct)`` where ``bar`` is like ``[███████░░░]`` and ``pct`` - is the spent/limit percentage clamped to 0..100. The bar fills by what's - LEFT (fuel-gauge), matching the shared model's ``fill_fraction``, the - top-up bar (full = balance), and the TUI — so the same account renders - identically on CLI and TUI. Box-drawing glyphs are not SGR codes, so this - is leak-safe even without ``_b()``/``_d()``. - """ - from decimal import Decimal - - try: - s = Decimal(str(spent)) if spent is not None else Decimal("0") - l = Decimal(str(limit)) if limit is not None else Decimal("0") - except Exception: - s, l = Decimal("0"), Decimal("0") - if l <= 0: - pct = 0 - filled = 0 - else: - pct = max(0, min(100, int((s / l) * 100))) - remaining = max(Decimal("0"), l - s) - filled = int(round((remaining / l) * cells)) - filled = max(0, min(cells, filled)) - bar = ("█" * filled) + ("░" * (cells - filled)) - return bar, pct - - def _usage_bar_lines(self, usage, plan_name) -> list: - """The plan + top-up dollar bars as ready-to-print lines (filled = remaining). - - Returns [] when there's nothing to draw. The caller resolves ``plan_name`` - (the plan-bar label) and picks its own print fn — block ordering differs - per surface (``_cprint`` vs ``print`` under patch_stdout). One source of - truth for the bar format across /usage, /subscription, and /topup. - """ - lines: list = [] - pb = getattr(usage, "plan_bar", None) if usage else None - if pb is not None and pb.total_usd > 0: - bar, _ = self._billing_spend_bar(pb.spent_usd, pb.total_usd) - pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else "" - label = (plan_name or "plan").ljust(8)[:8] - lines.append(f" {label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{pct_s}") - tb = getattr(usage, "topup_bar", None) if usage else None - if tb is not None and tb.remaining_usd > 0: - lines.append(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") - return lines - - def _billing_open_portal(self, state): - url = getattr(state, "portal_url", None) - if not url: - print(" No portal URL available.") - return - opened = False - try: - import webbrowser - - opened = webbrowser.open(url) - except Exception: - opened = False - if not opened: - print(f" Open this URL: {url}") - print(" Complete billing changes in the browser.") - - def _billing_require_admin(self, state) -> bool: - """Guard charge/auto-reload entry points; print + return False if blocked.""" - if not state.is_admin: - print() - _cprint(f" 💳 {_d('Billing actions require an org admin/owner.')}") - self._billing_portal_hint(state) - return False - if not state.cli_billing_enabled: - print() - _cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal first.") - return False - return True - - def _billing_add_card_flow(self, state): - """No saved card → guide adding one on the portal, with a re-check loop. - - Cards are added on the portal (never in-terminal). "I've added it" re-fetches - billing state so the purchase continues right here once the card is saved — - this also recovers a transient miss (the card display is best-effort - server-side). Returns the refreshed state (card present), or None to abandon. - """ - print() - _cprint(f" 💳 {_b('Add a card first')}") - _cprint(" No saved card on file.") - _cprint(f" {_d('Add a card once on the portal billing page — after that you can top up right from the terminal.')}") - choices = [ - ("portal", "Add a card on the portal", "opens the billing page in your browser"), - ("recheck", "I've added it — check again", "re-check for the card and continue"), - ("cancel", "Back", "do nothing"), - ] - for _ in range(8): # bounded: portal-open plus a handful of re-checks - raw = self._prompt_text_input_modal(title="Add a card", detail="", choices=choices) - choice = self._normalize_slash_confirm_choice(raw, choices) - if choice == "portal": - self._billing_open_portal(state) - _cprint(f" {_d('Add the card on the billing page, then pick “check again” here.')}") - continue - if choice == "recheck": - from agent.billing_view import build_billing_state - - try: - fresh = build_billing_state() - except Exception: - fresh = None - if fresh is not None and fresh.logged_in: - state = fresh - if state.card is not None: - _cprint(f" {_DIM}✓ Card found: {state.card.display} — continuing.{_RST}") - return state - print(" Still no card on file — finish adding it on the portal, then check again.") - continue - break - print(" Cancelled. No funds added.") - return None - - def _billing_buy_flow(self, state): - """Screen 2 (preset select) → Screen 3 (confirm + charge + poll).""" - from agent.billing_view import format_money, validate_charge_amount - - if not self._billing_require_admin(state): - return - - # No card / scope preflight here — that's the rejected anti-pattern. We let - # the charge fly and react to whatever 403 the server returns: scope first - # (insufficient_scope → in-flight reauth), then card (no_payment_method → - # portal handoff via _billing_render_charge_error). Mirrors the server's gate - # order; the user only hits the flow they actually need. - - # Screen 3 — preset selection. - if not getattr(self, "_app", None): - presets = ", ".join(format_money(p) for p in state.charge_presets) - print() - _cprint(f" 💳 {_b('Add funds')}") - print(f" Presets: {presets}") - print(" Run this in the interactive CLI to complete a purchase.") - self._billing_portal_hint(state) - return - - # No card on file → the guided ADD-CARD path first (portal + re-check), - # so the user isn't walked through picking an amount that will 403. - # Returns refreshed state with a card, or None (abandoned). - if state.card is None: - state = self._billing_add_card_flow(state) - if state is None or state.card is None: - return - - preset_choices = [] - for p in state.charge_presets: - preset_choices.append((str(p), format_money(p), "one-time credit purchase")) - preset_choices.append(("custom", "Custom amount…", "enter your own amount")) - preset_choices.append(("cancel", "Cancel", "do nothing")) - - card = state.card - detail = f"Payment: {card.display}" if card else "No saved card on file" - raw = self._prompt_text_input_modal( - title="Add funds", detail=detail, choices=preset_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, preset_choices) - if not choice or choice == "cancel": - print(" Cancelled. No funds added.") - return - - from decimal import Decimal - - if choice == "custom": - entered = self._prompt_text_input(" Amount (USD): ") - if entered is None: - # None = cancelled (e.g. slash-worker can't prompt off-thread). - print(" Cancelled. No funds added.") - return - v = validate_charge_amount( - entered or "", min_usd=state.min_usd, max_usd=state.max_usd - ) - if not v.ok: - print(f" 🔴 {v.error}") - return - amount = v.amount - else: - try: - amount = Decimal(choice) - except Exception: - print(" 🔴 Invalid selection.") - return - - self._billing_confirm_and_charge(state, amount) - - def _billing_confirm_and_charge(self, state, amount): - """Screen 3 — confirm total + consent, charge, then poll to settlement.""" - from agent.billing_view import format_money, new_idempotency_key - - card = state.card - print() - _cprint(f" 💳 {_b('Confirm purchase')}") - print(f" {'─' * 41}") - print(f" Total: {format_money(amount)}") - if card: - print(f" Payment: {card.display}") - # Provenance-less payloads (older NAS) keep the generic line; when - # the resolver says WHY this card, the Payment line carries it. - if card.provenance is None: - _cprint(f" {_d('Your card saved on the portal will be charged.')}") - print(f" {'─' * 41}") - _consent = ( - "By confirming, you allow Nous Research to charge your card." - ) - _cprint(f" {_d(_consent)}") - - confirm_choices = [ - ("pay", f"Pay {format_money(amount)} now", "submit the charge"), - ("portal", "Manage on portal", "manage your card / billing in the browser"), - ("cancel", "Go back", "do not charge"), - ] - if not getattr(self, "_app", None): - print(" Run in the interactive CLI to confirm a purchase.") - return - raw = self._prompt_text_input_modal( - title=f"Pay {format_money(amount)}?", - detail=(card.display if card else "no saved card"), - choices=confirm_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, confirm_choices) - if choice == "portal": - self._billing_open_portal(state) - return - if choice != "pay": - print(" Cancelled. No funds added.") - return - - # Submit the charge with a fresh idempotency key (reused on retry). - from hermes_cli.nous_billing import ( - BillingError, - BillingScopeRequired, - post_charge, - ) - - key = new_idempotency_key() - try: - result = post_charge(amount_usd=amount, idempotency_key=key) - except BillingScopeRequired: - # In-flight reauth: enable terminal billing, then resume THIS charge - # (press-Enter beat) — no command re-run. Reuses the same idem key. - self._billing_handle_scope_required(state, amount=amount, idempotency_key=key) - return - except BillingError as exc: - self._billing_render_charge_error(state, exc) - return - - charge_id = result.get("chargeId") - if not charge_id: - print(" 🔴 No charge id returned; please check the portal.") - return - _cprint(f" {_d('Charge submitted — confirming settlement…')}") - self._billing_poll_charge(state, charge_id, amount) - - def _billing_poll_charge(self, state, charge_id, amount): - """Poll loop: 2s interval, 5-min cap, cancellable. settled = ledger truth.""" - import time as _time - - from agent.billing_view import format_money - from hermes_cli.nous_billing import ( - BillingError, - BillingRateLimited, - get_charge_status, - ) - - deadline = _time.time() + 300 # 5-minute cap - interval = 2.0 - while _time.time() < deadline: - try: - status = get_charge_status(charge_id) - except BillingRateLimited as exc: - # Retry-after, NOT a failure — back off and keep polling. - wait = exc.retry_after or 5 - _time.sleep(min(wait, 30)) - continue - except BillingError as exc: - print(f" 🔴 Could not check the charge: {exc}") - return - - state_str = status.get("status") - if state_str == "settled": - amt = status.get("amountUsd") - from agent.billing_view import parse_money - - shown = format_money(parse_money(amt)) if amt else format_money(amount) - print(f" ✓ {shown} added to your balance.") - return - if state_str == "failed": - self._billing_render_charge_failed(state, status.get("reason")) - return - # pending → wait and poll again - _time.sleep(interval) - - # Past the cap with no terminal state = timeout (not an error). - print(" 🟡 Still processing after 5 minutes — this is a timeout, not a " - "failure. Check /billing or the portal shortly.") - self._billing_portal_hint(state) - - def _billing_render_charge_failed(self, state, reason): - """Branch the poll `failed` reasons to the right copy + portal funnel.""" - reason = (reason or "").strip() - if reason == "authentication_required": - print(" 🔴 Your bank requires verification (3DS). Complete it on the " - "portal to finish this purchase.") - elif reason == "payment_method_expired": - print(" 🔴 Your card has expired. Update it on the portal.") - elif reason == "card_declined": - print(" 🔴 Your card was declined. Try another card on the portal.") - else: - print(f" 🔴 The charge didn't go through ({reason or 'processing_error'}).") - self._billing_portal_hint(state) - - def _billing_render_charge_error(self, state, exc): - """Render a typed BillingError at submit time (pre-poll).""" - from hermes_cli.nous_billing import ( - BillingRateLimited, - BillingRemoteSpendingRevoked, - BillingSessionRevoked, - ) - - code = getattr(exc, "error", None) - actor = getattr(exc, "actor", None) - portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None) - if isinstance(exc, BillingRemoteSpendingRevoked) or code == "remote_spending_revoked": - # CF-4: this terminal's spend was revoked. Recovery is reconnect. - who = ("An admin stopped this terminal's spending." - if actor == "admin" - else "You stopped this terminal's spending.") - print(f" 🔴 {who} Reconnect to restore — run `hermes portal` to re-authorize.") - elif isinstance(exc, BillingSessionRevoked) or code == "session_revoked": - print(" 🔴 Your session was logged out. Run `hermes portal` to log in again.") - elif code == "no_payment_method": - print(" 💳 No card on file — top up and manage billing on the portal.") - elif code in ("cli_billing_disabled", "remote_spending_disabled") or \ - getattr(exc, "code", None) == "remote_spending_disabled": - print(" Terminal billing is off for this account — an admin must enable it on the portal.") - elif code == "role_required": - print(" Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.") - elif code == "idempotency_conflict": - print(" 🔴 That charge key was already used for a different amount. Start a fresh top-up.") - elif code == "monthly_cap_exceeded": - remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd") - if remaining is not None: - print(f" 🔴 Monthly spend cap reached — ${remaining} headroom left.") - else: - print(" 🔴 Monthly spend cap reached.") - elif isinstance(exc, BillingRateLimited): - wait = getattr(exc, "retry_after", None) - mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else "" - print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.") - elif code == "insufficient_scope": - # Never leak the raw billing:manage scope (the post-grant replay can - # re-raise it if the grant raced) — the concept is "terminal billing". - print(" 🔴 Terminal billing needs approval — run /topup to enable it, then retry.") - else: - print(f" 🔴 {exc}") - if portal_url: - print(f" Portal: {portal_url}") - - def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key=None): - """403 insufficient_scope → in-flight reauth, then resume the held charge. - - The buy path discovers terminal billing isn't enabled only when the - charge 403s — there is no preflight. We enable it in-flight ("Enable - terminal billing" → browser device-flow), then on return ask the user to - press Enter to resume the held ``amount`` (reusing ``idempotency_key`` so - the resumed charge collapses with the original). Never leaks the raw - billing:manage scope. - """ - from agent.billing_view import format_money - - amount_str = format_money(amount) if amount is not None else "your top-up" - print() - print(" ! One-time setup") - _cprint(f" {_d(f'To charge this terminal, enable terminal billing once. It opens your browser to authorize, then {amount_str} picks up right here.')}") - if not getattr(self, "_app", None): - print(" Run `hermes portal` and enable terminal billing, then retry.") - return - confirm_choices = [ - ("yes", "Enable terminal billing", "open your browser to authorize"), - ("no", "Not now", "cancel"), - ] - raw = self._prompt_text_input_modal( - title="Enable terminal billing", - detail="Opens your browser to authorize this terminal.", - choices=confirm_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, confirm_choices) - if choice != "yes": - print(" No charge made. Run /topup when you want to enable terminal billing.") - return - print(" Opening your browser to enable terminal billing…") - try: - from hermes_cli.auth import step_up_nous_billing_scope - - granted = step_up_nous_billing_scope(open_browser=True) - except Exception as exc: - print(f" Couldn't enable terminal billing: {exc}") - return - if not granted: - print(" Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.") - return - - # Granted. The token now carries the scope, but the ORG kill-switch - # (cli_billing_enabled) is a separate gate — re-fetch /state so we don't - # over-promise when a charge would still hit cli_billing_disabled. - from agent.billing_view import build_billing_state - - fresh = build_billing_state() - if not (fresh.logged_in and fresh.cli_billing_enabled): - print(" Terminal billing was enabled for this terminal, but it's still turned off for this org. Enable it in the portal, then run /topup again.") - self._billing_portal_hint(fresh) - return - - # Scope granted + org kill-switch on — but a charge still needs a card on - # file. If there's none, this is a half-done state: say so and route to the - # portal to top up / manage billing, rather than a bare "✓ enabled" that reads as done. - if fresh.card is None: - print(" ✓ Terminal billing enabled — but there's no card on file yet.") - _cprint(f" {_d('Top up and manage billing on the portal to continue.')}") - self._billing_portal_hint(fresh) - return - - # Nothing to resume (scope-required hit outside a charge, e.g. auto-reload - # config) → just tell the user it's ready. - if amount is None: - print(" ✓ Terminal billing enabled. Run /topup to continue.") - return - - # Press-Enter beat: the user is back from the browser; resume the held - # purchase on an explicit confirm (reassuring, not silent). - print(" ✓ Terminal billing enabled.") - resume_choices = [ - ("resume", f"Resume {format_money(amount)} top-up", "finish the held purchase"), - ("cancel", "Cancel", "do not charge"), - ] - raw = self._prompt_text_input_modal( - title="Resume your top-up", - detail=f"{format_money(amount)} is ready to finish — press Enter to resume.", - choices=resume_choices, - ) - if self._normalize_slash_confirm_choice(raw, resume_choices) != "resume": - print(" Cancelled. No funds added.") - return - - # Replay the held charge, reusing the original idempotency key so a - # double-submit collapses to one charge. - from hermes_cli.nous_billing import BillingError, post_charge - - from agent.billing_view import new_idempotency_key - - key = idempotency_key or new_idempotency_key() - try: - result = post_charge(amount_usd=amount, idempotency_key=key) - except BillingError as exc: - self._billing_render_charge_error(fresh, exc) - return - charge_id = result.get("chargeId") - if not charge_id: - print(" No charge id returned; please check the portal.") - return - _cprint(f" {_d('Resuming your top-up — confirming settlement…')}") - self._billing_poll_charge(fresh, charge_id, amount) - - def _billing_auto_reload_flow(self, state): - """Screen 4 — auto-reload config: threshold + reload-to → PATCH. - - Prefills the current values from ``state.auto_reload``. Validates both - amounts (2dp, within bounds, ``reload_to > threshold``). When auto-reload - is already on, offers a "Turn off" path (PATCH ``enabled:false``). - """ - from agent.billing_view import format_money, validate_charge_amount - - if not self._billing_require_admin(state): - return - - card = state.card - ar = state.auto_reload - currently_on = bool(ar and ar.enabled) - - print() - _cprint(f" 💳 {_b('Auto-reload')}") - print(f" {'─' * 41}") - _cprint(f" {_d('Automatically add funds when your balance is low.')}") - if card: - print(f" Card on file: {card.masked}") - else: - print(" No saved card — manage billing on the portal.") - self._billing_portal_hint(state) - return - if currently_on: - print( - f" Currently: below {format_money(ar.threshold_usd)} → " - f"reload to {format_money(ar.reload_to_usd)}" - ) - - if not getattr(self, "_app", None): - print(" Run in the interactive CLI to configure auto-reload.") - self._billing_portal_hint(state) - return - - # When already enabled, let the user turn it off without re-entering values. - if currently_on: - top_choices = [ - ("edit", "Edit thresholds", "change when / how much to reload"), - ("off", "Turn off", "disable auto-reload"), - ("cancel", "Cancel", "do nothing"), - ] - raw = self._prompt_text_input_modal( - title="Auto-reload", - detail=( - f"On — below {format_money(ar.threshold_usd)} → " - f"reload to {format_money(ar.reload_to_usd)}" - ), - choices=top_choices, - ) - top = self._normalize_slash_confirm_choice(raw, top_choices) - if top == "off": - self._billing_auto_reload_disable(state) - return - if top != "edit": - print(" 🟡 Cancelled.") - return - - # Field 1 — threshold (prefilled when editing an existing config). - cur_thr = format_money(ar.threshold_usd) if currently_on else None - thr_prompt = " When balance falls below (USD)" - thr_prompt += f" [{cur_thr}]: " if cur_thr else ": " - threshold_raw = self._prompt_text_input(thr_prompt) - if threshold_raw is None: - # None = cancelled (e.g. slash-worker can't prompt off-thread). - print(" 🟡 Cancelled.") - return - if not (threshold_raw or "").strip() and currently_on: - threshold_amt = ar.threshold_usd # keep current value on empty input - else: - tv = validate_charge_amount( - threshold_raw or "", min_usd=state.min_usd, max_usd=state.max_usd - ) - if not tv.ok or tv.amount is None: - print(f" 🔴 {tv.error}") - return - threshold_amt = tv.amount - - # Field 2 — reload-to (prefilled when editing an existing config). - cur_rel = format_money(ar.reload_to_usd) if currently_on else None - rel_prompt = " Reload balance to (USD)" - rel_prompt += f" [{cur_rel}]: " if cur_rel else ": " - reload_raw = self._prompt_text_input(rel_prompt) - if reload_raw is None: - print(" 🟡 Cancelled.") - return - if not (reload_raw or "").strip() and currently_on: - reload_amt = ar.reload_to_usd # keep current value on empty input - else: - rv = validate_charge_amount( - reload_raw or "", min_usd=state.min_usd, max_usd=state.max_usd - ) - if not rv.ok or rv.amount is None: - print(f" 🔴 {rv.error}") - return - reload_amt = rv.amount - - if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt: - print(" 🔴 Reload-to amount must be greater than the threshold.") - return - - print() - _ar_consent = ( - f"By confirming, you authorize Nous Research to charge {card.masked} " - f"whenever your balance reaches {format_money(threshold_amt)}. " - f"Turn off any time here or on the portal." - ) - _cprint(f" {_d(_ar_consent)}") - confirm_choices = [ - ("agree", "Agree and turn on", "enable auto-reload"), - ("cancel", "Cancel", "do nothing"), - ] - raw = self._prompt_text_input_modal( - title="Turn on auto-reload?", - detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}", - choices=confirm_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, confirm_choices) - if choice != "agree": - print(" 🟡 Cancelled.") - return - - from hermes_cli.nous_billing import ( - BillingError, - BillingScopeRequired, - patch_auto_top_up, - ) - - try: - patch_auto_top_up( - enabled=True, threshold=float(threshold_amt), top_up_amount=float(reload_amt) - ) - except BillingScopeRequired: - self._billing_handle_scope_required(state) - return - except BillingError as exc: - self._billing_render_charge_error(state, exc) - return - print(f" ✅ Auto-reload on: below {format_money(threshold_amt)} → " - f"reload to {format_money(reload_amt)}.") - - def _billing_auto_reload_disable(self, state): - """Turn off auto-reload (PATCH ``enabled:false``). - - The endpoint requires ``threshold``/``topUpAmount`` in the body even when - disabling, so we echo back the current values (falling back to 0). - """ - from hermes_cli.nous_billing import ( - BillingError, - BillingScopeRequired, - patch_auto_top_up, - ) - - ar = state.auto_reload - thr = float(ar.threshold_usd) if ar and ar.threshold_usd is not None else 0.0 - rel = float(ar.reload_to_usd) if ar and ar.reload_to_usd is not None else 0.0 - try: - patch_auto_top_up(enabled=False, threshold=thr, top_up_amount=rel) - except BillingScopeRequired: - self._billing_handle_scope_required(state) - return - except BillingError as exc: - self._billing_render_charge_error(state, exc) - return - print(" ✅ Auto-reload turned off.") - - def _billing_limit_screen(self, state): - """Screen 5 — monthly spend limit (read-only; cap is portal-only).""" - from agent.billing_view import format_money - - print() - _cprint(f" 💳 {_b('Monthly spend limit')}") - print(f" {'─' * 41}") - cap = state.monthly_cap - if cap is None or cap.limit_usd is None: - _cprint(f" {_d('No monthly cap visible (managed on the portal).')}") - else: - spent = format_money(cap.spent_this_month_usd) - limit = format_money(cap.limit_usd) - ceiling = " (default ceiling)" if cap.is_default_ceiling else "" - print(f" {spent} of {limit} used this month{ceiling}") - _limit_note = ( - "The monthly limit is set on the portal — the terminal shows " - "it read-only." - ) - _cprint(f" {_d(_limit_note)}") - self._billing_portal_hint(state) - def _show_insights(self, command: str = "/insights"): """Show usage insights and analytics from session history.""" # Parse optional --days flag diff --git a/hermes_cli/cli_billing_mixin.py b/hermes_cli/cli_billing_mixin.py new file mode 100644 index 000000000000..49b89fa77b93 --- /dev/null +++ b/hermes_cli/cli_billing_mixin.py @@ -0,0 +1,1455 @@ +"""Billing and subscription handlers for the interactive CLI (god-file decomposition). + +This module hosts the Nous billing/subscription methods lifted out of +``cli.py``'s ``HermesCLI`` class. ``HermesCLI`` inherits +``CLIBillingMixin`` so every ``self.`` call resolves unchanged +via the MRO — behavior-neutral apart from focused billing fixes. + +Import discipline mirrors ``hermes_cli.cli_commands_mixin``: + * Neutral, non-cyclic dependencies are imported at module top level below. + * cli.py-internal symbols (the ``_cprint``/``_b``/``_d`` helpers and + display constants) are imported LAZILY inside each method via + ``from cli import ...``. The mixin never imports ``cli`` at module load + time, avoiding the cycle created when ``cli.py`` imports this mixin. +""" + +from __future__ import annotations + +import time + + +class CLIBillingMixin: + """Mixin holding interactive-CLI billing and subscription handlers.""" + + def _print_nous_credits_block(self) -> bool: + """Print the Nous dollar balance block (two-bar view) when a Nous account + is logged in. Returns True if it printed anything. + + Prefers the shared dollar usage model (``agent.billing_usage`` — two-bar + plan/top-up view, dollars-only, the /usage + /subscription source of + truth). Falls back to the legacy ``nous_credits_lines`` text only when the + model is unavailable. Agent-independent (a portal fetch gated on "a Nous + account is logged in"), so /usage shows the block even in the TUI + slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and + wall-clock-bounded; honors HERMES_DEV_CREDITS_FIXTURE for offline testing. + """ + from cli import _cprint, _b, _d + + try: + from agent.billing_usage import build_usage_model, format_renews + + usage = build_usage_model() + except Exception: + usage = None + format_renews = None # type: ignore + + if usage is not None and usage.available and format_renews is not None: + printed_any = False + plan = usage.plan_name or ("Free" if usage.status == "free" else None) + renews_display = getattr(usage, "renews_display", None) or format_renews(usage.renews_at) + renews = f" · renews {renews_display}" if renews_display else "" + if plan: + print() + _cprint(f" {_b(f'Plan: {plan}{renews}')}") + printed_any = True + + # All lines below go through _cprint (same renderer as the Plan line) so + # ordering is deterministic: raw print() and _cprint() flush to different + # buffers under patch_stdout and interleave nondeterministically (the bar + # would race above/below the Plan line across states). Keep one path. + for _bar_ln in self._usage_bar_lines(usage, usage.plan_name): + _cprint(_bar_ln) + printed_any = True + if usage.has_topup and usage.total_spendable_usd is not None: + _cprint(f" Total spendable: ${usage.total_spendable_usd:,.2f}") + + if usage.status == "free": + _cprint(f" {_d('> Free · free models only. Run /subscription to reach paid models.')}") + printed_any = True + elif usage.status == "low": + _amt = f"${usage.total_spendable_usd:,.2f}" if usage.total_spendable_usd is not None else "under $5" + _low = f"! Low balance · {_amt} left. Run /topup or /subscription." + _cprint(f" {_low}") + printed_any = True + + if printed_any: + return True + + # Fallback: legacy text lines (only when the model is unavailable). + from agent.account_usage import nous_credits_lines + + lines = nous_credits_lines() + if not lines: + return False + print() + for line in lines: + print(f" {line}") + return True + + def _print_usage_cta(self) -> None: + """Print the `/usage` call-to-action pointing at /subscription + /topup. + + Mirrors the TUI's ``USAGE_CTA`` (``session.ts``) so every surface ends a + usage read with the same nudge. Only called when a Nous account is logged + in (the balance block printed), since both commands are Nous-account only. + """ + from cli import _cprint, _d + + _cprint(f" {_d('Run /subscription to change plan · /topup to add to your balance')}") + + # ------------------------------------------------------------------ + # /subscription — view plan + change it in the browser (CLI surface) + # ------------------------------------------------------------------ + + def _show_subscription(self): + """`/subscription` (alias `/upgrade`) — view the Nous plan + browser hand-off. + + The CLI mirror of the TUI ``SubscriptionOverlay``: a read of the current + plan, this cycle's subscription credits, renewal date, and the plans you + could switch to — then a deep-link to NAS's own ``/manage-subscription`` + page (NOT the Stripe portal; that page routes upgrade→Checkout / + downgrade→scheduled internally). The terminal NEVER charges for a + subscription. Fail-open: logged-out / portal hiccup degrades to a clear + message, never a crash. Mirrors ``_show_billing`` + discipline for the interactive-vs-text split. + """ + from cli import _cprint, _b, _d + + from agent.subscription_view import build_subscription_state, subscription_manage_url + + state = build_subscription_state() + + if not state.logged_in: + print() + if state.error: + _cprint(f" 💳 {_d(f'Could not load subscription: {state.error}')}") + else: + _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") + print(" Run `hermes portal` to log in, then /subscription.") + return + + # Team context: no personal plan — teams run on a shared balance. + if state.context == "team": + print() + _cprint(f" ⚕ {_b('Team subscription')}") + print(f" {'─' * 41}") + if state.org_name: + role = (state.role or "").title() + _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" + _cprint(f" {_d(_org_line)}") + org = state.org_name or "a team org" + print(f" This terminal is connected to {org}. Teams run on a shared") + print(" balance · use /topup to add funds.") + _cprint(f" {_d('Personal subscriptions live on your personal account.')}") + return + + self._subscription_overview(state, subscription_manage_url(state)) + + def _subscription_overview(self, state, manage_url): + """Print the plan read block, then the browser hand-off to manage it. + + Dollars-only (no "credits") — mirrors the TUI overlay: a status line, the + shared two-bar dollar usage view (plan + top-up) with the plan name on + the bar, and state-matched free/low nudges. No in-terminal tier picker — + the only action is managing the subscription on the portal. + """ + from cli import _cprint, _b, _d + + # Shared dollar usage model (the only source with top-up dollars). + from agent.billing_usage import format_renews + try: + from agent.billing_usage import build_usage_model + + usage = build_usage_model() + except Exception: + usage = None + + c = state.current + is_free = not (c and c.tier_id) + can_change = state.can_change_plan + + plan_name = (c.tier_name or c.tier_id) if c else (usage.plan_name if usage else None) + u_status = getattr(usage, "status", None) if usage else None + view_only = not can_change + renews_display = getattr(usage, "renews_display", None) if usage else None + if not renews_display and c and c.cycle_ends_at: + renews_display = format_renews(c.cycle_ends_at) + + # Status line — dollars-only, with a "→ Plus" echo of a pending change so + # the headline itself carries a scheduled downgrade/cancellation. + _flip = "" + if c and c.cancel_at_period_end: + _flip = " → cancels" + elif c and c.pending_downgrade_tier_name: + _flip = f" → {c.pending_downgrade_tier_name}" + if not plan_name: + status = "Plan: Free · free models only" + elif usage is not None and u_status == "low" and usage.total_spendable_usd is not None: + _tot = f"${usage.total_spendable_usd:,.2f}" + status = f"Plan: {plan_name}{_flip} · {_tot} left" + else: + _spend = getattr(usage, "total_spendable_usd", None) if usage else None + _left = f" · ${_spend:,.2f} left" if _spend is not None else "" + _tail = " · view only" if view_only else (f" · renews {renews_display}" if renews_display else "") + status = f"Plan: {plan_name}{_flip}{_left}{_tail}" + + # Lead with the scheduled change (cancel > downgrade) so it can't read as + # "nothing happened" — mirrors the TUI banner. All-`_cprint` (blanks + # included) so the block orders deterministically even when piped. + _trans = None + if c and c.cancel_at_period_end: + _when = format_renews(c.cancellation_effective_at) or "the end of the billing period" + _trans = ((c.tier_name or "your plan"), "cancels", _when) + elif c and c.pending_downgrade_tier_name: + _when = format_renews(c.pending_downgrade_at) or "the end of the cycle" + _trans = ((c.tier_name or "your plan"), c.pending_downgrade_tier_name, _when) + _cprint("") + if _trans: + _from, _to, _when = _trans + _cprint(f" ⏳ {_b('Scheduled change')}") + _cprint(f" {_from} ──▶ {_to} {_d('· ' + _when)}") + _cprint(f" {_d(f'You keep {_from} (and its credits) until then.')}") + _cprint("") + + _cprint(f" ⚕ {_b(status)}") + print(f" {'─' * 41}") + + # Two-bar dollar usage view — plan name labels the plan bar. + for _bar_ln in self._usage_bar_lines(usage, plan_name): + print(_bar_ln) + if usage and getattr(usage, "has_topup", False) and getattr(usage, "total_spendable_usd", None) is not None: + print(f" Total spendable: ${usage.total_spendable_usd:,.2f}") + + # State-matched nudge (free upsell / low alert; healthy stays silent). + if is_free: + _cprint(f" {_d('> Paid models need a subscription. Start one to reach them.')}") + elif u_status == "low": + _amt = f"${usage.total_spendable_usd:,.2f}" if usage is not None and usage.total_spendable_usd is not None else "under $5" + _low = f"! Low balance · {_amt} left. Top up or upgrade before a mid-run cutoff." + _cprint(f" {_low}") + + if state.org_name: + role = (state.role or "").title() + _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" + _cprint(f" {_d(_org_line)}") + print(f" {'─' * 41}") + + # ── Actions ── Members (non-admin) and non-interactive contexts fall back + # to the portal hand-off; a paid admin/owner gets the full in-terminal + # change flow (parity with the TUI overlay). + if not can_change: + print() + _cprint(f" {_d('Plan changes need an org admin/owner.')}") + if manage_url: + print(f" Manage on portal: {manage_url}") + return + + if not getattr(self, "_app", None): + # Non-interactive (TUI slash-worker / piped): the modal can't run. + print() + if manage_url: + print(f" Manage your subscription: {manage_url}") + print(" Open it in your browser, then re-run /subscription.") + return + + if is_free: + # Starting a NEW subscription needs a fresh card — deep-link only. + self._subscription_open_portal(state, manage_url, verb="Start a subscription") + return + + # Paid + admin/owner + interactive → the in-terminal change flow. + self._subscription_change_menu(state, manage_url) + + def _subscription_open_portal(self, state, manage_url, *, verb="Manage your subscription"): + """Open / copy the manage-subscription URL — the portal hand-off.""" + from cli import _cprint, _d + + if not manage_url: + print() + _cprint(f" {_d('No manage URL available — is your portal configured?')}") + return + print() + choices = [ + ("open", verb, "open the subscription page in your browser"), + ("copy", "Copy link", "copy the manage-subscription URL to your clipboard"), + ("cancel", "Cancel", "do nothing"), + ] + raw = self._prompt_text_input_modal(title=verb, detail="", choices=choices) + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice == "open": + opened = False + try: + import webbrowser + + opened = webbrowser.open(manage_url) + except Exception: + opened = False + if not opened: + print(f" Open this URL: {manage_url}") + print() + print(" Finish in your browser, then re-run /subscription.") + elif choice == "copy": + try: + self._write_osc52_clipboard(manage_url) + print(f" 📋 Copied: {manage_url}") + except Exception: + print(f" Manage URL: {manage_url}") + else: + print(" 🟡 Cancelled.") + + def _subscription_change_menu(self, state, manage_url): + """The in-terminal change menu for a paid admin/owner (interactive).""" + c = state.current + has_pending = bool(c and (c.cancel_at_period_end or c.pending_downgrade_tier_name)) + keep_name = (c.tier_name if c else None) or "your plan" + # When a change is already scheduled, undo is the most likely next intent → + # promote it first (parity with the TUI). The Close row uses value "close" + # (not "cancel") so typing the word "cancel" — which the alias table would + # map to a Close row — can't be confused with "Cancel subscription". + if has_pending: + choices = [ + ("keep", f"Keep {keep_name} (undo the scheduled change)", "cancel the pending change"), + ("change", "Change plan", "upgrade or downgrade in the terminal"), + ] + else: + choices = [ + ("change", "Change plan", "upgrade or downgrade in the terminal"), + ("cancel_sub", "Cancel subscription", "schedule cancellation at period end"), + ] + choices.append(("portal", "Manage on portal", "open the billing page in your browser")) + choices.append(("close", "Close", "do nothing")) + raw = self._prompt_text_input_modal(title="Manage your subscription", detail="", choices=choices) + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice == "change": + self._subscription_pick_tier(state) + elif choice == "keep": + self._subscription_apply(state, ("resume", None)) + elif choice == "cancel_sub": + self._subscription_confirm_cancel(state) + elif choice == "portal": + self._subscription_open_portal(state, manage_url) + else: + print(" 🟡 Closed. No plan change.") + + def _subscription_pick_tier(self, state): + """Tier picker → preview → confirm (mirrors the TUI picker screen).""" + from agent.billing_view import format_money + + c = state.current + tiers = tuple(state.tiers or ()) + cur_order = next((t.tier_order for t in tiers if t.is_current), 0) + # Selectable = enabled paid tiers other than current (free/no-sub excluded; + # dropping to free is a cancellation, on the change menu). Sorted by price. + selectable = sorted( + [t for t in tiers if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0], + key=lambda t: t.tier_order or 0, + ) + if not selectable: + print(" No other plans are available to switch to right now.") + return + choices = [] + for t in selectable: + direction = "upgrade" if (t.tier_order or 0) > cur_order else "downgrade" + choices.append((t.tier_id, f"{t.name} · {format_money(t.dollars_per_month)}/mo · {direction}", f"switch to {t.name}")) + choices.append(("cancel", "Back", "do nothing")) + raw = self._prompt_text_input_modal( + title="Change plan", + detail=f"Current: {c.tier_name if c else 'Free'}. Pick a plan to preview the effect.", + choices=choices, + ) + choice = self._normalize_slash_confirm_choice(raw, choices) + if not choice or choice == "cancel": + print(" 🟡 Cancelled. No plan change.") + return + self._subscription_preview_and_confirm(state, choice) + + def _subscription_preview_and_confirm(self, state, tier_id, *, allow_stepup=True): + """Preview the change (chargeless quote), show the effect, then confirm+apply. + + ``allow_stepup=False`` (a post-grant replay) declines a second step-up on a + repeated scope denial so the flow can't re-prompt/re-open the browser in a + loop. + """ + from cli import _cprint, _b, _d + + from agent.subscription_view import subscription_change_preview_from_payload + from hermes_cli.nous_billing import BillingError, BillingScopeRequired, post_subscription_preview + + _cprint(f" {_d('Checking the change…')}") + try: + payload = post_subscription_preview(subscription_type_id=tier_id) + except BillingScopeRequired: + if allow_stepup: + self._subscription_handle_scope_required(state, retry=("preview", tier_id)) + else: + print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") + return + except BillingError as exc: + self._subscription_render_error(state, exc) + return + p = subscription_change_preview_from_payload(payload) + effect = p.effect + target = p.target_tier_name or "the selected plan" + print() + if effect == "no_op": + _cprint(f" {_d(f'You are already on {target} — nothing to change.')}") + return + if effect not in ("charge_now", "scheduled"): + # blocked OR an unknown/unexpected effect → fail SAFE (never schedule a + # real change on an unrecognized string, unlike a bare `else`), and + # re-offer the portal hand-off like the TUI's blocked branch. + from agent.subscription_view import subscription_manage_url + + _cprint(f" 🟡 {p.reason or 'This change cannot be confirmed here — manage it on the portal.'}") + _mu = subscription_manage_url(state) + if _mu: + print(f" Manage on portal: {_mu}") + return + if effect == "charge_now": + _amt = f"${p.amount_due_now_cents / 100:.2f}" if p.amount_due_now_cents is not None else None + _cprint(f" {_b('Confirm plan change')} {_d('· charged now')}") + if _amt: + _cprint(f" Upgrade to {target}. You will be charged {_amt} now (prorated).") + else: + _cprint(f" Upgrade to {target}. You will be charged the prorated amount now.") + # Best-effort: name the exact card (billing.state), but only when the + # resolver rung matches what a subscription charge actually uses + # (subPin / customerDefault — Stripe's own precedence). Any failure or + # older NAS → the generic line stands. + _card_line = "The card on your subscription will be charged." + try: + from agent.billing_view import build_billing_state + + _bs = build_billing_state(timeout=6.0) + _c = _bs.card if _bs.logged_in else None + if _c is not None and _c.resolved_via in ("subPin", "customerDefault"): + _card_line = f"{_c.masked} — the card on your subscription — will be charged." + except Exception: + pass + _cprint(f" {_d(_card_line)}") + pay_label = f"Pay {_amt} & upgrade now" if _amt else "Upgrade now (prorated charge)" + action = ("upgrade", tier_id) + # The money-moving row is NOT the default — a bare Enter hits "Go back", + # so a single stray keystroke can't charge the card. + confirm_choices = [ + ("cancel", "Go back", "do not charge"), + ("yes", pay_label, "charge + upgrade now"), + ] + else: # scheduled (whitelisted above) + _when = p.effective_at[:10] if (p.effective_at and len(p.effective_at) >= 10) else "the end of the billing period" + _cprint(f" {_b('Confirm plan change')} {_d('· scheduled · not today')}") + _cprint(f" Change to {target} — takes effect {_when}. No charge now; you keep your current plan until then.") + pay_label = f"Schedule change to {target}" + action = ("schedule", tier_id) + confirm_choices = [ + ("yes", pay_label, "apply this change"), + ("cancel", "Go back", "do not change"), + ] + if p.monthly_credits_delta: + _cprint(f" {_d(f'Monthly credits change: {p.monthly_credits_delta}.')}") + raw = self._prompt_text_input_modal(title=pay_label, detail="", choices=confirm_choices) + if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": + print(" 🟡 Cancelled. No plan change.") + return + self._subscription_apply(state, action, allow_stepup=allow_stepup) + + def _subscription_confirm_cancel(self, state): + """Confirm, then schedule a cancellation at period end.""" + from cli import _cprint, _b, _d + + from agent.billing_usage import format_renews + + c = state.current + _end = (format_renews(c.cycle_ends_at) if (c and c.cycle_ends_at) else None) or "the end of the billing period" + print() + _cprint(f" {_b('Confirm cancellation')} {_d('· scheduled · not today')}") + _cprint(f" Cancel {(c.tier_name if c else 'your plan')} — it stays active until {_end}, then won't renew.") + _cprint(f" {_d('You keep your remaining credits for this period. You can resume before it ends.')}") + confirm_choices = [ + ("yes", "Cancel subscription", "schedule cancellation at period end"), + ("cancel", "Go back", "keep your plan"), + ] + raw = self._prompt_text_input_modal(title="Cancel subscription?", detail="", choices=confirm_choices) + if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": + print(" 🟡 Cancelled. Your plan is unchanged.") + return + self._subscription_apply(state, ("cancel", None)) + + def _subscription_apply(self, state, action, idempotency_key=None, *, allow_stepup=True): + """Run the mutation for `action`, handling the scope step-up + the result. + + `action` is one of ("upgrade", tier_id) / ("schedule", tier_id) / + ("cancel", None) / ("resume", None). insufficient_scope routes to the + step-up and replays; the upgrade idempotency key is reused across the replay. + ``allow_stepup=False`` (a post-grant replay) declines a second step-up on a + repeated scope denial so the flow can't re-prompt/re-open the browser in a loop. + """ + from cli import _cprint, _d, _DIM, _RST + + from hermes_cli.nous_billing import ( + BillingError, + BillingTransient, + BillingRemoteSpendingRevoked, + BillingScopeRequired, + BillingSessionRevoked, + delete_subscription_pending_change, + post_subscription_upgrade, + put_subscription_pending_change, + ) + + kind, arg = action + key = None + if kind == "upgrade": + from agent.billing_view import new_idempotency_key + + key = idempotency_key or new_idempotency_key() + try: + if kind == "upgrade": + try: + res = post_subscription_upgrade(subscription_type_id=arg, idempotency_key=key) or {} + except BillingScopeRequired: + raise # a scope denial rejects BEFORE charging → route to the step-up + except (BillingTransient, BillingSessionRevoked, BillingRemoteSpendingRevoked) as exc: + # Deterministic PRE-charge typed rejections (429 / 401 / 403) never + # reached Stripe → surface the CORRECT recovery (retry_after / re-login / + # reconnect), NOT the "maybe charged" ambiguity copy. + self._subscription_render_error(state, exc) + return + except BillingError as exc: + _status = getattr(exc, "status", None) + _code = getattr(exc, "error", None) + if _code in ("network_error", "endpoint_unavailable") or _status is None or _status >= 500: + # Genuinely INDETERMINATE — transport / unparseable 2xx / a 5xx the + # server hit mid-request: NAS may have already prorated + charged. + # Steer to a re-check, never a blind retry (a fresh key can't dedup → + # a real second charge). + self._subscription_render_upgrade_ambiguous(exc) + else: + # A deterministic 4xx (role_required / no_payment_method / …) → the + # normal error copy, not "maybe charged". + self._subscription_render_error(state, exc) + return + status = res.get("status") + name = res.get("targetTierName") or "your new plan" + _url = res.get("recoveryUrl") + if status == "already_on_tier": + _cprint(f" {_DIM}✓ You are already on {name}.{_RST}") + elif status == "upgraded": + _cprint(f" {_DIM}✓ Upgraded to {name}. Your new monthly credits land in a moment.{_RST}") + elif status == "requires_action": + _cprint(" 🟡 This upgrade needs extra verification (3DS). Finish it on the portal.") + if _url: + _cprint(f" Portal: {_url}") + elif status == "payment_failed": + _cprint(" 🔴 Your card was declined. Update your payment method on the portal and try again.") + if _url: + _cprint(f" Portal: {_url}") + else: + # Unknown / absent 2xx status → also ambiguous, not a flat failure. + self._subscription_render_upgrade_ambiguous(None) + return + if kind == "schedule": + put_subscription_pending_change(subscription_type_id=arg) + _cprint(f" {_DIM}✓ Scheduled — your plan doesn't change today. You keep it until the end of the billing period, then it switches.{_RST}") + elif kind == "cancel": + put_subscription_pending_change(cancel=True) + _cprint(f" {_DIM}✓ Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.{_RST}") + elif kind == "resume": + delete_subscription_pending_change() + _cprint(f" {_DIM}✓ Undone — you stay on your current plan.{_RST}") + _cprint(f" {_d('Re-run /subscription anytime to review it.')}") + except BillingScopeRequired: + if allow_stepup: + self._subscription_handle_scope_required(state, retry=action, idempotency_key=key) + else: + print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") + except BillingError as exc: + self._subscription_render_error(state, exc) + + def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=None): + """insufficient_scope → grant terminal billing (step-up), then replay `retry`. + + Mirrors _billing_handle_scope_required: the classic CLI calls + step_up_nous_billing_scope directly (it opens the browser + blocks), then + replays the held preview/mutation so the user never re-runs the command. + """ + from cli import _cprint, _d, _DIM, _RST + + print() + print(" ! One-time setup") + _cprint(f" {_d('To change your plan from the terminal, enable terminal billing once. It opens your browser to authorize, then your change picks up right here.')}") + if not getattr(self, "_app", None): + print(" Run `hermes portal` and enable terminal billing, then re-run /subscription.") + return + confirm_choices = [ + ("yes", "Enable terminal billing", "open your browser to authorize"), + ("no", "Not now", "cancel"), + ] + raw = self._prompt_text_input_modal( + title="Enable terminal billing", + detail="Opens your browser to authorize this terminal.", + choices=confirm_choices, + ) + if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": + print(" No change made. Enable terminal billing when you're ready.") + return + print(" Opening your browser to enable terminal billing…") + try: + from hermes_cli.auth import step_up_nous_billing_scope + + granted = step_up_nous_billing_scope(open_browser=True) + except Exception as exc: + print(f" Couldn't enable terminal billing: {exc}") + return + if not granted: + print(" Couldn't enable terminal billing — an org admin or owner has to approve it for this org.") + return + _cprint(f" {_DIM}✓ Terminal billing enabled.{_RST}") + # Bust the 30s token cache so the replay uses the freshly-scoped token. The + # cache still holds the pre-grant unscoped token, and _request only busts it + # on a 401 (not a 403 scope denial) — without this, the replay would 403 + # again and (before the allow_stepup guard) re-prompt in a loop. + try: + from hermes_cli import nous_billing as _nb + + _nb.invalidate_cached_token() + except Exception: + pass + # Re-fetch fresh state, then replay the held action ONCE (allow_stepup=False + # so a repeated scope denial can't re-enter the step-up). + from agent.subscription_view import build_subscription_state + + try: + fresh = build_subscription_state() + except Exception: + fresh = state + rkind, rarg = retry + if rkind == "preview": + self._subscription_preview_and_confirm(fresh, rarg, allow_stepup=False) + else: + self._subscription_apply(fresh, retry, idempotency_key=idempotency_key, allow_stepup=False) + + def _subscription_render_error(self, state, exc): + """Render a subscription BillingError (a lighter _billing_render_charge_error).""" + from cli import _cprint + + code = getattr(exc, "error", None) + msg = str(exc) or "Something went wrong." + if code == "insufficient_scope": + # Defensive: the flow routes scope to the step-up before reaching here. + _cprint(" 🟡 Terminal billing isn't enabled. Enable it, then retry.") + elif code in ("subscription_mutation_rejected", "preview_rejected"): + _cprint(f" 🟡 {msg}") + else: + _cprint(f" 🔴 {msg}") + _url = getattr(exc, "portal_url", None) + if _url: + _cprint(f" Portal: {_url}") + + def _subscription_render_upgrade_ambiguous(self, exc): + """A charge-route failure (transport / timeout / 500 / unknown status) is + AMBIGUOUS — NAS may have already prorated + charged. Steer to a re-check, + never a flat failure that invites a blind retry (mirrors the TUI's + upgradeResult(null) — the CLI can't persist the key across a command re-run, + so a re-check is the safe path).""" + from cli import _cprint, _d + + _cprint(" 🟡 Couldn't confirm the upgrade — your card may or may not have been charged.") + _cprint(f" {_d('Re-run /subscription to check your plan before trying again.')}") + _url = getattr(exc, "portal_url", None) if exc is not None else None + if _url: + _cprint(f" Portal: {_url}") + + # ------------------------------------------------------------------ + # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) + # ------------------------------------------------------------------ + + def _show_billing(self, command: str = "/topup"): + """`/topup` — terminal billing for Nous (one interactive modal). + + ZERO sub-commands: any argument is ignored. Bare ``/topup`` always + opens the Overview (Screen 1), whose numbered menu is the *only* way to + reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified + UX spec §0.4 — ``/topup buy`` etc. are gone; we don't error on a stray + arg, we just open the menu.) + + Interactive CLI uses the prompt_toolkit modal; non-interactive contexts + (TUI slash-worker / no live app) render text + the portal deep-link, never + prompting (the URL is the affordance), same discipline as ``_show_subscription``. + All money is Decimal end-to-end; the terminal never collects card details. + """ + from cli import _cprint, _d + + from agent.billing_view import build_billing_state + + state = build_billing_state() + if not state.logged_in: + print() + if state.error: + _msg = f"Couldn't load billing: {state.error}" + _cprint(f" 💳 {_d(_msg)}") + else: + _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") + print(" Run `hermes portal` to log in, then /topup.") + return + + # Any sub-arg is intentionally ignored — always open the menu. + self._billing_overview(state) + + def _billing_portal_hint(self, state, *, reason: str = "") -> None: + """Print a portal deep-link line (the funnel for portal-only actions).""" + url = getattr(state, "portal_url", None) + if not url: + return + if reason: + print(f" {reason}") + print(f" Manage on portal: {url}") + + def _billing_overview(self, state): + """Screen 1 — overview: balance in title, two-bar dollar usage, action menu. + + Dollars-only (no "credits") — mirrors the TUI /topup overlay: balance + leads in the title, the shared plan + top-up bars render below, then the + reordered menu (Add funds first). No scope preflight — terminal billing + is discovered reactively when a charge 403s insufficient_scope. + """ + from cli import _cprint, _b, _d + + from agent.billing_view import format_money + + # Shared dollar usage model (plan + top-up bars), same source as /usage. + try: + from agent.billing_usage import build_usage_model + + usage = build_usage_model() + except Exception: + usage = None + + print() + _cprint(f" 💳 {_b(f'Top up · balance {format_money(state.balance_usd)}')}") + if state.org_name: + role = (state.role or "").title() + _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" + _cprint(f" {_d(_org_line)}") + print(f" {'─' * 41}") + + # Two-bar dollar usage view (plan name on the plan bar; top-up below). + for _bar_ln in self._usage_bar_lines(usage, getattr(usage, "plan_name", None)): + print(_bar_ln) + + ar = state.auto_reload + if ar is not None: + if ar.enabled: + print( + f" Auto-reload: on — below {format_money(ar.threshold_usd)} " + f"→ reload to {format_money(ar.reload_to_usd)}" + ) + else: + print(" Auto-reload: off") + # Card presence at a glance: which card a charge would use (with why — + # "the card on your subscription"), or that none is saved. Only for the + # full-menu case (admin + billing on) — others get the portal note below. + if state.can_change_plan and state.cli_billing_enabled: + if state.card is not None: + print(f" Card: {state.card.display}") + else: + _cprint(f" {_d('No saved card on file — “Add funds” walks you through adding one.')}") + print(f" {'─' * 41}") + + # Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal. + if not state.can_change_plan: + _cprint(f" {_d('Billing actions require an org admin/owner.')}") + self._billing_portal_hint(state) + return + if not state.cli_billing_enabled: + _cprint(f" {_d('Terminal billing is turned off for this org.')}") + self._billing_portal_hint(state, reason="Enable it on the portal to add funds here.") + return + + # A missing card does NOT gate the whole overview — the org may already have + # balance, auto-reload, or a limit to view/manage. The card only matters at + # CHARGE time: "Add funds" -> _billing_buy_flow, which detects no card and + # hands off to the portal there. So always show the full menu below. + + # Non-interactive (slash-worker / no live app): no modal, no sub-command + # advertising — just the portal funnel (the URL is the affordance). + if not getattr(self, "_app", None): + self._billing_portal_hint(state) + return + + # Add funds first, then settings, then the scopeless browser handoff. + # No "Enable terminal billing" item — that's discovered at pay time. + # "Add funds" charges in-terminal against the org's portal-saved card + # (server-held via POST /charge — no card ref leaves the client). A + # missing card is NOT gated here: the buy flow reacts to the server's + # no_payment_method 403 and hands off to the portal at charge time. + choices = [ + ("buy", "Add funds", "add money to your balance"), + ("auto", "Auto-reload", "configure automatic top-ups"), + ("limit", "Monthly limit", "show the monthly spend cap (read-only)"), + ("portal", "Manage on portal", "open the billing page in your browser"), + ("cancel", "Cancel", "do nothing"), + ] + # The overview summary is already printed above; the modal only needs to + # present the action menu — repeating the title/balance reads as a dupe. + raw = self._prompt_text_input_modal( + title="Top up your balance", detail="", + choices=choices, + ) + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice == "buy": + self._billing_buy_flow(state) + elif choice == "auto": + self._billing_auto_reload_flow(state) + elif choice == "limit": + self._billing_limit_screen(state) + elif choice == "portal": + self._billing_open_portal(state) + else: + print(" Cancelled.") + + def _usage_bar_lines(self, usage, plan_name) -> list: + """The plan + top-up dollar bars as ready-to-print lines (filled = remaining). + + Returns [] when there's nothing to draw. The caller resolves ``plan_name`` + (the plan-bar label) and picks its own print fn — block ordering differs + per surface (``_cprint`` vs ``print`` under patch_stdout). One source of + truth for the bar format across /usage, /subscription, and /topup. + """ + lines: list = [] + pb = getattr(usage, "plan_bar", None) if usage else None + if pb is not None and pb.total_usd > 0: + filled = max(0, min(10, round(pb.fill_fraction * 10))) + bar = ("█" * filled) + ("░" * (10 - filled)) + pct_s = f" · {pb.pct_used}% used" if pb.pct_used is not None else "" + label = (plan_name or "plan").ljust(8)[:8] + lines.append(f" {label}[{bar}] ${pb.remaining_usd:,.2f} left of ${pb.total_usd:,.2f}{pct_s}") + tb = getattr(usage, "topup_bar", None) if usage else None + if tb is not None and tb.remaining_usd > 0: + lines.append(f" {'top-up'.ljust(8)}[{'█' * 10}] ${tb.remaining_usd:,.2f} · never expires") + return lines + + def _billing_open_portal(self, state): + url = getattr(state, "portal_url", None) + if not url: + print(" No portal URL available.") + return + opened = False + try: + import webbrowser + + opened = webbrowser.open(url) + except Exception: + opened = False + if not opened: + print(f" Open this URL: {url}") + print(" Complete billing changes in the browser.") + + def _billing_require_admin(self, state) -> bool: + """Guard charge/auto-reload entry points; print + return False if blocked.""" + from cli import _cprint, _d + + if not state.can_change_plan: + print() + _cprint(f" 💳 {_d('Billing actions require an org admin/owner.')}") + self._billing_portal_hint(state) + return False + if not state.cli_billing_enabled: + print() + _cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}") + self._billing_portal_hint(state, reason="Enable it on the portal first.") + return False + return True + + def _billing_add_card_flow(self, state): + """No saved card → guide adding one on the portal, with a re-check loop. + + Cards are added on the portal (never in-terminal). "I've added it" re-fetches + billing state so the purchase continues right here once the card is saved — + this also recovers a transient miss (the card display is best-effort + server-side). Returns the refreshed state (card present), or None to abandon. + """ + from cli import _cprint, _b, _d, _DIM, _RST + + print() + _cprint(f" 💳 {_b('Add a card first')}") + _cprint(" No saved card on file.") + _cprint(f" {_d('Add a card once on the portal billing page — after that you can top up right from the terminal.')}") + choices = [ + ("portal", "Add a card on the portal", "opens the billing page in your browser"), + ("recheck", "I've added it — check again", "re-check for the card and continue"), + ("cancel", "Back", "do nothing"), + ] + for _ in range(8): # bounded: portal-open plus a handful of re-checks + raw = self._prompt_text_input_modal(title="Add a card", detail="", choices=choices) + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice == "portal": + self._billing_open_portal(state) + _cprint(f" {_d('Add the card on the billing page, then pick “check again” here.')}") + continue + if choice == "recheck": + from agent.billing_view import build_billing_state + + try: + fresh = build_billing_state() + except Exception: + fresh = None + if fresh is not None and fresh.logged_in: + state = fresh + if state.card is not None: + _cprint(f" {_DIM}✓ Card found: {state.card.display} — continuing.{_RST}") + return state + print(" Still no card on file — finish adding it on the portal, then check again.") + continue + break + print(" Cancelled. No funds added.") + return None + + def _billing_buy_flow(self, state): + """Screen 2 (preset select) → Screen 3 (confirm + charge + poll).""" + from cli import _cprint, _b + + from agent.billing_view import format_money, validate_charge_amount + + if not self._billing_require_admin(state): + return + + # No card / scope preflight here — that's the rejected anti-pattern. We let + # the charge fly and react to whatever 403 the server returns: scope first + # (insufficient_scope → in-flight reauth), then card (no_payment_method → + # portal handoff via _billing_render_charge_error). Mirrors the server's gate + # order; the user only hits the flow they actually need. + + # Screen 3 — preset selection. + if not getattr(self, "_app", None): + presets = ", ".join(format_money(p) for p in state.charge_presets) + print() + _cprint(f" 💳 {_b('Add funds')}") + print(f" Presets: {presets}") + print(" Run this in the interactive CLI to complete a purchase.") + self._billing_portal_hint(state) + return + + # No card on file → the guided ADD-CARD path first (portal + re-check), + # so the user isn't walked through picking an amount that will 403. + # Returns refreshed state with a card, or None (abandoned). + if state.card is None: + state = self._billing_add_card_flow(state) + if state is None or state.card is None: + return + + preset_choices = [] + for p in state.charge_presets: + preset_choices.append((str(p), format_money(p), "one-time credit purchase")) + preset_choices.append(("custom", "Custom amount…", "enter your own amount")) + preset_choices.append(("cancel", "Cancel", "do nothing")) + + card = state.card + detail = f"Payment: {card.display}" if card else "No saved card on file" + raw = self._prompt_text_input_modal( + title="Add funds", detail=detail, choices=preset_choices, + ) + choice = self._normalize_slash_confirm_choice(raw, preset_choices) + if not choice or choice == "cancel": + print(" Cancelled. No funds added.") + return + + from decimal import Decimal + + if choice == "custom": + entered = self._prompt_text_input(" Amount (USD): ") + if entered is None: + # None = cancelled (e.g. slash-worker can't prompt off-thread). + print(" Cancelled. No funds added.") + return + v = validate_charge_amount( + entered or "", min_usd=state.min_usd, max_usd=state.max_usd + ) + if not v.ok: + print(f" 🔴 {v.error}") + return + amount = v.amount + else: + try: + amount = Decimal(choice) + except Exception: + print(" 🔴 Invalid selection.") + return + + self._billing_confirm_and_charge(state, amount) + + def _billing_confirm_and_charge(self, state, amount): + """Screen 3 — confirm total + consent, charge, then poll to settlement.""" + from cli import _cprint, _b, _d + + from agent.billing_view import format_money, new_idempotency_key + + card = state.card + print() + _cprint(f" 💳 {_b('Confirm purchase')}") + print(f" {'─' * 41}") + print(f" Total: {format_money(amount)}") + if card: + print(f" Payment: {card.display}") + # Provenance-less payloads (older NAS) keep the generic line; when + # the resolver says WHY this card, the Payment line carries it. + if card.provenance is None: + _cprint(f" {_d('Your card saved on the portal will be charged.')}") + print(f" {'─' * 41}") + _consent = ( + "By confirming, you allow Nous Research to charge your card." + ) + _cprint(f" {_d(_consent)}") + + confirm_choices = [ + ("pay", f"Pay {format_money(amount)} now", "submit the charge"), + ("portal", "Manage on portal", "manage your card / billing in the browser"), + ("cancel", "Go back", "do not charge"), + ] + if not getattr(self, "_app", None): + print(" Run in the interactive CLI to confirm a purchase.") + return + raw = self._prompt_text_input_modal( + title=f"Pay {format_money(amount)}?", + detail=(card.display if card else "no saved card"), + choices=confirm_choices, + ) + choice = self._normalize_slash_confirm_choice(raw, confirm_choices) + if choice == "portal": + self._billing_open_portal(state) + return + if choice != "pay": + print(" Cancelled. No funds added.") + return + + # Submit the charge with a fresh idempotency key (reused on retry). + from hermes_cli.nous_billing import ( + BillingError, + BillingScopeRequired, + post_charge, + ) + + key = new_idempotency_key() + try: + result = post_charge(amount_usd=amount, idempotency_key=key) + except BillingScopeRequired: + # In-flight reauth: enable terminal billing, then resume THIS charge + # (press-Enter beat) — no command re-run. Reuses the same idem key. + self._billing_handle_scope_required(state, amount=amount, idempotency_key=key) + return + except BillingError as exc: + self._billing_render_charge_error(state, exc) + return + + charge_id = result.get("chargeId") + if not charge_id: + print(" 🔴 No charge id returned; please check the portal.") + return + _cprint(f" {_d('Charge submitted — confirming settlement…')}") + self._billing_poll_charge(state, charge_id, amount) + + def _billing_poll_charge(self, state, charge_id, amount): + """Poll loop: 2s interval, 5-min cap, cancellable. settled = ledger truth.""" + import time as _time + + from agent.billing_view import format_money + from hermes_cli.nous_billing import ( + BillingError, + BillingTransient, + get_charge_status, + ) + + deadline = _time.time() + 300 # 5-minute cap + interval = 2.0 + while _time.time() < deadline: + try: + status = get_charge_status(charge_id) + except BillingTransient as exc: + # Retry-after, NOT a failure — back off and keep polling. + wait = exc.retry_after or 5 + _time.sleep(min(wait, 30)) + continue + except BillingError as exc: + print(f" 🔴 Could not check the charge: {exc}") + return + + state_str = status.get("status") + if state_str == "settled": + amt = status.get("amountUsd") + from agent.billing_view import parse_money + + shown = format_money(parse_money(amt)) if amt else format_money(amount) + print(f" ✓ {shown} added to your balance.") + return + if state_str == "failed": + self._billing_render_charge_failed(state, status.get("reason")) + return + # pending → wait and poll again + _time.sleep(interval) + + # Past the cap with no terminal state = timeout (not an error). + print(" 🟡 Still processing after 5 minutes — this is a timeout, not a " + "failure. Check /billing or the portal shortly.") + self._billing_portal_hint(state) + + def _billing_render_charge_failed(self, state, reason): + """Branch the poll `failed` reasons to the right copy + portal funnel.""" + reason = (reason or "").strip() + if reason == "authentication_required": + print(" 🔴 Your bank requires verification (3DS). Complete it on the " + "portal to finish this purchase.") + elif reason == "payment_method_expired": + print(" 🔴 Your card has expired. Update it on the portal.") + elif reason == "card_declined": + print(" 🔴 Your card was declined. Try another card on the portal.") + else: + print(f" 🔴 The charge didn't go through ({reason or 'processing_error'}).") + self._billing_portal_hint(state) + + def _billing_render_charge_error(self, state, exc): + """Render a typed BillingError at submit time (pre-poll).""" + from hermes_cli.nous_billing import ( + BillingTransient, + BillingRemoteSpendingRevoked, + BillingSessionRevoked, + ) + + code = getattr(exc, "error", None) + actor = getattr(exc, "actor", None) + portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None) + if isinstance(exc, BillingRemoteSpendingRevoked) or code == "remote_spending_revoked": + # CF-4: this terminal's spend was revoked. Recovery is reconnect. + who = ("An admin stopped this terminal's spending." + if actor == "admin" + else "You stopped this terminal's spending.") + print(f" 🔴 {who} Reconnect to restore — run `hermes portal` to re-authorize.") + elif isinstance(exc, BillingSessionRevoked) or code == "session_revoked": + print(" 🔴 Your session was logged out. Run `hermes portal` to log in again.") + elif code == "no_payment_method": + print(" 💳 No card on file — top up and manage billing on the portal.") + elif code in ("cli_billing_disabled", "remote_spending_disabled") or \ + getattr(exc, "code", None) == "remote_spending_disabled": + print(" Terminal billing is off for this account — an admin must enable it on the portal.") + elif code == "role_required": + print(" Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.") + elif code == "idempotency_conflict": + print(" 🔴 That charge key was already used for a different amount. Start a fresh top-up.") + elif code == "monthly_cap_exceeded": + remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd") + if remaining is not None: + print(f" 🔴 Monthly spend cap reached — ${remaining} headroom left.") + else: + print(" 🔴 Monthly spend cap reached.") + elif isinstance(exc, BillingTransient): + wait = getattr(exc, "retry_after", None) + mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else "" + print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.") + elif code == "insufficient_scope": + # Never leak the raw billing:manage scope (the post-grant replay can + # re-raise it if the grant raced) — the concept is "terminal billing". + print(" 🔴 Terminal billing needs approval — run /topup to enable it, then retry.") + else: + print(f" 🔴 {exc}") + if portal_url: + print(f" Portal: {portal_url}") + + def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key=None): + """403 insufficient_scope → in-flight reauth, then resume the held charge. + + The buy path discovers terminal billing isn't enabled only when the + charge 403s — there is no preflight. We enable it in-flight ("Enable + terminal billing" → browser device-flow), then on return ask the user to + press Enter to resume the held ``amount`` (reusing ``idempotency_key`` so + the resumed charge collapses with the original). Never leaks the raw + billing:manage scope. + """ + from cli import _cprint, _d + + from agent.billing_view import format_money + + amount_str = format_money(amount) if amount is not None else "your top-up" + print() + print(" ! One-time setup") + _cprint(f" {_d(f'To charge this terminal, enable terminal billing once. It opens your browser to authorize, then {amount_str} picks up right here.')}") + if not getattr(self, "_app", None): + print(" Run `hermes portal` and enable terminal billing, then retry.") + return + confirm_choices = [ + ("yes", "Enable terminal billing", "open your browser to authorize"), + ("no", "Not now", "cancel"), + ] + raw = self._prompt_text_input_modal( + title="Enable terminal billing", + detail="Opens your browser to authorize this terminal.", + choices=confirm_choices, + ) + choice = self._normalize_slash_confirm_choice(raw, confirm_choices) + if choice != "yes": + print(" No charge made. Run /topup when you want to enable terminal billing.") + return + print(" Opening your browser to enable terminal billing…") + try: + from hermes_cli.auth import step_up_nous_billing_scope + + granted = step_up_nous_billing_scope(open_browser=True) + except Exception as exc: + print(f" Couldn't enable terminal billing: {exc}") + return + if not granted: + print(" Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.") + return + + # Granted. The token now carries the scope, but the ORG kill-switch + # (cli_billing_enabled) is a separate gate — re-fetch /state so we don't + # over-promise when a charge would still hit cli_billing_disabled. + from agent.billing_view import build_billing_state + + fresh = build_billing_state() + if not (fresh.logged_in and fresh.cli_billing_enabled): + print(" Terminal billing was enabled for this terminal, but it's still turned off for this org. Enable it in the portal, then run /topup again.") + self._billing_portal_hint(fresh) + return + + # Scope granted + org kill-switch on — but a charge still needs a card on + # file. If there's none, this is a half-done state: say so and route to the + # portal to top up / manage billing, rather than a bare "✓ enabled" that reads as done. + if fresh.card is None: + print(" ✓ Terminal billing enabled — but there's no card on file yet.") + _cprint(f" {_d('Top up and manage billing on the portal to continue.')}") + self._billing_portal_hint(fresh) + return + + # Nothing to resume (scope-required hit outside a charge, e.g. auto-reload + # config) → just tell the user it's ready. + if amount is None: + print(" ✓ Terminal billing enabled. Run /topup to continue.") + return + + # Press-Enter beat: the user is back from the browser; resume the held + # purchase on an explicit confirm (reassuring, not silent). + print(" ✓ Terminal billing enabled.") + resume_choices = [ + ("resume", f"Resume {format_money(amount)} top-up", "finish the held purchase"), + ("cancel", "Cancel", "do not charge"), + ] + raw = self._prompt_text_input_modal( + title="Resume your top-up", + detail=f"{format_money(amount)} is ready to finish — press Enter to resume.", + choices=resume_choices, + ) + if self._normalize_slash_confirm_choice(raw, resume_choices) != "resume": + print(" Cancelled. No funds added.") + return + + # Replay the held charge, reusing the original idempotency key so a + # double-submit collapses to one charge. + from hermes_cli.nous_billing import BillingError, post_charge + + from agent.billing_view import new_idempotency_key + + key = idempotency_key or new_idempotency_key() + try: + result = post_charge(amount_usd=amount, idempotency_key=key) + except BillingError as exc: + self._billing_render_charge_error(fresh, exc) + return + charge_id = result.get("chargeId") + if not charge_id: + print(" No charge id returned; please check the portal.") + return + _cprint(f" {_d('Resuming your top-up — confirming settlement…')}") + self._billing_poll_charge(fresh, charge_id, amount) + + def _billing_auto_reload_flow(self, state): + """Screen 4 — auto-reload config: threshold + reload-to → PATCH. + + Prefills the current values from ``state.auto_reload``. Validates both + amounts (2dp, within bounds, ``reload_to > threshold``). When auto-reload + is already on, offers a "Turn off" path (PATCH ``enabled:false``). + """ + from cli import _cprint, _b, _d + + from agent.billing_view import format_money, validate_charge_amount + + if not self._billing_require_admin(state): + return + + card = state.card + ar = state.auto_reload + currently_on = bool(ar and ar.enabled) + + print() + _cprint(f" 💳 {_b('Auto-reload')}") + print(f" {'─' * 41}") + _cprint(f" {_d('Automatically add funds when your balance is low.')}") + if card: + print(f" Card on file: {card.masked}") + else: + print(" No saved card — manage billing on the portal.") + self._billing_portal_hint(state) + return + if currently_on: + print( + f" Currently: below {format_money(ar.threshold_usd)} → " + f"reload to {format_money(ar.reload_to_usd)}" + ) + + if not getattr(self, "_app", None): + print(" Run in the interactive CLI to configure auto-reload.") + self._billing_portal_hint(state) + return + + # When already enabled, let the user turn it off without re-entering values. + if currently_on: + top_choices = [ + ("edit", "Edit thresholds", "change when / how much to reload"), + ("off", "Turn off", "disable auto-reload"), + ("cancel", "Cancel", "do nothing"), + ] + raw = self._prompt_text_input_modal( + title="Auto-reload", + detail=( + f"On — below {format_money(ar.threshold_usd)} → " + f"reload to {format_money(ar.reload_to_usd)}" + ), + choices=top_choices, + ) + top = self._normalize_slash_confirm_choice(raw, top_choices) + if top == "off": + self._billing_auto_reload_disable(state) + return + if top != "edit": + print(" 🟡 Cancelled.") + return + + # Field 1 — threshold (prefilled when editing an existing config). + cur_thr = format_money(ar.threshold_usd) if currently_on else None + thr_prompt = " When balance falls below (USD)" + thr_prompt += f" [{cur_thr}]: " if cur_thr else ": " + threshold_raw = self._prompt_text_input(thr_prompt) + if threshold_raw is None: + # None = cancelled (e.g. slash-worker can't prompt off-thread). + print(" 🟡 Cancelled.") + return + if not (threshold_raw or "").strip() and currently_on: + threshold_amt = ar.threshold_usd # keep current value on empty input + else: + tv = validate_charge_amount( + threshold_raw or "", min_usd=state.min_usd, max_usd=state.max_usd + ) + if not tv.ok or tv.amount is None: + print(f" 🔴 {tv.error}") + return + threshold_amt = tv.amount + + # Field 2 — reload-to (prefilled when editing an existing config). + cur_rel = format_money(ar.reload_to_usd) if currently_on else None + rel_prompt = " Reload balance to (USD)" + rel_prompt += f" [{cur_rel}]: " if cur_rel else ": " + reload_raw = self._prompt_text_input(rel_prompt) + if reload_raw is None: + print(" 🟡 Cancelled.") + return + if not (reload_raw or "").strip() and currently_on: + reload_amt = ar.reload_to_usd # keep current value on empty input + else: + rv = validate_charge_amount( + reload_raw or "", min_usd=state.min_usd, max_usd=state.max_usd + ) + if not rv.ok or rv.amount is None: + print(f" 🔴 {rv.error}") + return + reload_amt = rv.amount + + if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt: + print(" 🔴 Reload-to amount must be greater than the threshold.") + return + + print() + _ar_consent = ( + f"By confirming, you authorize Nous Research to charge {card.masked} " + f"whenever your balance reaches {format_money(threshold_amt)}. " + f"Turn off any time here or on the portal." + ) + _cprint(f" {_d(_ar_consent)}") + confirm_choices = [ + ("agree", "Agree and turn on", "enable auto-reload"), + ("cancel", "Cancel", "do nothing"), + ] + raw = self._prompt_text_input_modal( + title="Turn on auto-reload?", + detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}", + choices=confirm_choices, + ) + choice = self._normalize_slash_confirm_choice(raw, confirm_choices) + if choice != "agree": + print(" 🟡 Cancelled.") + return + + from hermes_cli.nous_billing import ( + BillingError, + BillingScopeRequired, + patch_auto_top_up, + ) + + try: + patch_auto_top_up( + enabled=True, threshold=float(threshold_amt), top_up_amount=float(reload_amt) + ) + except BillingScopeRequired: + self._billing_handle_scope_required(state) + return + except BillingError as exc: + self._billing_render_charge_error(state, exc) + return + print(f" ✅ Auto-reload on: below {format_money(threshold_amt)} → " + f"reload to {format_money(reload_amt)}.") + + def _billing_auto_reload_disable(self, state): + """Turn off auto-reload (PATCH ``enabled:false``). + + The endpoint requires ``threshold``/``topUpAmount`` in the body even when + disabling, so we echo back the current values (falling back to 0). + """ + from hermes_cli.nous_billing import ( + BillingError, + BillingScopeRequired, + patch_auto_top_up, + ) + + ar = state.auto_reload + thr = float(ar.threshold_usd) if ar and ar.threshold_usd is not None else 0.0 + rel = float(ar.reload_to_usd) if ar and ar.reload_to_usd is not None else 0.0 + try: + patch_auto_top_up(enabled=False, threshold=thr, top_up_amount=rel) + except BillingScopeRequired: + self._billing_handle_scope_required(state) + return + except BillingError as exc: + self._billing_render_charge_error(state, exc) + return + print(" ✅ Auto-reload turned off.") + + def _billing_limit_screen(self, state): + """Screen 5 — monthly spend limit (read-only; cap is portal-only).""" + from cli import _cprint, _b, _d + + from agent.billing_view import format_money + + print() + _cprint(f" 💳 {_b('Monthly spend limit')}") + print(f" {'─' * 41}") + cap = state.monthly_cap + if cap is None or cap.limit_usd is None: + _cprint(f" {_d('No monthly cap visible (managed on the portal).')}") + else: + spent = format_money(cap.spent_this_month_usd) + limit = format_money(cap.limit_usd) + ceiling = " (default ceiling)" if cap.is_default_ceiling else "" + print(f" {spent} of {limit} used this month{ceiling}") + _limit_note = ( + "The monthly limit is set on the portal — the terminal shows " + "it read-only." + ) + _cprint(f" {_d(_limit_note)}") + self._billing_portal_hint(state) diff --git a/tests/agent/test_billing_view.py b/tests/agent/test_billing_view.py index dbe16e6c09e2..596570f0f574 100644 --- a/tests/agent/test_billing_view.py +++ b/tests/agent/test_billing_view.py @@ -39,6 +39,7 @@ BillingRateLimited, BillingScopeRequired, BillingStripeUnavailable, + BillingTransient, BillingUpgradeCapExceeded, _raise_for_error, resolve_portal_base_url, @@ -185,6 +186,19 @@ def test_state_can_change_plan_falls_back_to_legacy_role_check(): assert billing_state_from_payload(member).can_change_plan is False +def test_can_charge_finance_admin_with_server_capability(): + """Server capability can grant FINANCE_ADMIN charge access.""" + payload = _member_payload() + payload["org"]["role"] = "FINANCE_ADMIN" + payload["canChangePlan"] = True + + state = billing_state_from_payload(payload) + + assert state.is_admin is False + assert state.can_change_plan is True + assert state.can_charge is True + + def test_state_owner_tier_parse(): s = billing_state_from_payload(_owner_payload()) assert s.is_admin is True @@ -327,7 +341,8 @@ def test_specific_billing_throttle_errors_remain_distinguishable( assert ei.value.error == error assert ei.value.retry_after == 90 - assert isinstance(ei.value, BillingRateLimited) + assert isinstance(ei.value, BillingTransient) + assert not isinstance(ei.value, BillingRateLimited) @pytest.mark.parametrize( From 81d3482c4e52c9b88e20efe32b5bd93a6cd4492d Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sat, 18 Jul 2026 00:51:21 +0530 Subject: [PATCH 61/62] 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(). --- ui-tui/src/app/interfaces.ts | 2 + ui-tui/src/components/billingOverlay.tsx | 61 +++---------------- ui-tui/src/components/overlayPrimitives.tsx | 55 ++++++++++++++++- ui-tui/src/components/subscriptionOverlay.tsx | 60 +++--------------- 4 files changed, 72 insertions(+), 106 deletions(-) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 05fa333cdc17..655f50d11fe0 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -238,6 +238,8 @@ export interface SubscriptionPendingChange { export interface SubscriptionResult { message: string ok: boolean + /** Set on a successful upgrade; drives the ResultScreen apply-poll. */ + pendingTierId?: null | string /** A portal URL to finish an SCA/declined upgrade, when present. */ recoveryUrl?: null | string } diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index b75d88e2ca12..326f159db698 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -7,7 +7,7 @@ import type { BillingOverlayState } from '../app/interfaces.js' import type { BillingStateResponse } from '../gatewayTypes.js' import type { Theme } from '../theme.js' -import { ActionRow, footer, MenuRow, UsageBars } from './overlayPrimitives.js' +import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js' import { TextInput } from './textInput.js' interface BillingOverlayProps { @@ -104,8 +104,6 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { ? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel'] : ['Manage on portal', 'Cancel'] - const [sel, setSel] = useState(0) - const choose = (i: number) => { if (full) { if (i === 0) { @@ -132,29 +130,8 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { onClose() } - useInput((ch, key) => { - if (key.escape) { - return onClose() - } - - if (key.upArrow && sel > 0) { - setSel(v => v - 1) - } - - if (key.downArrow && sel < items.length - 1) { - setSel(v => v + 1) - } - - if (key.return) { - return choose(sel) - } - - const n = parseInt(ch, 10) - - if (n >= 1 && n <= items.length) { - return choose(n - 1) - } - }) + const rows: MenuRowSpec[] = items.map((label, i) => ({ label, run: () => choose(i) })) + const sel = useMenu(rows, onClose) const auto = autoReloadLine(s) // Balance leads, in the title — the first thing seen (review feedback). @@ -917,8 +894,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { // ── Screen 5: Monthly spend limit (read-only) ───────────────────────── function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { - const rows = ['Manage on portal', 'Cancel'] - const [sel, setSel] = useState(0) + const labels = ['Manage on portal', 'Cancel'] const choose = (i: number) => { if (i === 0 && s.portal_url) { @@ -930,29 +906,8 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { onPatch({ screen: 'overview' }) } - useInput((ch, key) => { - if (key.escape) { - return onPatch({ screen: 'overview' }) - } - - if (key.upArrow && sel > 0) { - setSel(v => v - 1) - } - - if (key.downArrow && sel < rows.length - 1) { - setSel(v => v + 1) - } - - if (key.return) { - return choose(sel) - } - - const n = parseInt(ch, 10) - - if (n >= 1 && n <= rows.length) { - return choose(n - 1) - } - }) + const rows: MenuRowSpec[] = labels.map((label, i) => ({ label, run: () => choose(i) })) + const sel = useMenu(rows, () => onPatch({ screen: 'overview' })) const cap = s.monthly_cap @@ -969,11 +924,11 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { {usageLine} The monthly limit is set on the portal — shown here read-only. - {rows.map((label, i) => ( + {labels.map((label, i) => ( ))} - {footer(`↑/↓ select · 1-${rows.length} quick pick · Enter confirm · Esc back`, t)} + {footer(`↑/↓ select · 1-${labels.length} quick pick · Enter confirm · Esc back`, t)} ) } diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx index e97639ba2bec..8a3b23f83428 100644 --- a/ui-tui/src/components/overlayPrimitives.tsx +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -1,9 +1,60 @@ -import { Text } from '@hermes/ink' -import type { ReactNode } from 'react' +import type { Key } from '@hermes/ink' +import { Text, useInput } from '@hermes/ink' +import { type ReactNode, useState } from 'react' import type { UsageModelData } from '../gatewayTypes.js' import type { Theme } from '../theme.js' +export interface MenuRowSpec { + color?: string + label: string + run: () => void +} + +/** + * ↑/↓ + Enter + number-key selection over `rows`; Esc runs `onEscape`. + * `onKey`, when given, runs first on every keypress — return `true` to mark + * the key fully handled and skip the default escape/arrow/enter/number + * handling for that keypress (e.g. a screen with a text-input sub-mode). + */ +export function useMenu( + rows: MenuRowSpec[], + onEscape: () => void, + onKey?: (ch: string, key: Key) => boolean +): number { + const [sel, setSel] = useState(0) + + useInput((ch, key) => { + if (onKey?.(ch, key)) { + return + } + + if (key.escape) { + return onEscape() + } + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + } + + if (key.downArrow && sel < rows.length - 1) { + setSel(v => v + 1) + } + + if (key.return) { + return rows[sel]?.run() + } + + const n = parseInt(ch, 10) + + if (n >= 1 && n <= rows.length) { + return rows[n - 1]?.run() + } + }) + + return Math.min(sel, Math.max(0, rows.length - 1)) +} + /** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */ export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) { return ( diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 250e23dd9857..22443f7ec7f5 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -16,7 +16,7 @@ import type { } from '../gatewayTypes.js' import type { Theme } from '../theme.js' -import { ActionRow, footer, MenuRow, UsageBars } from './overlayPrimitives.js' +import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js' const UPGRADE_CONFIRM_INTERVAL_MS = 2000 const UPGRADE_CONFIRM_ATTEMPTS = 15 @@ -70,48 +70,6 @@ interface ScreenProps { t: Theme } -/** A selectable menu row with its action. */ -interface Row { - color?: string - label: string - run: () => void -} - -interface SubscriptionResultWithPending extends SubscriptionResult { - pendingTierId?: null | string -} - -/** ↑/↓ + Enter + number-key selection over `rows`; Esc runs `onEscape`. */ -function useMenu(rows: Row[], onEscape: () => void): number { - const [sel, setSel] = useState(0) - - useInput((ch, key) => { - if (key.escape) { - return onEscape() - } - - if (key.upArrow && sel > 0) { - setSel(v => v - 1) - } - - if (key.downArrow && sel < rows.length - 1) { - setSel(v => v + 1) - } - - if (key.return) { - return rows[sel]?.run() - } - - const n = parseInt(ch, 10) - - if (n >= 1 && n <= rows.length) { - return rows[n - 1]?.run() - } - }) - - return Math.min(sel, Math.max(0, rows.length - 1)) -} - /** ISO datetime → YYYY-MM-DD for display, or a soft fallback. */ function shortDate(iso?: null | string): string { return iso && iso.length >= 10 ? iso.slice(0, 10) : 'the end of the billing period' @@ -145,7 +103,7 @@ function mutationResult(r: null | { message?: string; ok?: boolean }, okMessage: } /** Map an upgrade response, routing SCA / decline to a portal recovery. */ -function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: null | string): SubscriptionResultWithPending { +function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: null | string): SubscriptionResult { if (!r) { // null = a transport failure (WS drop / request timeout) on the CHARGING // route — NAS may have already prorated + charged. Report it as ambiguous and @@ -425,7 +383,7 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { void resumeAndRoute(ctx, onPatch) } - const rows: Row[] = [] + const rows: MenuRowSpec[] = [] if (canChange) { // When a change is already scheduled, undo is the most likely next intent — @@ -529,7 +487,7 @@ function PickerScreen({ onPatch, overlay, t }: ScreenProps) { const back = () => onPatch({ screen: 'overview' }) - const rows: Row[] = choices.map(tier => { + const rows: MenuRowSpec[] = choices.map(tier => { const direction = tier.tier_order > currentOrder ? 'upgrade' : 'downgrade' return { @@ -631,7 +589,7 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { const amount = centsDisplay(preview?.amount_due_now_cents) const targetName = isCancellation ? null : (preview?.target_tier_name ?? 'the selected plan') - let primary: null | Row = null + let primary: MenuRowSpec | null = null if (isCancellation) { primary = { color: t.color.warn, label: 'Cancel subscription', run: apply } @@ -643,7 +601,7 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { primary = { label: 'Manage on portal', run: manage } } - const rows: Row[] = primary ? [primary, { label: 'Back', run: back }] : [{ label: 'Back', run: back }] + const rows: MenuRowSpec[] = primary ? [primary, { label: 'Back', run: back }] : [{ label: 'Back', run: back }] const sel = useMenu(rows, back) // Chip contrasts an immediate charge vs a period-end schedule at a glance. const chip = effect === 'charge_now' ? { color: t.color.ok, label: 'charged now' } : effect === 'scheduled' ? { color: t.color.warn, label: 'scheduled · not today' } : null @@ -717,7 +675,7 @@ function ConfirmScreen({ onClose, onPatch, overlay, t }: ScreenProps) { function ResultScreen({ onClose, overlay, t }: Omit) { const { ctx } = overlay - const result = (overlay.result ?? null) as null | SubscriptionResultWithPending + const result = overlay.result ?? null const recoveryUrl = result?.recoveryUrl ?? null const pendingTierId = result?.pendingTierId ?? null const [applyState, setApplyState] = useState<'applying' | 'confirmed' | 'timed_out'>( @@ -792,7 +750,7 @@ function ResultScreen({ onClose, overlay, t }: Omit) { return onClose() } - const rows: Row[] = recoveryUrl + const rows: MenuRowSpec[] = recoveryUrl ? [{ color: t.color.accent, label: 'Open the portal to finish', run: openRecovery }, { label: 'Close', run: onClose }] : [{ label: 'Close', run: onClose }] @@ -897,7 +855,7 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { onPatch({ screen: retry?.kind === 'apply' ? 'confirm' : 'overview', stepUpRetry: null }) } - const rows: Row[] = + const rows: MenuRowSpec[] = phase === 'granted' ? [{ color: t.color.ok, label: retry?.kind === 'apply' ? 'Continue the change' : 'Continue', run: resume }, { label: 'Cancel', run: back }] : phase === 'prompt' From 31f989a399a331a87ebbdd4e24107770756718df Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Sat, 18 Jul 2026 00:51:21 +0530 Subject: [PATCH 62/62] =?UTF-8?q?docs(billing):=20correct=20the=20CLI-pari?= =?UTF-8?q?ty=20row=20=E2=80=94=20the=20CLI=20has=20the=20full=20in-termin?= =?UTF-8?q?al=20change=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/billing-lifecycle.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/billing-lifecycle.md b/docs/billing-lifecycle.md index 6b482846cd94..53151684214b 100644 --- a/docs/billing-lifecycle.md +++ b/docs/billing-lifecycle.md @@ -148,15 +148,16 @@ still isn't enabled for this org — enable it on the portal, then retry.` ## Text-mode (CLI) parity `cli.py`'s `_show_billing` / `_billing_overview` and `_show_subscription` / -`_subscription_overview` are read-mostly mirrors of the same state shapes -(balance title, two-bar dollar usage, auto-reload line, card line, monthly -cap) and share the "fail-open on logged-out/portal-hiccup, never crash" -discipline. The CLI's `/subscription` has **no in-terminal tier picker or -upgrade flow** — its only action is `_billing_portal_hint`'s deep-link to -`subscription_manage_url`; all plan changes happen on the portal. `/topup`'s -interactive modal (prompt_toolkit) is closer to parity with the TUI overlay, -but non-interactive contexts (TUI slash-worker, no live app) fall back to the -same text + portal-link rendering as `/subscription`, never prompting. +`_subscription_overview` render the same state shapes (balance title, two-bar +dollar usage, auto-reload line, card line, monthly cap) and share the +"fail-open on logged-out/portal-hiccup, never crash" discipline. The CLI's +`/subscription` gives a paid admin/owner in an interactive context the **full +in-terminal change flow** (tier picker → preview → confirm → apply, parity +with the TUI overlay); members and non-interactive contexts fall back to +`_billing_portal_hint`'s deep-link to `subscription_manage_url`. `/topup`'s +interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and +non-interactive contexts fall back to the same text + portal-link rendering, +never prompting. ## Forward compatibility