diff --git a/agent/account_usage.py b/agent/account_usage.py index 9e48b0aac0cb..571d18446daf 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/billing_usage.py b/agent/billing_usage.py new file mode 100644 index 000000000000..2ac762bc2b31 --- /dev/null +++ b/agent/billing_usage.py @@ -0,0 +1,323 @@ +"""Shared dollar-denominated usage model for the billing/subscription surfaces. + +The single source of truth behind the ``/usage`` and ``/subscription`` usage +bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show +**dollars**, never "credits", and every usage bar must make the monthly +subscription allowance and separately-purchased top-up dollars distinctly +visible. + +Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose +``paid_service_access_info`` carries the three dollar magnitudes we render +(despite the legacy ``*_credits`` field names, these are USD floats): + + - ``subscription_credits_remaining`` -> plan dollars left this month + - ``purchased_credits_remaining`` -> top-up dollars left (rolls over) + - ``total_usable_credits`` -> total spendable + +plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the +denominator for the "% used" plan bar) and ``current_period_end`` (renewal). + +Design: two SEPARATE bars (decided with the user) rather than one crammed +three-segment bar — at terminal widths three same-glyph density segments are +unreadable. The plan bar is "spent vs allowance this month" (carries % used); +the top-up bar is "money you bought, doesn't expire". Each gets full +resolution and a single fill glyph, so the bar is never ambiguous and never +relies on color. + +Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a +magnitudes-only view; a logged-out / unreachable portal yields +``available=False`` and the surface shows nothing. +""" + +from __future__ import annotations + +import logging +import math +import os +from dataclasses import dataclass, field +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert +# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold +# (user feedback): "any amount below $5 should be an alert status." +LOW_BALANCE_THRESHOLD_USD = 5.0 + + +def _finite(value: Any) -> Optional[float]: + """Return value as a float iff it's a real finite number (not bool/NaN/Inf).""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + f = float(value) + return f if math.isfinite(f) else None + + +def _fmt_usd(value: Optional[float]) -> str: + """``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence).""" + return f"${(value or 0.0):,.2f}" + + +def format_renews(value: Optional[str]) -> Optional[str]: + """Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``. + + Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw + string unchanged if it can't be parsed (never raises), and ``None`` for + empty input. + """ + if not value: + return None + from datetime import datetime + + text = str(value).strip() + if not text: + return None + iso = text[:-1] + "+00:00" if text.endswith("Z") else text + try: + dt = datetime.fromisoformat(iso) + except ValueError: + # Fall back to a bare date prefix (YYYY-MM-DD) if present. + try: + dt = datetime.strptime(text[:10], "%Y-%m-%d") + except ValueError: + return text + # %-d isn't portable to Windows; build the day without a leading zero. + return f"{dt.strftime('%b')} {dt.day}, {dt.year}" + + +@dataclass(frozen=True) +class UsageBar: + """One full-resolution bar: ``spent`` of ``total``, plus a remaining figure. + + ``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"`` + (purchased dollars, no denominator — ``spent`` is 0 and ``total`` == + ``remaining`` so it renders as a full bar of available balance). + """ + + kind: str # "plan" | "topup" + remaining_usd: float + total_usd: float + spent_usd: float = 0.0 + + @property + def pct_used(self) -> Optional[int]: + if self.kind != "plan" or self.total_usd <= 0: + return None + return max(0, min(100, round(self.spent_usd / self.total_usd * 100))) + + @property + def fill_fraction(self) -> float: + """Fraction of the bar that should read as 'remaining' (filled).""" + if self.total_usd <= 0: + return 0.0 + return max(0.0, min(1.0, self.remaining_usd / self.total_usd)) + + +@dataclass(frozen=True) +class UsageModel: + """Surface-agnostic dollar usage model shared by /usage and /subscription. + + ``status`` classifies the account for copy selection: + - ``"free"`` : no paid access / no subscription (free models only) + - ``"low"`` : paid, but total spendable < $5 (ALERT) + - ``"healthy"`` : paid, total spendable >= $5 + - ``"depleted"`` : paid access lost (balance exhausted) + """ + + available: bool + status: str = "free" + plan_name: Optional[str] = None + renews_at: Optional[str] = None + renews_display: Optional[str] = None + subscription_remaining_usd: Optional[float] = None + topup_remaining_usd: Optional[float] = None + total_spendable_usd: Optional[float] = None + plan_bar: Optional[UsageBar] = None + topup_bar: Optional[UsageBar] = None + + @property + def has_topup(self) -> bool: + return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0) + + +def usage_model_from_account(account_info: Any) -> UsageModel: + """Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open. + + Returns ``UsageModel(available=False)`` when there's no usable account info + (logged out, no entitlement block). Never raises. + """ + try: + if account_info is None or not getattr(account_info, "logged_in", False): + return UsageModel(available=False) + + access = getattr(account_info, "paid_service_access_info", None) + sub = getattr(account_info, "subscription", None) + paid = getattr(account_info, "paid_service_access", None) + + sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None + topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None + total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None + + plan_name = getattr(sub, "plan", None) if sub is not None else None + renews_at = getattr(sub, "current_period_end", None) if sub is not None else None + monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None + + has_subscription = bool(plan_name) or (monthly is not None and monthly > 0) + + # Total spendable: prefer the server's total; else sum the parts we have. + if total_usable is not None: + total_spendable = total_usable + else: + parts = [v for v in (sub_remaining, topup_remaining) if v is not None] + total_spendable = sum(parts) if parts else None + + # Status classification. + if paid is False: + status = "depleted" + elif not has_subscription and not (topup_remaining and topup_remaining > 0): + # No plan and no purchased balance -> free-models-only. + status = "free" + elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD: + status = "low" + else: + status = "healthy" + + # Plan bar — only with a positive monthly allowance AND a remaining we + # can place on it. spent = cap - remaining, clamped (a debt/over-cap + # balance reads as fully spent rather than a nonsensical negative). + plan_bar: Optional[UsageBar] = None + if monthly is not None and monthly > 0 and sub_remaining is not None: + remaining = max(0.0, min(monthly, sub_remaining)) + plan_bar = UsageBar( + kind="plan", + remaining_usd=remaining, + total_usd=monthly, + spent_usd=max(0.0, monthly - sub_remaining), + ) + + # Top-up bar — only when there are purchased dollars to show. No + # denominator (top-up has no monthly cap), so it renders full = balance. + topup_bar: Optional[UsageBar] = None + if topup_remaining is not None and topup_remaining > 0: + topup_bar = UsageBar( + kind="topup", + remaining_usd=topup_remaining, + total_usd=topup_remaining, + spent_usd=0.0, + ) + + return UsageModel( + available=True, + status=status, + plan_name=plan_name, + renews_at=renews_at, + renews_display=format_renews(renews_at), + subscription_remaining_usd=sub_remaining, + topup_remaining_usd=topup_remaining, + total_spendable_usd=total_spendable, + plan_bar=plan_bar, + topup_bar=topup_bar, + ) + except Exception: + logger.debug("usage ▸ model build failed (fail-open)", exc_info=True) + return UsageModel(available=False) + + +def build_usage_model(*, timeout: float = 10.0) -> UsageModel: + """Fetch account-info and build the shared usage model. Fail-open. + + Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so + every usage state is testable without a live account (mirrors the existing + ``/usage`` credits-block fixture path). + """ + fixture = _dev_fixture_usage_model() + if fixture is not None: + return fixture + + try: + from hermes_cli.auth import get_provider_auth_state + + tok = (get_provider_auth_state("nous") or {}).get("access_token") + if not (isinstance(tok, str) and tok.strip()): + return UsageModel(available=False) + except Exception: + return UsageModel(available=False) + + try: + import concurrent.futures + + from hermes_cli.nous_account import get_nous_portal_account_info + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout) + return usage_model_from_account(account) + except Exception: + logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True) + return UsageModel(available=False) + + +# ============================================================================= +# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) +# ============================================================================= + + +def _dev_fixture_usage_model() -> Optional[UsageModel]: + """Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work. + + Recognized names: ``free | healthy | low | topup | depleted``. Returns + ``None`` when the env var is unset (real portal path runs). + """ + name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower() + if not name: + return None + + if name == "free": + return UsageModel(available=True, status="free", plan_name=None) + + if name in ("healthy", "mid"): + return UsageModel( + available=True, + status="healthy", + plan_name="Plus", + renews_at="2026-07-01", + subscription_remaining_usd=14.0, + total_spendable_usd=14.0, + plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0), + ) + + if name in ("topup", "top-up"): + return UsageModel( + available=True, + status="healthy", + plan_name="Plus", + renews_at="2026-07-01", + subscription_remaining_usd=14.0, + topup_remaining_usd=12.0, + total_spendable_usd=26.0, + plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0), + topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0), + ) + + if name == "low": + return UsageModel( + available=True, + status="low", + plan_name="Plus", + renews_at="2026-07-01", + subscription_remaining_usd=3.4, + total_spendable_usd=3.4, + plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6), + ) + + if name == "depleted": + return UsageModel( + available=True, + status="depleted", + plan_name="Plus", + renews_at="2026-07-01", + subscription_remaining_usd=0.0, + total_spendable_usd=0.0, + plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0), + ) + + return None diff --git a/agent/billing_view.py b/agent/billing_view.py index ef97c8d0d645..0e9930fd3ea6 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -15,6 +15,7 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display. from __future__ import annotations import logging +import os import uuid from dataclasses import dataclass, field from decimal import Decimal, InvalidOperation @@ -64,15 +65,47 @@ def format_money(value: Optional[Decimal]) -> str: # ============================================================================= +# resolvedVia → the human answer to "why THIS card?". Keys are the server's card +# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label +# so the display degrades cleanly on servers that don't send resolvedVia yet. +_CARD_PROVENANCE_LABELS = { + "subPin": "the card on your subscription", + "customerDefault": "your default card saved on the portal", + "autoRefill": "your auto-reload card", +} + + @dataclass(frozen=True) class CardInfo: brand: str last4: str + # NAS card-on-file field (post card-resolver): which ladder rung found the + # card. Defaults off so pre-resolver payloads parse unchanged. + resolved_via: Optional[str] = None @property def masked(self) -> str: + # A Link payment method has no card number (last4 = "") — render the + # brand alone, not "Link ····". + if not self.last4: + return self.brand return f"{self.brand} ····{self.last4}" + @property + def provenance(self) -> Optional[str]: + """Human label for why this card was picked, or None (unknown rung / + server too old to say).""" + if self.resolved_via is None: + return None + return _CARD_PROVENANCE_LABELS.get(self.resolved_via) + + @property + def display(self) -> str: + """The one-line card display: ``Visa ····4242 — the card on your + subscription`` (or just the masked card when provenance is unknown).""" + label = self.provenance + return f"{self.masked} — {label}" if label else self.masked + @dataclass(frozen=True) class MonthlyCap: @@ -81,11 +114,20 @@ class MonthlyCap: is_default_ceiling: bool = False +@dataclass(frozen=True) +class AutoReloadCard: + kind: str # "canonical" | "distinct" | "none" + payment_method_id: Optional[str] = None + brand: Optional[str] = None + last4: Optional[str] = None + + @dataclass(frozen=True) class AutoReload: enabled: bool = False threshold_usd: Optional[Decimal] = None reload_to_usd: Optional[Decimal] = None + card: Optional[AutoReloadCard] = None @dataclass(frozen=True) @@ -100,7 +142,8 @@ class BillingState: org_id: Optional[str] = None org_slug: Optional[str] = None org_name: Optional[str] = None - role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER" + role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER" + can_change_plan_raw: Optional[bool] = None balance_usd: Optional[Decimal] = None cli_billing_enabled: bool = False charge_presets: tuple[Decimal, ...] = () @@ -115,17 +158,33 @@ class BillingState: @property def is_admin(self) -> bool: - """True for OWNER/ADMIN — the roles that can manage billing.""" + """Deprecated/display only — a legacy OWNER/ADMIN check. + + NOT a capability check; use :attr:`can_change_plan` for gating billing + plan-change actions. + """ return (self.role or "").upper() in ("OWNER", "ADMIN") + @property + def can_change_plan(self) -> bool: + """Server capability when supplied; otherwise the legacy role fallback.""" + if self.can_change_plan_raw is not None: + return self.can_change_plan_raw + return self.is_admin + @property def can_charge(self) -> bool: """True when the UI should offer charge/auto-reload actions. - Admin role AND the per-org kill-switch on. (The server still enforces; - this is just for graying out actions the user can't take.) + Uses the server-granted plan-change capability (``can_change_plan``, + which itself falls back to the legacy OWNER/ADMIN role check when the + server omits ``canChangePlan``) AND the per-org kill-switch. This lets + the server grant charge capability to non-OWNER/ADMIN roles (e.g. + FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the + deprecated 3-role admin check. (The server still enforces; this is + just for graying out actions the user can't take.) """ - return self.is_admin and self.cli_billing_enabled + return self.can_change_plan and self.cli_billing_enabled def _parse_card(raw: Any) -> Optional[CardInfo]: @@ -133,9 +192,13 @@ def _parse_card(raw: Any) -> Optional[CardInfo]: return None brand = raw.get("brand") last4 = raw.get("last4") - if isinstance(brand, str) and isinstance(last4, str): - return CardInfo(brand=brand, last4=last4) - return None + if not (isinstance(brand, str) and isinstance(last4, str)): + return None + # Post-resolver fields — all optional so both payload generations parse. + resolved_via = raw.get("resolvedVia") + if not isinstance(resolved_via, str): + resolved_via = None + return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via) def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]: @@ -155,6 +218,27 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]: enabled=bool(raw.get("enabled")), threshold_usd=parse_money(raw.get("thresholdUsd")), reload_to_usd=parse_money(raw.get("reloadToUsd")), + card=_parse_auto_reload_card(raw.get("card")), + ) + + +def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]: + if not isinstance(raw, dict): + return None + kind = raw.get("kind") + if kind not in ("canonical", "distinct", "none"): + return None + if kind in ("canonical", "none"): + return AutoReloadCard(kind=kind) + + payment_method_id = raw.get("paymentMethodId") + brand = raw.get("brand") + last4 = raw.get("last4") + return AutoReloadCard( + kind=kind, + payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None, + brand=brand if isinstance(brand, str) else None, + last4=last4 if isinstance(last4, str) else None, ) @@ -179,6 +263,11 @@ def billing_state_from_payload( org_slug=org.get("slug"), org_name=org.get("name"), role=org.get("role"), + can_change_plan_raw=( + payload.get("canChangePlan") + if isinstance(payload.get("canChangePlan"), bool) + else None + ), balance_usd=parse_money(payload.get("balanceUsd")), cli_billing_enabled=bool(payload.get("cliBillingEnabled")), charge_presets=tuple(presets), @@ -202,7 +291,15 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState: Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the surface can show a clear message rather than crashing. + + Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the + card-on-file / admin / scope states are testable offline (mirrors + ``HERMES_DEV_CREDITS_FIXTURE`` for the usage model). """ + fixture = _dev_fixture_billing_state() + if fixture is not None: + return fixture + try: from hermes_cli.nous_billing import ( BillingAuthError, @@ -243,6 +340,72 @@ def _fallback_portal_url(base: str) -> str: return f"{base.rstrip('/')}/billing?topup=open" +# ============================================================================= +# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) +# ============================================================================= + + +def _dev_fixture_billing_state() -> Optional[BillingState]: + """Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX. + + Recognized names:: + + nocard logged in · billing on · admin · NO card on file + card card on file · auto-reload off + card-autoreload card on file · auto-reload on + notadmin logged in · MEMBER role (billing actions disabled) + billing-off logged in · admin · per-org kill-switch OFF + logged-out not logged in + + Returns ``None`` when the env var is unset (the real portal path runs). + Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from + ``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state). + """ + name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower() + if not name: + return None + + # Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL — + # prod host, not staging; the ?topup=open suffix is the /topup deep-link). + portal = "https://portal.nousresearch.com/billing?topup=open" + common: dict[str, Any] = dict( + org_id="org_acme", + org_slug="acme", + org_name="Acme Inc", + role="OWNER", + balance_usd=Decimal("3.40"), + cli_billing_enabled=True, + charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")), + min_usd=Decimal("5"), + max_usd=Decimal("500"), + portal_url=portal, + ) + card = CardInfo(brand="Visa", last4="4242") + autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25")) + + if name in ("logged-out", "logged_out", "loggedout"): + return BillingState(logged_in=False) + if name == "nocard": + return BillingState(logged_in=True, card=None, **common) + if name == "card": + return BillingState(logged_in=True, card=card, **common) + if name in ("card-sub", "card_sub"): + # Post-resolver: the card came from the subscription (provenance label). + _sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin") + return BillingState(logged_in=True, card=_sub_card, **common) + if name in ("card-autoreload", "card_autoreload", "autoreload"): + return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common) + if name in ("notadmin", "not-admin", "member"): + opts = {**common, "role": "MEMBER"} + return BillingState(logged_in=True, card=card, **opts) + if name in ("billing-off", "billing_off", "off"): + opts = {**common, "cli_billing_enabled": False} + return BillingState(logged_in=True, card=None, **opts) + + # Unknown name → logged-out so the misconfiguration is visible. + return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}") + + # ============================================================================= # Idempotency # ============================================================================= 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/agent/subscription_view.py b/agent/subscription_view.py new file mode 100644 index 000000000000..f78fcb283d1c --- /dev/null +++ b/agent/subscription_view.py @@ -0,0 +1,421 @@ +"""Surface-agnostic core for the ``/subscription`` TUI screen. + +Companion to :mod:`agent.billing_view` — same fail-open philosophy: when not +logged in or the portal is unreachable, return a struct with ``logged_in=False`` +and let the surface degrade gracefully (never crash). Money is decimal end-to-end +(server emits decimal strings); we only format for display. + +The TUI ``SubscriptionOverlay`` drives the plan change in-terminal (V3): it +previews the effect, then schedules a downgrade / cancellation / resume +(chargeless) or applies an upgrade (charges the card on the subscription). The +portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the +fallback for an upgrade that needs 3DS / was declined. + +WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A). +Until it ships, the fail-open contract handles 404s — the builder returns +``logged_in=False`` and the surface degrades gracefully. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from decimal import Decimal +from typing import Any, Optional + +from agent.billing_view import parse_money + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Parsed sub-structures +# ============================================================================= + + +@dataclass(frozen=True) +class CurrentSubscription: + """The user's active subscription. ``None`` (not this object) = no plan. + + When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` / + ``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a + fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade + fields are optional. + """ + + tier_id: Optional[str] = None + tier_name: Optional[str] = None + monthly_credits: Optional[Decimal] = None + credits_remaining: Optional[Decimal] = None + cycle_ends_at: Optional[str] = None # ISO + pending_downgrade_tier_name: Optional[str] = None + pending_downgrade_at: Optional[str] = None # ISO + cancel_at_period_end: bool = False + cancellation_effective_at: Optional[str] = None # ISO + + +@dataclass(frozen=True) +class SubscriptionTier: + """A selectable plan in the catalog — one row of the in-terminal tier picker. + + Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan + (shown but not selectable); ``is_enabled=False`` is a grandfathered tier the + user is on but that can no longer be selected. ``tier_order`` sorts the picker + and drives the upgrade-vs-downgrade direction hint. + """ + + tier_id: str + name: str + tier_order: int = 0 + dollars_per_month: Optional[Decimal] = None + monthly_credits: Optional[Decimal] = None + is_current: bool = False + is_enabled: bool = True + + +@dataclass(frozen=True) +class SubscriptionChangePreview: + """Parsed ``POST /api/billing/subscription/preview`` — what a change would do. + + ``effect`` is the disposition the commit would take: + - ``charge_now`` → an upgrade; ``amount_due_now_cents`` is the prorated charge. + - ``scheduled`` → a downgrade / same-price change at ``effective_at`` (period end). + - ``no_op`` → already on the target tier. + - ``blocked`` → the commit would be refused; ``reason`` says why. + """ + + effect: str + reason: Optional[str] = None + current_tier_id: Optional[str] = None + current_tier_name: Optional[str] = None + target_tier_id: Optional[str] = None + target_tier_name: Optional[str] = None + monthly_credits_delta: Optional[Decimal] = None + amount_due_now_cents: Optional[int] = None + effective_at: Optional[str] = None # ISO + + +@dataclass(frozen=True) +class SubscriptionState: + """Parsed ``GET /api/billing/subscription`` — the overview screen's data. + + Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the + portal is unreachable. + """ + + logged_in: bool + org_name: Optional[str] = None + org_id: Optional[str] = None # org.id from the NAS response + role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER" + can_change_plan_raw: Optional[bool] = None + context: str = "personal" # "personal" | "team" + current: Optional[CurrentSubscription] = None + tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker) + portal_url: Optional[str] = None + # When the fetch failed (vs cleanly not-logged-in), the message for the surface. + error: Optional[str] = None + + @property + def is_admin(self) -> bool: + """Deprecated/display only — a legacy OWNER/ADMIN check. + + NOT a capability check; use :attr:`can_change_plan` for gating billing + plan-change actions. + """ + return (self.role or "").upper() in ("OWNER", "ADMIN") + + @property + def can_change_plan(self) -> bool: + """Server capability when supplied; otherwise the legacy role fallback.""" + if self.can_change_plan_raw is not None: + return self.can_change_plan_raw + return self.is_admin + + +# ============================================================================= +# Payload parsing +# ============================================================================= + + +def _parse_current(raw: Any) -> Optional[CurrentSubscription]: + # "No plan" is wire-represented as current:null (free personal OR team) — + # the old all-null-object shape is gone. A present current is a real plan, + # so guard on a real tier id and return None otherwise. + if not isinstance(raw, dict): + return None + tier_id = raw.get("tierId") or raw.get("id") + if not tier_id: + return None + return CurrentSubscription( + tier_id=tier_id, + tier_name=raw.get("tierName") or raw.get("name"), + monthly_credits=parse_money(raw.get("monthlyCredits")), + credits_remaining=parse_money(raw.get("creditsRemaining")), + cycle_ends_at=raw.get("cycleEndsAt"), + pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"), + pending_downgrade_at=raw.get("pendingDowngradeAt"), + cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")), + cancellation_effective_at=raw.get("cancellationEffectiveAt") or None, + ) + + +def _coalesce(*vals: Any) -> Any: + """First non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``). + + NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain + ``x or default`` would drop those, so coalesce on ``None`` specifically. + """ + for v in vals: + if v is not None: + return v + return None + + +def _parse_tier(raw: Any) -> Optional[SubscriptionTier]: + """Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`.""" + if not isinstance(raw, dict): + return None + tier_id = raw.get("tierId") or raw.get("id") + if not tier_id: + return None + return SubscriptionTier( + tier_id=tier_id, + name=raw.get("name") or "", + tier_order=int(_coalesce(raw.get("tierOrder"), 0)), + dollars_per_month=parse_money(raw.get("dollarsPerMonthDisplay")), + monthly_credits=parse_money(raw.get("monthlyCredits")), + is_current=bool(raw.get("isCurrent")), + is_enabled=bool(_coalesce(raw.get("isEnabled"), True)), + ) + + +def subscription_change_preview_from_payload( + payload: dict[str, Any], +) -> SubscriptionChangePreview: + """Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`.""" + effect = payload.get("effect") + cents = payload.get("amountDueNowCents") + return SubscriptionChangePreview( + # An unrecognized/missing effect is treated as ``blocked`` — fail safe, never + # charge on a malformed quote. + effect=effect if isinstance(effect, str) else "blocked", + reason=payload.get("reason") or None, + current_tier_id=payload.get("currentTierId"), + current_tier_name=payload.get("currentTierName"), + target_tier_id=payload.get("targetTierId"), + target_tier_name=payload.get("targetTierName"), + monthly_credits_delta=parse_money(payload.get("monthlyCreditsDelta")), + amount_due_now_cents=int(cents) if isinstance(cents, (int, float)) else None, + effective_at=payload.get("effectiveAt") or None, + ) + + +def subscription_state_from_payload( + payload: dict[str, Any], *, portal_url: Optional[str] = None +) -> SubscriptionState: + """Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`.""" + raw_org = payload.get("org") + org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {} + + raw_context = payload.get("context") + context = raw_context if raw_context in ("personal", "team") else "personal" + + raw_tiers = payload.get("tiers") + tiers = ( + tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None) + if isinstance(raw_tiers, list) + else () + ) + + return SubscriptionState( + logged_in=True, + org_name=org.get("name"), + org_id=org.get("id") or None, + role=org.get("role"), + can_change_plan_raw=( + payload.get("canChangePlan") + if isinstance(payload.get("canChangePlan"), bool) + else None + ), + context=context, + current=_parse_current(payload.get("current")), + tiers=tiers, + portal_url=portal_url, + ) + + +# ============================================================================= +# Fail-open builders (the surface front doors) +# ============================================================================= + + +def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState: + """Fetch + parse ``GET /api/billing/subscription``. Fail-open. + + Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a + portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the + surface can show a clear message rather than crashing. + + Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state, + ``/subscription`` renders from that fixture instead of the real portal — so + every plan/cancel/downgrade/team/not-admin state is testable on both + the CLI and TUI without a live account. Throwaway scaffolding; see + :func:`dev_fixture_subscription_state`. + """ + fixture = dev_fixture_subscription_state() + if fixture is not None: + return fixture + + try: + from hermes_cli.nous_billing import ( + BillingAuthError, + BillingError, + _absolutize_portal_url, + get_subscription_state, + resolve_portal_base_url, + ) + except Exception: + return SubscriptionState(logged_in=False, error="billing client unavailable") + + try: + payload = get_subscription_state(timeout=timeout) + except BillingAuthError: + return SubscriptionState(logged_in=False) + except BillingError as exc: + logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True) + return SubscriptionState(logged_in=False, error=str(exc)) + except Exception: + logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True) + return SubscriptionState(logged_in=False, error="could not load subscription state") + + raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None + portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None + if not portal_url: + try: + portal_url = resolve_portal_base_url() + except Exception: + portal_url = None + + return subscription_state_from_payload(payload, portal_url=portal_url) + + +def subscription_manage_url(state: SubscriptionState) -> Optional[str]: + """Build ``{portal_origin}/manage-subscription?org_id=`` from a state. + + Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link + target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing + Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled + internally. ``org_id`` pins the page to the right account in multi-org + situations. Returns ``None`` when no portal URL is resolvable. + """ + from urllib.parse import urlencode, urlsplit, urlunsplit + + if not state.portal_url: + return None + + try: + parts = urlsplit(state.portal_url) + except Exception: + return None + + if not parts.scheme or not parts.netloc: + return None + + query = urlencode({"org_id": state.org_id}) if state.org_id else "" + return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, "")) + + +# ============================================================================= +# Dev fixtures (throwaway scaffolding — env-var driven, no live portal) +# ============================================================================= + +_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing" + + +def _dev_current(**over: Any) -> CurrentSubscription: + base: dict[str, Any] = dict( + tier_id="plus", + tier_name="Plus", + monthly_credits=Decimal("1000"), + credits_remaining=Decimal("420"), + cycle_ends_at="2026-07-01", + ) + base.update(over) + return CurrentSubscription(**base) + + +def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]: + """A sample plan catalog for fixtures (marks ``current_id`` as the active tier).""" + specs = ( + ("free", "Free", 0, "0", "0"), + ("plus", "Plus", 1, "20", "1000"), + ("super", "Super", 2, "40", "3000"), + ("ultra", "Ultra", 3, "80", "7000"), + ) + return tuple( + SubscriptionTier( + tier_id=tid, + name=name, + tier_order=order, + dollars_per_month=parse_money(dpm), + monthly_credits=parse_money(mc), + is_current=(tid == current_id), + is_enabled=True, + ) + for tid, name, order, dpm, mc in specs + ) + + +def dev_fixture_subscription_state() -> Optional[SubscriptionState]: + """Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``. + + Lets every CLI/TUI subscription state be exercised without a live portal: + + free | mid | top | not-admin | downgrade | cancel | team | + logged-out + + Returns ``None`` when the env var is unset/empty (the real portal path runs). + Throwaway scaffolding — mirrors ``HERMES_DEV_CREDITS_FIXTURE``. + """ + name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower() + if not name: + return None + + common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL) + + if name in ("logged-out", "logged_out", "loggedout"): + return SubscriptionState(logged_in=False) + if name == "free": + return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common) + if name in ("mid", "mid-tier"): + return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common) + if name in ("top", "top-tier"): + return SubscriptionState( + logged_in=True, + current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")), + tiers=_dev_tiers("ultra"), + **common, + ) + if name in ("not-admin", "member"): + return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **{**common, "role": "MEMBER"}) + if name == "downgrade": + return SubscriptionState( + logged_in=True, + current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"), + tiers=_dev_tiers("super"), + **common, + ) + if name == "cancel": + return SubscriptionState( + logged_in=True, + current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"), + tiers=_dev_tiers("plus"), + **common, + ) + if name == "team": + return SubscriptionState(logged_in=True, context="team", current=None, org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL) + + # Unknown name → behave as logged-out so the misconfiguration is visible. + return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}") + diff --git a/cli.py b/cli.py index 6cb6345f4be4..23e3e97ebb43 100644 --- a/cli.py +++ b/cli.py @@ -54,6 +54,7 @@ import yaml from hermes_cli.fallback_config import get_fallback_chain from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin from hermes_cli.cli_commands_mixin import CLICommandsMixin +from hermes_cli.cli_billing_mixin import CLIBillingMixin # prompt_toolkit for fixed input area TUI from prompt_toolkit.history import FileHistory @@ -3698,7 +3699,7 @@ def save_config_value(key_path: str, value: any) -> bool: # HermesCLI Class # ============================================================================ -class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): +class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): """ Interactive CLI for the Hermes Agent. @@ -8760,9 +8761,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._manual_compress(cmd_original) elif canonical == "usage": self._handle_usage_command(cmd_original) - elif canonical == "credits": - self._show_credits() - elif canonical == "billing": + elif canonical == "subscription": + self._show_subscription() + elif canonical == "topup": self._show_billing(cmd_original) elif canonical == "insights": self._show_insights(cmd_original) @@ -9771,7 +9772,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): which would otherwise early-return before any credits showed. """ if not self.agent: - if not self._print_nous_credits_block(): + if self._print_nous_credits_block(): + self._print_usage_cta() + else: print("(._.) No active agent -- send a message first.") return @@ -9779,7 +9782,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): calls = agent.session_api_calls if calls == 0: - if not self._print_nous_credits_block(): + if self._print_nous_credits_block(): + self._print_usage_cta() + else: print("(._.) No API calls made yet in this session.") return @@ -9850,7 +9855,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Nous credits magnitudes + monthly-grant gauge (agent-independent — also # runs at the no-agent / no-calls early-returns above). See the helper. - self._print_nous_credits_block() + if self._print_nous_credits_block(): + self._print_usage_cta() if self.verbose: logging.getLogger().setLevel(logging.DEBUG) @@ -9866,730 +9872,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Console quietness is enforced by hermes_logging not # installing a console StreamHandler in non-verbose mode. - def _print_nous_credits_block(self) -> bool: - """Print the Nous credits magnitudes + monthly-grant gauge when a Nous account - is logged in. Returns True if it printed anything. - - Delegates to the shared ``agent.account_usage.nous_credits_lines`` helper — - the single source for the /usage credits block across CLI, gateway, and TUI. - It's agent-independent (a portal fetch gated on "a Nous account is logged in", - NOT the inference-provider string), so /usage shows the block even in the TUI - slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and - wall-clock-bounded inside the helper; also honors HERMES_DEV_CREDITS_FIXTURE - for offline testing — same behavior as every other surface. - """ - from agent.account_usage import nous_credits_lines - - lines = nous_credits_lines() - if not lines: - return False - print() - for line in lines: - print(f" {line}") - return True - - def _show_credits(self): - """`/credits` — focused Nous credit balance + top-up handoff. - - Interactive CLI: balance block + identity line + a 3-button panel - (Open top-up / Copy link / Cancel). Non-interactive contexts — the TUI - slash-worker subprocess and any place without a live prompt_toolkit app - (``self._app is None``) — render a text variant (balance + tappable - top-up URL), because the modal would try to read the RPC stdin and crash - the worker. The terminal never confirms or polls payment (billing phase - 2a). Fail-open: a portal hiccup or logged-out account degrades to a clear - message, never a crash. - """ - from agent.account_usage import build_credits_view - - view = build_credits_view() - - if not view.logged_in: - print() - _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") - print(" Run `hermes portal` to log in, then /credits.") - return - - print() - print(" 💳 Nous credits") - print(f" {'─' * 41}") - for line in view.balance_lines: - # Drop the helper's own "📈 Nous credits" header — we print our own. - if line.lstrip().startswith("📈"): - continue - print(f" {line}") - print(f" {'─' * 41}") - if view.identity_line: - print(f" {view.identity_line}") - - if not view.topup_url: - return - - # Non-interactive (TUI slash-worker, piped, no live app): the - # prompt_toolkit modal can't run here — it would read the worker's - # JSON-RPC stdin and crash the command. Render the text variant: the - # tappable URL IS the affordance, same as the messaging surfaces. - if not getattr(self, "_app", None): - print() - print(f" Top up: {view.topup_url}") - print(" Complete your top-up in the browser — credits will appear in /credits shortly.") - return - - choices = [ - ("open", "Open top-up in browser", "launch the portal billing page"), - ("copy", "Copy link", "copy the top-up URL to your clipboard"), - ("cancel", "Cancel", "do nothing"), - ] - raw = self._prompt_text_input_modal( - title="💳 Add credits?", - detail=f"Top-up page:\n{view.topup_url}", - choices=choices, - ) - choice = self._normalize_slash_confirm_choice(raw, choices) - - if choice == "open": - opened = False - try: - import webbrowser - - opened = webbrowser.open(view.topup_url) - except Exception: - opened = False - if not opened: - print(f" Open this URL to top up: {view.topup_url}") - print() - print(" Complete your top-up in the browser — credits will appear in /credits shortly.") - elif choice == "copy": - try: - self._write_osc52_clipboard(view.topup_url) - print(f" 📋 Copied: {view.topup_url}") - except Exception: - print(f" Top-up URL: {view.topup_url}") - else: - print(" 🟡 Cancelled. No credits added.") - - # ------------------------------------------------------------------ - # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) - # ------------------------------------------------------------------ - - def _show_billing(self, command: str = "/billing"): - """`/billing` — terminal billing for Nous (one interactive modal). - - ZERO sub-commands: any argument is ignored. Bare ``/billing`` always - opens the Overview (Screen 1), whose numbered menu is the *only* way to - reach the Buy / Auto-reload / Monthly-limit sub-screens. (Per the unified - UX spec §0.4 — ``/billing buy`` etc. are gone; we don't error on a stray - arg, we just open the menu.) - - Interactive CLI uses the prompt_toolkit modal; non-interactive contexts - (TUI slash-worker / no live app) render text + the portal deep-link, never - prompting (the URL is the affordance), same discipline as ``_show_credits``. - All money is Decimal end-to-end; the terminal never collects card details. - """ - from agent.billing_view import build_billing_state - - state = build_billing_state() - if not state.logged_in: - print() - if state.error: - _msg = f"Couldn't load billing: {state.error}" - _cprint(f" 💳 {_d(_msg)}") - else: - _cprint(f" 💳 {_d('Not logged into Nous Portal.')}") - print(" Run `hermes portal` to log in, then /billing.") - return - - # Any sub-arg is intentionally ignored — always open the menu. - self._billing_overview(state) - - def _billing_portal_hint(self, state, *, reason: str = "") -> None: - """Print a portal deep-link line (the funnel for portal-only actions).""" - url = getattr(state, "portal_url", None) - if not url: - return - if reason: - print(f" {reason}") - print(f" Manage on portal: {url}") - - def _billing_overview(self, state): - """Screen 1 — overview: balance, spend bar, role-gated action menu.""" - from agent.billing_view import format_money - - print() - _cprint(f" 💳 {_b('Usage credits')}") - print(f" {'─' * 41}") - - cap = state.monthly_cap - if cap is not None and cap.limit_usd is not None: - spent = format_money(cap.spent_this_month_usd) - limit = format_money(cap.limit_usd) - ceiling = " (default ceiling)" if cap.is_default_ceiling else "" - bar, pct = self._billing_spend_bar( - cap.spent_this_month_usd, cap.limit_usd - ) - print(f" {spent} of {limit} used{ceiling} {bar} {pct}%") - - print(f" Balance: {format_money(state.balance_usd)}") - - ar = state.auto_reload - if ar is not None: - if ar.enabled: - print( - f" Auto-reload: on — below {format_money(ar.threshold_usd)} " - f"→ reload to {format_money(ar.reload_to_usd)}" - ) - else: - print(" Auto-reload: off") - - if state.org_name: - role = (state.role or "").title() - _org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}" - _cprint(f" {_d(_org_line)}") - print(f" {'─' * 41}") - - # Action gating: admin + kill-switch for charge/auto-reload; everyone gets portal. - if not state.is_admin: - _cprint(f" {_d('Billing actions require an org admin/owner.')}") - self._billing_portal_hint(state) - return - if not state.cli_billing_enabled: - _cprint(f" {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal to buy credits here.") - return - - # Optimistic funnel: no card on file → a charge will 403 no_payment_method. - # Surface that up front (with the portal link) but DON'T hide Buy — /state.card - # can't fully prove CLI-chargeability, so we advise rather than gate. - if state.card is None: - _cprint( - f" {_d('No saved card for terminal charges yet — set one up on the portal first.')}" - ) - self._billing_portal_hint(state) - - # Non-interactive (slash-worker / no live app): no modal, no sub-command - # advertising — just the portal funnel (the URL is the affordance). - if not getattr(self, "_app", None): - self._billing_portal_hint(state) - return - - choices = [ - ("buy", "Buy credits", "purchase a one-time credit top-up"), - ("auto", "Adjust auto-reload", "configure automatic top-ups"), - ("limit", "Adjust monthly limit", "show the monthly spend cap (read-only)"), - ("portal", "Manage on portal", "open the billing page in your browser"), - ("cancel", "Cancel", "do nothing"), - ] - # The overview summary is already printed above; the modal only needs to - # present the action menu — repeating the title/balance reads as a dupe. - raw = self._prompt_text_input_modal( - title="💳 Choose an action", detail="", - choices=choices, - ) - choice = self._normalize_slash_confirm_choice(raw, choices) - if choice == "buy": - self._billing_buy_flow(state) - elif choice == "auto": - self._billing_auto_reload_flow(state) - elif choice == "limit": - self._billing_limit_screen(state) - elif choice == "portal": - self._billing_open_portal(state) - else: - print(" 🟡 Cancelled.") - - def _billing_spend_bar(self, spent, limit, *, cells: int = 10): - """Render a 10-cell `█`/`░` spend bar + integer percent from spent/limit. - - Returns ``(bar, pct)`` where ``bar`` is like ``[████░░░░░░]`` and ``pct`` - is the spent/limit percentage clamped to 0..100. Box-drawing glyphs are - not SGR codes, so this is leak-safe even without ``_b()``/``_d()``. - """ - from decimal import Decimal - - try: - s = Decimal(str(spent)) if spent is not None else Decimal("0") - l = Decimal(str(limit)) if limit is not None else Decimal("0") - except Exception: - s, l = Decimal("0"), Decimal("0") - if l <= 0: - pct = 0 - else: - pct = int((s / l) * 100) - pct = max(0, min(100, pct)) - filled = int(round(pct / 100 * cells)) - filled = max(0, min(cells, filled)) - bar = ("█" * filled) + ("░" * (cells - filled)) - return bar, pct - - def _billing_open_portal(self, state): - url = getattr(state, "portal_url", None) - if not url: - print(" No portal URL available.") - return - opened = False - try: - import webbrowser - - opened = webbrowser.open(url) - except Exception: - opened = False - if not opened: - print(f" Open this URL: {url}") - print(" Complete billing changes in the browser.") - - def _billing_require_admin(self, state) -> bool: - """Guard charge/auto-reload entry points; print + return False if blocked.""" - if not state.is_admin: - print() - _cprint(f" 💳 {_d('Billing actions require an org admin/owner.')}") - self._billing_portal_hint(state) - return False - if not state.cli_billing_enabled: - print() - _cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal first.") - return False - return True - - def _billing_buy_flow(self, state): - """Screen 2 (preset select) → Screen 3 (confirm + charge + poll).""" - from agent.billing_view import format_money, validate_charge_amount - - if not self._billing_require_admin(state): - return - - # Screen 3 — preset selection. - if not getattr(self, "_app", None): - presets = ", ".join(format_money(p) for p in state.charge_presets) - print() - _cprint(f" 💳 {_b('Buy usage credits')}") - print(f" Presets: {presets}") - print(" Run this in the interactive CLI to complete a purchase.") - self._billing_portal_hint(state) - return - - preset_choices = [] - for p in state.charge_presets: - preset_choices.append((str(p), format_money(p), "one-time credit purchase")) - preset_choices.append(("custom", "Custom amount…", "enter your own amount")) - preset_choices.append(("cancel", "Cancel", "do nothing")) - - card = state.card - detail = f"Payment: {card.masked}" if card else "No saved card on file" - raw = self._prompt_text_input_modal( - title="💳 Buy usage credits", detail=detail, choices=preset_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, preset_choices) - if not choice or choice == "cancel": - print(" 🟡 Cancelled. No credits added.") - return - - from decimal import Decimal - - if choice == "custom": - entered = self._prompt_text_input(" Amount (USD): ") - if entered is None: - # None = cancelled (e.g. slash-worker can't prompt off-thread). - print(" 🟡 Cancelled. No credits added.") - return - v = validate_charge_amount( - entered or "", min_usd=state.min_usd, max_usd=state.max_usd - ) - if not v.ok: - print(f" 🔴 {v.error}") - return - amount = v.amount - else: - try: - amount = Decimal(choice) - except Exception: - print(" 🔴 Invalid selection.") - return - - self._billing_confirm_and_charge(state, amount) - - def _billing_confirm_and_charge(self, state, amount): - """Screen 3 — confirm total + consent, charge, then poll to settlement.""" - from agent.billing_view import format_money, new_idempotency_key - - card = state.card - print() - _cprint(f" 💳 {_b('Confirm purchase')}") - print(f" {'─' * 41}") - print(f" Total: {format_money(amount)}") - if card: - print(f" Payment: {card.masked}") - print(f" {'─' * 41}") - _consent = ( - "By confirming, you allow Nous Research to charge your card." - ) - _cprint(f" {_d(_consent)}") - - confirm_choices = [ - ("pay", f"Pay {format_money(amount)} now", "submit the charge"), - ("cancel", "Go back", "do not charge"), - ] - if not getattr(self, "_app", None): - print(" Run in the interactive CLI to confirm a purchase.") - return - raw = self._prompt_text_input_modal( - title=f"💳 Pay {format_money(amount)}?", - detail=(card.masked if card else "no saved card"), - choices=confirm_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, confirm_choices) - if choice != "pay": - print(" 🟡 Cancelled. No credits added.") - return - - # Submit the charge with a fresh idempotency key (reused on retry). - from hermes_cli.nous_billing import ( - BillingError, - BillingScopeRequired, - post_charge, - ) - - key = new_idempotency_key() - try: - result = post_charge(amount_usd=amount, idempotency_key=key) - except BillingScopeRequired: - self._billing_handle_scope_required(state) - return - except BillingError as exc: - self._billing_render_charge_error(state, exc) - return - - charge_id = result.get("chargeId") - if not charge_id: - print(" 🔴 No charge id returned; please check the portal.") - return - _cprint(f" {_d('Charge submitted — confirming settlement…')}") - self._billing_poll_charge(state, charge_id, amount) - - def _billing_poll_charge(self, state, charge_id, amount): - """Poll loop: 2s interval, 5-min cap, cancellable. settled = ledger truth.""" - import time as _time - - from agent.billing_view import format_money - from hermes_cli.nous_billing import ( - BillingError, - BillingRateLimited, - get_charge_status, - ) - - deadline = _time.time() + 300 # 5-minute cap - interval = 2.0 - while _time.time() < deadline: - try: - status = get_charge_status(charge_id) - except BillingRateLimited as exc: - # Retry-after, NOT a failure — back off and keep polling. - wait = exc.retry_after or 5 - _time.sleep(min(wait, 30)) - continue - except BillingError as exc: - print(f" 🔴 Could not check the charge: {exc}") - return - - state_str = status.get("status") - if state_str == "settled": - amt = status.get("amountUsd") - from agent.billing_view import parse_money - - shown = format_money(parse_money(amt)) if amt else format_money(amount) - print(f" ✅ {shown} in credits added.") - return - if state_str == "failed": - self._billing_render_charge_failed(state, status.get("reason")) - return - # pending → wait and poll again - _time.sleep(interval) - - # Past the cap with no terminal state = timeout (not an error). - print(" 🟡 Still processing after 5 minutes — this is a timeout, not a " - "failure. Check /billing or the portal shortly.") - self._billing_portal_hint(state) - - def _billing_render_charge_failed(self, state, reason): - """Branch the poll `failed` reasons to the right copy + portal funnel.""" - reason = (reason or "").strip() - if reason == "authentication_required": - print(" 🔴 Your bank requires verification (3DS). Complete it on the " - "portal to finish this purchase.") - elif reason == "payment_method_expired": - print(" 🔴 Your card has expired. Update it on the portal.") - elif reason == "card_declined": - print(" 🔴 Your card was declined. Try another card on the portal.") - else: - print(f" 🔴 The charge didn't go through ({reason or 'processing_error'}).") - self._billing_portal_hint(state) - - def _billing_render_charge_error(self, state, exc): - """Render a typed BillingError at submit time (pre-poll).""" - from hermes_cli.nous_billing import BillingRateLimited - - code = getattr(exc, "error", None) - portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None) - if code == "no_payment_method": - print(" 💳 No saved card for terminal charges yet. Set one up on the " - "portal (one-time credit buys don't save a reusable card).") - elif code == "cli_billing_disabled": - print(" 🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.") - elif code == "monthly_cap_exceeded": - remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd") - if remaining is not None: - print(f" 🔴 Monthly spend cap reached — ${remaining} headroom left.") - else: - print(" 🔴 Monthly spend cap reached.") - elif isinstance(exc, BillingRateLimited): - wait = getattr(exc, "retry_after", None) - mins = f" (try again in ~{max(1, round(wait / 60))} min)" if wait else "" - print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.") - else: - print(f" 🔴 {exc}") - if portal_url: - print(f" Portal: {portal_url}") - - def _billing_handle_scope_required(self, state): - """403 insufficient_scope → lazy step-up re-auth (plan D-A).""" - print() - print(" 💳 Terminal billing needs an extra permission (billing:manage).") - _scope_msg = ( - "An org admin/owner must tick \"Allow terminal billing\" during " - "login." - ) - _cprint(f" {_d(_scope_msg)}") - if not getattr(self, "_app", None): - print(" Run `hermes portal` and approve terminal billing, then retry.") - return - confirm_choices = [ - ("yes", "Re-authorize now", "open the portal to grant billing access"), - ("no", "Not now", "cancel"), - ] - raw = self._prompt_text_input_modal( - title="💳 Grant terminal billing access?", - detail="Opens the portal device-authorization page.", - choices=confirm_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, confirm_choices) - if choice != "yes": - print(" 🟡 Cancelled.") - return - try: - from hermes_cli.auth import step_up_nous_billing_scope - - granted = step_up_nous_billing_scope(open_browser=True) - except Exception as exc: - print(f" 🔴 Re-authorization failed: {exc}") - return - if granted: - print(" ✅ Billing permission granted.") - # Step-up only grants the billing:manage TOKEN scope; the ORG - # kill-switch (cli_billing_enabled) is a separate gate. Re-fetch - # /state so we don't over-promise when a charge would still hit - # cli_billing_disabled. - from agent.billing_view import build_billing_state - - fresh = build_billing_state() - if fresh.logged_in and fresh.cli_billing_enabled: - print(" Run /billing buy again to continue.") - else: - print(" 🟡 Permission granted, but terminal billing is still turned " - "off for this org. Enable it in the portal, then run /billing again.") - self._billing_portal_hint(fresh) - else: - print(" 🟡 Terminal billing was not granted (an admin must tick the box).") - - def _billing_auto_reload_flow(self, state): - """Screen 4 — auto-reload config: threshold + reload-to → PATCH. - - Prefills the current values from ``state.auto_reload``. Validates both - amounts (2dp, within bounds, ``reload_to > threshold``). When auto-reload - is already on, offers a "Turn off" path (PATCH ``enabled:false``). - """ - from agent.billing_view import format_money, validate_charge_amount - - if not self._billing_require_admin(state): - return - - card = state.card - ar = state.auto_reload - currently_on = bool(ar and ar.enabled) - - print() - _cprint(f" 💳 {_b('Auto-reload')}") - print(f" {'─' * 41}") - _cprint(f" {_d('Automatically buy more credits when your balance is low.')}") - if card: - print(f" Card on file: {card.masked}") - else: - print(" No saved card — set one up on the portal first.") - self._billing_portal_hint(state) - return - if currently_on: - print( - f" Currently: below {format_money(ar.threshold_usd)} → " - f"reload to {format_money(ar.reload_to_usd)}" - ) - - if not getattr(self, "_app", None): - print(" Run in the interactive CLI to configure auto-reload.") - self._billing_portal_hint(state) - return - - # When already enabled, let the user turn it off without re-entering values. - if currently_on: - top_choices = [ - ("edit", "Edit thresholds", "change when / how much to reload"), - ("off", "Turn off", "disable auto-reload"), - ("cancel", "Cancel", "do nothing"), - ] - raw = self._prompt_text_input_modal( - title="💳 Auto-reload", - detail=( - f"On — below {format_money(ar.threshold_usd)} → " - f"reload to {format_money(ar.reload_to_usd)}" - ), - choices=top_choices, - ) - top = self._normalize_slash_confirm_choice(raw, top_choices) - if top == "off": - self._billing_auto_reload_disable(state) - return - if top != "edit": - print(" 🟡 Cancelled.") - return - - # Field 1 — threshold (prefilled when editing an existing config). - cur_thr = format_money(ar.threshold_usd) if currently_on else None - thr_prompt = " When balance falls below (USD)" - thr_prompt += f" [{cur_thr}]: " if cur_thr else ": " - threshold_raw = self._prompt_text_input(thr_prompt) - if threshold_raw is None: - # None = cancelled (e.g. slash-worker can't prompt off-thread). - print(" 🟡 Cancelled.") - return - if not (threshold_raw or "").strip() and currently_on: - threshold_amt = ar.threshold_usd # keep current value on empty input - else: - tv = validate_charge_amount( - threshold_raw or "", min_usd=state.min_usd, max_usd=state.max_usd - ) - if not tv.ok or tv.amount is None: - print(f" 🔴 {tv.error}") - return - threshold_amt = tv.amount - - # Field 2 — reload-to (prefilled when editing an existing config). - cur_rel = format_money(ar.reload_to_usd) if currently_on else None - rel_prompt = " Reload balance to (USD)" - rel_prompt += f" [{cur_rel}]: " if cur_rel else ": " - reload_raw = self._prompt_text_input(rel_prompt) - if reload_raw is None: - print(" 🟡 Cancelled.") - return - if not (reload_raw or "").strip() and currently_on: - reload_amt = ar.reload_to_usd # keep current value on empty input - else: - rv = validate_charge_amount( - reload_raw or "", min_usd=state.min_usd, max_usd=state.max_usd - ) - if not rv.ok or rv.amount is None: - print(f" 🔴 {rv.error}") - return - reload_amt = rv.amount - - if reload_amt is None or threshold_amt is None or reload_amt <= threshold_amt: - print(" 🔴 Reload-to amount must be greater than the threshold.") - return - - print() - _ar_consent = ( - f"By confirming, you authorize Nous Research to charge {card.masked} " - f"whenever your balance reaches {format_money(threshold_amt)}. " - f"Turn off any time here or on the portal." - ) - _cprint(f" {_d(_ar_consent)}") - confirm_choices = [ - ("agree", "Agree and turn on", "enable auto-reload"), - ("cancel", "Cancel", "do nothing"), - ] - raw = self._prompt_text_input_modal( - title="💳 Turn on auto-reload?", - detail=f"Below {format_money(threshold_amt)} → reload to {format_money(reload_amt)}", - choices=confirm_choices, - ) - choice = self._normalize_slash_confirm_choice(raw, confirm_choices) - if choice != "agree": - print(" 🟡 Cancelled.") - return - - from hermes_cli.nous_billing import ( - BillingError, - BillingScopeRequired, - patch_auto_top_up, - ) - - try: - patch_auto_top_up( - enabled=True, threshold=float(threshold_amt), top_up_amount=float(reload_amt) - ) - except BillingScopeRequired: - self._billing_handle_scope_required(state) - return - except BillingError as exc: - self._billing_render_charge_error(state, exc) - return - print(f" ✅ Auto-reload on: below {format_money(threshold_amt)} → " - f"reload to {format_money(reload_amt)}.") - - def _billing_auto_reload_disable(self, state): - """Turn off auto-reload (PATCH ``enabled:false``). - - The endpoint requires ``threshold``/``topUpAmount`` in the body even when - disabling, so we echo back the current values (falling back to 0). - """ - from hermes_cli.nous_billing import ( - BillingError, - BillingScopeRequired, - patch_auto_top_up, - ) - - ar = state.auto_reload - thr = float(ar.threshold_usd) if ar and ar.threshold_usd is not None else 0.0 - rel = float(ar.reload_to_usd) if ar and ar.reload_to_usd is not None else 0.0 - try: - patch_auto_top_up(enabled=False, threshold=thr, top_up_amount=rel) - except BillingScopeRequired: - self._billing_handle_scope_required(state) - return - except BillingError as exc: - self._billing_render_charge_error(state, exc) - return - print(" ✅ Auto-reload turned off.") - - def _billing_limit_screen(self, state): - """Screen 5 — monthly spend limit (read-only; cap is portal-only).""" - from agent.billing_view import format_money - - print() - _cprint(f" 💳 {_b('Monthly spend limit')}") - print(f" {'─' * 41}") - cap = state.monthly_cap - if cap is None or cap.limit_usd is None: - _cprint(f" {_d('No monthly cap visible (managed on the portal).')}") - else: - spent = format_money(cap.spent_this_month_usd) - limit = format_money(cap.limit_usd) - ceiling = " (default ceiling)" if cap.is_default_ceiling else "" - print(f" {spent} of {limit} used this month{ceiling}") - _limit_note = ( - "The monthly limit is set on the portal — the terminal shows " - "it read-only." - ) - _cprint(f" {_d(_limit_note)}") - self._billing_portal_hint(state) - def _show_insights(self, command: str = "/insights"): """Show usage insights and analytics from session history.""" # Parse optional --days flag diff --git a/docs/billing-lifecycle.md b/docs/billing-lifecycle.md new file mode 100644 index 000000000000..53151684214b --- /dev/null +++ b/docs/billing-lifecycle.md @@ -0,0 +1,171 @@ +# Billing lifecycle: client-side state, errors, and recovery + +This is the map from every `billing.*`/`subscription.*` state shape the gateway +serves (from NAS) to what the terminal actually renders, and from every typed +refusal/error code to its exact user-facing copy and recovery action. The +guarantee: no NAS billing state and no typed refusal falls through to a +generic toast — every case below is an explicit branch in +`ui-tui/src/app/slash/commands/topup.ts`, `ui-tui/src/components/billingOverlay.tsx`, +or `ui-tui/src/components/subscriptionOverlay.tsx`. An **unknown** code still +degrades gracefully: it hits the `default` branch (a generic-but-real message +pulled from the server payload, never a blank toast) rather than crashing or +silently dropping the refusal. + +## 1. `billing.state` shapes → render + +Source: `ui-tui/src/components/billingOverlay.tsx` (`OverviewScreen`, +`BuyScreen`, `AutoReloadScreen`), `ui-tui/src/app/slash/commands/topup.ts` (`/topup` run). + +| State shape | Render | +|---|---| +| Logged out (`s.logged_in === false`) | Overlay never opens. `sys`: `💳 Not logged into Nous Portal — run /portal to log in, then /topup.` | +| `billing.state` RPC fetch fails (transport/timeout) | **Fail-closed**: `.catch(ctx.guardedErr)` — overlay never opens, no state is assumed. `sys`: `error: `. Never renders "no card" or any other guessed state; user must retry `/topup`. | +| `card: null` (no saved card), full menu (`is_admin && cli_billing_enabled`) | Overview shows `No saved card on file — "Add funds" walks you through adding one.` "Add funds" opens the **add-card path**: `Add a card on the portal` / `I've added it — check again` / `Back` (never an amount picker, which would 403 `no_payment_method`). | +| `card` present, `resolved_via` set | `Card: {display}` (e.g. `Visa ····4242 — the card on your subscription`) using the provenance-aware `display` field. | +| `card` present, `resolved_via` absent (older NAS) | Falls back to the generic `Card: {masked}`; Confirm screen adds `Your card saved on the portal will be charged.` | +| `auto_reload: null` | No auto-reload line at all (`autoReloadLine` returns `null`) — the feature isn't surfaced. | +| `auto_reload.card.kind: 'canonical'` | No distinct-card warning; card line falls back to the card on file. | +| `auto_reload.card.kind: 'distinct'` | `⚠ Auto-refill is charging {brand} ••{last4} — not your card on file.` in the Auto-reload screen (the divergence notice). | +| `auto_reload.card.kind: 'none'` | Same as `canonical` rendering-wise — no distinct-card warning shown. | +| `monthly_cap` present, `limit_usd != null` | `{spent_display} of {limit_display} used this month` (+ ` (default ceiling)` iff `is_default_ceiling`). | +| `monthly_cap` absent or `limit_usd == null` | `No monthly cap visible (managed on the portal).` | +| Role without billing capability (`!is_admin`, menu collapses) | Note: `Billing actions need someone with billing permissions (owner, admin, or finance admin).` Menu collapses to `Manage on portal` / `Cancel`. | +| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Terminal billing is off for this org — manage it on the portal.` Same collapsed menu. | + +Note: `full = s.is_admin && s.cli_billing_enabled` gates the **org-level** +switch, not the per-terminal `billing:manage` scope — that's discovered +reactively (a charge 403s `insufficient_scope`) and routes to the resumable +step-up screen instead of a preflight check. + +## 2. Refusal codes (`renderBillingError`, in code order) + +Source: `renderBillingError` in `ui-tui/src/app/slash/commands/topup.ts:37-149`. +"Portal" row = `sys('Portal: {portal_url}')` is appended whenever `portal_url` is present, for every code (including default). + +| `error` code | Copy | Portal URL | `retry_after` | +|---|---|:-:|:-:| +| `insufficient_scope` | `This needs terminal billing enabled. Start a top-up to enable it, then retry.` | if present | — | +| `remote_spending_revoked` (CF-4) | `{An admin turned off terminal billing for this terminal. \| You turned off terminal billing for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — | +| `session_revoked` | `Your session was logged out. Run /portal to log in again.` Also clears `billing` overlay state. | if present | — | +| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Terminal billing is off for this account — an admin must enable it on the portal.` | if present | — | +| `role_required` | `Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.` | if present | — | +| `consent_required` | `This action needs a one-time card confirmation and consent step on the portal before it can proceed.` | if present | — | +| `org_access_denied` | `This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.` | if present | — | +| `upgrade_cap_exceeded` | `🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.` | if present | — | +| `auto_top_up_disabled_failures` | `Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.` | if present | — | +| `idempotency_conflict` | `🔴 That charge key was already used for a different amount. Start a fresh top-up.` | if present | — | +| `no_payment_method` | `💳 No saved card for terminal charges yet. Set one up on the portal (one-time credit buys don't save a reusable card).` | if present | — | +| `monthly_cap_exceeded` | `🔴 Monthly spend cap reached — ${remainingUsd} headroom left.` if `payload.remainingUsd` present, else `🔴 Monthly spend cap reached.` | if present | — | +| `rate_limited` / `temporarily_unavailable` | `🟡 Too many charges right now{ (try again in ~N min)}. This isn't a payment failure.` | if present | **yes** — minutes computed as `max(1, round(retry_after/60))` | +| `stripe_unavailable` | `🟡 Stripe is having trouble right now — try again shortly{ (try again in ~N min)}.` | if present | **yes** (same formula) | +| *default (unknown/other)* | `🔴 {message \|\| error \|\| 'Billing request failed.'}` — still surfaces whatever the server said, never a blank toast. | if present | — | + +## 3. Charge settlement outcomes (`pollCharge` / `renderChargeFailed`) + +Source: `pollCharge` (`ui-tui/src/app/slash/commands/topup.ts:170-258`) and +`renderChargeFailed` (`:260-290`). Poll cadence: 2s interval, 5-minute cap +(`POLL_INTERVAL_MS=2000`, `POLL_CAP_MS=5*60*1000`), applied on **every** +non-terminal path (pending *and* throttled), so a sustained 429/503 can't +keep the poll alive forever. + +| Outcome | Copy | Notes | +|---|---|---| +| `status: 'settled'` | `✅ ${amount_usd} added.` (or `✅ Credits added.` if no amount) | Terminal success. | +| `status: 'failed'`, `reason: 'authentication_required'` | `🔴 Your bank requires verification (3DS). Complete it on the portal to finish this purchase.` | + `Portal:` line if `portalUrl`. | +| `status: 'failed'`, `reason: 'payment_method_expired'` | `🔴 Your card has expired. Update it on the portal.` | + `Portal:` line. | +| `status: 'failed'`, `reason: 'card_declined'` | `🔴 Your card was declined. Try another card on the portal.` | + `Portal:` line. | +| `status: 'failed'`, `reason: 'processing_error'` | `🔴 The charge didn't go through (processing_error).` | + `Portal:` line. | +| `status: 'failed'`, unrecognized/missing `reason` | `🔴 The charge didn't go through ({reason \|\| 'processing_error'}).` | Same portal funnel — parity with `cli.py`'s `_billing_portal_hint`. | +| Poll timeout (still `pending` past the 5-min cap) | `🟡 Still processing after 5 minutes — this is a timeout, not a failure. Check /topup or the portal shortly.` | + `Portal:` line if `portalUrl`. Explicitly NOT called a failure. | +| Revocation mid-poll (`remote_spending_revoked` / `session_revoked` while polling) | Renders the matching §2 copy, **then** appends: `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` | CF-7 rule 4: a post-revoke 403 while polling is ambiguous (the charge may have already settled) — never call it "failed". | +| 429/503 while polling (`rate_limited`/`temporarily_unavailable`/`stripe_unavailable`) | No error shown; backs off using `retry_after` (default 5s, capped at 30s) and keeps polling until the 5-min cap, then reads as timeout. | Not a payment failure. | +| Other `!ok` status-check error | `🔴 Could not check the charge: {message \|\| error \|\| 'error'}` | | +| Transport loss (poll RPC throws/rejects) | `🟡 Your last charge's outcome is unconfirmed — check your balance/history before retrying.` (`UNCONFIRMED_CHARGE_MESSAGE`) | Same "unconfirmed, check balance" framing as revocation mid-poll — a dropped connection can never be read as "failed". | + +## 4. Subscription preview / pending-change / upgrade outcomes + +Source: `previewAndRoute`, `applyPendingAndRoute`, `upgradeResult`, +`stepUpDenialResult` in `ui-tui/src/components/subscriptionOverlay.tsx`. + +**Preview `effect` values** (drive the Confirm screen): + +| `effect` | Confirm screen copy | Primary action | +|---|---|---| +| `charge_now` | `Upgrade to {target}. You will be charged {amount} now (prorated).` (+ monthly-credits delta, + which card if resolver confidently knows) | `Pay {amount} & upgrade now` | +| `scheduled` | `Change to {target} — takes effect {date}. No charge now; you keep your current plan until then.` | `Schedule change to {target}` | +| `no_op` | `You are already on {target} — nothing to change.` | none (Back only) | +| `blocked` | `{preview.reason}` or fallback `That change cannot be made here — manage it on the portal.` | `Manage on portal` | +| Preview RPC returns `null`/transport failure | routes straight to Result: `Could not preview that change.` | — | +| Preview `!ok`, `insufficient_scope` | routes to `stepup` screen (`{kind:'preview', tierId}`) | — | +| Preview `!ok`, other error | routes to Result with `errorResult(p)` (`message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'`) | — | + +**Pending-change apply outcomes** (`applyPendingAndRoute`): + +| `pending.kind` | Success copy | +|---|---| +| `cancellation` | `Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.` | +| `tier_change` (downgrade/schedule) | `Scheduled — your plan doesn't change today. You keep your current plan until the end of the billing period, then it switches.` | +| `upgrade` | routed through `upgradeResult` (below) | +| any kind, mutation `insufficient_scope` | routes to stepup (`{kind:'apply'}`) | + +**Upgrade `status` × `reason` matrix** (`upgradeResult`, checked in this +order — `reason` is checked *before* `status`): + +| Condition | Result | +|---|---| +| `r === null` (transport failure on the charging route) | `Couldn't confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.` — ambiguous, never a blind retry. | +| `reason: 'authentication_required'` **or** `reason: 'subscription_payment_intent_requires_action'` | `Please verify your card in the portal to finish this upgrade.` → `recovery_url`. **Both reasons map to the same SCA copy** — the client branches on `reason`, not `status`, specifically so an SCA case that pre-#711 NAS mislabels with `status: 'payment_failed'` (no distinguishing reason yet) still routes to the correct "verify your card" copy instead of reading as a hard decline. | +| `reason: 'card_declined'` | `Your card was declined — try a different card on the portal.` → `recovery_url`. | +| `ok && status: 'already_on_tier'` | `You are already on {target_tier_name}.` (success) | +| `ok && status: 'upgraded'` | `Upgraded to {target_tier_name}. Your new monthly credits land in a moment.` — starts the eventual-consistency apply-poll (below). | +| `status: 'requires_action'` (no distinguishing reason) | `This upgrade needs extra verification (3DS). Finish it on the portal.` → `recovery_url`. | +| `status: 'payment_failed'` (no distinguishing reason) | `Your card was declined. Update your payment method on the portal and try again.` → `recovery_url`. | +| anything else | `errorResult(r)`: `message \|\| error \|\| 'Something went wrong. Try again, or manage on the portal.'` | + +**Eventual-consistency apply-poll** (`ResultScreen`, only after `status: +'upgraded'`): polls `billing`/subscription state every 2s +(`UPGRADE_CONFIRM_INTERVAL_MS`) up to 15 attempts +(`UPGRADE_CONFIRM_ATTEMPTS`, i.e. ~30s) until `current.tier_id` flips to the +target. While waiting the screen reads `Applying…`; if it never flips inside +the budget it reads `Still applying` / `Your upgrade succeeded and is still +applying — refresh in a moment.` — the upgrade is never re-reported as failed +just because NAS hasn't caught up yet. + +**Step-up denial copy** (`stepUpDenialResult`, subscription flow): + +| `error` | Copy | +|---|---| +| `session_revoked` | `Your session expired — run /portal to log in again, then retry the change.` | +| `remote_spending_revoked` | `{message}` or `Terminal spending was turned off for this session — reconnect from the portal, then retry.` | +| `rate_limited` | `Too many attempts — wait a moment, then try again.` | +| other/unknown | `{message}` or `Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.` | + +A **repeat** scope denial during a post-grant replay never re-enters the +step-up screen (it's already mounted there — re-patching would freeze it); +`allowStepUp=false` instead surfaces a terminal result: `Terminal billing +still isn't enabled for this org — enable it on the portal, then retry.` + +## Text-mode (CLI) parity + +`cli.py`'s `_show_billing` / `_billing_overview` and `_show_subscription` / +`_subscription_overview` render the same state shapes (balance title, two-bar +dollar usage, auto-reload line, card line, monthly cap) and share the +"fail-open on logged-out/portal-hiccup, never crash" discipline. The CLI's +`/subscription` gives a paid admin/owner in an interactive context the **full +in-terminal change flow** (tier picker → preview → confirm → apply, parity +with the TUI overlay); members and non-interactive contexts fall back to +`_billing_portal_hint`'s deep-link to `subscription_manage_url`. `/topup`'s +interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and +non-interactive contexts fall back to the same text + portal-link rendering, +never prompting. + +## Forward compatibility + +Any `error`/`status`/`reason` code not in the tables above lands on the +`default` branch in `renderBillingError` (§2) or `errorResult`/`upgradeResult`'s +fallthrough (§4): it still renders the server's own `message` (never blank, +never a crash), just without bespoke copy or a typed recovery affordance. +NAS W3 introduces card-health codes (`card_paused`, `card_expired`, +`card_mismatch`) that are not yet typed here — until a client update adds +explicit branches, they will arrive as unknown codes and degrade to this +default path. diff --git a/gateway/run.py b/gateway/run.py index 838b5b09d6f9..6deee096c5aa 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10279,8 +10279,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if canonical == "usage": return await self._handle_usage_command(event) - if canonical == "credits": - return await self._handle_credits_command(event) + if canonical == "topup": + return await self._handle_topup_command(event) if canonical == "insights": return await self._handle_insights_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 77ace8f0e96b..34c786b4893e 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -4095,15 +4095,15 @@ class GatewaySlashCommandsMixin: key = "gateway.branch.branched_one" if msg_count == 1 else "gateway.branch.branched_many" return t(key, title=branch_title, count=msg_count, parent=parent_session_id, new=new_session_id) - async def _handle_credits_command(self, event: MessageEvent) -> str: - """Handle /credits -- show Nous credit balance and the top-up handoff. + async def _handle_topup_command(self, event: MessageEvent) -> str: + """Handle /topup -- show the Nous balance and hand off to the portal. - Renders the balance block + identity line + a tappable top-up URL that - opens the portal billing page with the modal open. The terminal does NOT - confirm, poll, or track payment (billing phase 2a) — checkout happens in - the browser and the next /credits shows the new balance. The tappable URL - is the affordance: it works on every platform (button-capable or plain - text like SMS/email). Fetched off the event loop; fail-open. + Renders the balance block + identity line + a tappable portal URL that + opens the billing page. Terminal billing is managed on the portal: the + terminal does NOT charge, confirm, or track payment here — everything + happens in the browser and the next /topup shows the new balance. The + tappable URL is the affordance and works on every platform (button-capable + or plain text like SMS/email). Fetched off the event loop; fail-open. """ from agent.account_usage import build_credits_view @@ -4115,7 +4115,7 @@ class GatewaySlashCommandsMixin: if view is None or not view.logged_in: return t("gateway.credits.not_logged_in") - lines: list[str] = ["💳 **Nous credits**"] + lines: list[str] = ["💳 **Nous balance**"] for line in view.balance_lines: if line.lstrip().startswith("📈"): continue # drop the helper's header; we print our own @@ -4125,8 +4125,8 @@ class GatewaySlashCommandsMixin: lines.append(view.identity_line) if view.topup_url: lines.append("") - lines.append(f"Top up: {view.topup_url}") - lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.") + lines.append(f"Manage billing on the portal: {view.topup_url}") + lines.append("Top up and manage billing in the browser — your balance updates here after.") return "\n".join(lines) def _context_breakdown_lines(self, agent, source) -> list[str]: 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/hermes_cli/commands.py b/hermes_cli/commands.py index 10f8fbf046b8..8fb6926d8671 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -230,9 +230,9 @@ COMMAND_REGISTRY: list[CommandDef] = [ gateway_only=True), CommandDef("usage", "Show token usage and rate limits; `reset` redeems a banked Codex limit reset", "Info", args_hint="[reset [--force]]"), - CommandDef("credits", "Show Nous credit balance and top up", "Info"), - CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info", - cli_only=True), + CommandDef("subscription", "View your Nous plan and change it in the browser", "Info", + cli_only=True, aliases=("upgrade",)), + CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info"), CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", @@ -1159,12 +1159,12 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg") # surface (CLI, TUI, Telegram, Discord). Keep this list TIGHT and intentional — # the telegram-parity test reads it so an entry here is a deliberate # "Slack-via-/hermes" decision, not a silent clamp. -# - credits: the billing/top-up surface; reached via /hermes credits on Slack. -# - billing: the terminal-billing surface (buy/auto-reload/limit); /hermes billing. +# - topup: the billing/balance surface; reached via /hermes topup on Slack. +# (the rehaul folded the old /credits + /billing surfaces into /topup.) # - moa: high-cost slash mode, available through /hermes moa to avoid # displacing existing native Slack slash commands at the 50-command cap. # - debug: the log/report upload surface; reached via /hermes debug on Slack. -_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "moa", "debug"}) +_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug"}) def _sanitize_slack_name(raw: str) -> str: diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index 8bca89ed36c9..83c65f70de74 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -67,6 +67,9 @@ class BillingError(Exception): portal_url: Optional[str] = None, retry_after: Optional[int] = None, payload: Optional[dict[str, Any]] = None, + actor: Optional[str] = None, + code: Optional[str] = None, + recovery: Optional[str] = None, ) -> None: super().__init__(message) self.status = status @@ -74,6 +77,13 @@ class BillingError(Exception): self.portal_url = portal_url self.retry_after = retry_after self.payload = payload or {} + # Remote-Spending contract extras (NAS PR #481): `actor` (self|admin) on a + # revoke, `code` (the new machine code dual-emitted alongside `error`), and + # `recovery` (reconnect|login|enable_account_toggle). Additive — absent on + # older NAS / unrelated errors. + self.actor = actor + self.code = code + self.recovery = recovery class BillingScopeRequired(BillingError): @@ -86,17 +96,73 @@ class BillingScopeRequired(BillingError): """ -class BillingRateLimited(BillingError): +class BillingAuthError(BillingError): + """``401`` — missing/invalid bearer token (not logged in / expired).""" + + +class BillingRemoteSpendingRevoked(BillingError): + """``403 remote_spending_revoked`` — THIS terminal's spending was revoked. + + Distinct from ``insufficient_scope`` (never had the grant) and from + ``session_revoked`` (full logout). The terminal stays logged in; only the + money path is cut. ``actor`` is ``"admin"`` or ``"self"`` (absent → treat as + ``"self"``); recovery is **reconnect** (re-consent device-auth). The terminal + MUST disable charge/auto-reload immediately, without waiting for the next + token refresh (the current token still claims the scope for ~15 min). + """ + + +class BillingSessionRevoked(BillingAuthError): + """``401 session_revoked`` — the whole session was logged out. + + Stronger than a spend-revoke: recovery is **re-login** (full device-auth), + not just reconnect. Subclass of :class:`BillingAuthError` so existing 401 + handling still treats it as not-logged-in, but the typed code lets the + surface route to re-login with the right copy. + """ + + +class BillingTransient(BillingError): + """A deterministic non-charge outcome: the request definitely did NOT + reach/complete at Stripe, so it's always safe to retry after backoff — + never the "maybe charged" ambiguity of a real 5xx/timeout. Covers + 429 rate limiting, 503 gate-unavailable, Stripe being down, and the + daily upgrade cap — distinct failure modes that share this one + contract property. Catch this (not the old ad-hoc subclass hierarchy) + wherever the intent is "any transient, definitely-not-charged billing + failure, back off and retry/poll". + """ + + +class BillingRateLimited(BillingTransient): """``429 rate_limited`` or ``503 temporarily_unavailable``. NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell the user "try again in N min"; never auto-retry-spam (the limiter is - 5/org/hr + 5/token/hr and easy to dig deeper into). + 5/org/hr + 5/token/hr and easy to dig deeper into). A 503 is the gate backend + failing closed — back off, do NOT treat as revoked. """ -class BillingAuthError(BillingError): - """``401`` — missing/invalid bearer token (not logged in / expired).""" +class BillingStripeUnavailable(BillingTransient): + """``503 stripe_unavailable`` — Stripe itself is down. + + TRANSIENT: back off and retry using Retry-After; this is NOT the same as + being throttled by our own rate limiter, so surfaces must not render "rate + limited" copy for it — they should read ``.error`` to tell the two apart. + A BillingTransient sibling of BillingRateLimited (not a subclass) — surfaces + must not render "rate limited" copy for it; read ``.error`` to distinguish it. + """ + + +class BillingUpgradeCapExceeded(BillingTransient): + """``429 upgrade_cap_exceeded`` — the org hit its 5-upgrades/day cap. + + Distinct from the hourly ``rate_limited`` charge cap (same HTTP status, + different meaning + no useful short-Retry-After backoff). A BillingTransient + sibling of BillingRateLimited (not a subclass) — surfaces must read ``.error`` + to distinguish the failure mode. + """ # ============================================================================= @@ -148,6 +214,20 @@ _TOKEN_CACHE_TTL_SECONDS = 30.0 _token_cache: tuple[float, str, str] | None = None # (cached_at, token, base) +def invalidate_cached_token() -> None: + """Bust the 30s token cache so post-step-up replays use the freshly-scoped token. + + ``_request`` only self-busts the cache on a 401 (an expired/invalid + token), not on a 403 scope denial — so after a step-up grant, the + cache would otherwise still hold the pre-grant unscoped token and + the immediate replay would 403 again. Callers outside this module + (e.g. the CLI's scope step-up flow) call this instead of poking + the private ``_token_cache`` global directly. + """ + global _token_cache + _token_cache = None + + def _billing_not_logged_in(exc: Optional[BaseException] = None) -> "BillingAuthError": """Build the canonical 'not logged in' BillingAuthError (single source).""" err = BillingAuthError( @@ -234,9 +314,21 @@ def _retry_after_seconds(headers: Any) -> Optional[int]: def _raise_for_error( status: int, payload: dict[str, Any], headers: Any = None ) -> None: - """Map an HTTP error response to the right typed :class:`BillingError`.""" + """Map an HTTP error response to the right typed :class:`BillingError`. + + Recognizes the Remote-Spending gate contract (NAS PR #481): + 403 ``remote_spending_revoked`` (this terminal's spend revoked → reconnect), + 401 ``session_revoked`` (full logout → re-login), 503 ``temporarily_unavailable`` + (gate fail-closed → back off, NOT revoked). The business-denial codes + (``cli_billing_disabled`` + dual ``code:remote_spending_disabled``, + ``role_required``, ``idempotency_conflict``, …) flow through as a generic + BillingError carrying ``error``/``code``/``recovery`` for the surface to map. + """ error = payload.get("error") if isinstance(payload, dict) else None message = payload.get("message") if isinstance(payload, dict) else None + code = payload.get("code") if isinstance(payload, dict) else None + actor = payload.get("actor") if isinstance(payload, dict) else None + recovery = payload.get("recovery") if isinstance(payload, dict) else None portal_url = _absolutize_portal_url( payload.get("portalUrl") if isinstance(payload, dict) else None ) @@ -248,14 +340,42 @@ def _raise_for_error( "portal_url": portal_url, "retry_after": retry_after, "payload": payload if isinstance(payload, dict) else None, + "actor": actor, + "code": code, + "recovery": recovery, } - if status == 401: - raise BillingAuthError(message or "Authentication required.", **common) - if status == 403 and error == "insufficient_scope": - raise BillingScopeRequired( - message or "This action needs the billing:manage scope.", **common + if error == "stripe_unavailable": + raise BillingStripeUnavailable( + message or "Stripe is temporarily unavailable — try again shortly.", **common ) + if error == "upgrade_cap_exceeded": + raise BillingUpgradeCapExceeded( + message or "Daily plan-change limit reached — try again tomorrow.", **common + ) + + if status == 401: + # session_revoked is a full logout (→ re-login), stronger than a 401 + # expired-token. Both stay BillingAuthError-compatible for legacy callers. + if error == "session_revoked": + raise BillingSessionRevoked( + message or "Your session was logged out — log in again.", **common + ) + raise BillingAuthError(message or "Authentication required.", **common) + if status == 403: + # This terminal's spending was revoked (NOT the same as never having the + # scope). Disable spend UI immediately; recovery is reconnect. + if error == "remote_spending_revoked": + raise BillingRemoteSpendingRevoked( + message or "Remote Spending was revoked for this terminal.", **common + ) + if error == "insufficient_scope": + raise BillingScopeRequired( + message or "This action needs the billing:manage scope.", **common + ) + # Business 403s (cli_billing_disabled / role_required / no_payment_method / + # monthly_cap_exceeded / …) → generic BillingError with code/recovery. + raise BillingError(message or error or "Billing request denied.", **common) if status in (429, 503): raise BillingRateLimited( message or "Rate limited — try again shortly.", **common @@ -296,7 +416,23 @@ def _request( try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8") - return json.loads(raw) if raw.strip() else {} + if not raw.strip(): + return {} + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + # A 2xx with a non-JSON body means the endpoint isn't actually + # serving the billing API here — e.g. a reverse-proxy / SPA + # fallback HTML page when the route isn't deployed on this + # deployment. Surface it as a typed, non-auth error so callers + # degrade gracefully ("unavailable") instead of crashing with a + # raw JSONDecodeError that reads as "not logged in". + raise BillingError( + "Billing endpoint returned a non-JSON response " + "(it may not be available on this deployment).", + error="endpoint_unavailable", + status=getattr(resp, "status", None), + ) from exc except urllib.error.HTTPError as exc: # A 401 on a cached token → drop the cache and retry once with a fresh # (refresh-aware) resolve before surfacing the auth error. @@ -404,3 +540,133 @@ def get_charge_status( # guard against a stray slash that would change the path shape. safe_id = urllib.parse.quote(charge_id.strip(), safe="") return _request("GET", f"/api/billing/charge/{safe_id}", timeout=timeout) + + +def get_subscription_state(*, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]: + """``GET /api/billing/subscription`` — current plan, tiers, usage (no scope). + + Returns the raw JSON dict from NAS (WS1 Phase A). Read-only — no + ``billing:manage`` scope required. Raises :class:`BillingAuthError` + on 401 and :class:`BillingError` on other non-2xx. + """ + return _request("GET", "/api/billing/subscription", timeout=timeout) + + +# ============================================================================= +# Subscription change (V3) — preview + the pending-change resource + upgrade +# ============================================================================= +# +# Mutating the plan splits into a chargeless lane and the single money route: +# - preview → a quote (no mutation, no charge) of what a change would do. +# - PUT/DELETE pending-change → schedule / clear a downgrade or cancellation +# (chargeless; takes effect at period end). +# - POST upgrade → the ONE route that charges (prorate + charge the card on the +# subscription + flip the plan, in one Stripe op). +# All require the ``billing:manage`` scope (a 403 insufficient_scope raises +# :class:`BillingScopeRequired`, driving the device step-up) — including preview, +# which issues live Stripe calls and reveals charge amounts. + + +def post_subscription_preview( + *, subscription_type_id: str, timeout: float = DEFAULT_TIMEOUT +) -> dict[str, Any]: + """``POST /api/billing/subscription/preview`` — a chargeless effect quote. + + Quotes a change to ``subscription_type_id`` without mutating anything: + ``effect`` is ``charge_now`` (an upgrade → ``amountDueNowCents`` is the prorated + upfront charge), ``scheduled`` (a downgrade → ``effectiveAt`` is period end), + ``no_op`` (already on the tier), or ``blocked`` (``reason`` says why the commit + would be refused). Also returns the current + target tier and the monthly-credit + delta. ``amountDueNowCents`` is ``None`` when not a charge or when the proration + quote is unavailable. Requires ``billing:manage`` (live Stripe calls + amounts). + """ + return _request( + "POST", + "/api/billing/subscription/preview", + body={"subscriptionTypeId": subscription_type_id}, + timeout=timeout, + ) + + +def put_subscription_pending_change( + *, + subscription_type_id: str | None = None, + cancel: bool = False, + timeout: float = DEFAULT_TIMEOUT, +) -> dict[str, Any]: + """``PUT /api/billing/subscription/pending-change`` — set the end-of-period intent. + + A subscription has at most one pending disposition. Pass ``cancel=True`` to + schedule a cancellation, or a ``subscription_type_id`` to schedule a downgrade / + same-price change. UPGRADES are rejected here (they charge immediately — use + :func:`post_subscription_upgrade`). Chargeless; requires ``billing:manage``. + Returns ``{rail, changeType, targetTierName, message}`` for a tier change, or + ``{rail, cancelAtPeriodEnd, message}`` for a cancellation. + """ + if cancel: + body: dict[str, Any] = {"type": "cancellation"} + else: + if not ( + isinstance(subscription_type_id, str) and subscription_type_id.strip() + ): + raise BillingError( + "A subscription tier is required to schedule a plan change.", + error="invalid_subscription_type", + ) + body = { + "type": "tier_change", + "subscriptionTypeId": subscription_type_id.strip(), + } + return _request( + "PUT", + "/api/billing/subscription/pending-change", + body=body, + timeout=timeout, + ) + + +def delete_subscription_pending_change( + *, timeout: float = DEFAULT_TIMEOUT +) -> dict[str, Any]: + """``DELETE /api/billing/subscription/pending-change`` — clear it (resume / undo). + + Removes a scheduled downgrade OR cancellation in one call, restoring the live + active tier and recurring renewal. Chargeless, but it re-enables recurring + spend, so it requires ``billing:manage`` and is honored by the org kill-switch. + Returns ``{rail, cancelAtPeriodEnd: false, message}``. + """ + return _request( + "DELETE", + "/api/billing/subscription/pending-change", + timeout=timeout, + ) + + +def post_subscription_upgrade( + *, + subscription_type_id: str, + idempotency_key: str, + timeout: float = DEFAULT_TIMEOUT, +) -> dict[str, Any]: + """``POST /api/billing/subscription/upgrade`` — immediate paid upgrade. + + The SINGLE money route: one Stripe op prorates, charges the card already on the + subscription, and flips the plan. ``Idempotency-Key`` is MANDATORY (a missing + header is a server 400, not a default) — reuse the same key on retry so a replay + cannot double-charge. Returns ``{status:"upgraded"|"already_on_tier", ...}`` on + success, or ``{status:"requires_action"|"payment_failed", reason, recoveryUrl}`` + when the charge needs 3DS / was declined and must be finished in the portal at + ``recoveryUrl``. Requires ``billing:manage``. + """ + if not (isinstance(idempotency_key, str) and idempotency_key.strip()): + raise BillingError( + "Idempotency-Key is required for an upgrade.", + error="idempotency_key_required", + ) + return _request( + "POST", + "/api/billing/subscription/upgrade", + body={"subscriptionTypeId": subscription_type_id}, + extra_headers={"Idempotency-Key": idempotency_key.strip()}, + timeout=timeout, + ) diff --git a/tests/agent/test_billing_usage.py b/tests/agent/test_billing_usage.py new file mode 100644 index 000000000000..34a502e9d88d --- /dev/null +++ b/tests/agent/test_billing_usage.py @@ -0,0 +1,145 @@ +"""Tests for the shared dollar usage model (agent/billing_usage.py). + +Behavior contracts: status classification, bar math, fail-open, and the +dollars-only / topup-split invariants the billing UX requires. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import pytest + +from agent.billing_usage import LOW_BALANCE_THRESHOLD_USD, UsageBar, usage_model_from_account + + +# ── Lightweight stand-ins for the NousPortalAccountInfo shape ──────────────── + + +@dataclass +class _Access: + subscription_credits_remaining: Optional[float] = None + purchased_credits_remaining: Optional[float] = None + total_usable_credits: Optional[float] = None + + +@dataclass +class _Sub: + plan: Optional[str] = None + monthly_credits: Optional[float] = None + current_period_end: Optional[str] = None + + +@dataclass +class _Account: + logged_in: bool = True + paid_service_access: Optional[bool] = None + paid_service_access_info: Optional[_Access] = None + subscription: Optional[_Sub] = None + + +def _acct(**over): + return _Account(**over) + + +class _Boom: + @property + def logged_in(self): + raise RuntimeError("kaboom") + + +@pytest.mark.parametrize("account", [None, _acct(logged_in=False), _Boom()]) +def test_fails_open_to_unavailable(account): + assert usage_model_from_account(account).available is False + + +@pytest.mark.parametrize( + "account,expected", + [ + # no plan, no balance -> free + (_acct(paid_service_access_info=_Access()), "free"), + # paid access explicitly lost -> depleted + (_acct(paid_service_access=False, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=0.0, total_usable_credits=0.0)), "depleted"), + # above threshold -> healthy + (_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0)), "healthy"), + # under $5 spendable -> low + (_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=3.4, total_usable_credits=3.4)), "low"), + # exactly $5 -> healthy (the threshold boundary is exclusive) + (_acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=5.0, total_usable_credits=5.0)), "healthy"), + # top-up only, no plan -> usable (healthy), not free + (_acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=30.0, total_usable_credits=30.0)), "healthy"), + ], +) +def test_status_classification(account, expected): + m = usage_model_from_account(account) + assert m.available is True + assert m.status == expected + + +def test_threshold_constant_is_five(): + assert LOW_BALANCE_THRESHOLD_USD == 5.0 + + +def test_healthy_carries_plan_name_and_renewal(): + m = usage_model_from_account( + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0, current_period_end="2026-07-01"), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0)) + ) + assert m.plan_name == "Plus" and m.renews_at == "2026-07-01" + + +def test_plan_bar_spent_and_pct(): + m = usage_model_from_account( + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, total_usable_credits=14.0)) + ) + bar = m.plan_bar + assert bar is not None and bar.kind == "plan" + assert (bar.remaining_usd, bar.total_usd, bar.pct_used) == (14.0, 20.0, 30) + assert bar.spent_usd == pytest.approx(6.0) + + +def test_plan_bar_clamps_over_cap_to_zero_spent(): + # Rollover/debt: remaining > cap clamps to the cap and reads as zero spent. + m = usage_model_from_account( + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=25.0, total_usable_credits=25.0)) + ) + assert m.plan_bar.remaining_usd == 20.0 and m.plan_bar.spent_usd == 0.0 + + +def test_topup_bar_is_full_with_no_denominator(): + m = usage_model_from_account( + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=20.0), + paid_service_access_info=_Access(subscription_credits_remaining=14.0, purchased_credits_remaining=12.0, total_usable_credits=26.0)) + ) + tb = m.topup_bar + assert tb is not None and tb.kind == "topup" + assert tb.remaining_usd == 12.0 and tb.fill_fraction == 1.0 and tb.pct_used is None + assert m.total_spendable_usd == 26.0 and m.has_topup is True + + +def test_no_plan_bar_without_monthly_cap(): + m = usage_model_from_account( + _acct(paid_service_access=True, paid_service_access_info=_Access(purchased_credits_remaining=8.0, total_usable_credits=8.0)) + ) + assert m.plan_bar is None and m.topup_bar is not None + + +def test_non_finite_values_are_ignored(): + m = usage_model_from_account( + _acct(paid_service_access=True, subscription=_Sub(plan="Plus", monthly_credits=float("nan")), + paid_service_access_info=_Access(subscription_credits_remaining=float("inf"))) + ) + assert m.plan_bar is None + + +def test_usage_bar_fill_fraction_clamped(): + assert UsageBar(kind="plan", remaining_usd=30.0, total_usd=20.0).fill_fraction == 1.0 + assert UsageBar(kind="plan", remaining_usd=-5.0, total_usd=20.0).fill_fraction == 0.0 + assert UsageBar(kind="plan", remaining_usd=0.0, total_usd=0.0).fill_fraction == 0.0 diff --git a/tests/agent/test_billing_view.py b/tests/agent/test_billing_view.py index 288c125e4178..596570f0f574 100644 --- a/tests/agent/test_billing_view.py +++ b/tests/agent/test_billing_view.py @@ -21,6 +21,7 @@ import pytest import agent.billing_view as bv from agent.billing_view import ( AutoReload, + AutoReloadCard, BillingState, CardInfo, MonthlyCap, @@ -37,6 +38,9 @@ from hermes_cli.nous_billing import ( BillingError, BillingRateLimited, BillingScopeRequired, + BillingStripeUnavailable, + BillingTransient, + BillingUpgradeCapExceeded, _raise_for_error, resolve_portal_base_url, ) @@ -130,6 +134,71 @@ def test_state_member_tier_parse(): assert s.can_charge is False # not admin +@pytest.mark.parametrize( + "role,can_change_plan_raw,is_admin,can_change_plan", + [ + ("OWNER", None, True, True), + ("ADMIN", None, True, True), + ("FINANCE_ADMIN", True, False, True), + ("SECURITY_ADMIN", None, False, False), + ("MEMBER", None, False, False), + ], +) +def test_state_five_roles( + role, can_change_plan_raw, is_admin, can_change_plan +): + payload = _member_payload() + payload["org"]["role"] = role + if can_change_plan_raw is not None: + payload["canChangePlan"] = can_change_plan_raw + + state = billing_state_from_payload(payload) + + assert state.is_admin is is_admin + assert state.can_change_plan_raw is can_change_plan_raw + assert state.can_change_plan is can_change_plan + if role == "SECURITY_ADMIN": + assert state.card is None + assert state.monthly_cap is None + assert state.auto_reload is None + + +@pytest.mark.parametrize( + "role,server_capability", + [("MEMBER", True), ("OWNER", False)], +) +def test_state_can_change_plan_prefers_server_capability(role, server_capability): + payload = _member_payload() + payload["org"]["role"] = role + payload["canChangePlan"] = server_capability + + state = billing_state_from_payload(payload) + + assert state.can_change_plan is server_capability + + +def test_state_can_change_plan_falls_back_to_legacy_role_check(): + owner = _member_payload() + owner["org"]["role"] = "OWNER" + member = _member_payload() + + assert billing_state_from_payload(owner).can_change_plan is True + assert billing_state_from_payload(member).can_change_plan is False + + +def test_can_charge_finance_admin_with_server_capability(): + """Server capability can grant FINANCE_ADMIN charge access.""" + payload = _member_payload() + payload["org"]["role"] = "FINANCE_ADMIN" + payload["canChangePlan"] = True + + state = billing_state_from_payload(payload) + + assert state.is_admin is False + assert state.can_change_plan is True + assert state.can_charge is True + + def test_state_owner_tier_parse(): s = billing_state_from_payload(_owner_payload()) assert s.is_admin is True @@ -146,6 +215,54 @@ def test_state_owner_tier_parse(): ) +@pytest.mark.parametrize( + "raw_card,expected", + [ + ( + {"kind": "canonical", "paymentMethodId": "ignored", "brand": "ignored"}, + AutoReloadCard(kind="canonical"), + ), + ( + { + "kind": "distinct", + "paymentMethodId": "pm_auto", + "brand": None, + "last4": None, + }, + AutoReloadCard( + kind="distinct", + payment_method_id="pm_auto", + brand=None, + last4=None, + ), + ), + ( + {"kind": "none", "last4": "ignored"}, + AutoReloadCard(kind="none"), + ), + ], +) +def test_state_parses_auto_reload_card_variants(raw_card, expected): + payload = _owner_payload() + payload["autoReload"]["card"] = raw_card + + state = billing_state_from_payload(payload) + + assert state.auto_reload is not None + assert state.auto_reload.card == expected + + +@pytest.mark.parametrize("raw_card", [None, "canonical", {}, {"kind": "future"}]) +def test_state_ignores_unrecognized_auto_reload_card(raw_card): + payload = _owner_payload() + payload["autoReload"]["card"] = raw_card + + state = billing_state_from_payload(payload) + + assert state.auto_reload is not None + assert state.auto_reload.card is None + + def test_state_can_charge_false_when_killswitch_off(): p = _owner_payload() p["cliBillingEnabled"] = False @@ -205,6 +322,29 @@ def test_rate_limited_maps_with_retry_after(status): assert isinstance(ei.value, BillingRateLimited) +@pytest.mark.parametrize( + "status,error,expected_type", + [ + (503, "stripe_unavailable", BillingStripeUnavailable), + (429, "upgrade_cap_exceeded", BillingUpgradeCapExceeded), + ], +) +def test_specific_billing_throttle_errors_remain_distinguishable( + status, error, expected_type +): + with pytest.raises(expected_type) as ei: + _raise_for_error( + status, + {"error": error}, + _Headers({"Retry-After": "90"}), + ) + + assert ei.value.error == error + assert ei.value.retry_after == 90 + assert isinstance(ei.value, BillingTransient) + assert not isinstance(ei.value, BillingRateLimited) + + @pytest.mark.parametrize( "error", [ @@ -375,3 +515,50 @@ def test_validate_amount_rejections(raw, err_substr): v = validate_charge_amount(raw, min_usd=Decimal("10"), max_usd=Decimal("10000")) assert not v.ok assert err_substr.lower() in (v.error or "").lower() + + +# --------------------------------------------------------------------------- +# HERMES_DEV_BILLING_FIXTURE — offline card/scope state scaffolding (T0) +# --------------------------------------------------------------------------- + + +def test_billing_fixture_unset_returns_none(monkeypatch): + """No env var → fixture is inert (the real portal path runs).""" + monkeypatch.delenv("HERMES_DEV_BILLING_FIXTURE", raising=False) + assert bv._dev_fixture_billing_state() is None + + +@pytest.mark.parametrize( + "name,has_card,is_admin,billing_on", + [ + ("nocard", False, True, True), + ("card", True, True, True), + ("card-autoreload", True, True, True), + ("notadmin", True, False, True), + ("billing-off", False, True, False), + ], +) +def test_billing_fixture_card_and_gate_invariants(monkeypatch, name, has_card, is_admin, billing_on): + """Each fixture state honors the card/admin/kill-switch contract the gate reads.""" + monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", name) + s = build_billing_state() + assert s.logged_in is True + assert (s.card is not None) is has_card + assert s.is_admin is is_admin + assert s.cli_billing_enabled is billing_on + + +def test_billing_fixture_autoreload_state(monkeypatch): + """card-autoreload pairs a card with an enabled auto-reload (drives that screen).""" + monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "card-autoreload") + s = build_billing_state() + assert s.card is not None + assert s.auto_reload is not None and s.auto_reload.enabled is True + + +def test_billing_fixture_logged_out_and_unknown(monkeypatch): + monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "logged-out") + assert build_billing_state().logged_in is False + monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "bogus-state") + s = build_billing_state() + assert s.logged_in is False and "bogus-state" in (s.error or "") 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 @@ class TestNoticeCopy: s = CreditsState(paid_access=False) to_show, _ = evaluate_credits_notices(s, latch) depleted_notice = next(n for n in to_show if n.key == "credits.depleted") - assert "/credits" in depleted_notice.text + assert "/topup" in depleted_notice.text # ── Scenario 8: severity order in a single call ────────────────────────────── diff --git a/tests/agent/test_credits_view.py b/tests/agent/test_credits_view.py index 04aab21fe62c..a20bd8f14a68 100644 --- a/tests/agent/test_credits_view.py +++ b/tests/agent/test_credits_view.py @@ -131,7 +131,7 @@ def test_view_fetch_failure_is_logged_out(monkeypatch): assert view.logged_in is False -# ── gateway _handle_credits_command ───────────────────────────────────────── +# ── gateway _handle_topup_command (the messaging billing surface) ──────────── class _FakeEvent: @@ -139,7 +139,7 @@ class _FakeEvent: def _make_gateway_stub(): - """Minimal object exposing the mixin's _handle_credits_command.""" + """Minimal object exposing the mixin's _handle_topup_command.""" from gateway.slash_commands import GatewaySlashCommandsMixin class _Stub(GatewaySlashCommandsMixin): @@ -149,7 +149,7 @@ def _make_gateway_stub(): return _Stub() -def test_gateway_credits_renders_block_and_url(monkeypatch): +def test_gateway_topup_renders_block_and_url(monkeypatch): view = CreditsView( logged_in=True, balance_lines=("📈 Nous credits", "Total usable: $52.50"), @@ -160,101 +160,55 @@ def test_gateway_credits_renders_block_and_url(monkeypatch): monkeypatch.setattr(account_usage, "build_credits_view", lambda *a, **kw: view) stub = _make_gateway_stub() - out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + out = asyncio.run(stub._handle_topup_command(_FakeEvent())) assert "💳" in out assert "Total usable: $52.50" in out assert "Topping up as alice@example.test / org Acme" in out assert "https://portal.example.test/orgs/acme/billing?topup=open" in out - assert "credits will appear in /credits shortly" in out + assert "Manage billing on the portal" in out # The helper's own 📈 header line is dropped (we render our own 💳 header). assert "📈 Nous credits" not in out -def test_gateway_credits_not_logged_in(monkeypatch): +def test_gateway_topup_not_logged_in(monkeypatch): monkeypatch.setattr( account_usage, "build_credits_view", lambda *a, **kw: CreditsView(logged_in=False) ) stub = _make_gateway_stub() - out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + out = asyncio.run(stub._handle_topup_command(_FakeEvent())) assert "Not logged into Nous Portal" in out -def test_gateway_credits_fetch_exception_is_not_logged_in(monkeypatch): +def test_gateway_topup_fetch_exception_is_not_logged_in(monkeypatch): def _boom(*a, **kw): raise RuntimeError("boom") monkeypatch.setattr(account_usage, "build_credits_view", _boom) stub = _make_gateway_stub() - out = asyncio.run(stub._handle_credits_command(_FakeEvent())) + out = asyncio.run(stub._handle_topup_command(_FakeEvent())) assert "Not logged into Nous Portal" in out # ── command registry ──────────────────────────────────────────────────────── -def test_credits_command_registered(): +def test_credits_command_fully_removed(): + """`/credits` and the old `/billing` are gone entirely — not commands, not + aliases. Billing lives only on /topup, with NO aliases, on every platform.""" from hermes_cli.commands import resolve_command, COMMAND_REGISTRY - cmd = resolve_command("credits") - assert cmd is not None and cmd.name == "credits" - # Available on every surface (not cli_only / gateway_only). - entry = next(c for c in COMMAND_REGISTRY if c.name == "credits") + # Both old names resolve to nothing. + assert resolve_command("credits") is None + assert resolve_command("billing") is None + # No standalone command for either remains in the registry. + assert not any(c.name in ("credits", "billing") for c in COMMAND_REGISTRY) + # And no command carries either as an alias. + for c in COMMAND_REGISTRY: + assert "credits" not in (c.aliases or ()) + assert "billing" not in (c.aliases or ()) + # /topup is the billing surface, on every surface, and carries no aliases. + entry = next(c for c in COMMAND_REGISTRY if c.name == "topup") assert entry.cli_only is False assert entry.gateway_only is False - - -# ── CLI _show_credits non-interactive (TUI slash-worker) path ─────────────── - - -def test_cli_show_credits_non_interactive_renders_text_not_modal(monkeypatch, capsys): - """In the TUI slash-worker (no self._app), /credits must render the text - variant — never invoke the prompt_toolkit modal, which would read the - worker's JSON-RPC stdin and crash the command (only the depleted banner - would survive). Regression for that exact failure. - """ - import agent.account_usage as account_usage - from cli import HermesCLI - - monkeypatch.setattr( - account_usage, - "build_credits_view", - lambda *a, **k: CreditsView( - logged_in=True, - balance_lines=("📈 Nous credits", "Total usable: $0.00"), - identity_line="Topping up as a@b.c / org Acme", - topup_url="https://prev.test/orgs/acme/billing?topup=open", - depleted=True, - ), - ) - - cli = HermesCLI.__new__(HermesCLI) - cli._app = None # non-interactive, like the slash worker - - # Must NOT call the modal in this context. - def _boom_modal(*a, **k): - raise AssertionError("modal must not run without a live app") - - monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False) - - cli._show_credits() - - out = capsys.readouterr().out - assert "💳 Nous credits" in out - assert "Total usable: $0.00" in out - assert "Topping up as a@b.c / org Acme" in out - assert "https://prev.test/orgs/acme/billing?topup=open" in out - assert "credits will appear in /credits shortly" in out - - -def test_cli_show_credits_logged_out(monkeypatch, capsys): - import agent.account_usage as account_usage - from cli import HermesCLI - - monkeypatch.setattr( - account_usage, "build_credits_view", lambda *a, **k: CreditsView(logged_in=False) - ) - cli = HermesCLI.__new__(HermesCLI) - cli._app = None - cli._show_credits() - assert "Not logged into Nous Portal" in capsys.readouterr().out + assert not entry.aliases 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/tests/agent/test_subscription_view.py b/tests/agent/test_subscription_view.py new file mode 100644 index 000000000000..6c80c1ef52fa --- /dev/null +++ b/tests/agent/test_subscription_view.py @@ -0,0 +1,290 @@ +"""Tests for agent.subscription_view — the surface-agnostic /subscription core. + +Behavior contracts (not change-detectors): the manage-URL builder's shape, the +payload parser's field mapping + fail-open posture, and the dev-fixture states +that drive the CLI/TUI without a live portal. +""" + +from decimal import Decimal + +import pytest + +from agent.subscription_view import ( + SubscriptionState, + build_subscription_state, + dev_fixture_subscription_state, + subscription_change_preview_from_payload, + subscription_manage_url, + subscription_state_from_payload, +) + + +# ── subscription_manage_url ────────────────────────────────────────── + + +def test_manage_url_attaches_org_and_path_to_portal_origin(): + s = SubscriptionState( + logged_in=True, + org_id="org_x", + portal_url="https://portal.nousresearch.com/billing/whatever", + ) + # Path is replaced with /manage-subscription; org_id is pinned; origin kept. + assert ( + subscription_manage_url(s) + == "https://portal.nousresearch.com/manage-subscription?org_id=org_x" + ) + + +def test_manage_url_omits_org_when_absent(): + s = SubscriptionState(logged_in=True, org_id=None, portal_url="https://p.example.com/") + url = subscription_manage_url(s) + assert url == "https://p.example.com/manage-subscription" + assert "org_id" not in url + + +def test_manage_url_none_without_portal(): + assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url=None)) is None + + +def test_manage_url_none_for_garbage_portal(): + # No scheme/netloc → can't build a deep-link; fail closed (None), not crash. + assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url="not a url")) is None + + +# ── payload parser ─────────────────────────────────────────────────── + + +def test_parser_maps_camelCase_payload_fields(): + payload = { + "org": {"name": "Acme", "id": "org_1", "role": "ADMIN"}, + "context": "personal", + "current": { + "tierId": "plus", + "tierName": "Plus", + "monthlyCredits": "1000", + "creditsRemaining": "420", + "cycleEndsAt": "2026-07-01", + "cancelAtPeriodEnd": True, + "cancellationEffectiveAt": "2026-07-01", + }, + } + s = subscription_state_from_payload(payload, portal_url="https://p/billing") + + assert s.logged_in is True + assert s.org_name == "Acme" and s.org_id == "org_1" + assert s.is_admin is True and s.can_change_plan is True + assert s.current is not None + assert s.current.tier_name == "Plus" + assert s.current.cancel_at_period_end is True + assert s.current.monthly_credits == Decimal("1000") + + +def test_parser_no_plan_is_none_not_all_null_object(): + # "No plan" is current:null on the wire; a current-shaped dict with no + # tierId must parse to None (not an all-null CurrentSubscription). + s = subscription_state_from_payload({"current": {"tierId": None}}, portal_url=None) + assert s.current is None + + +def test_parser_member_role_cannot_change_plan(): + s = subscription_state_from_payload({"org": {"role": "MEMBER"}}, portal_url=None) + assert s.is_admin is False + assert s.can_change_plan is False + + +@pytest.mark.parametrize( + "role,can_change_plan_raw,is_admin,can_change_plan", + [ + ("OWNER", None, True, True), + ("ADMIN", None, True, True), + ("FINANCE_ADMIN", True, False, True), + ("SECURITY_ADMIN", None, False, False), + ("MEMBER", None, False, False), + ], +) +def test_parser_five_roles( + role, can_change_plan_raw, is_admin, can_change_plan +): + payload = {"org": {"role": role}} + if can_change_plan_raw is not None: + payload["canChangePlan"] = can_change_plan_raw + + state = subscription_state_from_payload(payload, portal_url=None) + + assert state.is_admin is is_admin + assert state.can_change_plan_raw is can_change_plan_raw + assert state.can_change_plan is can_change_plan + + +@pytest.mark.parametrize( + "role,server_capability", + [("MEMBER", True), ("OWNER", False)], +) +def test_parser_can_change_plan_prefers_server_capability(role, server_capability): + state = subscription_state_from_payload( + {"org": {"role": role}, "canChangePlan": server_capability}, + portal_url=None, + ) + + assert state.can_change_plan is server_capability + + +def test_parser_can_change_plan_falls_back_to_legacy_role_check(): + owner = subscription_state_from_payload( + {"org": {"role": "OWNER"}}, portal_url=None + ) + member = subscription_state_from_payload( + {"org": {"role": "MEMBER"}}, portal_url=None + ) + + assert owner.can_change_plan is True + assert member.can_change_plan is False + + +def test_parser_defaults_unknown_context_to_personal(): + s = subscription_state_from_payload({"context": "wat"}, portal_url=None) + assert s.context == "personal" + + +# ── tier catalog parsing (the picker) ──────────────────────────────── + + +def test_parser_maps_tiers_catalog(): + payload = { + "tiers": [ + { + "tierId": "free", + "name": "Free", + "tierOrder": 0, + "dollarsPerMonthDisplay": "0", + "monthlyCredits": "0", + "isCurrent": False, + "isEnabled": True, + }, + { + "tierId": "plus", + "name": "Plus", + "tierOrder": 1, + "dollarsPerMonthDisplay": "20", + "monthlyCredits": "1000", + "isCurrent": True, + "isEnabled": True, + }, + ], + } + s = subscription_state_from_payload(payload, portal_url=None) + assert len(s.tiers) == 2 + free, plus = s.tiers + # The free tier's 0s must survive (coalesce-on-None, not falsy `or`). + assert free.tier_id == "free" and free.tier_order == 0 + assert free.dollars_per_month == Decimal("0") + assert plus.is_current is True + assert plus.dollars_per_month == Decimal("20") and plus.monthly_credits == Decimal("1000") + + +def test_parser_tiers_absent_is_empty_tuple(): + assert subscription_state_from_payload({}, portal_url=None).tiers == () + + +# ── preview parser (POST /preview) ─────────────────────────────────── + + +def test_preview_parser_charge_now(): + p = subscription_change_preview_from_payload( + { + "effect": "charge_now", + "reason": None, + "currentTierId": "plus", + "currentTierName": "Plus", + "targetTierId": "ultra", + "targetTierName": "Ultra", + "monthlyCreditsDelta": "6000", + "amountDueNowCents": 1234, + "effectiveAt": None, + } + ) + assert p.effect == "charge_now" + assert p.amount_due_now_cents == 1234 + assert p.target_tier_name == "Ultra" + assert p.monthly_credits_delta == Decimal("6000") + + +def test_preview_parser_scheduled_has_effective_at_and_no_charge(): + p = subscription_change_preview_from_payload( + {"effect": "scheduled", "amountDueNowCents": None, "effectiveAt": "2026-08-01"} + ) + assert p.effect == "scheduled" + assert p.amount_due_now_cents is None + assert p.effective_at == "2026-08-01" + + +def test_preview_parser_blocked_carries_reason(): + p = subscription_change_preview_from_payload( + {"effect": "blocked", "reason": "Retract the cancellation before upgrading."} + ) + assert p.effect == "blocked" + assert p.reason and "Retract" in p.reason + + +def test_preview_parser_missing_effect_fails_safe_to_blocked(): + # A malformed quote must never read as a charge — default to blocked. + p = subscription_change_preview_from_payload({}) + assert p.effect == "blocked" + assert p.amount_due_now_cents is None + + +# ── dev fixtures (env-driven, no live portal) ──────────────────────── + + +def test_no_fixture_when_env_unset(monkeypatch): + monkeypatch.delenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", raising=False) + assert dev_fixture_subscription_state() is None + + +@pytest.mark.parametrize( + "name,checker", + [ + ("free", lambda s: s.logged_in and s.current is None), + ("mid", lambda s: s.current and s.current.tier_id == "plus"), + ("top", lambda s: s.current and s.current.tier_id == "ultra"), + ("not-admin", lambda s: s.role == "MEMBER" and not s.can_change_plan), + ("downgrade", lambda s: s.current and s.current.pending_downgrade_tier_name == "Plus"), + ("cancel", lambda s: s.current and s.current.cancel_at_period_end), + ("team", lambda s: s.context == "team" and s.current is None), + ("logged-out", lambda s: not s.logged_in), + ], +) +def test_dev_fixture_states(monkeypatch, name, checker): + monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", name) + s = dev_fixture_subscription_state() + assert s is not None + assert checker(s) + + +def test_dev_fixture_exposes_tier_catalog(monkeypatch): + # A picker needs a catalog: the mid fixture lists tiers with the active one flagged. + monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid") + s = dev_fixture_subscription_state() + assert s is not None and len(s.tiers) >= 2 + current = [t for t in s.tiers if t.is_current] + assert len(current) == 1 and current[0].tier_id == "plus" + + +def test_dev_fixture_unknown_name_fails_safe(monkeypatch): + monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "bogus") + s = dev_fixture_subscription_state() + assert s is not None + assert s.logged_in is False + assert s.error and "bogus" in s.error + + +def test_build_subscription_state_uses_fixture(monkeypatch): + # build_subscription_state must short-circuit to the fixture (no portal call). + monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid") + s = build_subscription_state() + assert s.logged_in is True + assert s.current is not None and s.current.tier_id == "plus" + # The manage URL is buildable from the fixture's portal_url + org_id. + url = subscription_manage_url(s) + assert url is not None + assert url.endswith("/manage-subscription?org_id=org_acme") diff --git a/tests/hermes_cli/test_billing_cli.py b/tests/hermes_cli/test_billing_cli.py index 31c645760c5d..6c1a7c3b6b08 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,10 @@ def test_billing_sub_arg_ignored_opens_overview(cli, monkeypatch, capsys): monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) cli._show_billing("/billing buy") # arg is ignored out = capsys.readouterr().out - assert "Usage credits" in out # overview, NOT the buy screen - assert "Buy usage credits" not in out + assert "Top up · balance" in out # overview, NOT the buy screen + # The buy screen's preset list isn't shown. (The overview's no-card hint may + # legitimately mention the "Add funds" menu item, so key on presets instead.) + assert "Presets:" not in out def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys): @@ -131,6 +131,106 @@ def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys): # Reached via the menu in real use; non-interactively it defers to the portal. cli._billing_buy_flow(state) out = capsys.readouterr().out - assert "Buy usage credits" in out + assert "Add funds" in out assert "$25" in out and "$50" in out and "$100" in out assert "interactive CLI" in out # defers; no charge attempted non-interactively + + +# ── Card visibility + the add-card path (inline w/ NAS card-resolver) ── + + +def _scripted(*responses): + it = iter(responses) + + def _modal(self, **kw): + return next(it) + + return _modal + + +def test_overview_shows_card_with_provenance(cli, monkeypatch, capsys): + state = BillingState( + logged_in=True, role="OWNER", balance_usd=Decimal("10"), + cli_billing_enabled=True, charge_presets=(Decimal("25"),), + card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin"), + portal_url="https://portal/billing", + ) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) + cli._show_billing("/topup") + out = capsys.readouterr().out + assert "Card: Visa ····4242 — the card on your subscription" in out + + +def test_overview_shows_no_card_hint(cli, monkeypatch, capsys): + state = BillingState( + logged_in=True, role="OWNER", balance_usd=Decimal("10"), + cli_billing_enabled=True, charge_presets=(Decimal("25"),), + card=None, portal_url="https://portal/billing", + ) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) + cli._show_billing("/topup") + out = capsys.readouterr().out + assert "No saved card on file" in out + assert "Add funds" in out # the hint names the path + + +def test_link_card_renders_brand_alone(cli, monkeypatch, capsys): + # A Link payment method has no card number (last4 = "") — never "Link ····". + state = BillingState( + logged_in=True, role="OWNER", balance_usd=Decimal("10"), + cli_billing_enabled=True, charge_presets=(Decimal("25"),), + card=CardInfo(brand="Link", last4=""), portal_url="https://portal/billing", + ) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) + cli._show_billing("/topup") + out = capsys.readouterr().out + assert "Card: Link" in out + assert "Link ····" not in out + + +def test_buy_flow_no_card_guides_then_continues_after_recheck(cli, monkeypatch, capsys): + # No card → the guided add-card path; "check again" re-fetches state and, + # once the card exists, continues straight into the preset menu. + cli._app = object() + common = dict( + logged_in=True, role="OWNER", cli_billing_enabled=True, + charge_presets=(Decimal("25"), Decimal("50")), + min_usd=Decimal("5"), max_usd=Decimal("500"), + portal_url="https://portal/billing", + ) + nocard = BillingState(card=None, **common) + withcard = BillingState(card=CardInfo(brand="Visa", last4="4242", resolved_via="customerDefault"), **common) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: withcard) + # add-card modal → "recheck"; preset modal → "cancel" (we only test the routing) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("recheck", "cancel"), raising=False) + + cli._billing_buy_flow(nocard) + out = capsys.readouterr().out + + assert "Add a card first" in out + assert "Card found: Visa ····4242 — your default card saved on the portal" in out + assert "Cancelled. No funds added." in out # reached the preset menu, then bailed + + +def test_buy_flow_no_card_back_abandons(cli, monkeypatch, capsys): + cli._app = object() + nocard = BillingState( + logged_in=True, role="OWNER", cli_billing_enabled=True, + charge_presets=(Decimal("25"),), card=None, + portal_url="https://portal/billing", + ) + calls = {"n": 0} + + def _no_fetch(*a, **kw): + calls["n"] += 1 + return nocard + + monkeypatch.setattr(bv, "build_billing_state", _no_fetch) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False) + + cli._billing_buy_flow(nocard) + out = capsys.readouterr().out + + assert "Add a card first" in out + assert "Cancelled. No funds added." in out + assert calls["n"] == 0 # backed out before any re-check 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..92471ee2b131 --- /dev/null +++ b/tests/hermes_cli/test_nous_billing_request.py @@ -0,0 +1,161 @@ +"""Tests for the hermes_cli.nous_billing HTTP client's response handling. + +Focus: a 2xx response with a NON-JSON body (e.g. a reverse-proxy / SPA fallback +HTML page when a route isn't actually serving the billing API) must surface as a +typed BillingError, NOT a raw json.JSONDecodeError that escapes the typed-error +contract and reads downstream as "not logged in". +""" + +from __future__ import annotations + +import io +import json +from contextlib import contextmanager + +import pytest + +from hermes_cli import nous_billing as nb + + +class _FakeResp(io.BytesIO): + """Minimal urlopen() context-manager stand-in with a .status attribute.""" + + def __init__(self, body: bytes, status: int = 200): + super().__init__(body) + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *a): + self.close() + + +@contextmanager +def _stub(monkeypatch, body: bytes, status: int = 200): + # Bypass auth/token resolution entirely — we only exercise response parsing. + monkeypatch.setattr(nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example")) + monkeypatch.setattr(nb, "_token_cache", None, raising=False) + monkeypatch.setattr(nb.urllib.request, "urlopen", lambda req, timeout=None: _FakeResp(body, status)) + yield + + +def test_non_json_2xx_body_raises_typed_billing_error(monkeypatch): + # A 200 that returns an HTML page (route not actually mounted) must NOT crash + # with json.JSONDecodeError — it becomes a typed, non-auth BillingError. + html = b"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 + + +def test_transient_siblings_not_parent_child(): + assert issubclass(nb.BillingRateLimited, nb.BillingTransient) + assert issubclass(nb.BillingStripeUnavailable, nb.BillingTransient) + assert issubclass(nb.BillingUpgradeCapExceeded, nb.BillingTransient) + assert not issubclass(nb.BillingStripeUnavailable, nb.BillingRateLimited) + assert not issubclass(nb.BillingUpgradeCapExceeded, nb.BillingRateLimited) + assert not issubclass(nb.BillingRateLimited, nb.BillingStripeUnavailable) + + +# --------------------------------------------------------------------------- +# Subscription change (V3): the request the client actually puts on the wire. +# --------------------------------------------------------------------------- + + +@contextmanager +def _capture(monkeypatch, body: bytes = b"{}", status: int = 200): + """Stub urlopen, recording the urllib.request.Request the client built.""" + seen: dict[str, object] = {} + monkeypatch.setattr( + nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example") + ) + + def _fake_urlopen(req, timeout=None): + seen["method"] = req.get_method() + seen["url"] = req.full_url + seen["data"] = json.loads(req.data.decode()) if req.data else None + seen["headers"] = {k.lower(): v for k, v in req.header_items()} + return _FakeResp(body, status) + + monkeypatch.setattr(nb.urllib.request, "urlopen", _fake_urlopen) + yield seen + + +def test_post_subscription_preview_request(monkeypatch): + with _capture(monkeypatch) as seen: + nb.post_subscription_preview(subscription_type_id="nous-chat-plan-40") + assert seen["method"] == "POST" + assert seen["url"] == "https://portal.example/api/billing/subscription/preview" + assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"} + + +def test_put_pending_change_tier_change_request(monkeypatch): + with _capture(monkeypatch) as seen: + nb.put_subscription_pending_change(subscription_type_id="nous-chat-plan-10") + assert seen["method"] == "PUT" + assert ( + seen["url"] == "https://portal.example/api/billing/subscription/pending-change" + ) + assert seen["data"] == { + "type": "tier_change", + "subscriptionTypeId": "nous-chat-plan-10", + } + + +def test_put_pending_change_cancellation_request(monkeypatch): + with _capture(monkeypatch) as seen: + nb.put_subscription_pending_change(cancel=True) + assert seen["method"] == "PUT" + assert seen["data"] == {"type": "cancellation"} + + +def test_put_pending_change_without_tier_or_cancel_raises(): + # No urlopen stub: a bad call must fail BEFORE any network I/O. + with pytest.raises(nb.BillingError) as ei: + nb.put_subscription_pending_change() + assert getattr(ei.value, "error", None) == "invalid_subscription_type" + + +def test_delete_pending_change_request(monkeypatch): + with _capture(monkeypatch) as seen: + nb.delete_subscription_pending_change() + assert seen["method"] == "DELETE" + assert ( + seen["url"] == "https://portal.example/api/billing/subscription/pending-change" + ) + assert seen["data"] is None + + +def test_post_subscription_upgrade_sends_idempotency_key(monkeypatch): + with _capture(monkeypatch) as seen: + nb.post_subscription_upgrade( + subscription_type_id="nous-chat-plan-40", idempotency_key="abc-123" + ) + assert seen["method"] == "POST" + assert seen["url"] == "https://portal.example/api/billing/subscription/upgrade" + assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"} + assert seen["headers"].get("idempotency-key") == "abc-123" + + +def test_post_subscription_upgrade_blank_key_raises(): + with pytest.raises(nb.BillingError) as ei: + nb.post_subscription_upgrade( + subscription_type_id="nous-chat-plan-40", idempotency_key=" " + ) + assert getattr(ei.value, "error", None) == "idempotency_key_required" 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..308f970f28e3 --- /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_preserves_server_code_with_retry(): + env = _serialize(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"}) + assert env["error"] == "temporarily_unavailable" + assert env["retry_after"] == 30 + + +def test_envelope_business_code_survives(): + env = _serialize(403, {"error": "cli_billing_disabled", "code": "remote_spending_disabled", "recovery": "enable_account_toggle"}) + assert env["error"] == "cli_billing_disabled" + assert env["code"] == "remote_spending_disabled" + assert env["recovery"] == "enable_account_toggle" diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py new file mode 100644 index 000000000000..7a5b2babb59b --- /dev/null +++ b/tests/hermes_cli/test_subscription_cli.py @@ -0,0 +1,357 @@ +"""Tests for the /subscription CLI change flow (cli.py::_show_subscription). + +Parity with the TUI overlay: the classic CLI now previews + applies a plan change +in-terminal (picker → preview → confirm → apply), grants terminal billing inline on +insufficient_scope, and leads a scheduled downgrade/cancel with a prominent banner. +Interactive screens are driven by mocking `_prompt_text_input_modal`. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +import agent.billing_usage as bu +import agent.subscription_view as sv +import hermes_cli.nous_billing as nb +from agent.subscription_view import CurrentSubscription, SubscriptionState, SubscriptionTier +from cli import HermesCLI + + +@pytest.fixture +def cli(): + obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed) + obj._app = None # non-interactive by default; tests flip it on + return obj + + +_TIERS = ( + SubscriptionTier(tier_id="free", name="Free", tier_order=0, dollars_per_month=Decimal("0"), monthly_credits=Decimal("0"), is_current=False, is_enabled=True), + SubscriptionTier(tier_id="plus", name="Plus", tier_order=1, dollars_per_month=Decimal("20"), monthly_credits=Decimal("22"), is_current=False, is_enabled=True), + SubscriptionTier(tier_id="ultra", name="Ultra", tier_order=3, dollars_per_month=Decimal("200"), monthly_credits=Decimal("220"), is_current=True, is_enabled=True), +) + + +def _sub_state(**current_over) -> SubscriptionState: + current_fields = dict(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("220"), cycle_ends_at="2026-07-28") + current_fields.update(current_over) + current = CurrentSubscription(**current_fields) + return SubscriptionState( + logged_in=True, + org_name="Acme", + org_id="org_1", + role="OWNER", + context="personal", + current=current, + tiers=_TIERS, + portal_url="https://portal.example/billing", + ) + + +def _scripted_modal(*responses): + it = iter(responses) + + def _modal(self, **kw): + return next(it) + + return _modal + + +@pytest.fixture(autouse=True) +def _no_usage_model(monkeypatch): + # The overview's usage model needs a live portal; None → the plan-field fallback. + monkeypatch.setattr(bu, "build_usage_model", lambda *a, **kw: None, raising=False) + + +def test_overview_leads_with_scheduled_downgrade_banner(cli, monkeypatch, capsys): + st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28") + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "Scheduled change" in out + assert "──▶" in out + assert "Plus" in out + # the status line itself echoes the transition + assert "Plan: Ultra → Plus" in out + + +def test_change_flow_schedules_a_downgrade(cli, monkeypatch, capsys): + cli._app = object() # interactive + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + # change menu → "change"; picker → "plus" (only selectable); confirm → "yes" + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes"), raising=False) + monkeypatch.setattr( + nb, "post_subscription_preview", + lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28T00:00:00Z", "monthlyCreditsDelta": "-198"}, + ) + seen = {} + monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: seen.update(kw) or {"message": "Scheduled."}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert seen.get("subscription_type_id") == "plus" + assert "doesn't change today" in out + + +def test_change_flow_upgrade_charges_now(cli, monkeypatch, capsys): + cli._app = object() + # Current = Plus so Ultra is a selectable upgrade. + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple(SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) for t in _TIERS) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + seen = {} + monkeypatch.setattr(nb, "post_subscription_upgrade", lambda **kw: seen.update(kw) or {"status": "upgraded", "targetTierName": "Ultra"}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert seen.get("subscription_type_id") == "ultra" + assert seen.get("idempotency_key") # minted + assert "$46.30" in out + assert "Upgraded to Ultra" in out + + +def test_change_menu_cancel_schedules_cancellation(cli, monkeypatch, capsys): + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("cancel_sub", "yes"), raising=False) + seen = {} + monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: seen.update(kw) or {"message": "Cancelled."}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert seen.get("cancel") is True + assert "cancels" in out.lower() + + +def test_pending_change_menu_offers_undo(cli, monkeypatch, capsys): + cli._app = object() + st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28") + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("keep"), raising=False) + called = {"n": 0} + monkeypatch.setattr(nb, "delete_subscription_pending_change", lambda **kw: called.update(n=called["n"] + 1) or {"message": "Resumed."}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert called["n"] == 1 + assert "Undone" in out + + +def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsys): + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + # menu → change; picker → plus; confirm → yes; step-up → yes + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"}) + calls = {"n": 0} + + def _put(**kw): + calls["n"] += 1 + if calls["n"] == 1: + raise nb.BillingScopeRequired("terminal billing required") + return {"message": "Scheduled."} + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + import hermes_cli.auth as auth + + monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True, raising=False) + + cli._show_subscription() + out = capsys.readouterr().out + + # applied once (scope-denied), granted, replayed → applied again + assert calls["n"] == 2 + assert "Terminal billing enabled" in out + + +def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys): + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"}) + calls = {"n": 0} + + def _put(**kw): + calls["n"] += 1 + raise nb.BillingScopeRequired("terminal billing required") + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + import hermes_cli.auth as auth + + monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: False, raising=False) + + cli._show_subscription() + out = capsys.readouterr().out + + assert calls["n"] == 1 # applied once, grant denied, no replay + assert "Couldn't enable terminal billing" in out + + +def test_unknown_preview_effect_fails_safe(cli, monkeypatch, capsys): + # An unrecognized effect string must NOT schedule a real change (fail safe). + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "weird_unknown", "targetTierName": "Plus"}) + put = {"n": 0} + monkeypatch.setattr(nb, "put_subscription_pending_change", lambda **kw: put.update(n=put["n"] + 1) or {}) + + cli._show_subscription() + out = capsys.readouterr().out + + assert put["n"] == 0 # no mutation on an unknown effect + assert "portal" in out.lower() + + +def test_bounded_stepup_does_not_loop_on_repeat_denial(cli, monkeypatch, capsys): + # Grant "succeeds" but the scope stays denied → replay ONCE (allow_stepup=False), + # then stop — no re-prompt / re-open-browser loop. + cli._app = object() + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus", "yes", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "scheduled", "targetTierName": "Plus", "effectiveAt": "2026-07-28"}) + calls = {"n": 0} + + def _put(**kw): + calls["n"] += 1 + raise nb.BillingScopeRequired("still no scope") + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + import hermes_cli.auth as auth + + monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True, raising=False) + + cli._show_subscription() + out = capsys.readouterr().out + + assert calls["n"] == 2 # applied, granted, replayed once — no third attempt + assert "still isn't enabled" in out + + +def test_upgrade_transport_failure_is_ambiguous_not_flat_failure(cli, monkeypatch, capsys): + # BUG B: a charge-route failure must warn "may or may not have been charged" + # (steer to a re-check), never a flat failure that invites a blind retry (which + # would mint a fresh idempotency key the server can't dedup → a real 2nd charge). + cli._app = object() + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple( + SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) + for t in _TIERS + ) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + + def _boom(**kw): + raise nb.BillingError("Could not reach Nous Portal", error="endpoint_unavailable") + + monkeypatch.setattr(nb, "post_subscription_upgrade", _boom) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "may or may not have been charged" in out + assert "Re-run /subscription" in out + assert "could not be completed" not in out # not the old flat failure + + +def test_upgrade_rate_limit_is_deterministic_not_ambiguous(cli, monkeypatch, capsys): + # R2: a typed PRE-charge rejection (429 rate-limit) must NOT be mislabeled + # "may or may not have been charged" — it never reached Stripe. It gets the + # normal error copy, not the ambiguous one. + cli._app = object() + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple( + SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) + for t in _TIERS + ) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + + def _rl(**kw): + raise nb.BillingRateLimited("Slow down — too many requests.", error="rate_limited", status=429, retry_after=30) + + monkeypatch.setattr(nb, "post_subscription_upgrade", _rl) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "may or may not have been charged" not in out # NOT the ambiguous copy + assert "Slow down" in out # the real, deterministic error + + +def test_upgrade_transport_failure_still_ambiguous_after_narrowing(cli, monkeypatch, capsys): + # Regression floor for R2: a genuine transport failure (network_error, no status) + # must STILL be ambiguous after the narrowing. + cli._app = object() + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple( + SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) + for t in _TIERS + ) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "yes"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + + def _net(**kw): + raise nb.BillingError("Could not reach Nous Portal: timeout", error="network_error") + + monkeypatch.setattr(nb, "post_subscription_upgrade", _net) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "may or may not have been charged" in out + + +@pytest.fixture(autouse=True) +def _no_card_lookup(monkeypatch): + # The charge_now confirm best-effort-fetches billing state to NAME the card + # being charged; keep unit tests offline (generic line) unless overridden. + import agent.billing_view as bv + + def _offline(*a, **kw): + raise RuntimeError("offline") + + monkeypatch.setattr(bv, "build_billing_state", _offline, raising=False) + + +def test_upgrade_confirm_names_the_subscription_card(cli, monkeypatch, capsys): + # Post-card-resolver NAS: the confirm names the exact card when it resolved + # via the subscription rung (what the upgrade actually charges). + cli._app = object() + st = _sub_state(tier_id="plus", tier_name="Plus") + tiers = tuple( + SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True) + for t in _TIERS + ) + object.__setattr__(st, "tiers", tiers) + monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st) + # picker → ultra; charge confirm → back out (we only assert the card line) + monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra", "cancel"), raising=False) + monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "charge_now", "targetTierName": "Ultra", "amountDueNowCents": 4630}) + import agent.billing_view as bv + from agent.billing_view import BillingState as _BS + from agent.billing_view import CardInfo as _CI + + _state = _BS(logged_in=True, card=_CI(brand="Visa", last4="4242", resolved_via="subPin")) + monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: _state, raising=False) + + cli._show_subscription() + out = capsys.readouterr().out + + assert "Visa ····4242 — the card on your subscription — will be charged." in out diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 810e2cf226ec..b58826bc117e 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9908,6 +9908,242 @@ def test_start_agent_build_passes_session_model_override(monkeypatch): server._sessions.clear() +# ── billing/subscription state + error serialization ───────────────── + + +@pytest.mark.parametrize( + "card,expected", + [ + ("canonical", {"kind": "canonical"}), + ( + "distinct", + { + "kind": "distinct", + "payment_method_id": "pm_auto", + "brand": None, + "last4": None, + }, + ), + ("none", {"kind": "none"}), + ], +) +def test_billing_state_serializes_auto_reload_card_union(monkeypatch, card, expected): + from agent.billing_view import AutoReload, AutoReloadCard, BillingState + + monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False}) + auto_reload_card = AutoReloadCard( + kind=card, + payment_method_id="pm_auto" if card == "distinct" else None, + ) + state = BillingState( + logged_in=True, + auto_reload=AutoReload(enabled=True, card=auto_reload_card), + ) + + result = server._serialize_billing_state(state) + + assert result["auto_reload"]["card"] == expected + + +def test_billing_state_serializes_server_plan_capability(monkeypatch): + from agent.billing_view import BillingState + + monkeypatch.setattr(server, "_usage_payload", lambda state: {"available": False}) + state = BillingState( + logged_in=True, + role="MEMBER", + can_change_plan_raw=True, + ) + + result = server._serialize_billing_state(state) + + assert result["is_admin"] is False + assert result["can_change_plan"] is True + + +class _BillingHeaders: + def __init__(self, values): + self._values = values + + def get(self, key): + return self._values.get(key) + + +@pytest.mark.parametrize( + "status,error,retry_after", + [ + (503, "stripe_unavailable", 75), + (429, "upgrade_cap_exceeded", None), + (429, "rate_limited", None), + ], +) +def test_billing_error_serialization_preserves_server_code( + status, error, retry_after +): + import hermes_cli.nous_billing as nb + + headers = _BillingHeaders({"Retry-After": str(retry_after)}) if retry_after else None + with pytest.raises(nb.BillingTransient) as ei: + nb._raise_for_error(status, {"error": error}, headers) + + result = server._serialize_billing_error(ei.value) + + assert result["error"] == error + assert ei.value.error == error + assert result["retry_after"] == retry_after + + +def test_billing_rate_limit_without_error_defaults_wire_code(): + import hermes_cli.nous_billing as nb + + exc = nb.BillingRateLimited("slow down", status=429, retry_after=10) + + result = server._serialize_billing_error(exc) + + assert result["error"] == "rate_limited" + + +# ── subscription change RPCs (V3): preview + pending-change + upgrade ── + + +def _sub_rpc(method, params): + # These RPCs are in _LONG_HANDLERS (pool-routed → dispatch returns None and the + # worker writes via the transport), so drive the inline handler directly. + return server.handle_request({"id": "1", "method": method, "params": params})["result"] + + +def test_subscription_preview_serializes_quote(monkeypatch): + import hermes_cli.nous_billing as nb + + monkeypatch.setattr( + nb, + "post_subscription_preview", + lambda subscription_type_id: { + "effect": "charge_now", + "reason": None, + "currentTierId": "plus", + "currentTierName": "Plus", + "targetTierId": "ultra", + "targetTierName": "Ultra", + "monthlyCreditsDelta": "6000", + "amountDueNowCents": 1234, + "effectiveAt": None, + }, + ) + res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"}) + assert res["ok"] is True + assert res["effect"] == "charge_now" + assert res["amount_due_now_cents"] == 1234 + assert res["target_tier_name"] == "Ultra" + assert res["monthly_credits_delta"] == "6000" + + +def test_subscription_preview_requires_tier(): + res = _sub_rpc("subscription.preview", {}) + assert res["ok"] is False + assert res["error"] == "invalid_request" + + +def test_subscription_preview_scope_error_maps_to_step_up(monkeypatch): + import hermes_cli.nous_billing as nb + + def _raise(subscription_type_id): + raise nb.BillingScopeRequired("billing:manage required") + + monkeypatch.setattr(nb, "post_subscription_preview", _raise) + res = _sub_rpc("subscription.preview", {"subscription_type_id": "ultra"}) + assert res["ok"] is False + assert res["error"] == "insufficient_scope" + + +def test_subscription_change_cancellation(monkeypatch): + import hermes_cli.nous_billing as nb + + seen = {} + + def _put(*, subscription_type_id=None, cancel=False): + seen["tier"] = subscription_type_id + seen["cancel"] = cancel + return {"rail": "stripe", "cancelAtPeriodEnd": True, "message": "Scheduled to cancel."} + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + res = _sub_rpc("subscription.change", {"cancel": True}) + assert res["ok"] is True + assert seen == {"tier": None, "cancel": True} + assert res["message"] == "Scheduled to cancel." + + +def test_subscription_change_tier_downgrade(monkeypatch): + import hermes_cli.nous_billing as nb + + seen = {} + + def _put(*, subscription_type_id=None, cancel=False): + seen["tier"] = subscription_type_id + seen["cancel"] = cancel + return {"rail": "stripe", "changeType": "downgrade", "targetTierName": "Plus", "message": "Scheduled."} + + monkeypatch.setattr(nb, "put_subscription_pending_change", _put) + res = _sub_rpc("subscription.change", {"subscription_type_id": "plus"}) + assert res["ok"] is True + assert seen == {"tier": "plus", "cancel": False} + + +def test_subscription_change_requires_tier_or_cancel(): + res = _sub_rpc("subscription.change", {}) + assert res["ok"] is False + assert res["error"] == "invalid_request" + + +def test_subscription_resume(monkeypatch): + import hermes_cli.nous_billing as nb + + monkeypatch.setattr( + nb, + "delete_subscription_pending_change", + lambda: {"rail": "stripe", "cancelAtPeriodEnd": False, "message": "Resumed."}, + ) + res = _sub_rpc("subscription.resume", {}) + assert res["ok"] is True + assert res["message"] == "Resumed." + + +def test_subscription_upgrade_echoes_status_and_idempotency(monkeypatch): + import hermes_cli.nous_billing as nb + + seen = {} + + def _upgrade(*, subscription_type_id, idempotency_key): + seen["key"] = idempotency_key + return {"status": "upgraded", "targetTierId": "ultra", "targetTierName": "Ultra"} + + monkeypatch.setattr(nb, "post_subscription_upgrade", _upgrade) + res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra", "idempotency_key": "k-1"}) + assert res["ok"] is True + assert res["status"] == "upgraded" + assert res["target_tier_name"] == "Ultra" + assert res["idempotency_key"] == "k-1" + assert seen["key"] == "k-1" + + +def test_subscription_upgrade_requires_action_surfaces_recovery(monkeypatch): + import hermes_cli.nous_billing as nb + + monkeypatch.setattr( + nb, + "post_subscription_upgrade", + lambda *, subscription_type_id, idempotency_key: { + "status": "requires_action", + "reason": "authentication_required", + "recoveryUrl": "https://portal.example/subscription?org_id=o", + }, + ) + res = _sub_rpc("subscription.upgrade", {"subscription_type_id": "ultra"}) + # The RPC succeeds; the CHARGE needs 3DS → status + recovery_url for the portal. + assert res["ok"] is True + assert res["status"] == "requires_action" + assert res["recovery_url"].startswith("https://portal.example") + assert res["idempotency_key"] # minted when the caller omits one # ── _get_usage active_subagents (TUI status-bar ⛓ indicator) ────────────── # Mirrors the classic CLI status bar: _get_usage embeds a live count of # background/async subagents from tools.async_delegation.active_count() so the diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5c142328c1c8..740fd3c4d02c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -177,6 +177,20 @@ _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"}) # response writes are safe. _LONG_HANDLERS = frozenset( { + # Billing/usage reads each do a blocking portal HTTP fetch (state + usage + # is two serial round-trips); keep them off the main stdin loop so a slow + # portal can't stall approval.respond / session.interrupt / other RPCs. + "billing.state", + "subscription.state", + # Subscription change (V3): preview + the pending-change mutations + upgrade + # each do a blocking portal round-trip (preview + upgrade also hit Stripe, + # which can take seconds) — keep them off the main stdin loop. + "subscription.preview", + "subscription.change", + "subscription.resume", + "subscription.upgrade", + "usage.bars", + "session.usage", "billing.step_up", "browser.manage", "cli.exec", @@ -7882,37 +7896,6 @@ def _(rid, params: dict) -> dict: return _err(rid, 5031, f"pet.hatch failed: {exc}") -@method("credits.view") -def _(rid, params: dict) -> dict: - """Structured Nous credit view for the TUI /credits command. - - Account-independent (a portal fetch gated on "a Nous account is logged in"), - so it works with no live agent / on a resumed session — same as the /usage - credits block. Returns the surface-agnostic CreditsView fields so the TUI can - render a clickable top-up . Fail-open: a portal hiccup or logged-out - account yields {logged_in: false}, never an error the user has to parse. - """ - try: - from agent.account_usage import build_credits_view - - view = build_credits_view() - return _ok( - rid, - { - "logged_in": bool(view.logged_in), - "balance_lines": [ - line for line in view.balance_lines if not line.lstrip().startswith("📈") - ], - "identity_line": view.identity_line, - "topup_url": view.topup_url, - "depleted": bool(view.depleted), - }, - ) - except Exception: - # Fail-open: TUI treats this as "not logged in" and shows the prompt. - return _ok(rid, {"logged_in": False, "balance_lines": [], "identity_line": None, "topup_url": None, "depleted": False}) - - # =========================================================================== # Phase 2b terminal billing RPC methods # =========================================================================== @@ -7922,21 +7905,27 @@ def _(rid, params: dict) -> dict: # Ink side can branch on the typed billing error code (insufficient_scope, # rate_limited, no_payment_method, …) to render the right affordance instead of # landing in a generic catch. The data-building lives in the shared core -# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /credits. +# (agent/billing_view.py + hermes_cli/nous_billing.py) — same as /topup. def _serialize_billing_error(exc) -> dict: """Map a BillingError into the result.error envelope the TUI branches on.""" from hermes_cli.nous_billing import ( - BillingRateLimited, + BillingRemoteSpendingRevoked, BillingScopeRequired, + BillingSessionRevoked, + BillingTransient, ) kind = "error" - if isinstance(exc, BillingScopeRequired): + if isinstance(exc, BillingRemoteSpendingRevoked): + kind = "remote_spending_revoked" + elif isinstance(exc, BillingSessionRevoked): + kind = "session_revoked" + elif isinstance(exc, BillingScopeRequired): kind = "insufficient_scope" - elif isinstance(exc, BillingRateLimited): - kind = "rate_limited" + elif isinstance(exc, BillingTransient): + kind = str(exc.error) if getattr(exc, "error", None) else "rate_limited" elif getattr(exc, "error", None): kind = str(exc.error) return { @@ -7946,6 +7935,11 @@ def _serialize_billing_error(exc) -> dict: "portal_url": getattr(exc, "portal_url", None), "retry_after": getattr(exc, "retry_after", None), "payload": getattr(exc, "payload", {}) or {}, + # Remote-Spending contract extras (threaded so the TUI can render + # actor-aware copy + route recovery without re-parsing the message). + "actor": getattr(exc, "actor", None), + "code": getattr(exc, "code", None), + "recovery": getattr(exc, "recovery", None), } @@ -7958,7 +7952,18 @@ def _serialize_billing_state(state) -> dict: card = None if state.card is not None: - card = {"brand": state.card.brand, "last4": state.card.last4, "masked": state.card.masked} + card = { + "brand": state.card.brand, + "last4": state.card.last4, + "masked": state.card.masked, + # Post-card-resolver fields (None/False on older NAS payloads): + # display = "Visa ····4242 — the card on your subscription"; + # resolved_via = the raw resolution rung, for rung-gated surfaces + # (the /subscription confirm only shows the card when the rung + # matches what a subscription charge would use). + "display": state.card.display, + "resolved_via": state.card.resolved_via, + } monthly_cap = None if state.monthly_cap is not None: mc = state.monthly_cap @@ -7972,12 +7977,24 @@ def _serialize_billing_state(state) -> dict: auto_reload = None if state.auto_reload is not None: ar = state.auto_reload + card_out = None + if ar.card is not None: + if ar.card.kind == "distinct": + card_out = { + "kind": "distinct", + "payment_method_id": ar.card.payment_method_id, + "brand": ar.card.brand, + "last4": ar.card.last4, + } + else: + card_out = {"kind": ar.card.kind} auto_reload = { "enabled": ar.enabled, "threshold_usd": _s(ar.threshold_usd), "threshold_display": format_money(ar.threshold_usd), "reload_to_usd": _s(ar.reload_to_usd), "reload_to_display": format_money(ar.reload_to_usd), + "card": card_out, } return { "ok": True, @@ -7986,6 +8003,7 @@ def _serialize_billing_state(state) -> dict: "org_slug": state.org_slug, "role": state.role, "is_admin": state.is_admin, + "can_change_plan": state.can_change_plan, "can_charge": state.can_charge, "balance_usd": _s(state.balance_usd), "balance_display": format_money(state.balance_usd), @@ -7999,14 +8017,35 @@ def _serialize_billing_state(state) -> dict: "auto_reload": auto_reload, "portal_url": state.portal_url, "error": state.error, + # Shared dollar usage model (two-bar view) embedded so /topup renders the + # same plan + top-up bars as /usage and /subscription from its single + # fetch. Built from the separate account-info path; fail-open when logged + # out or the portal is down. + "usage": _usage_payload(state), } +def _usage_payload(state) -> dict: + """Best-effort shared usage model for the /topup + /subscription overlay bars. + + Only fetched when logged in; fail-open to {available:false} so the overview + still renders if the account-info path is down. + """ + if not getattr(state, "logged_in", False): + return {"available": False} + try: + from agent.billing_usage import build_usage_model + + return _serialize_usage_model(build_usage_model()) + except Exception: + return {"available": False} + + @method("billing.state") def _(rid, params: dict) -> dict: """GET /api/billing/state → serialized BillingState (Screen 1 + 5). - Fail-open like credits.view: a logged-out / unreachable portal yields + Fail-open like the other billing RPCs: a logged-out / unreachable portal yields {ok:true, logged_in:false}. No scope required for this endpoint. """ try: @@ -8018,6 +8057,269 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"}) +def _serialize_usage_bar(bar) -> Optional[dict]: + """Serialize a UsageBar (dollar magnitudes → display strings + fractions).""" + if bar is None: + return None + from agent.billing_usage import _fmt_usd + + return { + "kind": bar.kind, + "remaining_display": _fmt_usd(bar.remaining_usd), + "total_display": _fmt_usd(bar.total_usd), + "spent_display": _fmt_usd(bar.spent_usd), + "pct_used": bar.pct_used, + "fill_fraction": bar.fill_fraction, + } + + +def _serialize_usage_model(model) -> dict: + """Serialize a UsageModel for the wire — the shared two-bar dollar view. + + Dollars-only (no 'credits'); fail-open shape mirrors the other billing RPCs + ({ok, available:false} when logged out / unreachable). + """ + from agent.billing_usage import _fmt_usd, format_renews + + if model is None or not getattr(model, "available", False): + return {"ok": True, "available": False} + + return { + "ok": True, + "available": True, + "status": model.status, + "plan_name": model.plan_name, + "renews_at": model.renews_at, + "renews_display": getattr(model, "renews_display", None) or format_renews(model.renews_at), + "subscription_remaining_display": ( + None if model.subscription_remaining_usd is None else _fmt_usd(model.subscription_remaining_usd) + ), + "topup_remaining_display": ( + None if model.topup_remaining_usd is None else _fmt_usd(model.topup_remaining_usd) + ), + "total_spendable_display": ( + None if model.total_spendable_usd is None else _fmt_usd(model.total_spendable_usd) + ), + "has_topup": model.has_topup, + "plan_bar": _serialize_usage_bar(model.plan_bar), + "topup_bar": _serialize_usage_bar(model.topup_bar), + } + + +@method("usage.bars") +def _(rid, params: dict) -> dict: + """Shared dollar usage model (two-bar view) for /usage + /subscription. + + Fail-open: logged-out / unreachable portal → {ok:true, available:false}. + No scope required (read-only). + """ + try: + from agent.billing_usage import build_usage_model + + return _ok(rid, _serialize_usage_model(build_usage_model())) + except Exception: + return _ok(rid, {"ok": True, "available": False}) + + +def _serialize_subscription_state(state) -> dict: + """Serialize a SubscriptionState for the wire (Decimals → strings).""" + from agent.billing_usage import format_renews + from agent.billing_view import format_money + + def _s(value): + return None if value is None else str(value) + + current = None + if state.current is not None: + c = state.current + current = { + "tier_id": c.tier_id, + "tier_name": c.tier_name, + "monthly_credits": _s(c.monthly_credits), + "credits_remaining": _s(c.credits_remaining), + "cycle_ends_at": c.cycle_ends_at, + "pending_downgrade_tier_name": c.pending_downgrade_tier_name, + "pending_downgrade_at": c.pending_downgrade_at, + "pending_downgrade_display": format_renews(c.pending_downgrade_at), + "cancel_at_period_end": c.cancel_at_period_end, + "cancellation_effective_at": c.cancellation_effective_at, + "cancellation_effective_display": format_renews(c.cancellation_effective_at), + } + # Selectable catalog for the in-terminal tier picker; price is pre-formatted + # ($X / $X.YY) so the TUI renders it directly. + tiers = [ + { + "tier_id": t.tier_id, + "name": t.name, + "tier_order": t.tier_order, + "dollars_per_month_display": format_money(t.dollars_per_month), + "monthly_credits": _s(t.monthly_credits), + "is_current": t.is_current, + "is_enabled": t.is_enabled, + } + for t in state.tiers + ] + return { + "ok": True, + "logged_in": state.logged_in, + "is_admin": state.is_admin, + "can_change_plan": state.can_change_plan, + "org_name": state.org_name, + "org_id": state.org_id, + "role": state.role, + "context": state.context, + "current": current, + "tiers": tiers, + "portal_url": state.portal_url, + "error": state.error, + # Shared dollar usage model (two-bar view) embedded so /subscription + # renders the same bars as /usage from its single fetch. Built from the + # separate account-info path (the only source with top-up dollars); + # fail-open → {available:false}. Computed lazily so a logged-out state + # adds no cost. + "usage": _usage_payload(state), + } + + +@method("subscription.state") +def _(rid, params: dict) -> dict: + """GET /api/billing/subscription → serialized SubscriptionState. + + Fail-open like billing.state: logged-out / unreachable portal → + {ok:true, logged_in:false}. No scope required (read-only). + """ + try: + from agent.subscription_view import build_subscription_state + + state = build_subscription_state() + return _ok(rid, _serialize_subscription_state(state)) + except Exception: + return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"}) + + +def _serialize_subscription_preview(p) -> dict: + """Serialize a SubscriptionChangePreview for the wire (Decimal → string).""" + return { + "ok": True, + "effect": p.effect, + "reason": p.reason, + "current_tier_id": p.current_tier_id, + "current_tier_name": p.current_tier_name, + "target_tier_id": p.target_tier_id, + "target_tier_name": p.target_tier_name, + "monthly_credits_delta": ( + None if p.monthly_credits_delta is None else str(p.monthly_credits_delta) + ), + "amount_due_now_cents": p.amount_due_now_cents, + "effective_at": p.effective_at, + } + + +@method("subscription.preview") +def _(rid, params: dict) -> dict: + """POST /api/billing/subscription/preview → serialized quote or typed error. + + params: {subscription_type_id: str}. Chargeless effect quote. Requires + billing:manage (live Stripe calls + amounts), so a 403 → insufficient_scope + drives the device step-up exactly like the mutations. + """ + from agent.subscription_view import subscription_change_preview_from_payload + from hermes_cli.nous_billing import BillingError, post_subscription_preview + + tier_id = params.get("subscription_type_id") + if not tier_id: + return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"}) + try: + preview = subscription_change_preview_from_payload( + post_subscription_preview(subscription_type_id=tier_id) + ) + return _ok(rid, _serialize_subscription_preview(preview)) + except BillingError as exc: + return _ok(rid, _serialize_billing_error(exc)) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + + +@method("subscription.change") +def _(rid, params: dict) -> dict: + """PUT /api/billing/subscription/pending-change → {ok, message} or typed error. + + params: {subscription_type_id?: str, cancel?: bool}. Schedules a downgrade / + same-price change OR a cancellation at period end (chargeless). Requires + billing:manage. + """ + from hermes_cli.nous_billing import BillingError, put_subscription_pending_change + + cancel = bool(params.get("cancel")) + tier_id = params.get("subscription_type_id") + if not cancel and not tier_id: + return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id or cancel is required"}) + try: + result = put_subscription_pending_change(subscription_type_id=tier_id, cancel=cancel) + return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result}) + except BillingError as exc: + return _ok(rid, _serialize_billing_error(exc)) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + + +@method("subscription.resume") +def _(rid, params: dict) -> dict: + """DELETE /api/billing/subscription/pending-change → {ok, message} or typed error. + + Clears a scheduled downgrade or cancellation (resume / undo). Chargeless, but it + re-enables recurring spend → requires billing:manage and honors the kill-switch. + """ + from hermes_cli.nous_billing import BillingError, delete_subscription_pending_change + + try: + result = delete_subscription_pending_change() + return _ok(rid, {"ok": True, "message": result.get("message"), "payload": result}) + except BillingError as exc: + return _ok(rid, _serialize_billing_error(exc)) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc)}) + + +@method("subscription.upgrade") +def _(rid, params: dict) -> dict: + """POST /api/billing/subscription/upgrade → {ok, status, ...} or typed error. + + params: {subscription_type_id: str, idempotency_key?: str}. The single money + route: prorate + charge the card on the subscription + flip the plan. SCA / + decline come back as status requires_action / payment_failed with a recovery_url + to finish in the portal. The idempotency key is minted if absent and echoed so + the TUI reuses it on retry of the SAME upgrade. Requires billing:manage. + """ + from agent.billing_view import new_idempotency_key + from hermes_cli.nous_billing import BillingError, post_subscription_upgrade + + tier_id = params.get("subscription_type_id") + if not tier_id: + return _ok(rid, {"ok": False, "error": "invalid_request", "message": "subscription_type_id is required"}) + key = params.get("idempotency_key") or new_idempotency_key() + try: + result = post_subscription_upgrade(subscription_type_id=tier_id, idempotency_key=key) + return _ok( + rid, + { + "ok": True, + "status": result.get("status"), + "target_tier_name": result.get("targetTierName"), + "recovery_url": result.get("recoveryUrl"), + "reason": result.get("reason"), + "idempotency_key": key, + }, + ) + except BillingError as exc: + env = _serialize_billing_error(exc) + env["idempotency_key"] = key # so the TUI can reuse on retry + return _ok(rid, env) + except Exception as exc: + return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "idempotency_key": key}) + + + @method("billing.charge") def _(rid, params: dict) -> dict: """POST /api/billing/charge → {ok, chargeId} or a typed error envelope. @@ -8112,6 +8414,7 @@ def _(rid, params: dict) -> dict: sid = params.get("session_id") or "" try: from hermes_cli.auth import step_up_nous_billing_scope + from hermes_cli.nous_billing import BillingError def _on_verification(url: str, code: str) -> None: _emit( @@ -8124,6 +8427,13 @@ def _(rid, params: dict) -> dict: open_browser=False, on_verification=_on_verification ) return _ok(rid, {"ok": True, "granted": bool(granted)}) + except BillingError as exc: + # Route typed billing errors (e.g. session_revoked when the token expires + # mid-device-flow) through the shared spine like the other write handlers, + # so the TUI maps them to the right copy instead of a generic failure. + env = _serialize_billing_error(exc) + env["granted"] = False + return _ok(rid, env) except Exception as exc: return _ok(rid, {"ok": False, "error": "error", "message": str(exc), "granted": False}) diff --git a/ui-tui/scripts/billing-fixtures.tsx b/ui-tui/scripts/billing-fixtures.tsx new file mode 100644 index 000000000000..f55eb725db14 --- /dev/null +++ b/ui-tui/scripts/billing-fixtures.tsx @@ -0,0 +1,242 @@ +/** + * Billing/Subscription TUI fixture harness — renders any single overlay STATE + * live in the terminal so it can be screenshotted (tmux) and UX-reviewed. + * + * This is a DEV/REVIEW tool, not shipped behaviour. It bypasses the gateway and + * mounts the real Ink overlay components directly with a hand-built state object, + * exactly the way the vitest render tests do — so what you see is pixel-identical + * to what `/subscription` and `/topup` draw at runtime. + * + * Usage: + * npx tsx scripts/billing-fixtures.tsx + * 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), + sys: () => {} +} + +const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pendingTargetTierId: string | null = null): SubscriptionOverlayState => ({ + ctx: subCtx, + screen, + state: s, + pendingTargetTierId +}) + +// ── billing/topup fixtures ─────────────────────────────────────────── + +const billState = (o: Partial = {}): BillingStateResponse => ({ + ok: true, + logged_in: true, + is_admin: true, + cli_billing_enabled: true, + can_charge: true, + card: { brand: 'Visa', last4: '4242', masked: 'Visa •••• 4242' }, + balance_display: '$12.00', + balance_usd: '12.00', + min_usd: '5', + max_usd: '500', + monthly_cap: { + is_default_ceiling: false, + limit_display: '$20', + limit_usd: '20', + spent_display: '$8.00', + spent_this_month_usd: '8' + }, + auto_reload: { enabled: false, reload_to_display: '$25', reload_to_usd: '25', threshold_display: '$5', threshold_usd: '5' }, + org_name: 'Acme Inc', + role: 'OWNER', + portal_url: 'https://portal.nousresearch.com/billing', + charge_presets: ['10', '25', '50', '100'], + charge_presets_display: ['$10', '$25', '$50', '$100'], + ...o +}) + +const billCtx = { + applyAutoReload: () => Promise.resolve(true), + charge: () => Promise.resolve('submitted' as const), + openPortal: () => {}, + requestRemoteSpending: () => Promise.resolve(true), + sys: () => {}, + validate: (raw: string) => ({ amount: raw }) +} + +const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({ + ctx: billCtx, + pendingCharge: screen === 'confirm' || screen === 'stepup' ? { amount: '100' } : null, + screen, + state: s +}) + +// ── fixture registry ───────────────────────────────────────────────── + +type Fixture = { desc: string; node: React.ReactElement } + +const subEl = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pending: string | null = null) => + React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay: sub(s, screen, pending), t }) + +const billEl = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview') => + React.createElement(BillingOverlay, { onClose: () => {}, onPatch: () => {}, overlay: bill(s, screen), t }) + +const FIXTURES: Record = { + // /subscription — overview states + 'sub-free': { + desc: 'Free / no sub — upgradeable (primary conversion state)', + node: subEl(subState({ current: null })) + }, + 'sub-mid': { + desc: 'Subscriber mid-tier (Plus) — usage bar + up/downgrade targets', + node: subEl(subState({ current: cur(), tiers: tierList('plus') })) + }, + 'sub-top': { + desc: 'Subscriber top-tier (Ultra) — "on the top plan"', + node: subEl(subState({ current: cur({ tier_id: 'ultra', tier_name: 'Ultra', monthly_credits: '7000', credits_remaining: '5000' }), tiers: tierList('ultra') })) + }, + 'sub-not-admin': { + desc: 'Member (not admin/owner) — read-only, no tier picker', + node: subEl(subState({ is_admin: false, can_change_plan: false, role: 'MEMBER', current: cur(), tiers: tierList('plus') })) + }, + 'sub-downgrade': { + desc: 'Downgrade scheduled — pending-switch banner', + node: subEl(subState({ current: cur({ pending_downgrade_tier_name: 'Plus', pending_downgrade_at: '2026-07-15' }), tiers: tierList('super') })) + }, + 'sub-cancel': { + desc: 'Cancellation scheduled — stays active until effective date', + node: subEl(subState({ current: cur({ cancel_at_period_end: true, cancellation_effective_at: '2026-07-01' }), tiers: tierList('plus') })) + }, + 'sub-team': { + desc: 'Team org context — shared credits, redirect to /topup', + node: subEl(subState({ context: 'team', current: null, org_name: 'Acme Engineering' })) + }, + // /subscription — non-overview screens + 'sub-confirm': { + desc: 'Confirm plan change (deep-link, no in-terminal charge)', + node: subEl(subState({ current: cur(), tiers: tierList('plus') }), 'confirm', 'super') + }, + 'sub-confirm-new': { + desc: 'Confirm first subscription (free → paid)', + node: subEl(subState({ current: null }), 'confirm', 'plus') + }, + 'sub-handoff': { + desc: 'Handoff transient — opening subscription page in browser', + node: subEl(subState({ current: cur() }), 'handoff') + }, + // /topup (renamed /billing) + 'topup-overview': { + desc: '/topup overview — admin, card on file, full menu', + node: billEl(billState()) + }, + 'topup-no-card': { + desc: '/topup overview — admin, NO saved card (card hint)', + node: billEl(billState({ card: null })) + }, + 'topup-not-admin': { + desc: '/topup overview — member, read-only', + node: billEl(billState({ is_admin: false })) + }, + 'topup-disabled': { + desc: '/topup overview — terminal billing OFF for org', + node: billEl(billState({ cli_billing_enabled: false })) + }, + 'topup-buy': { + desc: '/topup buy screen — presets', + node: billEl(billState(), 'buy') + }, + 'topup-stepup': { + desc: '/topup step-up — "Allow Remote Spending" (resumable, holds $100 buy)', + node: billEl(billState(), 'stepup') + } +} + +// ── driver ─────────────────────────────────────────────────────────── + +const arg = process.argv[2] + +if (!arg || arg === '--list' || arg === '-l') { + const names = Object.keys(FIXTURES) + process.stdout.write('Billing/Subscription TUI fixtures:\n\n') + for (const name of names) { + process.stdout.write(` ${name.padEnd(18)} ${FIXTURES[name]!.desc}\n`) + } + process.stdout.write(`\n ${names.length} fixtures. Run: npx tsx scripts/billing-fixtures.tsx \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__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx new file mode 100644 index 000000000000..f59b1ef2ac88 --- /dev/null +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -0,0 +1,185 @@ +import { PassThrough } from 'stream' + +import { renderSync } from '@hermes/ink' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +// Stub useInput so the overlay doesn't enter raw mode under renderSync. +vi.mock('@hermes/ink', async importOriginal => { + const mod = await importOriginal() + + return { ...mod, useInput: () => {} } +}) + +import type { BillingOverlayState } from '../app/interfaces.js' +import { BillingOverlay } from '../components/billingOverlay.js' +import type { BillingStateResponse } from '../gatewayTypes.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +const t = DEFAULT_THEME + +function render(overlay: BillingOverlayState): string { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + + let output = '' + + Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync( + React.createElement(BillingOverlay, { + onClose: () => {}, + onPatch: () => {}, + overlay, + t + }), + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + instance.unmount() + instance.cleanup() + + return stripAnsi(output) +} + +const billState = (overrides: Partial = {}): BillingStateResponse => + ({ + auto_reload: null, + balance_display: '$12.00', + balance_usd: '12', + can_charge: true, + card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' }, + charge_presets: ['25', '50'], + charge_presets_display: ['$25', '$50'], + cli_billing_enabled: true, + is_admin: true, + logged_in: true, + max_usd: '1000', + min_usd: '10', + monthly_cap: null, + ok: true, + org_name: 'Acme', + portal_url: 'https://portal/billing', + role: 'OWNER', + ...overrides + }) as BillingStateResponse + +const ctx = { + applyAutoReload: vi.fn(() => Promise.resolve(true)), + charge: vi.fn(() => Promise.resolve('submitted' as const)), + openPortal: vi.fn(), + refreshState: vi.fn(() => Promise.resolve(null)), + requestRemoteSpending: vi.fn(() => Promise.resolve(true)), + sys: vi.fn(), + validate: vi.fn((raw: string) => ({ amount: raw })) +} + +const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => ({ + ctx, + pendingCharge: { amount: '100' }, + screen, + state: billState() +}) + +describe('BillingOverlay — step-up screen (Enable terminal billing)', () => { + it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => { + const out = render(overlay('stepup')) + expect(out).toContain('One-time setup') + expect(out).toContain('Enable terminal billing') + expect(out).toContain('$100') // resumes the held purchase + expect(out).toContain('Not now') + expect(out).not.toContain('billing:manage') + }) +}) + +describe('BillingOverlay — overview (reordered, dollars)', () => { + it('leads with balance in the title, Add funds first, no "credits"', () => { + const out = render(overlay('overview')) + expect(out).toContain('Top up · balance $12.00') // balance in the title + expect(out).toContain('Add funds') // buy action, renamed + expect(out).toContain('Auto-reload') + expect(out).toContain('Manage on portal') + expect(out.toLowerCase()).not.toContain('credits') // dollars only + // No standalone "Enable terminal billing" item — discovered at pay time. + expect(out).not.toContain('Enable terminal billing') + }) + + it('renders the two-bar dollar usage when a usage model is present', () => { + const withUsage: BillingOverlayState = { + ...overlay('overview'), + state: { + ...billState(), + usage: { + available: true, + status: 'healthy', + plan_name: 'Plus', + has_topup: true, + plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 }, + topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 } + } + } + } + + const out = render(withUsage) + expect(out).toContain('$14.00 left of $20.00') + expect(out).toContain('30% used') + expect(out).toContain('never expires') + }) +}) + +describe('BillingOverlay — auto-reload card divergence', () => { + const autoReload = (card: NonNullable['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__/creditsCommand.test.ts b/ui-tui/src/__tests__/creditsCommand.test.ts deleted file mode 100644 index b78f9205e159..000000000000 --- a/ui-tui/src/__tests__/creditsCommand.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' -import { creditsCommands } from '../app/slash/commands/credits.js' -import type { CreditsViewResponse } from '../gatewayTypes.js' - -// The command opens the top-up URL through this helper on confirm. Mock it so -// the test never shells out to a real browser/`xdg-open` and we can assert the -// success/failure messaging deterministically. -vi.mock('../lib/openExternalUrl.js', () => ({ - openExternalUrl: vi.fn(() => true) -})) - -import { openExternalUrl } from '../lib/openExternalUrl.js' - -const openExternalUrlMock = vi.mocked(openExternalUrl) - -const creditsCommand = creditsCommands.find(cmd => cmd.name === 'credits')! - -const buildView = (overrides: Partial = {}): CreditsViewResponse => ({ - 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 () => { - 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__/subscriptionCommand.test.ts b/ui-tui/src/__tests__/subscriptionCommand.test.ts new file mode 100644 index 000000000000..31486c90ec58 --- /dev/null +++ b/ui-tui/src/__tests__/subscriptionCommand.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' +import { subscriptionCommands } from '../app/slash/commands/subscription.js' +import { findSlashCommand } from '../app/slash/registry.js' +import type { SubscriptionStateResponse } from '../gatewayTypes.js' + +vi.mock('../lib/openExternalUrl.js', () => ({ + openExternalUrl: vi.fn(() => true) +})) + +const subscriptionCommand = subscriptionCommands.find(cmd => cmd.name === 'subscription')! + +const loggedInState = (overrides: Partial = {}): SubscriptionStateResponse => ({ + ok: true, + logged_in: true, + is_admin: true, + can_change_plan: true, + org_name: 'Acme', + role: 'OWNER', + current: null, + portal_url: 'https://portal.nousresearch.com/billing', + ...overrides +}) + +const guarded = + (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() + }) + + await run('') + + const overlay = getOverlayState().subscription + + expect(overlay).not.toBeNull() + expect(overlay?.screen).toBe('overview') + }) + + it('shows portal-login sys line when not logged in', async () => { + const { run, sys } = buildCtx({ + 'subscription.state': loggedInState({ logged_in: false }) + }) + + await run('') + + expect(printed(sys)).toContain('Not logged into Nous Portal') + expect(getOverlayState().subscription).toBeNull() + }) + + it('/upgrade alias resolves to the same command', () => { + expect(findSlashCommand('upgrade')).toBe(subscriptionCommand) + }) + + it('/subscription resolves to the same command', () => { + expect(findSlashCommand('subscription')).toBe(subscriptionCommand) + }) +}) diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx new file mode 100644 index 000000000000..22cfec4e77e9 --- /dev/null +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -0,0 +1,481 @@ +import { PassThrough } from 'stream' + +import { renderSync } from '@hermes/ink' +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +const inputHarness = vi.hoisted(() => ({ + handler: undefined as undefined | ((input: string, key: Record) => void) +})) + +// Stub useInput so the overlay doesn't try to enter raw mode under renderSync +// (PassThrough stdin doesn't support it). Box/Text pass through to real Ink. +vi.mock('@hermes/ink', async importOriginal => { + const mod = await importOriginal() + + return { + ...mod, + useInput: (handler: (input: string, key: Record) => void) => { + inputHarness.handler = handler + } + } +}) + +import type { SubscriptionOverlayState } from '../app/interfaces.js' +import { SubscriptionOverlay } from '../components/subscriptionOverlay.js' +import type { SubscriptionStateResponse } from '../gatewayTypes.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +const t = DEFAULT_THEME + +/** Mount a SubscriptionOverlay via renderSync + PassThrough. */ +function mount(overlay: SubscriptionOverlayState, onPatch: (next: Partial) => void = () => {}) { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + + let output = '' + + Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + inputHarness.handler = undefined + const element = React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch, overlay, t }) + const instance = renderSync( + element, + { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + } + ) + + return { + cleanup: () => { + instance.unmount() + instance.cleanup() + }, + output: () => stripAnsi(output), + rerender: () => instance.rerender(element) + } +} + +/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */ +function render(overlay: SubscriptionOverlayState): string { + const mounted = mount(overlay) + const output = mounted.output() + mounted.cleanup() + + return output +} + +const TIERS = [ + { tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0', is_current: false, is_enabled: true }, + { tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true, is_enabled: true }, + { tier_id: 'ultra', name: 'Ultra', tier_order: 2, dollars_per_month_display: '$40', monthly_credits: '3000', is_current: false, is_enabled: true } +] + +const state = (overrides: Partial = {}): SubscriptionStateResponse => ({ + ok: true, + logged_in: true, + is_admin: true, + can_change_plan: true, + org_name: 'Acme', + org_id: 'org_acme', + role: 'OWNER', + current: null, + tiers: [], + portal_url: 'https://portal.nousresearch.com/billing', + ...overrides +}) + +const ctx = { + fetchCard: vi.fn(() => Promise.resolve(null)), + openManageLink: vi.fn(() => Promise.resolve(true)), + openPortal: vi.fn(), + preview: vi.fn(() => Promise.resolve(null)), + refreshState: vi.fn(() => Promise.resolve(null)), + requestRemoteSpending: vi.fn(() => Promise.resolve({ granted: true })), + resume: vi.fn(() => Promise.resolve(null)), + scheduleCancellation: vi.fn(() => Promise.resolve(null)), + scheduleChange: vi.fn(() => Promise.resolve(null)), + sys: vi.fn(), + upgrade: vi.fn(() => Promise.resolve(null)) +} + +const overlay = (s: SubscriptionStateResponse): SubscriptionOverlayState => ({ ctx, screen: 'overview', state: s }) + +// Overview: the entry screen across every account state (plan + usage + the +// actions that enter the in-terminal change flow). +describe('SubscriptionOverlay — overview', () => { + it('free: upsell + "Start a subscription", no tier list, no "credits"', () => { + const out = render(overlay(state({ current: null, usage: { available: true, status: 'free', plan_name: null } }))) + + expect(out).toContain('Plan: Free · free models only') + expect(out).toContain('Paid models need a subscription') + expect(out).toContain('Start a subscription') + expect(out).not.toContain('$20/mo') + expect(out.toLowerCase()).not.toContain('credits') + }) + + it('subscriber: status line + plan bar + top-up bar, no "credits"', () => { + const out = render( + overlay( + state({ + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '700', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { + available: true, + status: 'healthy', + plan_name: 'Pro', + renews_display: 'Jul 1, 2026', + total_spendable_display: '$26.00', + has_topup: true, + plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 }, + topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 } + } + }) + ) + ) + + expect(out).toContain('Plan: Pro') + expect(out).toContain('$14.00 left of $20.00') + expect(out).toContain('30% used') + expect(out).toContain('top-up') + expect(out).toContain('never expires') + expect(out.toLowerCase()).not.toContain('credits') + }) + + it('low balance: shows alert nudge', () => { + const out = render( + overlay( + state({ + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '170', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { available: true, status: 'low', plan_name: 'Pro', total_spendable_display: '$3.40', plan_bar: { kind: 'plan', remaining_display: '$3.40', total_display: '$20.00', spent_display: '$16.60', pct_used: 83, fill_fraction: 0.17 } } + }) + ) + ) + + expect(out).toContain('Plan: Pro · $3.40 left') + expect(out).toContain('Low balance') + }) + + it('not-admin: shows read-only note', () => { + const out = render( + overlay( + state({ + is_admin: false, + can_change_plan: false, + role: 'MEMBER', + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: null, pending_downgrade_at: null }, + usage: { available: true, status: 'healthy', plan_name: 'Pro' } + }) + ) + ) + + expect(out).toContain('view only') + expect(out).toContain('Manage on portal') + }) + + it('downgrade-pending: leads with a Pro ──▶ Free banner + status echo', () => { + const out = render( + overlay( + state({ + current: { tier_id: 'pro', tier_name: 'Pro', monthly_credits: '1000', credits_remaining: '500', cycle_ends_at: '2026-07-01', pending_downgrade_tier_name: 'Free', pending_downgrade_at: '2026-07-15', pending_downgrade_display: 'Jul 15, 2026' }, + usage: { available: true, status: 'healthy', plan_name: 'Pro' } + }) + ) + ) + + expect(out).toContain('Scheduled change') + expect(out).toContain('──▶') + expect(out).toContain('Free') + expect(out).toContain('Jul 15, 2026') + // the status line itself echoes the transition + expect(out).toContain('Plan: Pro → Free') + }) + + it('team context: redirects to /topup, no tier picker', () => { + const out = render(overlay(state({ context: 'team', current: null }))) + + expect(out).toContain('shared balance') + expect(out).toContain('/topup') + }) +}) + +// In-terminal change flow (V3): picker → confirm → result. useInput is mocked +// (no key simulation), so these assert each screen's rendered content. + +const subscriber = (overrides: Partial = {}): SubscriptionStateResponse => + 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 + } + }) + ) + ) + + // undo is promoted to the first action; the banner shows the pending cancel + expect(out).toContain('Keep Plus (undo this change)') + expect(out).toContain('cancels') + expect(out).not.toContain('Cancel subscription') + }) +}) + +describe('SubscriptionOverlay — step-up', () => { + it('prompts to enable terminal billing (never leaks the raw scope)', () => { + const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } })) + + expect(out).toContain('Terminal billing') + expect(out).toContain('Enable terminal billing') + expect(out).not.toContain('billing:manage') + }) +}) + +describe('SubscriptionOverlay — picker', () => { + it('lists other paid tiers with direction hints; hides current + free', () => { + const out = render(at('picker', subscriber())) + + expect(out).toContain('Ultra') + expect(out).toContain('$40/mo') + expect(out).toContain('upgrade') // ultra (order 2) > plus (order 1) + expect(out).not.toContain('Plus · $20/mo') // current tier is not selectable + expect(out).not.toContain('$0/mo') // free tier excluded — use Cancel instead + }) +}) + +describe('SubscriptionOverlay — confirm', () => { + it('charge_now: shows the prorated charge + upgrade copy', () => { + const out = render( + at('confirm', subscriber(), { + pending: { + kind: 'upgrade', + targetTierId: 'ultra', + preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234, monthly_credits_delta: '2000' } + } + }) + ) + + expect(out).toContain('Pay $12.34 & upgrade now') + expect(out).toContain('Upgrade to Ultra') + }) + + it('scheduled: shows effective date + no charge now', () => { + const out = render( + at('confirm', subscriber(), { + pending: { + kind: 'tier_change', + targetTierId: 'plus', + preview: { ok: true, effect: 'scheduled', target_tier_name: 'Plus', effective_at: '2026-08-01T00:00:00Z', amount_due_now_cents: null } + } + }) + ) + + expect(out).toContain('Schedule change to Plus') + expect(out).toContain('2026-08-01') + expect(out).toContain('No charge now') + }) + + it('cancellation: shows cancel-at-period-end copy', () => { + const out = render(at('confirm', subscriber(), { pending: { kind: 'cancellation', targetTierId: null, preview: null } })) + + expect(out).toContain('Confirm cancellation') + expect(out).toContain('will not renew') + }) + + it('blocked: shows the reason + Manage on portal', () => { + const out = render( + at('confirm', subscriber(), { + pending: { kind: 'tier_change', targetTierId: 'ultra', preview: { ok: true, effect: 'blocked', reason: 'Retract the cancellation before upgrading.' } } + }) + ) + + expect(out).toContain('Retract the cancellation') + expect(out).toContain('Manage on portal') + }) +}) + +describe('SubscriptionOverlay — result', () => { + it('ok: shows Done + the re-run hint', () => { + const out = render(at('result', subscriber(), { result: { ok: true, message: 'Upgraded to Ultra.' } })) + + expect(out).toContain('Done') + expect(out).toContain('Upgraded to Ultra.') + expect(out).toContain('Re-run /subscription') + }) + + it('error with recovery: shows the message + Open the portal', () => { + const out = render( + at('result', subscriber(), { + result: { ok: false, message: 'This upgrade needs extra verification (3DS).', recoveryUrl: 'https://portal.example/x' } + }) + ) + + expect(out).toContain('Could not complete') + expect(out).toContain('3DS') + expect(out).toContain('Open the portal to finish') + }) +}) + +describe('SubscriptionOverlay — upgrade response mapping', () => { + const applyUpgrade = async (response: unknown) => { + const onPatch = vi.fn() + const upgrade = vi.fn(() => Promise.resolve(response)) + const mounted = mount( + at('confirm', subscriber(), { + ctx: { ...ctx, upgrade } as SubscriptionOverlayState['ctx'], + pending: { + idempotencyKey: 'upgrade-key', + kind: 'upgrade', + preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234 }, + targetTierId: 'ultra' + } + }), + onPatch + ) + + inputHarness.handler?.('', { return: true }) + await vi.waitFor(() => expect(onPatch).toHaveBeenCalled()) + mounted.cleanup() + + return onPatch.mock.calls.at(-1)?.[0] as Partial + } + + 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__/billingCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts similarity index 59% rename from ui-tui/src/__tests__/billingCommand.test.ts rename to ui-tui/src/__tests__/topupCommand.test.ts index f27f474e5618..63b967b26d1f 100644 --- a/ui-tui/src/__tests__/billingCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -1,17 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' -import { billingCommands } from '../app/slash/commands/billing.js' +import { topupCommands } from '../app/slash/commands/topup.js' import type { BillingStateResponse } from '../gatewayTypes.js' vi.mock('../lib/openExternalUrl.js', () => ({ openExternalUrl: vi.fn(() => true) })) -const billingCommand = billingCommands.find(cmd => cmd.name === 'billing')! +const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')! const ownerState = (overrides: Partial = {}): BillingStateResponse => ({ auto_reload: { + card: { kind: 'canonical' }, enabled: false, reload_to_display: '—', reload_to_usd: null, @@ -72,7 +73,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() @@ -231,19 +232,157 @@ describe('/billing slash command (overlay-driven)', () => { expect(out).toContain('Portal: /billing?topup=open') }) - it('ctx.charge insufficient_scope → arms step-up confirm', async () => { + it('ctx.charge consent_required → one-time portal confirmation copy + portal funnel', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { + ok: false, + error: 'consent_required', + portal_url: '/billing/consent', + idempotency_key: 'k' + } + }) + + await run('') + await getOverlayState().billing!.ctx.charge('100') + const out = printed(sys) + expect(out).toContain('one-time card confirmation') + expect(out).toContain('Portal: /billing/consent') + }) + + it.each([ + ['org_access_denied', "This token isn't bound to an org you can manage"], + ['upgrade_cap_exceeded', 'Daily plan-change limit reached'], + ['auto_top_up_disabled_failures', 'Auto-reload was turned off after repeated charge failures'] + ])('ctx.charge %s → typed recovery copy', async (error, copy) => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: false, error, idempotency_key: 'k' } + }) + + await run('') + await getOverlayState().billing!.ctx.charge('100') + expect(printed(sys)).toContain(copy) + }) + + it.each([ + [undefined, 'Stripe is having trouble right now — try again shortly.'], + [120, 'Stripe is having trouble right now — try again shortly (try again in ~2 min).'] + ])('ctx.charge stripe_unavailable (retry_after=%s) → transient Stripe copy', async (retryAfter, copy) => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { + ok: false, + error: 'stripe_unavailable', + idempotency_key: 'k', + ...(retryAfter == null ? {} : { retry_after: retryAfter }) + } + }) + + await run('') + await getOverlayState().billing!.ctx.charge('100') + const out = printed(sys) + expect(out).toContain(copy) + expect(out).not.toContain('Too many charges') + }) + + it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => { const { run } = buildCtx({ 'billing.state': ownerState(), 'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' } }) + await run('') + const outcome = await getOverlayState().billing!.ctx.charge('100') + // No separate confirm overlay is armed anymore — the overlay's stepup + // screen owns the UX; the ctx just reports the outcome. + expect(outcome).toBe('needs_remote_spending') + expect(getOverlayState().confirm).toBeNull() + }) + + it.each([[true], [false]])('ctx.requestRemoteSpending → billing.step_up resolves %s', async granted => { + const { run, calls } = buildCtx({ 'billing.state': ownerState(), 'billing.step_up': { ok: true, granted } }) + + await run('') + expect(await getOverlayState().billing!.ctx.requestRemoteSpending()).toBe(granted) + expect(calls.find(c => c.method === 'billing.step_up')).toBeTruthy() + }) + + // ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ── + + it.each([ + ['admin', 'An admin turned off terminal billing for this terminal'], + ['self', 'You turned off terminal billing for this terminal'] + ])('ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy', async (actor, copy) => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: false, error: 'remote_spending_revoked', actor, recovery: 'reconnect', idempotency_key: 'k' } + }) + await run('') getOverlayState().billing!.ctx.charge('100') await Promise.resolve() await Promise.resolve() - // The charge failed with insufficient_scope → a NEW confirm (step-up) is armed. - const stepUp = getOverlayState().confirm - expect(stepUp?.title).toBe('Grant terminal billing access?') + expect(printed(sys)).toContain(copy) + expect(getOverlayState().billing).toBeNull() + }) + + it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' } + }) + + await run('') + getOverlayState().billing!.ctx.charge('100') + await Promise.resolve() + await Promise.resolve() + expect(printed(sys)).toContain('Your session was logged out') + expect(getOverlayState().billing).toBeNull() + }) + + it('ctx.charge → poll transport loss reports an unconfirmed outcome', async () => { + const { ctx, rpc, run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } + }) + + await run('') + rpc.mockImplementation((method: string) => { + if (method === 'billing.charge_status') { + return Promise.reject(new Error('socket closed')) + } + + return Promise.resolve( + method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null + ) + }) + await getOverlayState().billing!.ctx.charge('100') + await vi.waitFor(() => expect(printed(sys)).toContain('outcome is unconfirmed')) + expect(ctx.guardedErr).toHaveBeenCalled() + }) + + it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { + ok: false, + error: 'cli_billing_disabled', + code: 'remote_spending_disabled', + recovery: 'enable_account_toggle', + portal_url: '/billing', + idempotency_key: 'k' + } + }) + + await run('') + getOverlayState().billing!.ctx.charge('100') + await Promise.resolve() + await Promise.resolve() + const out = printed(sys) + expect(out).toContain('Terminal billing is off for this account') + // Account-wide switch is NOT a per-terminal revoke — overlay stays open. + expect(getOverlayState().billing).toBeTruthy() }) it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => { @@ -263,6 +402,7 @@ describe('/billing slash command (overlay-driven)', () => { const { run, calls } = buildCtx({ 'billing.state': ownerState({ auto_reload: { + card: { kind: 'canonical' }, enabled: true, reload_to_display: '$100', reload_to_usd: '100', @@ -292,6 +432,25 @@ describe('/billing slash command (overlay-driven)', () => { expect(printed(sys)).toContain('Monthly spend cap reached.') }) + it('ctx.charge → poll → processing_error has intentional failure copy', async () => { + vi.useFakeTimers() + + try { + const { run, sys } = buildCtx({ + 'billing.state': ownerState(), + 'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }, + 'billing.charge_status': { ok: true, status: 'failed', reason: 'processing_error' } + }) + + await run('') + getOverlayState().billing!.ctx.charge('100') + await vi.runAllTimersAsync() + expect(printed(sys)).toContain("The charge didn't go through (processing_error).") + } finally { + vi.useRealTimers() + } + }) + it('ctx.openPortal opens the URL + echoes a transcript line', async () => { const { run, sys } = buildCtx({ 'billing.state': ownerState() }) await run('') diff --git a/ui-tui/src/__tests__/turnControllerNotice.test.ts b/ui-tui/src/__tests__/turnControllerNotice.test.ts index 33459046d439..c3531cb8db58 100644 --- a/ui-tui/src/__tests__/turnControllerNotice.test.ts +++ b/ui-tui/src/__tests__/turnControllerNotice.test.ts @@ -35,12 +35,7 @@ describe('turnController.startMessage — flash-and-yield notices clear on next it('leaves a sticky credits.depleted notice across a new turn', () => { patchUiState({ - notice: { - key: 'credits.depleted', - kind: 'sticky', - level: 'error', - text: '✕ Credit access paused · run /credits to top up' - } + notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' } }) turnController.startMessage() expect(getUiState().notice?.key).toBe('credits.depleted') diff --git a/ui-tui/src/__tests__/usageCommand.test.ts b/ui-tui/src/__tests__/usageCommand.test.ts new file mode 100644 index 000000000000..312fc28d9c48 --- /dev/null +++ b/ui-tui/src/__tests__/usageCommand.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { sessionCommands } from '../app/slash/commands/session.js' +import type { SessionUsageResponse } from '../gatewayTypes.js' + +const usageCommand = sessionCommands.find(cmd => cmd.name === 'usage')! + +const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance' + +const guarded = + (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) => 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') + +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('always shows the CTA; "no API calls yet" only when there is no balance', async () => { + const empty = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: [] }) }) + await empty.run('') + expect(printed(empty.sys)).toContain('no API calls yet') + expect(printed(empty.sys)).toContain(USAGE_CTA) + + const withBalance = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] }) }) + await withBalance.run('') + expect(printed(withBalance.sys)).not.toContain('no API calls yet') + expect(printed(withBalance.sys)).toContain(USAGE_CTA) + }) + + it('renders the dollar two-bar model (no "credits" wording) when available', async () => { + const { panel, run } = buildCtx({ + 'session.usage': baseUsage({ + usage: { + available: true, + status: 'healthy', + plan_name: 'Plus', + renews_display: 'Jul 1, 2026', + total_spendable_display: '$26.00', + has_topup: true, + plan_bar: { kind: 'plan', remaining_display: '$14.00', total_display: '$20.00', spent_display: '$6.00', pct_used: 30, fill_fraction: 0.7 }, + topup_bar: { kind: 'topup', remaining_display: '$12.00', total_display: '$12.00', spent_display: '$0.00', pct_used: null, fill_fraction: 1 } + } + }) + }) + + await run('') + + const body = balancePanel(panel) + expect(body).toContain('Plus') + expect(body).toContain('$14.00 left of $20.00') + expect(body).toContain('30% used') + expect(body).toContain('top-up') + expect(body).toContain('$12.00') + expect(body.toLowerCase()).not.toContain('credits') + }) + + it('shows the free-models upsell for a free account', async () => { + const { panel, run } = buildCtx({ + 'session.usage': baseUsage({ usage: { available: true, status: 'free', plan_name: null } }) + }) + + await run('') + + const body = balancePanel(panel) + expect(body).toContain('free models only') + expect(body).toContain('/subscription') + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 85586f66aaa7..655f50d11fe0 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 { BillingCardInfo, BillingMutationResponse, BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../gatewayTypes.js' import type { ParsedVoiceRecordKey } from '../lib/platform.js' import type { RpcResult } from '../lib/rpc.js' import type { Theme } from '../theme.js' @@ -92,7 +92,13 @@ export interface GatewayProviderProps { // the SAME RPCs as the old slash flows (billing.charge / charge_status / // auto_reload / step_up). Backend is unchanged & shared with the CLI. -export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview' +export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview' | 'stepup' + +/** Outcome of a charge attempt — lets the overlay route without tearing down. */ +export type BillingChargeOutcome = + | 'submitted' // 202 accepted; settlement is reported via transcript lines + | 'needs_remote_spending' // insufficient_scope → route to the stepup screen + | 'error' // any other failure (already surfaced via sys) /** * The functions the overlay needs to talk to the gateway and emit @@ -104,10 +110,27 @@ export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overvi export interface BillingOverlayCtx { /** Run `billing.auto_reload` (enabled/threshold/top_up) → resolve ok/false. */ applyAutoReload: (enabled: boolean, threshold?: number, topUp?: number) => Promise - /** Submit `billing.charge` for `amount` and poll to settlement (non-blocking). */ - charge: (amount: string) => void + /** + * Submit `billing.charge` for `amount` and poll to settlement. Resolves a + * discriminated outcome so the overlay can route to the resumable step-up on + * `needs_remote_spending` instead of tearing down. Settlement/most errors are + * still reported via transcript lines (the poll is non-blocking). + */ + charge: (amount: string, idempotencyKey?: string) => Promise + /** + * 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 + /** + * 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). */ @@ -117,6 +140,12 @@ export interface BillingOverlayCtx { /** Pending confirm built when leaving the buy/autoreload screen. */ export interface BillingPendingCharge { amount: string + /** + * Stable idempotency key for THIS purchase, minted when the amount is chosen. + * Reused across the step-up replay so a re-charge after the grant dedups + * server-side (and a double-submit collapses to one charge). + */ + idempotencyKey?: string } export interface BillingOverlayState { @@ -127,6 +156,106 @@ export interface BillingOverlayState { state: BillingStateResponse } +// ── Subscription overlay (in-terminal plan change, V3) ── + +// A small state machine: overview → picker → confirm → result, with a stepup +// screen spliced in on demand. +// overview — plan + status, entry to the picker / resume / manage-on-portal. +// picker — the tier catalog (up/down direction hints; current tier shown, +// not selectable). +// confirm — the previewed effect of the chosen change (charge $X now / +// scheduled at date / no-op / blocked) + the apply action. +// result — the outcome, including an SCA/decline upgrade handed off to the +// portal. +// stepup — reached when a mutation returns insufficient_scope: grants the +// terminal-billing scope in place, then auto-replays the held action. +export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' | 'stepup' + +// The action held while the stepup screen grants terminal billing, replayed on +// grant: re-preview a tier, re-apply the confirmed pending change, or re-resume. +export type SubscriptionStepUpRetry = + | { kind: 'apply' } + | { kind: 'preview'; tierId: string } + | { kind: 'resume' } + +/** Outcome of a terminal-billing step-up: granted, plus the typed denial (for copy). */ +export interface StepUpResult { + granted: boolean + error?: string + message?: string +} + +export interface SubscriptionOverlayCtx { + /** + * Best-effort card lookup (`billing.state`) for the upgrade confirm — shows + * WHICH card the upgrade will charge. Resolves null on any failure or when + * the server doesn't say (older NAS): the confirm keeps its generic line. + */ + fetchCard: () => Promise + /** 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 + /** + * Run the `billing.step_up` device flow (grant terminal billing / "Remote + * Spending"). Resolves `{granted}` plus the typed denial (`error`/`message`) so + * the stepup screen shows the right recovery. The browser opens via the + * gateway's out-of-band verification event — the stepup screen just awaits. + */ + requestRemoteSpending: () => Promise + /** Emit a transcript system line. */ + sys: (text: string) => void +} + +/** What the confirm screen is about to apply, plus its preview quote. */ +export interface SubscriptionPendingChange { + /** The target tier (null for a cancellation). */ + targetTierId: string | null + /** How it will be applied — drives which ctx call confirm makes. */ + kind: 'cancellation' | 'tier_change' | 'upgrade' + /** The preview quote shown on confirm (null = the quote call failed). */ + preview?: null | SubscriptionPreviewResponse + /** + * Stable idempotency key for an upgrade charge, minted when confirm opens. + * Reused on retry so a re-submit dedups server-side. + */ + idempotencyKey?: string +} + +/** The outcome rendered on the result screen. */ +export interface SubscriptionResult { + message: string + ok: boolean + /** Set on a successful upgrade; drives the ResultScreen apply-poll. */ + pendingTierId?: null | string + /** A portal URL to finish an SCA/declined upgrade, when present. */ + recoveryUrl?: null | string +} + +export interface SubscriptionOverlayState { + ctx: SubscriptionOverlayCtx + /** Set on the 'confirm' screen: the change being confirmed + its preview. */ + pending?: null | SubscriptionPendingChange + /** Set on the 'result' screen: the outcome to render. */ + result?: null | SubscriptionResult + screen: SubscriptionScreen + state: SubscriptionStateResponse + /** Held while on the 'stepup' screen: the action to replay once the grant lands. */ + stepUpRetry?: null | SubscriptionStepUpRetry +} + export interface OverlayState { agents: boolean agentsInitialHistoryIndex: number @@ -142,6 +271,7 @@ export interface OverlayState { secret: null | SecretReq sessions: boolean skillsHub: boolean + subscription: SubscriptionOverlayState | null sudo: null | SudoReq } diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index b09ed08a4325..9273a138e95b 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -17,6 +17,7 @@ const buildOverlayState = (): OverlayState => ({ secret: null, sessions: false, skillsHub: false, + subscription: null, sudo: null }) @@ -38,23 +39,25 @@ export const $isBlocked = computed( secret, sessions, skillsHub, + subscription, sudo }) => Boolean( agents || - approval || - billing || - clarify || - confirm || - journey || - modelPicker || - pager || - petPicker || - pluginsHub || - secret || - sessions || - skillsHub || - sudo + approval || + billing || + clarify || + confirm || + journey || + modelPicker || + pager || + petPicker || + pluginsHub || + secret || + sessions || + skillsHub || + subscription || + sudo ) ) 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 195eb7d105fd..000000000000 --- a/ui-tui/src/app/slash/commands/credits.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { CreditsViewResponse } from '../../../gatewayTypes.js' -import { openExternalUrl } from '../../../lib/openExternalUrl.js' -import { patchOverlayState } from '../../overlayStore.js' -import type { SlashCommand } from '../types.js' - -export const creditsCommands: SlashCommand[] = [ - { - help: 'Show Nous credit balance and top up', - name: 'credits', - run: (_arg, ctx) => { - ctx.gateway - .rpc('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/session.ts b/ui-tui/src/app/slash/commands/session.ts index 56e878f229d1..03e8cd1d9870 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 { sessionScopedModelArg, TUI_SESSION_MODEL_FLAG } from '../../../domain/slash.js' import type { @@ -19,6 +20,8 @@ import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' +const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance' + const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`) const modelValueForConfigSet = (arg: string) => { @@ -574,26 +577,60 @@ export const sessionCommands: SlashCommand[] = [ return } + const sys = ctx.transcript.sys + if (r) { patchUiState({ usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 } }) } - // Nous credits block is agent-independent (a portal fetch), so it shows - // even with zero API calls or on a resumed session. Render it whenever - // present, before the token panel. - const creditsLines = r?.credits_lines ?? [] + // Nous balance block is agent-independent (a portal fetch), so it shows + // even with zero API calls or on a resumed session. Prefer the shared + // dollar usage model (two-bar view, dollars-only); fall back to the + // legacy text lines only when the model is unavailable. + const usageModel = r?.usage + const barLines = usageBarsText(usageModel) + let showedBalance = false - if (creditsLines.length) { - ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }]) + if (usageModel?.available && (barLines.length || usageModel.status === 'free')) { + const sections: PanelSection[] = [] + const plan = usageModel.plan_name ?? (usageModel.status === 'free' ? 'Free' : null) + + if (plan) { + sections.push({ text: `Plan: ${plan}${usageModel.renews_display ? ` · renews ${usageModel.renews_display}` : ''}` }) + } + + if (barLines.length) { + sections.push({ text: barLines.join('\n') }) + } + + if (usageModel.status === 'free') { + sections.push({ text: '> Free · free models only. Run /subscription to reach paid models.' }) + } else if (usageModel.status === 'low') { + sections.push({ + text: `! Low balance · ${usageModel.total_spendable_display ?? 'under $5'} left. Run /topup or /subscription.` + }) + } + + ctx.transcript.panel('Balance', sections) + showedBalance = true + } else { + const creditsLines = r?.credits_lines ?? [] + + if (creditsLines.length) { + ctx.transcript.panel('Nous balance', [{ text: creditsLines.join('\n') }]) + showedBalance = true + } } if (!r?.calls) { - if (!creditsLines.length) { - ctx.transcript.sys('no API calls yet') + if (!showedBalance) { + sys('no API calls yet') } + sys(USAGE_CTA) + return } @@ -618,6 +655,8 @@ export const sessionCommands: SlashCommand[] = [ } ctx.transcript.panel('Usage', sections) + + sys(USAGE_CTA) }) } } 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..98c8bdb0d2f3 --- /dev/null +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -0,0 +1,169 @@ +import type { + BillingMutationResponse, + BillingStateResponse, + SubscriptionPreviewResponse, + SubscriptionStateResponse, + SubscriptionUpgradeResponse +} from '../../../gatewayTypes.js' +import { openExternalUrl } from '../../../lib/openExternalUrl.js' +import type { SubscriptionOverlayCtx } from '../../interfaces.js' +import { patchOverlayState } from '../../overlayStore.js' +import type { SlashCommand, SlashRunCtx } from '../types.js' + +type Sys = (text: string) => void + +/** + * Build the manage-subscription URL locally from the loaded subscription state. + * + * Uses `portal_url` (the resolved portal base URL carried in the state) and + * `org_id` to construct `{portal_base}/manage-subscription?org_id=`. + * `org_id` pins the page to the correct account in multi-org situations. + * Falls back to bare `/manage-subscription` if org_id is absent. + */ +function buildManageUrl(s: SubscriptionStateResponse): string | null { + // portal_url is already an absolute URL resolved by resolve_portal_base_url() + // on the Python side (e.g. https://portal.nousresearch.com/billing). Strip any + // path so we can attach /manage-subscription cleanly. + let base: string | null = null + + if (s.portal_url) { + try { + base = new URL(s.portal_url).origin + } catch { + // A malformed portal_url must not throw out of the Ink key handler + // (it would crash the overlay) — treat it as "no manage URL". + return null + } + } + + if (!base) { + return null + } + + const url = new URL('/manage-subscription', base) + + if (s.org_id) { + url.searchParams.set('org_id', s.org_id) + } + + return url.toString() +} + +/** + * Build the ctx the overlay uses to talk to the gateway + emit transcript + * lines. Mirrors topup.ts's buildOverlayCtx — all RPC + error-mapping logic + * lives here (single source of truth); the overlay only renders + routes keys. + */ +const buildSubscriptionCtx = ( + ctx: SlashRunCtx, + sys: Sys, + initialState: SubscriptionStateResponse +): SubscriptionOverlayCtx => ({ + fetchCard: () => + ctx.gateway + .rpc('billing.state', {}) + .then(r => (r?.ok ? (r.card ?? null) : null)) + .catch(() => null), + openManageLink: () => { + const url = buildManageUrl(initialState) + + if (!url) { + sys('Could not build manage URL — is your portal configured?') + + return Promise.resolve(false) + } + + const opened = openExternalUrl(url) + + if (opened) { + sys('Opening your subscription page in the browser — finish there, then re-run /subscription.') + } else { + sys('Could not open browser — visit your subscription page manually at ' + url) + } + + return Promise.resolve(opened) + }, + openPortal: (url: string) => { + if (openExternalUrl(url)) { + sys('Opening the portal in your browser — finish there, then re-run /subscription.') + } else { + sys('Could not open browser — visit ' + url + ' to finish.') + } + }, + preview: tierId => + ctx.gateway + .rpc('subscription.preview', { subscription_type_id: tierId }) + .then(r => r ?? null) + .catch(() => null), + refreshState: () => + ctx.gateway + .rpc('subscription.state', {}) + .then(r => r ?? null) + .catch(() => null), + requestRemoteSpending: () => + ctx.gateway + .rpc('billing.step_up', { session_id: ctx.sid ?? undefined }) + // Carry the typed denial (session_revoked / remote_spending_revoked / + // rate_limited / …) so the stepup screen shows the right recovery. + .then(r => ({ error: r?.error, granted: !!(r && r.ok && r.granted), message: r?.message })) + .catch(() => ({ granted: false, message: 'Could not reach the billing service — check your connection, then retry.' })), + resume: () => + ctx.gateway + .rpc('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[] = [ + { + help: 'View or change your Nous subscription plan', + name: 'subscription', + aliases: ['upgrade'], + // ZERO sub-commands: bare `/subscription` fetches state and opens the + // overlay's in-terminal change flow (only /upgrade's charge_now confirm + // moves money, via the V3 upgrade route). + run: (_arg, ctx) => { + const sys: Sys = ctx.transcript.sys + + ctx.gateway + .rpc('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, s), + screen: 'overview', + state: s + } + }) + }) + ) + .catch(ctx.guardedErr) + } + } +] diff --git a/ui-tui/src/app/slash/commands/billing.ts b/ui-tui/src/app/slash/commands/topup.ts similarity index 50% rename from ui-tui/src/app/slash/commands/billing.ts rename to ui-tui/src/app/slash/commands/topup.ts index 5bcb7a38ac75..ae0b7b9f3130 100644 --- a/ui-tui/src/app/slash/commands/billing.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -6,13 +6,14 @@ import type { BillingStateResponse } from '../../../gatewayTypes.js' import { openExternalUrl } from '../../../lib/openExternalUrl.js' -import type { BillingOverlayCtx } from '../../interfaces.js' +import type { BillingChargeOutcome, BillingOverlayCtx } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' import type { SlashCommand, SlashRunCtx } from '../types.js' // Poll cadence (plan §5, frozen): 2s interval, 5-minute cap. const POLL_INTERVAL_MS = 2000 const POLL_CAP_MS = 5 * 60 * 1000 +const UNCONFIRMED_CHARGE_MESSAGE = '🟡 Your last charge’s outcome is unconfirmed — check your balance/history before retrying.' type Sys = (text: string) => void @@ -21,10 +22,13 @@ const renderBillingError = ( sys: Sys, ctx: SlashRunCtx, env: { + actor?: string + code?: string error?: string message?: string payload?: BillingErrorPayload portal_url?: string | null + recovery?: string retry_after?: number | null } ): void => { @@ -32,9 +36,72 @@ const renderBillingError = ( switch (env.error) { case 'insufficient_scope': - armStepUp(sys, ctx) + // Reached by non-charge mutations (e.g. auto-reload config) that need + // terminal billing enabled. The resumable step-up lives on the buy/charge + // path; point the user there rather than leaking the raw scope name. + sys('This needs terminal billing enabled. Start a top-up to enable it, then retry.') + + break + case 'remote_spending_revoked': { + // CF-4: this terminal's spend was revoked. Kill the spend UI NOW (don't + // wait for the token refresh ~15 min away) and tell the user who did it. + patchOverlayState({ billing: null }) + + const who = + env.actor === 'admin' + ? 'An admin turned off terminal billing for this terminal.' + : 'You turned off terminal billing for this terminal.' + + sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`) return + } + + case 'session_revoked': + // Stronger than a spend-revoke: the whole session is gone → full re-login. + patchOverlayState({ billing: null }) + sys('Your session was logged out. Run /portal to log in again.') + + return + + case 'cli_billing_disabled': + + case 'remote_spending_disabled': + // Account-wide switch is OFF (dual-emitted error/code). An admin must flip + // it on the portal; this is NOT a per-terminal revoke. + sys('Terminal billing is off for this account — an admin must enable it on the portal.') + + break + + case 'role_required': + sys('Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.') + + break + + case 'consent_required': + sys('This action needs a one-time card confirmation and consent step on the portal before it can proceed.') + + break + + case 'org_access_denied': + sys("This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.") + + break + + case 'upgrade_cap_exceeded': + sys('🔴 Daily plan-change limit reached (5 per org) — try again tomorrow, or manage this on the portal.') + + break + + case 'auto_top_up_disabled_failures': + sys('Auto-reload was turned off after repeated charge failures. Fix the card issue, then re-enable it from /topup → Auto-reload.') + + break + + case 'idempotency_conflict': + sys('🔴 That charge key was already used for a different amount. Start a fresh top-up.') + + break case 'no_payment_method': sys( @@ -42,11 +109,6 @@ const renderBillingError = ( "(one-time credit buys don't save a reusable card)." ) - break - - case 'cli_billing_disabled': - sys('🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.') - break case 'monthly_cap_exceeded': { // Surface the remaining headroom the server attaches (parity with the CLI). @@ -60,13 +122,23 @@ const renderBillingError = ( break } - case 'rate_limited': { + case 'rate_limited': + case 'temporarily_unavailable': { + // 429 throttle OR 503 gate-fail-closed: NOT a payment failure, NOT a + // revoke. Back off and tell the user to retry. const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : '' sys(`🟡 Too many charges right now${mins}. This isn't a payment failure.`) break } + case 'stripe_unavailable': { + const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : '' + sys(`🟡 Stripe is having trouble right now — try again shortly${mins}.`) + + break + } + default: sys(`🔴 ${env.message || env.error || 'Billing request failed.'}`) } @@ -76,71 +148,46 @@ const renderBillingError = ( } } -/** 403 insufficient_scope → arm a ConfirmReq that runs the lazy step-up. */ -const armStepUp = (sys: Sys, ctx: SlashRunCtx): void => { - sys('💳 Terminal billing needs an extra permission (billing:manage).') - patchOverlayState({ - confirm: { - cancelLabel: 'Not now', - confirmLabel: 'Re-authorize', - detail: 'An org admin/owner must tick "Allow terminal billing" in the portal.', - onConfirm: () => { - // session_id lets the gateway route the billing.step_up.verification - // event (the verification link) back to this session — the device flow - // runs headless in the gateway, so the link can't be printed there. - ctx.gateway - .rpc('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 => { const start = Date.now() + // The 5-min cap, honored on EVERY non-terminal path (pending AND throttled) + // so a sustained 429/503 can't keep the poll alive forever. + const timedOut = (): boolean => { + if (Date.now() - start < POLL_CAP_MS) { + return false + } + + sys( + '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + 'Check /topup or the portal shortly.' + ) + + if (portalUrl) { + sys(`Portal: ${portalUrl}`) + } + + return true + } + const tick = (): void => { if (ctx.stale()) { return @@ -152,13 +199,27 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st ctx.guarded(r => { if (!r.ok) { // 429/503 while polling = retry-after, NOT a failure. Back off + continue. - if (r.error === 'rate_limited') { + if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable' || r.error === 'stripe_unavailable') { + if (timedOut()) { + return + } + const wait = (r.retry_after ?? 5) * 1000 setTimeout(tick, Math.min(wait, 30000)) return } + // CF-7 rule 4: a post-revoke 403 (or session loss) while polling means + // the prior charge's outcome is AMBIGUOUS — it may have settled. Do not + // call it failed; surface the revoke + tell the user to verify balance. + if (r.error === 'remote_spending_revoked' || r.error === 'session_revoked') { + renderBillingError(sys, ctx, r) + sys(UNCONFIRMED_CHARGE_MESSAGE) + + return + } + sys(`🔴 Could not check the charge: ${r.message || r.error || 'error'}`) return @@ -177,23 +238,20 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st } // pending → keep polling until the 5-min cap, then call it a timeout. - if (Date.now() - start >= POLL_CAP_MS) { - sys( - '🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + - 'Check /billing or the portal shortly.' - ) - - if (portalUrl) { - sys(`Portal: ${portalUrl}`) - } - + if (timedOut()) { return } setTimeout(tick, POLL_INTERVAL_MS) }) ) - .catch(ctx.guardedErr) + .catch(e => { + ctx.guardedErr(e) + + if (!ctx.stale()) { + sys(UNCONFIRMED_CHARGE_MESSAGE) + } + }) } tick() @@ -216,6 +274,11 @@ const renderChargeFailed = (sys: Sys, reason?: string | null, portalUrl?: string break + case 'processing_error': + sys("🔴 The charge didn't go through (processing_error).") + + break + default: sys(`🔴 The charge didn't go through (${reason || 'processing_error'}).`) } @@ -280,34 +343,60 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B return false }), - charge: (amount: string) => { + charge: (amount: string, idempotencyKey?: string): Promise => { sys('💳 Charge submitted — confirming settlement…') - 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) + + return ctx.gateway + .rpc('billing.charge', { + amount_usd: amount, + ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}) + }) + .then((r): BillingChargeOutcome => { + if (!r) { + return 'error' + } + + if (r.ok && r.charge_id) { + pollCharge(sys, ctx, r.charge_id, s.portal_url) + + return 'submitted' + } + + // insufficient_scope → the overlay routes to the resumable step-up + // (no error line here; the stepup screen owns that UX). + if (r.error === 'insufficient_scope') { + return 'needs_remote_spending' + } + + renderBillingError(sys, ctx, r) + + return 'error' + }) + .catch((e): BillingChargeOutcome => { + ctx.guardedErr(e) + + return 'error' + }) }, + requestRemoteSpending: () => requestRemoteSpending(ctx), openPortal: (url: string) => { openExternalUrl(url) sys(`Opening portal: ${url}`) }, + refreshState: () => + ctx.gateway + .rpc('billing.state', {}) + .then(r => (r?.ok ? r : null)) + .catch(() => null), sys, validate: (raw: string) => validateAmount(raw, s) }) -export const billingCommands: SlashCommand[] = [ +export const topupCommands: SlashCommand[] = [ { - help: 'Manage Nous terminal billing — buy credits, auto-reload, limits', - name: 'billing', - // ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/billing` + help: 'Show your balance and manage billing — add funds, auto-reload, limits', + name: 'topup', + // ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/topup` // fetches state and opens the interactive overlay (CLI/TUI parity). run: (_arg, ctx) => { const sys: Sys = ctx.transcript.sys @@ -317,7 +406,7 @@ export const billingCommands: SlashCommand[] = [ .then( ctx.guarded(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 64759593711a..f87d53f9bfe7 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -1,17 +1,17 @@ -import { billingCommands } from './commands/billing.js' import { coreCommands } from './commands/core.js' -import { creditsCommands } from './commands/credits.js' import { debugCommands } from './commands/debug.js' import { opsCommands } from './commands/ops.js' import { sessionCommands } from './commands/session.js' import { setupCommands } from './commands/setup.js' +import { subscriptionCommands } from './commands/subscription.js' +import { topupCommands } from './commands/topup.js' import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [ ...coreCommands, - ...billingCommands, - ...creditsCommands, + ...topupCommands, ...sessionCommands, + ...subscriptionCommands, ...opsCommands, ...setupCommands, ...debugCommands diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 3461cd241a88..e65a2e3ed8e8 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -195,6 +195,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ billing: null }) } + if (overlay.subscription) { + return patchOverlayState({ subscription: null }) + } + if (overlay.skillsHub) { return patchOverlayState({ skillsHub: false }) } @@ -324,7 +328,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { // answering felt like the prompt had locked the entire UI. Explicitly // skip the prompt-overlay early-return for scroll keys so they fall // through to the wheel / PageUp / Shift+arrow handlers below. - const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm + const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm || overlay.subscription const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key) if (promptOverlay && !fallThroughForScroll) { diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 24b45d33e248..b58a114f6f23 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -16,6 +16,7 @@ import { PetPicker } from './petPicker.js' import { PluginsHub } from './pluginsHub.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SkillsHub } from './skillsHub.js' +import { SubscriptionOverlay } from './subscriptionOverlay.js' const COMPLETION_WINDOW = 16 @@ -52,6 +53,21 @@ export function PromptZone({ ) } + if (overlay.subscription) { + const current = overlay.subscription + + const onPatch = (next: Partial) => + patchOverlayState(prev => (prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev)) + + const onClose = () => patchOverlayState({ subscription: null }) + + return ( + + + + ) + } + if (overlay.confirm) { const req = overlay.confirm diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 6fbe9cddc5f7..326f159db698 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -1,14 +1,15 @@ +import { randomUUID } from 'node:crypto' + import { Box, Text, useInput } from '@hermes/ink' -import { useState } from 'react' +import { useRef, useState } from 'react' import type { BillingOverlayState } from '../app/interfaces.js' import type { BillingStateResponse } from '../gatewayTypes.js' import type { Theme } from '../theme.js' +import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js' import { TextInput } from './textInput.js' -const SPEND_BAR_CELLS = 10 - interface BillingOverlayProps { /** Replace the overlay slot (screen transitions + pending data). */ onPatch: (next: Partial) => void @@ -18,54 +19,6 @@ interface BillingOverlayProps { t: Theme } -/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */ -function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) { - return ( - - - {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 - - if (!cap || cap.limit_usd == null) { - return null - } - - const limit = Number(cap.limit_usd) - const spent = Number(cap.spent_this_month_usd ?? '0') - - if (!(limit > 0) || Number.isNaN(spent)) { - return null - } - - const ratio = Math.max(0, Math.min(1, spent / limit)) - const filled = Math.round(ratio * SPEND_BAR_CELLS) - const bar = '█'.repeat(filled) + '░'.repeat(SPEND_BAR_CELLS - filled) - const pct = Math.round(ratio * 100) - const ceiling = cap.is_default_ceiling ? ' (default ceiling)' : '' - - return `${cap.spent_display} of ${cap.limit_display} used ${bar} ${pct}%${ceiling}` -} - function autoReloadLine(s: BillingStateResponse): null | string { if (!s.auto_reload) { return null @@ -76,8 +29,6 @@ function autoReloadLine(s: BillingStateResponse): null | string { : 'Auto-reload: off' } -const footer = (extra: string, t: Theme) => {extra} - /** * The /billing modal. A self-contained state machine: * overview → buy | autoreload | limit (and buy → confirm). @@ -96,14 +47,25 @@ export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayP onPatch({ pendingCharge: null, screen: 'buy' })} onClose={onClose} + onPatch={onPatch} s={s} t={t} /> )} {screen === 'autoreload' && } {screen === 'limit' && } + {screen === 'stepup' && ( + + )} ) } @@ -119,27 +81,29 @@ interface ScreenProps { } function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { - // Gate: full menu only for an admin with the kill-switch on. Otherwise the - // menu collapses to Manage-on-portal / Cancel + a one-line note. + // Full charge menu only for an admin with the org kill-switch on; otherwise it + // collapses to Manage-on-portal / Close + a one-line note. NOTE: this is the + // ORG-level gate (cli_billing_enabled), NOT the per-terminal billing scope — + // that's discovered reactively at pay time (a charge 403s insufficient_scope + // and the confirm screen routes into the resumable step-up). We deliberately + // do NOT preflight the scope here. const full = s.is_admin && s.cli_billing_enabled const note = !s.is_admin - ? 'Billing actions need an org admin/owner.' + ? 'Billing actions need someone with billing permissions (owner, admin, or finance admin).' : !s.cli_billing_enabled - ? 'Terminal billing is off for this org — enable it on the portal.' + ? 'Terminal billing is off for this org — manage it on the portal.' : null - // Optimistic funnel: admin + kill-switch on but no saved card → a charge will - // 403 no_payment_method. Advise up front (Buy stays available — /state.card - // can't fully prove CLI-chargeability, so we hint rather than hide). - const cardHint = full && !s.card ? 'No saved card for terminal charges yet — set one up on the portal first.' : null - + // Always show the full billing menu for an admin/billing-on org — a missing + // card does NOT mean nothing can be done (the org may already have balance, + // auto-reload, a limit). The card only matters at CHARGE time: with no card + // on file, "Add funds" opens the guided add-card path (portal + check-again) + // instead of an amount picker that would 403 no_payment_method. const items = full - ? ['Buy credits', 'Adjust auto-reload', 'Adjust monthly limit', 'Manage on portal', 'Cancel'] + ? ['Add funds', 'Auto-reload', 'Monthly limit', 'Manage on portal', 'Cancel'] : ['Manage on portal', 'Cancel'] - const [sel, setSel] = useState(0) - const choose = (i: number) => { if (full) { if (i === 0) { @@ -148,76 +112,59 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { onPatch({ screen: 'autoreload' }) } else if (i === 2) { onPatch({ screen: 'limit' }) - } else if (i === 3) { - if (s.portal_url) { + } else { + if (i === 3 && s.portal_url) { ctx.openPortal(s.portal_url) } onClose() - } else { - onClose() - } - } else { - if (i === 0 && s.portal_url) { - ctx.openPortal(s.portal_url) } - onClose() + return } + + if (i === 0 && s.portal_url) { + ctx.openPortal(s.portal_url) + } + + onClose() } - useInput((ch, key) => { - if (key.escape) { - return onClose() - } + const rows: MenuRowSpec[] = items.map((label, i) => ({ label, run: () => choose(i) })) + const sel = useMenu(rows, onClose) - if (key.upArrow && sel > 0) { - setSel(v => v - 1) - } - - if (key.downArrow && sel < items.length - 1) { - setSel(v => v + 1) - } - - if (key.return) { - return choose(sel) - } - - const n = parseInt(ch, 10) - - if (n >= 1 && n <= items.length) { - return choose(n - 1) - } - }) - - const bar = spendBar(s) const auto = autoReloadLine(s) + // Balance leads, in the title — the first thing seen (review feedback). + const title = `Top up · balance ${s.balance_display}` return ( - Usage 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}} + {/* 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.'} + + )} {note && ( {note} )} - {cardHint && ( - - {cardHint} - - )} - {cardHint && s.portal_url && Portal: {s.portal_url}} {items.map((label, i) => ( @@ -235,17 +182,60 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { const presets = s.charge_presets_display const rawPresets = s.charge_presets - // rows: [...presets, 'Custom amount…', 'Cancel'] - const rows = [...presets, 'Custom amount…', 'Cancel'] + // No card on file → the buy screen becomes the ADD-CARD path: cards are added + // on the portal (never in-terminal), and "check again" re-fetches state so the + // flow continues right here once the card is saved. Card present → the normal + // preset menu. (The card display is best-effort server-side, so "check again" + // also recovers a transient miss.) + const noCard = !s.card + + const rows = noCard + ? ['Add a card on the portal', 'I’ve added it — check again', 'Back'] + : [...presets, 'Custom amount…', 'Cancel'] + const customIdx = presets.length const [sel, setSel] = useState(0) const [typing, setTyping] = useState(false) const [custom, setCustom] = useState('') const [error, setError] = useState(null) + const [checking, setChecking] = useState(false) + // Synchronous guard: double-Enter on "check again" must not stack re-fetches. + const checkingRef = useRef(false) + + const recheck = () => { + if (checkingRef.current) { + return + } + + checkingRef.current = true + setChecking(true) + void ctx.refreshState().then(fresh => { + checkingRef.current = false + setChecking(false) + + if (!fresh) { + return setError('Could not refresh billing state — try again in a moment.') + } + + setError(null) + // Re-render with the fresh state: if the card is now on file, this same + // screen flips into the preset menu and the purchase continues here. + onPatch({ state: fresh }) + + if (fresh.card) { + ctx.sys(`✓ Card found: ${fresh.card.display ?? fresh.card.masked} — pick an amount.`) + } else { + ctx.sys('Still no card on file — finish adding it on the portal, then check again.') + } + }) + } const toConfirm = (amount: string) => { - onPatch({ pendingCharge: { amount }, screen: 'confirm' }) + // Mint the idempotency key here (purchase identity = this amount). It rides + // pendingCharge into Confirm AND the step-up replay, so a retried charge + // dedups server-side; a fresh amount selection gets a fresh key. + onPatch({ pendingCharge: { amount, idempotencyKey: randomUUID() }, screen: 'confirm' }) } const pickPreset = (i: number) => { @@ -275,6 +265,25 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { } const choose = (i: number) => { + if (noCard) { + if (i === 0) { + if (s.portal_url) { + ctx.openPortal(s.portal_url) + ctx.sys('Add a card on the billing page, then come back and pick “check again”.') + } else { + setError('Could not build the portal link — is your portal configured?') + } + + return + } + + if (i === 1) { + return recheck() + } + + return onPatch({ screen: 'overview' }) + } + if (i < presets.length) { pickPreset(i) } else if (i === customIdx) { @@ -303,7 +312,7 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { } if (key.return) { - return choose(sel) + return choose(Math.min(sel, rows.length - 1)) } const n = parseInt(ch, 10) @@ -313,13 +322,16 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { } }) - const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file' + // sel can go stale when a refresh flips the row set (3 add-card rows ↔ N + // preset rows) — clamp for render + Enter. + const cSel = Math.min(sel, rows.length - 1) + const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file' if (typing) { return ( - Buy usage credits + Add funds {payLine} @@ -335,15 +347,34 @@ 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 ( - Buy usage credits + Add funds {payLine} {rows.map((label, i) => ( - + ))} {error && {error}} @@ -357,25 +388,50 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) { function ConfirmScreen({ amount, ctx, + idempotencyKey, onBack, onClose, + onPatch, s, t }: { amount: string ctx: BillingOverlayState['ctx'] + idempotencyKey?: string onBack: () => void onClose: () => void + onPatch: (next: Partial) => void s: BillingStateResponse t: Theme }) { // rows: Pay $X now / Cancel const [sel, setSel] = useState(0) + const [submitting, setSubmitting] = useState(false) + // Synchronous guard: two key events can both observe `submitting === false` + // before React commits the state update, double-firing the charge (and the + // gateway mints a fresh idempotency key per call → two charges). + const submittingRef = useRef(false) const pay = () => { - ctx.charge(amount) - // Settlement is reported via transcript lines; close the overlay now. - onClose() + if (submittingRef.current || submitting) { + return + } + + submittingRef.current = true + setSubmitting(true) + void ctx.charge(amount, idempotencyKey).then(outcome => { + if (outcome === 'needs_remote_spending') { + // Resumable step-up: keep the modal MOUNTED, switch to the stepup + // screen (which holds pendingCharge.amount for the post-grant replay). + onPatch({ screen: 'stepup' }) + + return + } + + // submitted (settlement reported via transcript) or error (already + // surfaced) → close the overlay. The transcript carries the outcome. + onClose() + }) } const back = () => onBack() @@ -408,7 +464,7 @@ function ConfirmScreen({ } }) - const payLine = s.card ? `Payment: ${s.card.masked}` : 'No saved card on file' + const payLine = s.card ? `Payment: ${s.card.display ?? s.card.masked}` : 'No saved card on file' return ( @@ -417,6 +473,9 @@ function ConfirmScreen({ Total: ${amount} {payLine} + {/* 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.} By confirming, you allow Nous Research to charge your card. @@ -427,11 +486,201 @@ function ConfirmScreen({ ) } +// ── Screen: Step-up (resumable "Enable terminal billing") ──────────── +// Reached ONLY when a charge returns insufficient_scope — there is no preflight +// or scope check anywhere; the buy path discovers it reactively. The modal stays +// MOUNTED through the browser device-flow: +// prompt (heads-up) → waiting (browser authorize) → granted (press Enter to +// resume) → replay the held charge (pendingCharge.amount) → settle → close. +// Never leaks the raw billing:manage scope — the user-facing concept is +// "terminal billing". + +function StepUpScreen({ + amount, + ctx, + idempotencyKey, + onClose, + t +}: { + amount: string + ctx: BillingOverlayState['ctx'] + idempotencyKey?: string + onClose: () => void + t: Theme +}) { + const [sel, setSel] = useState(0) + const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt') + + const allow = () => { + if (phase !== 'prompt') { + return + } + + setPhase('waiting') + ctx.sys('Opening your browser to enable terminal billing…') + + void ctx.requestRemoteSpending().then(granted => { + if (!granted) { + ctx.sys( + "! Couldn't enable terminal billing — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged." + ) + onClose() + + return + } + + // Granted → hold here and wait for an explicit Enter to resume the held + // purchase (the reassuring "you're back, press Enter" beat). + setPhase('granted') + }) + } + + const resume = () => { + if (phase !== 'granted') { + return + } + + setPhase('resuming') + ctx.sys('✓ Terminal billing enabled — resuming your purchase.') + void ctx.charge(amount, idempotencyKey).then(outcome => { + // If the replay STILL can't spend (grant raced/expired or downscoped), + // say so — don't close on a reassuring line with no charge made. + if (outcome === 'needs_remote_spending') { + ctx.sys('! Terminal billing still needs approval — run /topup to try again. Your card was not charged.') + } + + onClose() + }) + } + + const decline = () => { + ctx.sys('No charge made. Run /topup when you want to enable terminal billing.') + onClose() + } + + useInput((ch, key) => { + if (phase === 'waiting' || phase === 'resuming') { + // While the device flow / replay runs, only Esc (give up) is live. + if (key.escape) { + onClose() + } + + return + } + + if (phase === 'granted') { + // Back from the browser — Enter resumes, Esc abandons. + if (key.escape) { + return onClose() + } + + if (key.return) { + return resume() + } + + return + } + + // phase === 'prompt' + if (key.escape) { + return decline() + } + + const lower = ch.toLowerCase() + + if (lower === 'y') { + return allow() + } + + if (lower === 'n') { + return decline() + } + + if (key.upArrow) { + setSel(0) + } + + if (key.downArrow) { + setSel(1) + } + + if (key.return) { + return sel === 0 ? allow() : decline() + } + }) + + if (phase === 'waiting') { + return ( + + + 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 + + Resuming your ${amount} top-up… + + {footer('Esc cancel', t)} + + ) + } + + // phase === 'prompt' — the one heads-up, triggered only by the 403. + return ( + + + 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. + + + + + + {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) { const ar = s.auto_reload const enabled = Boolean(ar?.enabled) + const distinctCard = ar?.card.kind === 'distinct' ? ar.card : null + const distinctCardName = distinctCard + ? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') || 'a different card' + : null + const manageCardLabel = 'Use your card on file — manage on portal' // Prefill from state (strip the $ from the *_usd raw fields if present). const prefill = (raw?: null | string) => (raw == null ? '' : String(raw).replace(/^\$/, '').trim()) @@ -440,7 +689,15 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const [field, setField] = useState<'reloadTo' | 'threshold'>('threshold') const [error, setError] = useState(null) // 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) @@ -476,7 +733,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const turnOn = () => { if (noCard) { - ctx.sys('🔴 No saved card — set one up on the portal first.') + ctx.sys('🔴 No saved card — manage billing on the portal.') if (s.portal_url) { ctx.openPortal(s.portal_url) @@ -502,7 +759,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { } const turnOff = () => { - void ctx.applyAutoReload(false).then(ok => { + // The PATCH requires threshold/top_up_amount even when disabling (parity + // with the CLI's _billing_auto_reload_disable) — echo the current values, + // else the gateway rejects with invalid_request and auto-reload stays ON. + const thr = Number(prefill(ar?.threshold_usd)) || 0 + const rel = Number(prefill(ar?.reload_to_usd)) || 0 + void ctx.applyAutoReload(false, thr, rel).then(ok => { if (ok) { ctx.sys('✅ Auto-reload turned off.') } @@ -515,6 +777,12 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { turnOn() } else if (label === 'Turn off') { turnOff() + } else if (label === manageCardLabel) { + if (s.portal_url) { + ctx.openPortal(s.portal_url) + } + + onClose() } else { onPatch({ screen: 'overview' }) } @@ -561,6 +829,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { }) const cardLine = s.card ? `Card on file: ${s.card.masked}` : 'No saved card on file' + const chargeCardName = distinctCardName ?? (s.card ? s.card.masked : 'your card') const fieldBox = (label: string, value: string, onChange: (v: string) => void, focused: boolean, key: string) => ( @@ -592,22 +861,25 @@ 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} + {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) => ( { if (i === 0 && s.portal_url) { @@ -635,29 +906,8 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { onPatch({ screen: 'overview' }) } - useInput((ch, key) => { - if (key.escape) { - return onPatch({ screen: 'overview' }) - } - - if (key.upArrow && sel > 0) { - setSel(v => v - 1) - } - - if (key.downArrow && sel < rows.length - 1) { - setSel(v => v + 1) - } - - if (key.return) { - return choose(sel) - } - - const n = parseInt(ch, 10) - - if (n >= 1 && n <= rows.length) { - return choose(n - 1) - } - }) + const rows: MenuRowSpec[] = labels.map((label, i) => ({ label, run: () => choose(i) })) + const sel = useMenu(rows, () => onPatch({ screen: 'overview' })) const cap = s.monthly_cap @@ -674,11 +924,11 @@ function LimitScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { {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 new file mode 100644 index 000000000000..8a3b23f83428 --- /dev/null +++ b/ui-tui/src/components/overlayPrimitives.tsx @@ -0,0 +1,186 @@ +import type { Key } from '@hermes/ink' +import { Text, useInput } from '@hermes/ink' +import { type ReactNode, useState } from 'react' + +import type { UsageModelData } from '../gatewayTypes.js' +import type { Theme } from '../theme.js' + +export interface MenuRowSpec { + color?: string + label: string + run: () => void +} + +/** + * ↑/↓ + Enter + number-key selection over `rows`; Esc runs `onEscape`. + * `onKey`, when given, runs first on every keypress — return `true` to mark + * the key fully handled and skip the default escape/arrow/enter/number + * handling for that keypress (e.g. a screen with a text-input sub-mode). + */ +export function useMenu( + rows: MenuRowSpec[], + onEscape: () => void, + onKey?: (ch: string, key: Key) => boolean +): number { + const [sel, setSel] = useState(0) + + useInput((ch, key) => { + if (onKey?.(ch, key)) { + return + } + + if (key.escape) { + return onEscape() + } + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + } + + if (key.downArrow && sel < rows.length - 1) { + setSel(v => v + 1) + } + + if (key.return) { + return rows[sel]?.run() + } + + const n = parseInt(ch, 10) + + if (n >= 1 && n <= rows.length) { + return rows[n - 1]?.run() + } + }) + + return Math.min(sel, Math.max(0, rows.length - 1)) +} + +/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */ +export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) { + return ( + + + {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) } +} + +/** + * 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 new file mode 100644 index 000000000000..22443f7ec7f5 --- /dev/null +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -0,0 +1,930 @@ +import { randomUUID } from 'node:crypto' + +import { Box, Text, useInput } from '@hermes/ink' +import { useEffect, useRef, useState } from 'react' + +import type { + SubscriptionOverlayState, + SubscriptionPendingChange, + SubscriptionResult, + SubscriptionStepUpRetry +} from '../app/interfaces.js' +import type { + SubscriptionStateResponse, + SubscriptionTierOption, + SubscriptionUpgradeResponse +} from '../gatewayTypes.js' +import type { Theme } from '../theme.js' + +import { ActionRow, footer, MenuRow, type MenuRowSpec, UsageBars, useMenu } from './overlayPrimitives.js' + +const UPGRADE_CONFIRM_INTERVAL_MS = 2000 +const UPGRADE_CONFIRM_ATTEMPTS = 15 + +interface SubscriptionOverlayProps { + /** Close the overlay entirely. */ + onClose: () => void + /** Merge a partial into the overlay state (screen transitions + pending/result). */ + onPatch: (next: Partial) => void + overlay: SubscriptionOverlayState + t: Theme +} + +/** + * The /subscription modal — an in-terminal plan-change flow (V3). A small state + * machine: overview → picker → confirm → result, with a stepup screen spliced in + * when a mutation needs terminal billing. Downgrades / cancellations / resume are + * chargeless; an upgrade charges the card on the subscription, and an SCA/decline + * is handed off to the portal. Starting a NEW subscription still deep-links (needs + * a fresh card). All RPCs live in subscription.ts, reached via `overlay.ctx`. + */ +export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) { + const { screen, state: s } = overlay + + // Teams have no personal subscription — dead-end to /topup, no picker. + if (s.context === 'team') { + return ( + + + + ) + } + + return ( + + {screen === 'picker' && } + {screen === 'confirm' && } + {screen === 'result' && } + {screen === 'stepup' && } + {screen === 'overview' && } + + ) +} + +// ── Shared helpers ─────────────────────────────────────────────────── + +interface ScreenProps { + onClose: () => void + onPatch: (next: Partial) => void + overlay: SubscriptionOverlayState + t: Theme +} + +/** ISO datetime → YYYY-MM-DD for display, or a soft fallback. */ +function shortDate(iso?: null | string): string { + return iso && iso.length >= 10 ? iso.slice(0, 10) : 'the end of the billing period' +} + +/** Integer cents → "$X.YY", or null when no amount is quoted. */ +function centsDisplay(cents?: null | number): null | string { + return typeof cents === 'number' ? `$${(cents / 100).toFixed(2)}` : null +} + +/** True when a response is the insufficient_scope denial (route to step-up). */ +function isScopeDenial(r: { error?: string; ok?: boolean } | null): boolean { + return !!r && !r.ok && r.error === 'insufficient_scope' +} + +/** + * Map a failed RPC envelope to a result. (insufficient_scope is intercepted + * earlier and routed to the step-up screen, so it should not reach here.) + */ +function errorResult(r: { error?: string; message?: string; portal_url?: null | string } | null): SubscriptionResult { + return { + message: r?.message || r?.error || 'Something went wrong. Try again, or manage on the portal.', + ok: false, + recoveryUrl: r?.portal_url ?? null + } +} + +/** Map a chargeless pending-change mutation (schedule / cancel / resume). */ +function mutationResult(r: null | { message?: string; ok?: boolean }, okMessage: string): SubscriptionResult { + return r?.ok ? { message: r.message || okMessage, ok: true } : errorResult(r) +} + +/** Map an upgrade response, routing SCA / decline to a portal recovery. */ +function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: null | string): SubscriptionResult { + if (!r) { + // null = a transport failure (WS drop / request timeout) on the CHARGING + // route — NAS may have already prorated + charged. Report it as ambiguous and + // steer to a safe re-check, never a blind retry (which #2's dedup can't cover + // once the key is lost). + return { + message: + 'Couldn’t confirm the upgrade — your card may or may not have been charged. Re-run /subscription to check your plan before trying again.', + ok: false + } + } + + if (r.reason === 'authentication_required' || r.reason === 'subscription_payment_intent_requires_action') { + return { + message: 'Please verify your card in the portal to finish this upgrade.', + ok: false, + recoveryUrl: r.recovery_url ?? null + } + } + + if (r.reason === 'card_declined') { + return { + message: 'Your card was declined — try a different card on the portal.', + ok: false, + recoveryUrl: r.recovery_url ?? null + } + } + + if (r.ok && r.status === 'already_on_tier') { + return { + message: `You are already on ${r.target_tier_name ?? 'this plan'}.`, + ok: true + } + } + + if (r.ok && r.status === 'upgraded') { + return { + message: `Upgraded to ${r.target_tier_name ?? 'your new plan'}. Your new monthly credits land in a moment.`, + ok: true, + pendingTierId: pendingTierId ?? null + } + } + + if (r.status === 'requires_action') { + return { + message: 'This upgrade needs extra verification (3DS). Finish it on the portal.', + ok: false, + recoveryUrl: r.recovery_url ?? null + } + } + + if (r.status === 'payment_failed') { + return { + message: 'Your card was declined. Update your payment method on the portal and try again.', + ok: false, + recoveryUrl: r.recovery_url ?? null + } + } + + return errorResult(r) +} + +/** Map a failed terminal-billing step-up to the right recovery copy (typed). */ +function stepUpDenialResult(res: { error?: string; message?: string }): SubscriptionResult { + if (res.error === 'session_revoked') { + return { message: 'Your session expired — run /portal to log in again, then retry the change.', ok: false } + } + + if (res.error === 'remote_spending_revoked') { + return { message: res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.', ok: false } + } + + if (res.error === 'rate_limited') { + return { message: 'Too many attempts — wait a moment, then try again.', ok: false } + } + + return { + message: + res.message || 'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.', + ok: false + } +} + +// ── Scope-aware routing (shared by the picker, confirm, overview + step-up) ── + +// A REPEAT scope denial during a post-grant replay must NOT route back to the +// stepup screen: we're already mounted there (in the 'resuming' phase), so an +// onPatch({screen:'stepup'}) is a no-op that never remounts → the screen freezes. +// Post-grant replays pass allowStepUp=false and surface this instead (mirrors the +// CLI's allow_stepup=False cap). +const scopeStillDeniedResult: SubscriptionResult = { + message: 'Terminal billing still isn’t enabled for this org — enable it on the portal, then retry.', + ok: false +} + +/** Preview a tier and route: confirm (ok), stepup (scope), or result (other error). */ +function previewAndRoute( + ctx: SubscriptionOverlayState['ctx'], + tierId: string, + onPatch: ScreenProps['onPatch'], + allowStepUp = true +): Promise { + return ctx.preview(tierId).then(p => { + if (!p) { + return onPatch({ result: { message: 'Could not preview that change.', ok: false }, screen: 'result' }) + } + + if (!p.ok) { + if (isScopeDenial(p)) { + return allowStepUp + ? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'preview', tierId } }) + : onPatch({ result: scopeStillDeniedResult, screen: 'result' }) + } + + return onPatch({ result: errorResult(p), screen: 'result' }) + } + + // charge_now ⇒ an upgrade (charges now); everything else schedules at period + // end. blocked/no_op still go to confirm, which shows why + no apply. + const kind = p.effect === 'charge_now' ? 'upgrade' : 'tier_change' + + // Mint the upgrade idempotency key HERE so it rides `pending` into confirm AND + // the step-up replay — a re-submit / post-grant replay dedups server-side + // (mirrors billingOverlay's pendingCharge.idempotencyKey). + const pending: SubscriptionPendingChange = + kind === 'upgrade' + ? { idempotencyKey: randomUUID(), kind, preview: p, targetTierId: tierId } + : { kind, preview: p, targetTierId: tierId } + + onPatch({ pending, screen: 'confirm' }) + }) +} + +/** Apply the confirmed pending change and route: result (ok/err) or stepup (scope). */ +function applyPendingAndRoute( + ctx: SubscriptionOverlayState['ctx'], + pending: null | SubscriptionPendingChange, + onPatch: ScreenProps['onPatch'], + allowStepUp = true +): Promise { + if (!pending) { + // Nothing to apply (defensive) — return to the overview rather than stranding. + onPatch({ screen: 'overview' }) + + return Promise.resolve() + } + + const toStepUp = () => + allowStepUp + ? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'apply' } }) + : onPatch({ result: scopeStillDeniedResult, screen: 'result' }) + + const finish = (result: SubscriptionResult) => onPatch({ result, screen: 'result' }) + + if (pending.kind === 'cancellation') { + return ctx.scheduleCancellation().then(r => + isScopeDenial(r) + ? toStepUp() + : finish(mutationResult(r, 'Scheduled — your plan stays active until the end of the billing period, then it cancels. Nothing changes today.')) + ) + } + + if (pending.kind === 'upgrade') { + return ctx.upgrade(pending.targetTierId ?? '', pending.idempotencyKey).then(r => + isScopeDenial(r) ? toStepUp() : finish(upgradeResult(r, pending.targetTierId)) + ) + } + + return ctx.scheduleChange(pending.targetTierId ?? '').then(r => + isScopeDenial(r) + ? toStepUp() + : finish(mutationResult(r, 'Scheduled — your plan doesn’t change today. You keep your current plan until the end of the billing period, then it switches.')) + ) +} + +/** Resume (undo the pending change) and route: result (ok/err) or stepup (scope). */ +function resumeAndRoute(ctx: SubscriptionOverlayState['ctx'], onPatch: ScreenProps['onPatch'], allowStepUp = true): Promise { + return ctx.resume().then(r => { + if (isScopeDenial(r)) { + return allowStepUp + ? onPatch({ screen: 'stepup', stepUpRetry: { kind: 'resume' } }) + : onPatch({ result: scopeStillDeniedResult, screen: 'result' }) + } + + return onPatch({ result: mutationResult(r, 'Your pending change was undone — you stay on your current plan.'), screen: 'result' }) + }) +} + +// ── The pending scheduled change (drives the banner + status echo) ── + +interface PendingTransition { + to: string + when: string +} + +/** The scheduled downgrade/cancel as a from→to transition, or null. */ +function pendingTransition(c: SubscriptionStateResponse['current']): null | PendingTransition { + if (!c) { + return null + } + + if (c.cancel_at_period_end) { + return { to: 'cancels', when: c.cancellation_effective_display ?? shortDate(c.cancellation_effective_at) } + } + + if (c.pending_downgrade_tier_name) { + return { to: c.pending_downgrade_tier_name, when: c.pending_downgrade_display ?? shortDate(c.pending_downgrade_at) } + } + + return null +} + +// ── Screen: Overview (plan + usage + entry to the change flow) ──────── + +/** Status line — dollars-only, and echoes a pending "Ultra → Plus" transition. */ +function statusLine(s: SubscriptionStateResponse): string { + const u = s.usage + const c = s.current + const plan = c?.tier_name ?? u?.plan_name ?? null + const trans = pendingTransition(c) + const flip = plan && trans ? ` → ${trans.to}` : '' + const renewsRaw = u?.renews_display ?? null + const renews = renewsRaw ? ` · renews ${renewsRaw}` : '' + const viewOnly = !s.can_change_plan + + if (!plan) { + return 'Plan: Free · free models only' + } + + if (u?.status === 'low' && u.total_spendable_display) { + return `Plan: ${plan}${flip} · ${u.total_spendable_display} left` + } + + const left = u?.total_spendable_display ? ` · ${u.total_spendable_display} left` : '' + + return `Plan: ${plan}${flip}${left}${viewOnly ? ' · view only' : renews}` +} + +function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { + const { ctx, state: s } = overlay + const c = s.current + const isFree = !c?.tier_id + const currentName = c?.tier_name ?? 'your plan' + const trans = pendingTransition(c) + const hasPendingChange = !!trans + // Admin/owner on a personal paid plan can change it in-terminal; otherwise the + // portal enforces who can act (members) / starting a new sub needs a card. + const canChange = s.can_change_plan && !isFree + + // Guard the async resume so a double-press cannot fire two DELETEs mid-await. + const busyRef = useRef(false) + + const u = s.usage + const freeNudge = isFree ? 'Paid models need a subscription. Start one to reach them.' : null + + const lowNudge = + u?.status === 'low' + ? `Low balance · ${u.total_spendable_display ?? 'under $5'} left. Top up or upgrade before a mid-run cutoff.` + : null + + const doManage = () => { + if (s.portal_url) { + void ctx.openManageLink() + } else { + ctx.sys('🔴 No portal URL available — manage your subscription on the Nous portal.') + } + + return onClose() + } + + const doResume = () => { + if (busyRef.current) { + return + } + + busyRef.current = true + void resumeAndRoute(ctx, onPatch) + } + + const rows: MenuRowSpec[] = [] + + if (canChange) { + // When a change is already scheduled, undo is the most likely next intent — + // promote it to the first, highlighted action. + if (hasPendingChange) { + rows.push({ color: t.color.ok, label: `Keep ${currentName} (undo this change)`, run: doResume }) + rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) }) + } else { + rows.push({ label: 'Change plan', run: () => onPatch({ pending: null, screen: 'picker' }) }) + rows.push({ + label: 'Cancel subscription', + run: () => onPatch({ pending: { kind: 'cancellation', preview: null, targetTierId: null }, screen: 'confirm' }) + }) + } + } + + rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage }) + rows.push({ label: 'Close', run: onClose }) + + const sel = useMenu(rows, onClose) + + return ( + + {/* 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)} + + + {freeNudge && ( + + + {'> '} + {freeNudge} + + + )} + {lowNudge && ( + + + {'! '} + {lowNudge} + + + )} + {s.org_name && ( + + Org: {s.org_name} + {s.role ? ` · ${s.role}` : ''} + + )} + + + {rows.map((row, i) => ( + + ))} + + + {footer('↑/↓ select · Enter confirm · Esc close', t)} + + ) +} + +// ── Screen: Picker (choose a tier → preview → confirm) ─────────────── + +function PickerScreen({ onPatch, overlay, t }: ScreenProps) { + const { ctx, state: s } = overlay + const currentOrder = s.tiers.find(tier => tier.is_current)?.tier_order ?? 0 + + // Selectable = enabled, not the current plan, and not the free/no-sub tier + // (going to free is a cancellation, offered on the overview). Sorted by price. + const choices: SubscriptionTierOption[] = s.tiers + .filter(tier => tier.is_enabled && !tier.is_current && tier.tier_order > 0) + .sort((a, b) => a.tier_order - b.tier_order) + + // Guard the async preview so a double-press cannot fire two quotes. + const busyRef = useRef(false) + + const pick = (tier: SubscriptionTierOption) => { + if (busyRef.current) { + return + } + + busyRef.current = true + void previewAndRoute(ctx, tier.tier_id, onPatch) + } + + const back = () => onPatch({ screen: 'overview' }) + + const rows: MenuRowSpec[] = choices.map(tier => { + const direction = tier.tier_order > currentOrder ? 'upgrade' : 'downgrade' + + return { + label: `${tier.name} · ${tier.dollars_per_month_display}/mo · ${direction}`, + run: () => pick(tier) + } + }) + + rows.push({ label: 'Back', run: back }) + + const sel = useMenu(rows, back) + + return ( + + + 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 = () => { + // Don't navigate away while an apply is in flight: the screen hasn't changed + // yet (applyPendingAndRoute patches only after the RPC resolves), so a fresh + // re-mount would re-fire the mutation — a second charge on the upgrade path. + if (submittingRef.current) { + return + } + + onPatch({ pending: null, screen: isCancellation ? 'overview' : 'picker' }) + } + + const apply = () => { + if (submittingRef.current || !pending) { + return + } + + submittingRef.current = true + setSubmitting(true) + void applyPendingAndRoute(ctx, pending, onPatch) + } + + const manage = () => { + void ctx.openManageLink() + + return onClose() + } + + // WHICH card the upgrade will charge (brand + last4) — best-effort via + // billing.state, shown only when the resolver rung matches what a + // subscription charge actually uses (subPin / customerDefault, mirroring + // Stripe's own precedence). Anything else → the generic line stands. + const [chargeCard, setChargeCard] = useState(null) + + useEffect(() => { + if (isCancellation || effect !== 'charge_now') { + return + } + + let cancelled = false + + void ctx.fetchCard().then(card => { + if (!cancelled && card && (card.resolved_via === 'subPin' || card.resolved_via === 'customerDefault')) { + setChargeCard(card.masked) + } + }) + + return () => { + cancelled = true + } + }, [ctx, effect, isCancellation]) + + const amount = centsDisplay(preview?.amount_due_now_cents) + const targetName = isCancellation ? null : (preview?.target_tier_name ?? 'the selected plan') + + let primary: MenuRowSpec | null = null + + if (isCancellation) { + primary = { color: t.color.warn, label: 'Cancel subscription', run: apply } + } else if (effect === 'charge_now') { + primary = { color: t.color.ok, label: amount ? `Pay ${amount} & upgrade now` : 'Upgrade now (prorated charge)', run: apply } + } else if (effect === 'scheduled') { + primary = { color: t.color.ok, label: `Schedule change to ${targetName}`, run: apply } + } else if (effect === 'blocked') { + primary = { label: 'Manage on portal', run: manage } + } + + const rows: MenuRowSpec[] = primary ? [primary, { label: 'Back', run: back }] : [{ label: 'Back', run: back }] + const sel = useMenu(rows, back) + // Chip contrasts an immediate charge vs a period-end schedule at a glance. + const chip = effect === 'charge_now' ? { color: t.color.ok, label: 'charged now' } : effect === 'scheduled' ? { color: t.color.warn, label: 'scheduled · not today' } : null + + return ( + + + + {isCancellation ? 'Confirm cancellation' : 'Confirm plan change'} + + {chip && · {chip.label}} + + {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}. + )} + + {chargeCard ? `${chargeCard} — the card on your subscription — will be charged.` : '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 = overlay.result ?? null + const recoveryUrl = result?.recoveryUrl ?? null + const pendingTierId = result?.pendingTierId ?? null + const [applyState, setApplyState] = useState<'applying' | 'confirmed' | 'timed_out'>( + pendingTierId ? 'applying' : 'confirmed' + ) + + useEffect(() => { + if (!pendingTierId) { + return + } + + let attempts = 0 + let cancelled = false + let timer: ReturnType | undefined + + const scheduleOrFinish = () => { + if (cancelled) { + return + } + + if (attempts >= UPGRADE_CONFIRM_ATTEMPTS) { + setApplyState('timed_out') + + return + } + + timer = setTimeout(tick, UPGRADE_CONFIRM_INTERVAL_MS) + } + + const tick = () => { + attempts += 1 + void ctx + .refreshState() + .then(fresh => { + if (cancelled) { + return + } + + if (fresh?.current?.tier_id === pendingTierId) { + setApplyState('confirmed') + + return + } + + scheduleOrFinish() + }) + .catch(scheduleOrFinish) + } + + timer = setTimeout(tick, UPGRADE_CONFIRM_INTERVAL_MS) + + return () => { + cancelled = true + + if (timer) { + clearTimeout(timer) + } + } + }, [ctx, pendingTierId]) + + const applying = result?.ok && applyState === 'applying' + const timedOut = result?.ok && applyState === 'timed_out' + const message = timedOut + ? 'Your upgrade succeeded and is still applying — refresh in a moment.' + : (result?.message ?? '') + + const openRecovery = () => { + if (recoveryUrl) { + ctx.openPortal(recoveryUrl) + } + + return onClose() + } + + const rows: MenuRowSpec[] = recoveryUrl + ? [{ color: t.color.accent, label: 'Open the portal to finish', run: openRecovery }, { label: 'Close', run: onClose }] + : [{ label: 'Close', run: onClose }] + + const sel = useMenu(rows, onClose) + + return ( + + + {applying ? 'Applying…' : timedOut ? 'Still applying' : result?.ok ? 'Done' : 'Could not complete'} + + {message} + {result?.ok && !applying && !timedOut && Re-run /subscription anytime to review it.} + + {rows.map((row, i) => ( + + ))} + + {footer('↑/↓ select · Enter · Esc close', t)} + + ) +} + +// ── Screen: Step-up (grant terminal billing inline, then replay) ────── + +function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { + const { ctx } = overlay + const retry: null | SubscriptionStepUpRetry = overlay.stepUpRetry ?? null + const [phase, setPhase] = useState<'granted' | 'prompt' | 'resuming' | 'waiting'>('prompt') + const startedRef = useRef(false) + // Set when the user cancels while the browser grant is still in flight. The + // grant's late `.then` MUST NOT fire the held change after a cancel — otherwise + // a cancel-then-approve charges the card the user just declined. + const abortedRef = useRef(false) + // Guards the post-grant replay from double-firing (double-Enter on the default + // 'Continue' row) — mirrors billingOverlay.resume()'s phase flip. + const resumingRef = useRef(false) + + const enable = () => { + if (startedRef.current) { + return + } + + startedRef.current = true + setPhase('waiting') + void ctx.requestRemoteSpending().then(res => { + if (abortedRef.current) { + return + } + + if (res.granted) { + // HOLD — do not auto-fire the held change. Require an explicit Continue so + // a cancelled/late grant can never charge (mirrors billingOverlay's + // 'granted' phase). The user already consented at confirm; this reconfirms. + return setPhase('granted') + } + + // Typed denial (session_revoked / remote_spending_revoked / rate_limited / + // admin-approval) → the right recovery copy, not a flat "admin must allow". + onPatch({ result: stepUpDenialResult(res), screen: 'result', stepUpRetry: null }) + }) + } + + const resume = () => { + // Fire the held replay at most once. Without this, a double-Enter on the + // default 'Continue' row sends two mutations (the upgrade dedups on the shared + // idempotency key, but schedule/cancel/resume replays carry none). + if (resumingRef.current || phase !== 'granted') { + return + } + + resumingRef.current = true + setPhase('resuming') + onPatch({ stepUpRetry: null }) + + if (!retry) { + return onPatch({ screen: 'overview' }) + } + + // allowStepUp=false: a repeat scope denial surfaces a result, never a frozen + // re-entry into this (already-mounted) stepup screen. + if (retry.kind === 'preview') { + return void previewAndRoute(ctx, retry.tierId, onPatch, false) + } + + if (retry.kind === 'resume') { + return void resumeAndRoute(ctx, onPatch, false) + } + + return void applyPendingAndRoute(ctx, overlay.pending ?? null, onPatch, false) + } + + const back = () => { + // Once a replay is firing, block abandon — the mutation/charge is in flight and + // re-mounting confirm would let a second submit through. + if (resumingRef.current) { + return + } + + // Abandon. If a grant is in flight, mark it aborted so its .then no-ops (no + // un-consented charge); if already granted, just leave without replaying. + abortedRef.current = true + onPatch({ screen: retry?.kind === 'apply' ? 'confirm' : 'overview', stepUpRetry: null }) + } + + const rows: MenuRowSpec[] = + phase === 'granted' + ? [{ color: t.color.ok, label: retry?.kind === 'apply' ? 'Continue the change' : 'Continue', run: resume }, { label: 'Cancel', run: back }] + : phase === 'prompt' + ? [{ color: t.color.ok, label: 'Enable terminal billing', run: enable }, { label: 'Cancel', run: back }] + : [] + + const sel = useMenu(rows, back) + + return ( + + + Terminal billing + + {phase === 'prompt' && ( + <> + Changing your plan needs terminal billing enabled for this org. Enable it here, then continue. + Someone with billing permissions (owner, admin, or finance admin) 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.} + {phase === 'resuming' && Applying your change…} + + {rows.map((row, i) => ( + + ))} + + {footer(phase === 'waiting' ? 'Waiting for approval… · Esc to cancel' : phase === 'resuming' ? 'Working…' : '↑/↓ select · Enter · Esc back', t)} + + ) +} + +// ── 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 a shared balance · use /topup to add + funds. + + Personal subscriptions live on your personal account. + + + {footer('Enter/Esc close', t)} + + ) +} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index de24b522d91a..312385b7c6eb 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -43,22 +43,16 @@ export interface SlashExecResponse { warning?: string } -// ── Credits / top-up ───────────────────────────────────────────────── - -export interface CreditsViewResponse { - balance_lines: string[] - depleted: boolean - identity_line: string | null - logged_in: boolean - topup_url: string | null -} - // ── Terminal billing (Phase 2b) ────────────────────────────────────── export interface BillingCardInfo { brand: string last4: string masked: string + /** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */ + display?: string + /** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */ + resolved_via?: null | string } export interface BillingMonthlyCap { @@ -70,6 +64,15 @@ export interface BillingMonthlyCap { } export interface BillingAutoReload { + card: + | { kind: 'canonical' } + | { + kind: 'distinct' + payment_method_id: string + brand: string | null + last4: string | null + } + | { kind: 'none' } enabled: boolean reload_to_display: string reload_to_usd: string | null @@ -96,6 +99,9 @@ export interface BillingStateResponse { org_name: string | null portal_url: string | null role: string | null + // Shared dollar usage model (two-bar view), embedded by the gateway so /topup + // renders the same bars as /usage and /subscription from this single fetch. + usage?: UsageModelData } /** @@ -109,13 +115,16 @@ export interface BillingErrorPayload { } export interface BillingChargeResponse { + actor?: string charge_id?: string + code?: string error?: string idempotency_key?: string message?: string ok: boolean payload?: BillingErrorPayload portal_url?: string | null + recovery?: string retry_after?: number | null } @@ -133,15 +142,104 @@ export interface BillingChargeStatusResponse { } export interface BillingMutationResponse { + actor?: string + code?: string error?: string granted?: boolean message?: string ok: boolean payload?: BillingErrorPayload portal_url?: string | null + recovery?: string retry_after?: number | null } +export interface SubscriptionTierOption { + tier_id: string + name: string + tier_order: number // sorts the picker + upgrade/downgrade hint + dollars_per_month_display: string // pre-formatted ($X / $X.YY) + monthly_credits: string | null + is_current: boolean // the active plan: shown, not selectable + is_enabled: boolean // false = grandfathered current tier +} + +export interface SubscriptionStateResponse { + ok: boolean + logged_in: boolean + is_admin: boolean + can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS + org_name: string | null + org_id: string | null // org.id from the NAS response + role: string | null + context: 'personal' | 'team' // personal account vs team/org terminal + current: { + tier_id: string | null // null = free (no active sub) + tier_name: string | null + monthly_credits: string | null + credits_remaining: string | null + cycle_ends_at: string | null // ISO + pending_downgrade_tier_name: string | null + pending_downgrade_at: string | null + pending_downgrade_display: string | null // formatted pending_downgrade_at + cancel_at_period_end: boolean // subscription scheduled to cancel at period end + cancellation_effective_at: string | null // ISO when cancellation takes effect + cancellation_effective_display: string | null // formatted cancellation_effective_at + } | null + tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker + portal_url: string | null + error?: string | null + // Shared dollar usage model (two-bar view), embedded by the gateway so the + // overlay renders the same bars as /usage from this single fetch. + usage?: UsageModelData +} + +// A chargeless quote (POST /subscription/preview) of what a change would do. +// `effect` drives the confirm copy; a failed preview reuses the typed-error +// envelope fields (same as the mutations) so a 403 still triggers the step-up. +export interface SubscriptionPreviewResponse { + ok: boolean + effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked' + reason?: string | null + current_tier_id?: string | null + current_tier_name?: string | null + target_tier_id?: string | null + target_tier_name?: string | null + monthly_credits_delta?: string | null + amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade + effective_at?: string | null // ISO, when a scheduled change lands + // typed-error envelope (present when ok=false) + error?: string + message?: string + portal_url?: string | null + retry_after?: number | null + payload?: BillingErrorPayload + actor?: string + code?: string + recovery?: string +} + +// The single money route (POST /subscription/upgrade). `status` distinguishes a +// completed upgrade from an SCA/decline that must finish in the portal at +// `recovery_url`. `idempotency_key` is echoed so a retry reuses it. +export interface SubscriptionUpgradeResponse { + ok: boolean + status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed' + target_tier_name?: string | null + recovery_url?: string | null + reason?: string | null + idempotency_key?: string + // typed-error envelope (present when ok=false) + error?: string + message?: string + portal_url?: string | null + retry_after?: number | null + payload?: BillingErrorPayload + actor?: string + code?: string + recovery?: string +} + export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } @@ -330,6 +428,34 @@ export interface SessionUsageResponse { model?: string output?: number total?: number + // Shared dollar usage model (two-bar view) so /usage renders the same bars + // as /subscription. Dollars only — never "credits". + usage?: UsageModelData +} + +/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */ +export interface UsageBarData { + kind: 'plan' | 'topup' + remaining_display: string + total_display: string + spent_display: string + pct_used: null | number + fill_fraction: number +} + +/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */ +export interface UsageModelData { + available: boolean + status?: string + plan_name?: null | string + renews_at?: null | string + renews_display?: null | string + subscription_remaining_display?: null | string + topup_remaining_display?: null | string + total_spendable_display?: null | string + has_topup?: boolean + plan_bar?: null | UsageBarData + topup_bar?: null | UsageBarData } export interface SessionStatusResponse {