Compare commits

...

18 Commits

Author SHA1 Message Date
alt-glitch
a83550b5ab feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path
Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
  needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
  opens via the existing out-of-band billing.step_up.verification event) →
  replay the held charge (pendingCharge.amount) and settle, with no command
  re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
  the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.

Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).
2026-06-24 15:31:50 +05:30
alt-glitch
a75aea8f8a refactor(subscription): remove dead step-up scaffolding from /subscription
/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.
2026-06-24 15:31:44 +05:30
alt-glitch
37154fa36f feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)
Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
  reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
  from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
  recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
  immediately (no 15-min zombie button), handles session_revoked, the dual-
  emitted cli_billing_disabled/remote_spending_disabled, role_required,
  idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
  balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.

Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.
2026-06-24 15:06:16 +05:30
alt-glitch
1a082b780c feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan
- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
  bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
  (a card-failing subscriber returns as a normal plan now), and treat no-plan as
  current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
  drive every state (CLI + live TUI) with no portal.

Verified against handoff 2026-06-24_subscription-tui-handoff.md.
2026-06-24 15:06:10 +05:30
alt-glitch
cb8a19ae3a feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs
Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
  'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).
2026-06-24 15:06:01 +05:30
alt-glitch
e78bf4b7d8 chore(subscription): drop unused format_money import 2026-06-24 08:12:02 +05:30
alt-glitch
a5902cd267 fix(subscription): drop manage-link gateway RPC, build URL locally
The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.

- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
  it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
  /manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)
2026-06-24 08:03:45 +05:30
alt-glitch
3831e78d37 feat(tui/subscription): team-context screen — redirect to /topup for team orgs
Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:

  'This terminal is connected to {org_name}. Teams run on shared
   credits — use /topup to add funds. Personal subscriptions live
   on your personal account.'

The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.
2026-06-24 07:35:27 +05:30
alt-glitch
2958145f11 feat(tui/subscription): render cancellation-scheduled note with headline precedence
Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'

Headline precedence when multiple flags co-occur:
  past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.
2026-06-24 07:34:08 +05:30
alt-glitch
e4c46be204 fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type
Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).
2026-06-24 07:33:07 +05:30
alt-glitch
e854528a85 feat(tui): add /subscription command + overlay wiring
- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
  refreshState, requestRemoteSpending) + run handler that fetches
  subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
  includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
  /upgrade alias, /subscription resolves).
2026-06-23 20:45:31 +05:30
alt-glitch
66d22cac97 feat(tui): build SubscriptionOverlay — overview + confirm + handoff
Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.
2026-06-23 20:39:50 +05:30
alt-glitch
1cecb2fbb9 feat(tui): add subscription overlay state types + store slot
Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.
2026-06-23 20:27:54 +05:30
alt-glitch
ac8a790b67 feat(gateway): add subscription.state + subscription.manage_link RPCs
- agent/subscription_view.py: SubscriptionState dataclass + fail-open
  build_subscription_state() (mirrors billing_view pattern) +
  get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
  post_subscription_manage_link() HTTP helpers for the two NAS endpoints
  (WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
  when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
  subscription.state RPC (fail-open) + subscription.manage_link RPC
  (returns {ok,kind,url} or typed error envelope via
  _serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
  HTTP round-trip, not a device flow.
2026-06-23 20:25:58 +05:30
alt-glitch
df4350c5ad feat(tui): add subscription wire types
Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.
2026-06-23 20:19:47 +05:30
alt-glitch
75e4bfd183 feat(tui): add /subscription + /topup CTAs to /usage output
Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.
2026-06-23 20:18:35 +05:30
alt-glitch
aab4ba454f refactor(tui): extract overlay primitives to shared module
Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.
2026-06-23 20:15:39 +05:30
alt-glitch
30a1254c36 feat(tui): rename /billing slash command to /topup
Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.
2026-06-23 19:45:27 +05:30
25 changed files with 2833 additions and 158 deletions

353
agent/subscription_view.py Normal file
View File

@@ -0,0 +1,353 @@
"""Surface-agnostic core for the ``/subscription`` TUI screen.
Companion to :mod:`agent.billing_view` — same fail-open philosophy: when not
logged in or the portal is unreachable, return a struct with ``logged_in=False``
and let the surface degrade gracefully (never crash). Money is decimal end-to-end
(server emits decimal strings); we only format for display.
The TUI ``SubscriptionOverlay`` is **deep-link only** — it never charges
in-terminal. The manage URL is built locally on the TUI side from the
``portal_url`` and ``org_id`` fields in the subscription state.
WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A).
Until it ships, the fail-open contract handles 404s — the builder returns
``logged_in=False`` and the surface degrades gracefully.
"""
from __future__ import annotations
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 SubscriptionTier:
"""A plan tier in the catalog."""
tier_id: str
name: str
tier_order: int
dollars_per_month: Optional[Decimal] = None
monthly_credits: Optional[Decimal] = None
is_current: bool = False
is_enabled: bool = True
@dataclass(frozen=True)
class CurrentSubscription:
"""The user's active subscription. ``None`` (not this object) = no plan.
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 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" | "MEMBER"
context: str = "personal" # "personal" | "team"
current: Optional[CurrentSubscription] = None
tiers: tuple[SubscriptionTier, ...] = ()
portal_url: Optional[str] = None
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
error: Optional[str] = None
@property
def is_admin(self) -> bool:
"""True for OWNER/ADMIN — the roles that can change plans."""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""True when the UI should offer plan-change actions (role gate from NAS)."""
return self.is_admin
# =============================================================================
# Payload parsing
# =============================================================================
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
name = raw.get("name")
if not (isinstance(tier_id, str) and isinstance(name, str)):
return None
return SubscriptionTier(
tier_id=tier_id,
name=name,
tier_order=int(raw.get("tierOrder") or raw.get("order") or 0),
dollars_per_month=parse_money(raw.get("dollarsPerMonth") or raw.get("priceUsd")),
monthly_credits=parse_money(raw.get("monthlyCredits")),
is_current=bool(raw.get("isCurrent")),
is_enabled=bool(raw.get("isEnabled", True)),
)
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
# "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 subscription_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> SubscriptionState:
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
tiers: list[SubscriptionTier] = []
for item in payload.get("tiers") or ():
parsed = _parse_tier(item)
if parsed is not None:
tiers.append(parsed)
raw_context = payload.get("context")
context = raw_context if raw_context in ("personal", "team") else "personal"
return SubscriptionState(
logged_in=True,
org_name=org.get("name"),
org_id=org.get("id") or None,
role=org.get("role"),
context=context,
current=_parse_current(payload.get("current")),
tiers=tuple(tiers),
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open.
Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a
portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the
surface can show a clear message rather than crashing.
Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state,
``/subscription`` renders from that fixture instead of the real portal — so
every plan/cancel/downgrade/team/not-admin state is testable on both
the CLI and TUI without a live account. Throwaway scaffolding; see
:func:`dev_fixture_subscription_state`.
"""
fixture = dev_fixture_subscription_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_subscription_state,
resolve_portal_base_url,
)
except Exception:
return SubscriptionState(logged_in=False, error="billing client unavailable")
try:
payload = get_subscription_state(timeout=timeout)
except BillingAuthError:
return SubscriptionState(logged_in=False)
except BillingError as exc:
logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error=str(exc))
except Exception:
logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error="could not load subscription state")
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = resolve_portal_base_url()
except Exception:
portal_url = None
return subscription_state_from_payload(payload, portal_url=portal_url)
def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled
internally. ``org_id`` pins the page to the right account in multi-org
situations. Returns ``None`` when no portal URL is resolvable.
"""
from urllib.parse import urlencode, urlsplit, urlunsplit
if not state.portal_url:
return None
try:
parts = urlsplit(state.portal_url)
except Exception:
return None
if not parts.scheme or not parts.netloc:
return None
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
specs = [
("free", "Free", 0, Decimal("0"), Decimal("0")),
("plus", "Plus", 1, Decimal("20"), Decimal("1000")),
("super", "Super", 2, Decimal("50"), Decimal("3000")),
("ultra", "Ultra", 3, Decimal("99"), Decimal("7000")),
]
return tuple(
SubscriptionTier(
tier_id=tid,
name=name,
tier_order=order,
dollars_per_month=price,
monthly_credits=credits,
is_current=(tid == current_id),
is_enabled=True,
)
for tid, name, order, price, credits in specs
)
def _dev_current(**over: Any) -> CurrentSubscription:
base: dict[str, Any] = dict(
tier_id="plus",
tier_name="Plus",
monthly_credits=Decimal("1000"),
credits_remaining=Decimal("420"),
cycle_ends_at="2026-07-01",
)
base.update(over)
return CurrentSubscription(**base)
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
"""Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``.
Lets every CLI/TUI subscription state be exercised without a live portal:
free | mid | top | not-admin | downgrade | cancel | team |
logged-out
Returns ``None`` when the env var is unset/empty (the real portal path runs).
Throwaway scaffolding — mirrors ``HERMES_DEV_CREDITS_FIXTURE``.
"""
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
if not name:
return None
common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
if name in ("logged-out", "logged_out", "loggedout"):
return SubscriptionState(logged_in=False)
if name == "free":
return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common)
if name in ("mid", "mid-tier"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common)
if name in ("top", "top-tier"):
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
tiers=_dev_tiers("ultra"),
**common,
)
if name in ("not-admin", "member"):
return SubscriptionState(
logged_in=True,
current=_dev_current(),
tiers=_dev_tiers("plus"),
org_name="Acme Inc",
org_id="org_acme",
role="MEMBER",
portal_url=_DEV_FIXTURE_PORTAL,
)
if name == "downgrade":
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
tiers=_dev_tiers("super"),
**common,
)
if name == "cancel":
return SubscriptionState(
logged_in=True,
current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"),
tiers=_dev_tiers("plus"),
**common,
)
if name == "team":
return SubscriptionState(logged_in=True, context="team", current=None, tiers=(), org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
# Unknown name → behave as logged-out so the misconfiguration is visible.
return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}")

222
cli.py
View File

@@ -8012,7 +8012,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._show_usage()
elif canonical == "credits":
self._show_credits()
elif canonical == "billing":
elif canonical == "subscription":
self._show_subscription()
elif canonical == "topup":
self._show_billing(cmd_original)
elif canonical == "insights":
self._show_insights(cmd_original)
@@ -8782,7 +8784,9 @@ 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
@@ -8790,7 +8794,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
@@ -8887,7 +8893,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)
@@ -8925,6 +8932,187 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
print(f" {line}")
return True
def _print_usage_cta(self) -> None:
"""Print the `/usage` call-to-action pointing at /subscription + /topup.
Mirrors the TUI's ``USAGE_CTA`` (``session.ts``) so every surface ends a
usage read with the same nudge. Only called when a Nous account is logged
in (the credits block printed), since both commands are Nous-account only.
"""
_cprint(f" {_d('Run /subscription to change plan · /topup to add credits')}")
# ------------------------------------------------------------------
# /subscription — view plan + change it in the browser (CLI surface)
# ------------------------------------------------------------------
def _show_subscription(self):
"""`/subscription` (alias `/upgrade`) — view the Nous plan + browser hand-off.
The CLI mirror of the TUI ``SubscriptionOverlay``: a read of the current
plan, this cycle's subscription credits, renewal date, and the plans you
could switch to — then a deep-link to NAS's own ``/manage-subscription``
page (NOT the Stripe portal; that page routes upgrade→Checkout /
downgrade→scheduled internally). The terminal NEVER charges for a
subscription. Fail-open: logged-out / portal hiccup degrades to a clear
message, never a crash. Mirrors ``_show_credits`` / ``_show_billing``
discipline for the interactive-vs-text split.
"""
from agent.subscription_view import build_subscription_state, subscription_manage_url
state = build_subscription_state()
if not state.logged_in:
print()
if state.error:
_cprint(f" 💳 {_d(f'Could not load subscription: {state.error}')}")
else:
_cprint(f" 💳 {_d('Not logged into Nous Portal.')}")
print(" Run `hermes portal` to log in, then /subscription.")
return
# Team context: no personal plan — teams run on shared credits.
if state.context == "team":
print()
_cprint(f"{_b('Team subscription')}")
print(f" {'' * 41}")
if state.org_name:
role = (state.role or "").title()
_org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}"
_cprint(f" {_d(_org_line)}")
org = state.org_name or "a team org"
print(f" This terminal is connected to {org}. Teams run on shared")
print(" credits — use /topup to add funds.")
_cprint(f" {_d('Personal subscriptions live on your personal account.')}")
return
self._subscription_overview(state, subscription_manage_url(state))
def _subscription_overview(self, state, manage_url):
"""Print the plan read block + tier list, then the browser hand-off."""
from agent.billing_view import format_money
c = state.current
is_free = not (c and c.tier_id)
can_change = state.can_change_plan and state.is_admin
print()
if is_free:
_cprint(f"{_b('Subscribe to a plan')}")
else:
_cprint(f"{_b(f'Your plan: {c.tier_name or c.tier_id}')}")
print(f" {'' * 41}")
# Usage bar from the subscription allowance (monthly vs remaining).
if c and c.monthly_credits and c.credits_remaining:
try:
monthly = float(c.monthly_credits)
remaining = float(c.credits_remaining)
except (TypeError, ValueError):
monthly = remaining = 0.0
if monthly > 0:
spent = max(0.0, monthly - remaining)
bar, pct = self._billing_spend_bar(spent, monthly)
# Credits are a COUNT, not money — grouped integers, no "$".
def _grp(v):
try:
return f"{int(float(v)):,}"
except (TypeError, ValueError):
return str(v)
print(
f" {_grp(c.credits_remaining)} of {_grp(c.monthly_credits)} "
f"remaining {bar} {100 - pct}% left"
)
if c and c.cycle_ends_at:
print(f" Renews: {c.cycle_ends_at}")
if state.org_name:
role = (state.role or "").title()
_org_line = f"Org: {state.org_name}{f' · {role}' if role else ''}"
_cprint(f" {_d(_org_line)}")
print(f" {'' * 41}")
# Headline precedence: cancel-scheduled > downgrade-pending.
if c and c.cancel_at_period_end:
if c.cancellation_effective_at:
_cprint(f" {_d(f'Cancels on {c.cancellation_effective_at} — your plan stays active until then.')}")
else:
_cprint(f" {_d('Cancellation scheduled — your plan stays active until the end of the billing period.')}")
elif c and c.pending_downgrade_tier_name:
when = c.pending_downgrade_at or "the end of the cycle"
_cprint(f" {_d(f'Scheduled to switch to {c.pending_downgrade_tier_name} on {when}.')}")
# Tier catalog (enabled tiers, current marked). Read-only for members.
enabled = [t for t in state.tiers if t.is_enabled]
if enabled:
print()
for t in enabled:
mark = "" if t.is_current else " "
price = format_money(t.dollars_per_month) if t.dollars_per_month is not None else "$0"
# Credits are a COUNT, not money — render grouped, no "$".
if t.monthly_credits is not None:
try:
credits = f"{int(t.monthly_credits):,}"
except (TypeError, ValueError):
credits = str(t.monthly_credits)
else:
credits = "0"
_cprint(f" {mark}{t.name}{price}/mo ({credits} credits)")
if not can_change:
print()
_cprint(f" {_d('Plan changes need an org admin/owner.')}")
if manage_url:
print(f" Manage on portal: {manage_url}")
return
if not manage_url:
print()
_cprint(f" {_d('No manage URL available — is your portal configured?')}")
return
# Non-interactive (TUI slash-worker / piped / no live app): the
# prompt_toolkit modal can't run here. Render the text hand-off — the
# URL is the affordance, same discipline as _show_credits.
if not getattr(self, "_app", None):
print()
print(f" Change plan: {manage_url}")
print(" Finish the change in your browser, then re-run /subscription.")
return
print()
choices = [
("open", "Open subscription page", "change your plan in the browser"),
("copy", "Copy link", "copy the manage-subscription URL to your clipboard"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="⚕ Change your plan?",
detail=f"Manage your subscription in your browser:\n{manage_url}",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "open":
opened = False
try:
import webbrowser
opened = webbrowser.open(manage_url)
except Exception:
opened = False
if not opened:
print(f" Open this URL to change your plan: {manage_url}")
print()
print(" Finish the change in your browser, then re-run /subscription.")
elif choice == "copy":
try:
self._write_osc52_clipboard(manage_url)
print(f" 📋 Copied: {manage_url}")
except Exception:
print(f" Manage URL: {manage_url}")
else:
print(" 🟡 Cancelled. No plan change.")
def _show_credits(self):
"""`/credits` — focused Nous credit balance + top-up handoff.
@@ -9363,15 +9551,33 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _billing_render_charge_error(self, state, exc):
"""Render a typed BillingError at submit time (pre-poll)."""
from hermes_cli.nous_billing import BillingRateLimited
from hermes_cli.nous_billing import (
BillingRateLimited,
BillingRemoteSpendingRevoked,
BillingSessionRevoked,
)
code = getattr(exc, "error", None)
actor = getattr(exc, "actor", None)
portal_url = getattr(exc, "portal_url", None) or getattr(state, "portal_url", None)
if code == "no_payment_method":
if isinstance(exc, BillingRemoteSpendingRevoked) or code == "remote_spending_revoked":
# CF-4: this terminal's spend was revoked. Recovery is reconnect.
who = ("An admin stopped this terminal's spending."
if actor == "admin"
else "You stopped this terminal's spending.")
print(f" 🔴 {who} Reconnect to restore — run `hermes portal` to re-authorize.")
elif isinstance(exc, BillingSessionRevoked) or code == "session_revoked":
print(" 🔴 Your session was logged out. Run `hermes portal` to log in again.")
elif code == "no_payment_method":
print(" 💳 No saved card for terminal charges yet. Set one up on the "
"portal (one-time credit buys don't save a reusable card).")
elif code == "cli_billing_disabled":
print(" 🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.")
elif code in ("cli_billing_disabled", "remote_spending_disabled") or \
getattr(exc, "code", None) == "remote_spending_disabled":
print(" 🔴 Remote Spending is off for this account — an admin must enable it on the portal.")
elif code == "role_required":
print(" 🔴 Buying credits needs an org admin/owner. Ask an admin, or manage on the portal.")
elif code == "idempotency_conflict":
print(" 🔴 That charge key was already used for a different amount. Start a fresh top-up.")
elif code == "monthly_cap_exceeded":
remaining = (getattr(exc, "payload", {}) or {}).get("remainingUsd")
if remaining is not None:

View File

@@ -218,8 +218,10 @@ COMMAND_REGISTRY: list[CommandDef] = [
gateway_only=True),
CommandDef("usage", "Show token usage and rate limits for the current session", "Info"),
CommandDef("credits", "Show Nous credit balance and top up", "Info"),
CommandDef("billing", "Manage Nous terminal billing — buy credits, auto-reload, limits", "Info",
cli_only=True),
CommandDef("subscription", "View your Nous plan and change it in the browser", "Info",
cli_only=True, aliases=("upgrade",)),
CommandDef("topup", "Top up Nous credits — buy credits, auto-reload, limits", "Info",
cli_only=True, aliases=("billing",)),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
@@ -1058,9 +1060,8 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
# the telegram-parity test reads it so an entry here is a deliberate
# "Slack-via-/hermes" decision, not a silent clamp.
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
# - billing: the terminal-billing surface (buy/auto-reload/limit); /hermes billing.
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "billing", "debug"})
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"})
def _sanitize_slack_name(raw: str) -> str:

View File

@@ -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,19 +96,42 @@ class BillingScopeRequired(BillingError):
"""
class BillingAuthError(BillingError):
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
class BillingRemoteSpendingRevoked(BillingError):
"""``403 remote_spending_revoked`` — THIS terminal's spending was revoked.
Distinct from ``insufficient_scope`` (never had the grant) and from
``session_revoked`` (full logout). The terminal stays logged in; only the
money path is cut. ``actor`` is ``"admin"`` or ``"self"`` (absent → treat as
``"self"``); recovery is **reconnect** (re-consent device-auth). The terminal
MUST disable charge/auto-reload immediately, without waiting for the next
token refresh (the current token still claims the scope for ~15 min).
"""
class BillingSessionRevoked(BillingAuthError):
"""``401 session_revoked`` — the whole session was logged out.
Stronger than a spend-revoke: recovery is **re-login** (full device-auth),
not just reconnect. Subclass of :class:`BillingAuthError` so existing 401
handling still treats it as not-logged-in, but the typed code lets the
surface route to re-login with the right copy.
"""
class BillingRateLimited(BillingError):
"""``429 rate_limited`` or ``503 temporarily_unavailable``.
NOT a payment failure. Carries ``retry_after`` (seconds) — back off and tell
the user "try again in N min"; never auto-retry-spam (the limiter is
5/org/hr + 5/token/hr and easy to dig deeper into).
5/org/hr + 5/token/hr and easy to dig deeper into). A 503 is the gate backend
failing closed — back off, do NOT treat as revoked.
"""
class BillingAuthError(BillingError):
"""``401`` — missing/invalid bearer token (not logged in / expired)."""
# =============================================================================
# Base-URL + auth resolution
# =============================================================================
@@ -234,9 +267,21 @@ def _retry_after_seconds(headers: Any) -> Optional[int]:
def _raise_for_error(
status: int, payload: dict[str, Any], headers: Any = None
) -> None:
"""Map an HTTP error response to the right typed :class:`BillingError`."""
"""Map an HTTP error response to the right typed :class:`BillingError`.
Recognizes the Remote-Spending gate contract (NAS PR #481):
403 ``remote_spending_revoked`` (this terminal's spend revoked → reconnect),
401 ``session_revoked`` (full logout → re-login), 503 ``temporarily_unavailable``
(gate fail-closed → back off, NOT revoked). The business-denial codes
(``cli_billing_disabled`` + dual ``code:remote_spending_disabled``,
``role_required``, ``idempotency_conflict``, …) flow through as a generic
BillingError carrying ``error``/``code``/``recovery`` for the surface to map.
"""
error = payload.get("error") if isinstance(payload, dict) else None
message = payload.get("message") if isinstance(payload, dict) else None
code = payload.get("code") if isinstance(payload, dict) else None
actor = payload.get("actor") if isinstance(payload, dict) else None
recovery = payload.get("recovery") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(
payload.get("portalUrl") if isinstance(payload, dict) else None
)
@@ -248,14 +293,33 @@ def _raise_for_error(
"portal_url": portal_url,
"retry_after": retry_after,
"payload": payload if isinstance(payload, dict) else None,
"actor": actor,
"code": code,
"recovery": recovery,
}
if status == 401:
# session_revoked is a full logout (→ re-login), stronger than a 401
# expired-token. Both stay BillingAuthError-compatible for legacy callers.
if error == "session_revoked":
raise BillingSessionRevoked(
message or "Your session was logged out — log in again.", **common
)
raise BillingAuthError(message or "Authentication required.", **common)
if status == 403 and error == "insufficient_scope":
raise BillingScopeRequired(
message or "This action needs the billing:manage scope.", **common
)
if status == 403:
# This terminal's spending was revoked (NOT the same as never having the
# scope). Disable spend UI immediately; recovery is reconnect.
if error == "remote_spending_revoked":
raise BillingRemoteSpendingRevoked(
message or "Remote Spending was revoked for this terminal.", **common
)
if error == "insufficient_scope":
raise BillingScopeRequired(
message or "This action needs the billing:manage scope.", **common
)
# Business 403s (cli_billing_disabled / role_required / no_payment_method /
# monthly_cap_exceeded / …) → generic BillingError with code/recovery.
raise BillingError(message or error or "Billing request denied.", **common)
if status in (429, 503):
raise BillingRateLimited(
message or "Rate limited — try again shortly.", **common
@@ -404,3 +468,15 @@ 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)

View File

@@ -0,0 +1,150 @@
"""Tests for agent.subscription_view — the surface-agnostic /subscription core.
Behavior contracts (not change-detectors): the manage-URL builder's shape, the
payload parser's field mapping + fail-open posture, and the dev-fixture states
that drive the CLI/TUI without a live portal.
"""
from decimal import Decimal
import pytest
from agent.subscription_view import (
SubscriptionState,
build_subscription_state,
dev_fixture_subscription_state,
subscription_manage_url,
subscription_state_from_payload,
)
# ── subscription_manage_url ──────────────────────────────────────────
def test_manage_url_attaches_org_and_path_to_portal_origin():
s = SubscriptionState(
logged_in=True,
org_id="org_x",
portal_url="https://portal.nousresearch.com/billing/whatever",
)
# Path is replaced with /manage-subscription; org_id is pinned; origin kept.
assert (
subscription_manage_url(s)
== "https://portal.nousresearch.com/manage-subscription?org_id=org_x"
)
def test_manage_url_omits_org_when_absent():
s = SubscriptionState(logged_in=True, org_id=None, portal_url="https://p.example.com/")
url = subscription_manage_url(s)
assert url == "https://p.example.com/manage-subscription"
assert "org_id" not in url
def test_manage_url_none_without_portal():
assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url=None)) is None
def test_manage_url_none_for_garbage_portal():
# No scheme/netloc → can't build a deep-link; fail closed (None), not crash.
assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url="not a url")) is None
# ── payload parser ───────────────────────────────────────────────────
def test_parser_maps_camelCase_payload_fields():
payload = {
"org": {"name": "Acme", "id": "org_1", "role": "ADMIN"},
"context": "personal",
"current": {
"tierId": "plus",
"tierName": "Plus",
"monthlyCredits": "1000",
"creditsRemaining": "420",
"cycleEndsAt": "2026-07-01",
"cancelAtPeriodEnd": True,
"cancellationEffectiveAt": "2026-07-01",
},
"tiers": [
{"tierId": "free", "name": "Free", "tierOrder": 0, "monthlyCredits": "0"},
{"tierId": "plus", "name": "Plus", "tierOrder": 1, "dollarsPerMonth": "20", "monthlyCredits": "1000", "isCurrent": True},
],
}
s = subscription_state_from_payload(payload, portal_url="https://p/billing")
assert s.logged_in is True
assert s.org_name == "Acme" and s.org_id == "org_1"
assert s.is_admin is True and s.can_change_plan is True
assert s.current is not None
assert s.current.tier_name == "Plus"
assert s.current.cancel_at_period_end is True
assert s.current.monthly_credits == Decimal("1000")
assert len(s.tiers) == 2
assert s.tiers[1].is_current is True
def test_parser_no_plan_is_none_not_all_null_object():
# "No plan" is current:null on the wire; a current-shaped dict with no
# tierId must parse to None (not an all-null CurrentSubscription).
s = subscription_state_from_payload({"current": {"tierId": None}}, portal_url=None)
assert s.current is None
def test_parser_member_role_cannot_change_plan():
s = subscription_state_from_payload({"org": {"role": "MEMBER"}}, portal_url=None)
assert s.is_admin is False
assert s.can_change_plan is False
def test_parser_defaults_unknown_context_to_personal():
s = subscription_state_from_payload({"context": "wat"}, portal_url=None)
assert s.context == "personal"
# ── dev fixtures (env-driven, no live portal) ────────────────────────
def test_no_fixture_when_env_unset(monkeypatch):
monkeypatch.delenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", raising=False)
assert dev_fixture_subscription_state() is None
@pytest.mark.parametrize(
"name,checker",
[
("free", lambda s: s.logged_in and s.current is None and len(s.tiers) == 4),
("mid", lambda s: s.current and s.current.tier_id == "plus"),
("top", lambda s: s.current and s.current.tier_id == "ultra"),
("not-admin", lambda s: s.role == "MEMBER" and not s.can_change_plan),
("downgrade", lambda s: s.current and s.current.pending_downgrade_tier_name == "Plus"),
("cancel", lambda s: s.current and s.current.cancel_at_period_end),
("team", lambda s: s.context == "team" and s.current is None),
("logged-out", lambda s: not s.logged_in),
],
)
def test_dev_fixture_states(monkeypatch, name, checker):
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", name)
s = dev_fixture_subscription_state()
assert s is not None
assert checker(s)
def test_dev_fixture_unknown_name_fails_safe(monkeypatch):
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "bogus")
s = dev_fixture_subscription_state()
assert s is not None
assert s.logged_in is False
assert s.error and "bogus" in s.error
def test_build_subscription_state_uses_fixture(monkeypatch):
# build_subscription_state must short-circuit to the fixture (no portal call).
monkeypatch.setenv("HERMES_DEV_SUBSCRIPTION_FIXTURE", "mid")
s = build_subscription_state()
assert s.logged_in is True
assert s.current is not None and s.current.tier_id == "plus"
# The manage URL is buildable from the fixture's portal_url + org_id.
url = subscription_manage_url(s)
assert url is not None
assert url.endswith("/manage-subscription?org_id=org_acme")

View File

@@ -0,0 +1,120 @@
"""Tests for the Remote-Spending gate denial contract (NAS PR #481).
Behavior contracts: the HTTP→exception mapping in
``hermes_cli.nous_billing._raise_for_error`` and the
``tui_gateway.server._serialize_billing_error`` envelope the TUI branches on.
These assert the wire contract (CF-4) — error code, actor, recovery, retry —
not specific copy.
"""
import pytest
from hermes_cli.nous_billing import (
BillingError,
BillingRateLimited,
BillingRemoteSpendingRevoked,
BillingScopeRequired,
BillingSessionRevoked,
_raise_for_error,
)
def _raise(status, payload, headers=None):
"""Run _raise_for_error and return the exception it raises."""
with pytest.raises(BillingError) as ei:
_raise_for_error(status, payload, headers)
return ei.value
# ── exception mapping (hermes_cli.nous_billing) ──────────────────────
def test_403_remote_spending_revoked_maps_to_typed_exc_with_actor():
exc = _raise(403, {"error": "remote_spending_revoked", "recovery": "reconnect", "actor": "admin"})
assert isinstance(exc, BillingRemoteSpendingRevoked)
assert exc.actor == "admin"
assert exc.recovery == "reconnect"
def test_403_revoked_absent_actor_is_none_not_crash():
exc = _raise(403, {"error": "remote_spending_revoked"})
assert isinstance(exc, BillingRemoteSpendingRevoked)
assert exc.actor is None # surface treats absent as "self"
def test_401_session_revoked_is_distinct_from_plain_401():
revoked = _raise(401, {"error": "session_revoked", "recovery": "login"})
assert isinstance(revoked, BillingSessionRevoked)
assert revoked.recovery == "login"
plain = _raise(401, {"error": "invalid_token"})
assert not isinstance(plain, BillingSessionRevoked)
def test_403_insufficient_scope_still_maps_to_scope_required():
exc = _raise(403, {"error": "insufficient_scope"})
assert isinstance(exc, BillingScopeRequired)
# NOT mistaken for a revoke.
assert not isinstance(exc, BillingRemoteSpendingRevoked)
def test_503_is_rate_limited_not_revoked_and_carries_retry_after():
exc = _raise(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"})
assert isinstance(exc, BillingRateLimited)
assert not isinstance(exc, BillingRemoteSpendingRevoked)
assert exc.retry_after == 30
def test_403_business_denial_carries_code_and_recovery():
exc = _raise(403, {
"error": "cli_billing_disabled",
"code": "remote_spending_disabled",
"recovery": "enable_account_toggle",
"portalUrl": "/billing",
})
# Generic BillingError (not a typed revoke) — the surface maps on code.
assert type(exc) is BillingError
assert exc.error == "cli_billing_disabled"
assert exc.code == "remote_spending_disabled"
assert exc.recovery == "enable_account_toggle"
def test_409_idempotency_conflict_passes_through():
exc = _raise(409, {"error": "idempotency_conflict", "message": "same key, different amount"})
assert exc.error == "idempotency_conflict"
# ── envelope serialization (tui_gateway.server) ──────────────────────
def _serialize(status, payload, headers=None):
import tui_gateway.server as srv
return srv._serialize_billing_error(_raise(status, payload, headers))
def test_envelope_threads_actor_code_recovery():
env = _serialize(403, {"error": "remote_spending_revoked", "actor": "admin", "recovery": "reconnect"})
assert env["error"] == "remote_spending_revoked"
assert env["actor"] == "admin"
assert env["recovery"] == "reconnect"
assert env["ok"] is False
def test_envelope_session_revoked_kind():
env = _serialize(401, {"error": "session_revoked", "recovery": "login"})
assert env["error"] == "session_revoked"
assert env["recovery"] == "login"
def test_envelope_503_is_rate_limited_with_retry():
env = _serialize(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"})
assert env["error"] == "rate_limited"
assert env["retry_after"] == 30
def test_envelope_business_code_survives():
env = _serialize(403, {"error": "cli_billing_disabled", "code": "remote_spending_disabled", "recovery": "enable_account_toggle"})
assert env["error"] == "cli_billing_disabled"
assert env["code"] == "remote_spending_disabled"
assert env["recovery"] == "enable_account_toggle"

View File

@@ -5491,11 +5491,17 @@ def _serialize_billing_error(exc) -> dict:
"""Map a BillingError into the result.error envelope the TUI branches on."""
from hermes_cli.nous_billing import (
BillingRateLimited,
BillingRemoteSpendingRevoked,
BillingScopeRequired,
BillingSessionRevoked,
)
kind = "error"
if isinstance(exc, BillingScopeRequired):
if isinstance(exc, BillingRemoteSpendingRevoked):
kind = "remote_spending_revoked"
elif isinstance(exc, BillingSessionRevoked):
kind = "session_revoked"
elif isinstance(exc, BillingScopeRequired):
kind = "insufficient_scope"
elif isinstance(exc, BillingRateLimited):
kind = "rate_limited"
@@ -5508,6 +5514,11 @@ def _serialize_billing_error(exc) -> dict:
"portal_url": getattr(exc, "portal_url", None),
"retry_after": getattr(exc, "retry_after", None),
"payload": getattr(exc, "payload", {}) or {},
# Remote-Spending contract extras (threaded so the TUI can render
# actor-aware copy + route recovery without re-parsing the message).
"actor": getattr(exc, "actor", None),
"code": getattr(exc, "code", None),
"recovery": getattr(exc, "recovery", None),
}
@@ -5580,6 +5591,71 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"})
def _serialize_subscription_state(state) -> dict:
"""Serialize a SubscriptionState for the wire (Decimals → strings)."""
from agent.billing_view import format_money
def _s(value):
return None if value is None else str(value)
current = None
if state.current is not None:
c = state.current
current = {
"tier_id": c.tier_id,
"tier_name": c.tier_name,
"monthly_credits": _s(c.monthly_credits),
"credits_remaining": _s(c.credits_remaining),
"cycle_ends_at": c.cycle_ends_at,
"pending_downgrade_tier_name": c.pending_downgrade_tier_name,
"pending_downgrade_at": c.pending_downgrade_at,
"cancel_at_period_end": c.cancel_at_period_end,
"cancellation_effective_at": c.cancellation_effective_at,
}
tiers = []
for t in state.tiers:
tiers.append({
"tier_id": t.tier_id,
"name": t.name,
"tier_order": t.tier_order,
"dollars_per_month_display": format_money(t.dollars_per_month),
"monthly_credits": _s(t.monthly_credits),
"is_current": t.is_current,
"is_enabled": t.is_enabled,
})
return {
"ok": True,
"logged_in": state.logged_in,
"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,
}
@method("subscription.state")
def _(rid, params: dict) -> dict:
"""GET /api/billing/subscription → serialized SubscriptionState.
Fail-open like billing.state: logged-out / unreachable portal →
{ok:true, logged_in:false}. No scope required (read-only).
"""
try:
from agent.subscription_view import build_subscription_state
state = build_subscription_state()
return _ok(rid, _serialize_subscription_state(state))
except Exception:
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load subscription state"})
@method("billing.charge")
def _(rid, params: dict) -> dict:
"""POST /api/billing/charge → {ok, chargeId} or a typed error envelope.

View File

@@ -0,0 +1,242 @@
/**
* Billing/Subscription TUI fixture harness — renders any single overlay STATE
* live in the terminal so it can be screenshotted (tmux) and UX-reviewed.
*
* This is a DEV/REVIEW tool, not shipped behaviour. It bypasses the gateway and
* mounts the real Ink overlay components directly with a hand-built state object,
* exactly the way the vitest render tests do — so what you see is pixel-identical
* to what `/subscription` and `/topup` draw at runtime.
*
* Usage:
* npx tsx scripts/billing-fixtures.tsx <fixture-name>
* npx tsx scripts/billing-fixtures.tsx --list
*
* Drive a specific screen of a fixture with SCREEN=<screen>, e.g.:
* SCREEN=confirm npx tsx scripts/billing-fixtures.tsx sub-free
* SCREEN=handoff npx tsx scripts/billing-fixtures.tsx sub-mid
*
* The selection cursor can be moved with ↑/↓ once it's live (the components own
* their own useInput); Esc/Enter behave as in production. Ctrl-C to exit.
*/
import { render } from '@hermes/ink'
import React from 'react'
import type { BillingOverlayState, SubscriptionOverlayState, SubscriptionScreen } from '../src/app/interfaces.js'
import { BillingOverlay } from '../src/components/billingOverlay.js'
import { SubscriptionOverlay } from '../src/components/subscriptionOverlay.js'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from '../src/gatewayTypes.js'
import { DEFAULT_THEME } from '../src/theme.js'
const t = DEFAULT_THEME
// ── helpers ──────────────────────────────────────────────────────────
const tier = (o: Partial<SubscriptionTierOption> = {}): SubscriptionTierOption => ({
tier_id: 'free',
name: 'Free',
tier_order: 0,
dollars_per_month_display: '$0',
monthly_credits: '0',
is_current: false,
is_enabled: true,
...o
})
const TIERS = {
free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }),
plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }),
super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }),
ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' })
}
const tierList = (currentId?: string): SubscriptionTierOption[] =>
Object.values(TIERS).map(x => ({ ...x, is_current: x.tier_id === currentId }))
const subState = (o: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme Inc',
org_id: 'org_acme',
role: 'OWNER',
context: 'personal',
current: null,
tiers: tierList(),
portal_url: 'https://portal.nousresearch.com/billing',
...o
})
const cur = (o: Record<string, unknown> = {}) => ({
tier_id: 'plus',
tier_name: 'Plus',
monthly_credits: '1000',
credits_remaining: '420',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null,
cancel_at_period_end: false,
cancellation_effective_at: null,
...o
})
const subCtx: SubscriptionOverlayState['ctx'] = {
openManageLink: () => Promise.resolve(true),
refreshState: () => Promise.resolve(null),
sys: () => {}
}
const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pendingTargetTierId: string | null = null): SubscriptionOverlayState => ({
ctx: subCtx,
screen,
state: s,
pendingTargetTierId
})
// ── billing/topup fixtures ───────────────────────────────────────────
const billState = (o: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
cli_billing_enabled: true,
can_charge: true,
card: { brand: 'Visa', last4: '4242', masked: 'Visa •••• 4242' },
balance_display: '$12.00',
balance_usd: '12.00',
min_usd: '5',
max_usd: '500',
monthly_cap: {
is_default_ceiling: false,
limit_display: '$20',
limit_usd: '20',
spent_display: '$8.00',
spent_this_month_usd: '8'
},
auto_reload: { enabled: false, reload_to_display: '$25', reload_to_usd: '25', threshold_display: '$5', threshold_usd: '5' },
org_name: 'Acme Inc',
role: 'OWNER',
portal_url: 'https://portal.nousresearch.com/billing',
charge_presets: ['10', '25', '50', '100'],
charge_presets_display: ['$10', '$25', '$50', '$100'],
...o
})
const billCtx = {
applyAutoReload: () => Promise.resolve(true),
charge: () => Promise.resolve('submitted' as const),
openPortal: () => {},
requestRemoteSpending: () => Promise.resolve(true),
sys: () => {},
validate: (raw: string) => ({ amount: raw })
}
const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({
ctx: billCtx,
pendingCharge: screen === 'confirm' || screen === 'stepup' ? { amount: '100' } : null,
screen,
state: s
})
// ── fixture registry ─────────────────────────────────────────────────
type Fixture = { desc: string; node: React.ReactElement }
const subEl = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pending: string | null = null) =>
React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay: sub(s, screen, pending), t })
const billEl = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview') =>
React.createElement(BillingOverlay, { onClose: () => {}, onPatch: () => {}, overlay: bill(s, screen), t })
const FIXTURES: Record<string, Fixture> = {
// /subscription — overview states
'sub-free': {
desc: 'Free / no sub — upgradeable (primary conversion state)',
node: subEl(subState({ current: null }))
},
'sub-mid': {
desc: 'Subscriber mid-tier (Plus) — usage bar + up/downgrade targets',
node: subEl(subState({ current: cur(), tiers: tierList('plus') }))
},
'sub-top': {
desc: 'Subscriber top-tier (Ultra) — "on the top plan"',
node: subEl(subState({ current: cur({ tier_id: 'ultra', tier_name: 'Ultra', monthly_credits: '7000', credits_remaining: '5000' }), tiers: tierList('ultra') }))
},
'sub-not-admin': {
desc: 'Member (not admin/owner) — read-only, no tier picker',
node: subEl(subState({ is_admin: false, can_change_plan: false, role: 'MEMBER', current: cur(), tiers: tierList('plus') }))
},
'sub-downgrade': {
desc: 'Downgrade scheduled — pending-switch banner',
node: subEl(subState({ current: cur({ pending_downgrade_tier_name: 'Plus', pending_downgrade_at: '2026-07-15' }), tiers: tierList('super') }))
},
'sub-cancel': {
desc: 'Cancellation scheduled — stays active until effective date',
node: subEl(subState({ current: cur({ cancel_at_period_end: true, cancellation_effective_at: '2026-07-01' }), tiers: tierList('plus') }))
},
'sub-team': {
desc: 'Team org context — shared credits, redirect to /topup',
node: subEl(subState({ context: 'team', current: null, org_name: 'Acme Engineering' }))
},
// /subscription — non-overview screens
'sub-confirm': {
desc: 'Confirm plan change (deep-link, no in-terminal charge)',
node: subEl(subState({ current: cur(), tiers: tierList('plus') }), 'confirm', 'super')
},
'sub-confirm-new': {
desc: 'Confirm first subscription (free → paid)',
node: subEl(subState({ current: null }), 'confirm', 'plus')
},
'sub-handoff': {
desc: 'Handoff transient — opening subscription page in browser',
node: subEl(subState({ current: cur() }), 'handoff')
},
// /topup (renamed /billing)
'topup-overview': {
desc: '/topup overview — admin, card on file, full menu',
node: billEl(billState())
},
'topup-no-card': {
desc: '/topup overview — admin, NO saved card (card hint)',
node: billEl(billState({ card: null }))
},
'topup-not-admin': {
desc: '/topup overview — member, read-only',
node: billEl(billState({ is_admin: false }))
},
'topup-disabled': {
desc: '/topup overview — terminal billing OFF for org',
node: billEl(billState({ cli_billing_enabled: false }))
},
'topup-buy': {
desc: '/topup buy screen — presets',
node: billEl(billState(), 'buy')
},
'topup-stepup': {
desc: '/topup step-up — "Allow Remote Spending" (resumable, holds $100 buy)',
node: billEl(billState(), 'stepup')
}
}
// ── driver ───────────────────────────────────────────────────────────
const arg = process.argv[2]
if (!arg || arg === '--list' || arg === '-l') {
const names = Object.keys(FIXTURES)
process.stdout.write('Billing/Subscription TUI fixtures:\n\n')
for (const name of names) {
process.stdout.write(` ${name.padEnd(18)} ${FIXTURES[name]!.desc}\n`)
}
process.stdout.write(`\n ${names.length} fixtures. Run: npx tsx scripts/billing-fixtures.tsx <name>\n`)
process.exit(0)
}
const fixture = FIXTURES[arg]
if (!fixture) {
process.stderr.write(`Unknown fixture: ${arg}\nRun with --list to see all.\n`)
process.exit(1)
}
render(fixture.node)

View File

@@ -0,0 +1,107 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
// Stub useInput so the overlay doesn't enter raw mode under renderSync.
vi.mock('@hermes/ink', async importOriginal => {
const mod = await importOriginal()
return { ...mod, useInput: () => {} }
})
import type { BillingOverlayState } from '../app/interfaces.js'
import { BillingOverlay } from '../components/billingOverlay.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
function render(overlay: BillingOverlayState): string {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
React.createElement(BillingOverlay, {
onClose: () => {},
onPatch: () => {},
overlay,
t
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
return stripAnsi(output)
}
const billState = (): BillingStateResponse =>
({
auto_reload: null,
balance_display: '$12.00',
balance_usd: '12',
can_charge: true,
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
charge_presets: ['25', '50'],
charge_presets_display: ['$25', '$50'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '1000',
min_usd: '10',
monthly_cap: null,
ok: true,
org_name: 'Acme',
portal_url: 'https://portal/billing',
role: 'OWNER'
}) as BillingStateResponse
const ctx = {
applyAutoReload: vi.fn(() => Promise.resolve(true)),
charge: vi.fn(() => Promise.resolve('submitted' as const)),
openPortal: vi.fn(),
requestRemoteSpending: vi.fn(() => Promise.resolve(true)),
sys: vi.fn(),
validate: vi.fn((raw: string) => ({ amount: raw }))
}
const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => ({
ctx,
pendingCharge: { amount: '100' },
screen,
state: billState()
})
describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => {
it('renders the Allow Remote Spending prompt with the held amount', () => {
const out = render(overlay('stepup'))
expect(out).toContain('Allow Remote Spending')
expect(out).toContain('one-time browser authorization')
expect(out).toContain('$100') // resumes the held purchase
expect(out).toContain('Not now')
})
it('NEVER leaks the raw billing:manage scope in copy', () => {
const out = render(overlay('stepup'))
expect(out).not.toContain('billing:manage')
})
})

View File

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { subscriptionCommands } from '../app/slash/commands/subscription.js'
import { findSlashCommand } from '../app/slash/registry.js'
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
const subscriptionCommand = subscriptionCommands.find(cmd => cmd.name === 'subscription')!
const loggedInState = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme',
role: 'OWNER',
current: null,
tiers: [],
portal_url: 'https://portal.nousresearch.com/billing',
...overrides
})
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const calls: Array<{ method: string; params: unknown }> = []
const rpc = vi.fn((method: string, params: unknown) => {
calls.push({ method, params })
return Promise.resolve(results[method])
})
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), panel: vi.fn(), sys }
}
const run = async (arg: string) => {
subscriptionCommand.run(arg, ctx as any, 'subscription')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { calls, ctx, rpc, run, sys }
}
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
describe('/subscription slash command', () => {
beforeEach(() => {
resetOverlayState()
})
it('fetches subscription.state and opens the overlay', async () => {
const { run } = buildCtx({
'subscription.state': loggedInState({ tiers: [{ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: false, is_enabled: true }] })
})
await run('')
const overlay = getOverlayState().subscription
expect(overlay).not.toBeNull()
expect(overlay?.screen).toBe('overview')
expect(overlay?.state.tiers).toHaveLength(1)
})
it('shows portal-login sys line when not logged in', async () => {
const { run, sys } = buildCtx({
'subscription.state': loggedInState({ logged_in: false })
})
await run('')
expect(printed(sys)).toContain('Not logged into Nous Portal')
expect(getOverlayState().subscription).toBeNull()
})
it('/upgrade alias resolves to the same command', () => {
expect(findSlashCommand('upgrade')).toBe(subscriptionCommand)
})
it('/subscription resolves to the same command', () => {
expect(findSlashCommand('subscription')).toBe(subscriptionCommand)
})
})

View File

@@ -0,0 +1,241 @@
import { PassThrough } from 'stream'
import { renderSync } from '@hermes/ink'
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
// Stub useInput so the overlay doesn't try to enter raw mode under renderSync
// (PassThrough stdin doesn't support it). Box/Text pass through to real Ink.
vi.mock('@hermes/ink', async importOriginal => {
const mod = await importOriginal()
return {
...mod,
useInput: () => {}
}
})
import type { SubscriptionOverlayState } from '../app/interfaces.js'
import { SubscriptionOverlay } from '../components/subscriptionOverlay.js'
import type { SubscriptionStateResponse, SubscriptionTierOption } from '../gatewayTypes.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
const t = DEFAULT_THEME
/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */
function render(overlay: SubscriptionOverlayState): string {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
React.createElement(SubscriptionOverlay, {
onClose: () => {},
onPatch: () => {},
overlay,
t
}),
{
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
return stripAnsi(output)
}
const tier = (overrides: Partial<SubscriptionTierOption> = {}): SubscriptionTierOption => ({
tier_id: 'free',
name: 'Free',
tier_order: 0,
dollars_per_month_display: '$0',
monthly_credits: '0',
is_current: false,
is_enabled: true,
...overrides
})
const state = (overrides: Partial<SubscriptionStateResponse> = {}): 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 = {
openManageLink: vi.fn(() => Promise.resolve(true)),
refreshState: vi.fn(() => Promise.resolve(null)),
sys: vi.fn()
}
const overlay = (s: SubscriptionStateResponse, screen: SubscriptionOverlayState['screen'] = 'overview'): SubscriptionOverlayState => ({
ctx,
screen,
state: s,
pendingTargetTierId: null
})
describe('SubscriptionOverlay — overview screen', () => {
it('(a) free-upgradeable: shows tier list + subscribe header', () => {
const s = state({
current: null,
tiers: [
tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }),
tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: false }),
tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000', is_current: false })
]
})
const out = render(overlay(s))
expect(out).toContain('Subscribe to a plan')
expect(out).toContain('Pro')
expect(out).toContain('Scale')
expect(out).toContain('$20')
expect(out).toContain('1000 credits')
})
it('(b) subscriber mid-tier: shows current plan + usage bar', () => {
const s = state({
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '420',
cycle_ends_at: '2026-07-01T00:00:00Z',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
tiers: [
tier({ tier_id: 'free', name: 'Free', tier_order: 0 }),
tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000', is_current: true }),
tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000' })
]
})
const out = render(overlay(s))
expect(out).toContain('Your plan: Pro')
expect(out).toContain('remaining')
expect(out).toContain('Scale')
})
it('(c) subscriber top-tier: shows top plan note', () => {
const s = state({
current: {
tier_id: 'scale',
tier_name: 'Scale',
monthly_credits: '5000',
credits_remaining: '3000',
cycle_ends_at: '2026-07-01T00:00:00Z',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
tiers: [
tier({ tier_id: 'free', name: 'Free', tier_order: 0 }),
tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: false }),
tier({ tier_id: 'scale', name: 'Scale', tier_order: 2, dollars_per_month_display: '$99', monthly_credits: '5000', is_current: true })
]
})
const out = render(overlay(s))
expect(out).toContain('Your plan: Scale')
expect(out).toContain("You're on the top plan.")
})
it('(d) not-admin: shows read-only note + no tier list', () => {
const s = state({
is_admin: false,
can_change_plan: false,
role: 'MEMBER',
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01T00:00:00Z',
pending_downgrade_tier_name: null,
pending_downgrade_at: null
},
tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })]
})
const out = render(overlay(s))
expect(out).toContain('Plan changes need an org admin/owner.')
expect(out).toContain('Manage on portal')
})
it('(e) downgrade-pending: shows scheduled switch banner', () => {
const s = state({
current: {
tier_id: 'pro',
tier_name: 'Pro',
monthly_credits: '1000',
credits_remaining: '500',
cycle_ends_at: '2026-07-01T00:00:00Z',
pending_downgrade_tier_name: 'Free',
pending_downgrade_at: '2026-07-15T00:00:00Z'
},
tiers: [
tier({ tier_id: 'free', name: 'Free', tier_order: 0 }),
tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, is_current: true })
]
})
const out = render(overlay(s))
expect(out).toContain('Scheduled to switch to Free')
expect(out).toContain('2026-07-15T00:00:00Z')
})
})
describe('SubscriptionOverlay — confirm screen', () => {
it('shows tier summary + Stripe disclosure', () => {
const s = state({
current: null,
tiers: [tier({ tier_id: 'pro', name: 'Pro', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1000' })]
})
const out = render({ ...overlay(s, 'confirm'), pendingTargetTierId: 'pro' })
expect(out).toContain('Confirm subscription')
expect(out).toContain('Pro')
expect(out).toContain('$20')
expect(out).toContain('securely on your subscription page')
expect(out).toContain('Continue to your subscription page')
})
})
describe('SubscriptionOverlay — handoff screen', () => {
it('shows opening-subscription-page copy', () => {
const out = render(overlay(state(), 'handoff'))
expect(out).toContain('Opening your subscription page')
expect(out).toContain('browser')
expect(out).toContain('Re-run /subscription')
})
})

View File

@@ -1,14 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { billingCommands } from '../app/slash/commands/billing.js'
import { topupCommands } from '../app/slash/commands/topup.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
vi.mock('../lib/openExternalUrl.js', () => ({
openExternalUrl: vi.fn(() => true)
}))
const billingCommand = billingCommands.find(cmd => cmd.name === 'billing')!
const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')!
const ownerState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
auto_reload: {
@@ -72,7 +72,7 @@ const buildCtx = (results: Record<string, unknown>) => {
}
const run = async (arg: string) => {
billingCommand.run(arg, ctx as any, 'billing')
topupCommand.run(arg, ctx as any, 'topup')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
@@ -231,19 +231,132 @@ describe('/billing slash command (overlay-driven)', () => {
expect(out).toContain('Portal: /billing?topup=open')
})
it('ctx.charge insufficient_scope → arms step-up confirm', async () => {
it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => {
const { run } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' }
})
await run('')
const outcome = await getOverlayState().billing!.ctx.charge('100')
// No separate confirm overlay is armed anymore — the overlay's stepup
// screen owns the UX; the ctx just reports the outcome.
expect(outcome).toBe('needs_remote_spending')
expect(getOverlayState().confirm).toBeNull()
})
it('ctx.requestRemoteSpending → billing.step_up, resolves granted', async () => {
const { run, calls } = buildCtx({
'billing.state': ownerState(),
'billing.step_up': { ok: true, granted: true }
})
await run('')
const granted = await getOverlayState().billing!.ctx.requestRemoteSpending()
expect(granted).toBe(true)
const su = calls.find(c => c.method === 'billing.step_up')
expect(su).toBeTruthy()
})
it('ctx.requestRemoteSpending → not granted resolves false', async () => {
const { run } = buildCtx({
'billing.state': ownerState(),
'billing.step_up': { ok: true, granted: false }
})
await run('')
const granted = await getOverlayState().billing!.ctx.requestRemoteSpending()
expect(granted).toBe(false)
})
it('ctx.charge happy path resolves submitted', async () => {
vi.useFakeTimers()
try {
const { run } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
'billing.charge_status': { ok: true, status: 'settled', amount_usd: '100' }
})
await run('')
const outcome = await getOverlayState().billing!.ctx.charge('100')
expect(outcome).toBe('submitted')
await vi.runAllTimersAsync()
} finally {
vi.useRealTimers()
}
})
// ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ──
it('ctx.charge remote_spending_revoked (admin) → clears overlay + admin copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'remote_spending_revoked', actor: 'admin', recovery: 'reconnect', idempotency_key: 'k' }
})
await run('')
expect(getOverlayState().billing).toBeTruthy()
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
// The charge failed with insufficient_scope → a NEW confirm (step-up) is armed.
const stepUp = getOverlayState().confirm
expect(stepUp?.title).toBe('Grant terminal billing access?')
const out = printed(sys)
expect(out).toContain('An admin stopped this terminal')
expect(out).toContain('Reconnect to restore')
// Spend UI is killed immediately — no zombie button waiting for refresh.
expect(getOverlayState().billing).toBeNull()
})
it('ctx.charge remote_spending_revoked (self) → self copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'remote_spending_revoked', actor: 'self', recovery: 'reconnect', idempotency_key: 'k' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain('You stopped this terminal')
expect(getOverlayState().billing).toBeNull()
})
it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' }
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
expect(printed(sys)).toContain('Your session was logged out')
expect(getOverlayState().billing).toBeNull()
})
it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => {
const { run, sys } = buildCtx({
'billing.state': ownerState(),
'billing.charge': {
ok: false,
error: 'cli_billing_disabled',
code: 'remote_spending_disabled',
recovery: 'enable_account_toggle',
portal_url: '/billing',
idempotency_key: 'k'
}
})
await run('')
getOverlayState().billing!.ctx.charge('100')
await Promise.resolve()
await Promise.resolve()
const out = printed(sys)
expect(out).toContain('Remote Spending is off for this account')
// Account-wide switch is NOT a per-terminal revoke — overlay stays open.
expect(getOverlayState().billing).toBeTruthy()
})
it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => {

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sessionCommands } from '../app/slash/commands/session.js'
import type { SessionUsageResponse } from '../gatewayTypes.js'
const usageCommand = sessionCommands.find(cmd => cmd.name === 'usage')!
const USAGE_CTA = 'Run /subscription to change plan · /topup to add credits'
const guarded =
<T>(fn: (r: T) => void) =>
(r: null | T) => {
if (r) {
fn(r)
}
}
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
const sys = vi.fn()
const panel = vi.fn()
const rpc = vi.fn((method: string, _params: unknown) => {
return Promise.resolve(results[method])
})
const ctx = {
gateway: { rpc },
guarded,
guardedErr: vi.fn(),
sid: 'sid-1',
stale: () => false,
transcript: { page: vi.fn(), panel, sys }
}
const run = async (arg: string) => {
usageCommand.run(arg, ctx as any, 'usage')
await rpc.mock.results[0]?.value
await Promise.resolve()
await Promise.resolve()
}
return { ctx, panel, run, sys }
}
const baseUsage = (overrides: Partial<SessionUsageResponse> = {}): SessionUsageResponse =>
({
calls: 0,
input: 0,
output: 0,
total: 0,
...overrides
}) as SessionUsageResponse
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
describe('/usage slash command', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('appends the CTA in the with-calls render (healthy path)', async () => {
const { run, sys } = buildCtx({
'session.usage': baseUsage({
calls: 12,
input: 1000,
output: 500,
total: 1500,
model: 'test-model',
credits_lines: ['$50.00 remaining']
})
})
await run('')
expect(printed(sys)).toContain(USAGE_CTA)
})
it('appends the CTA in the no-calls render (depleted/empty path)', async () => {
const { run, sys } = buildCtx({
'session.usage': baseUsage({ calls: 0, credits_lines: [] })
})
await run('')
expect(printed(sys)).toContain('no API calls yet')
expect(printed(sys)).toContain(USAGE_CTA)
})
it('appends the CTA when credits exist but there are no calls', async () => {
const { run, sys } = buildCtx({
'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] })
})
await run('')
expect(printed(sys)).toContain(USAGE_CTA)
expect(printed(sys)).not.toContain('no API calls yet')
})
})

View File

@@ -3,7 +3,7 @@ import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'rea
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { BillingStateResponse, ImageAttachResponse, SessionCloseResponse } from '../gatewayTypes.js'
import type { BillingStateResponse, ImageAttachResponse, SessionCloseResponse, SubscriptionStateResponse } from '../gatewayTypes.js'
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
@@ -92,7 +92,13 @@ export interface GatewayProviderProps {
// the SAME RPCs as the old slash flows (billing.charge / charge_status /
// auto_reload / step_up). Backend is unchanged & shared with the CLI.
export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview'
export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overview' | 'stepup'
/** Outcome of a charge attempt — lets the overlay route without tearing down. */
export type BillingChargeOutcome =
| 'submitted' // 202 accepted; settlement is reported via transcript lines
| 'needs_remote_spending' // insufficient_scope → route to the stepup screen
| 'error' // any other failure (already surfaced via sys)
/**
* The functions the overlay needs to talk to the gateway and emit
@@ -104,8 +110,19 @@ export type BillingScreen = 'autoreload' | 'buy' | 'confirm' | 'limit' | 'overvi
export interface BillingOverlayCtx {
/** Run `billing.auto_reload` (enabled/threshold/top_up) → resolve ok/false. */
applyAutoReload: (enabled: boolean, threshold?: number, topUp?: number) => Promise<boolean>
/** Submit `billing.charge` for `amount` and poll to settlement (non-blocking). */
charge: (amount: string) => void
/**
* Submit `billing.charge` for `amount` and poll to settlement. Resolves a
* discriminated outcome so the overlay can route to the resumable step-up on
* `needs_remote_spending` instead of tearing down. Settlement/most errors are
* still reported via transcript lines (the poll is non-blocking).
*/
charge: (amount: string) => Promise<BillingChargeOutcome>
/**
* Run the `billing.step_up` device flow (grant Remote Spending). Resolves
* `true` when the grant lands. The browser opens via the gateway's
* out-of-band `billing.step_up.verification` event — the overlay just awaits.
*/
requestRemoteSpending: () => Promise<boolean>
/** Open the portal in the browser + echo a transcript line. */
openPortal: (url: string) => void
/** Emit a transcript system line. */
@@ -127,6 +144,30 @@ export interface BillingOverlayState {
state: BillingStateResponse
}
// ── Subscription overlay (deep-link only, NEVER charges in-terminal) ──
export type SubscriptionScreen =
| 'overview' // shows plan + usage bar + tier list (states ae collapse into this)
| 'confirm' // y/n confirm before opening the manage-subscription URL
| 'handoff' // transient: "Opening your subscription page in your browser…"
export interface SubscriptionOverlayCtx {
/** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */
openManageLink: () => Promise<boolean>
/** Re-fetch subscription.state. */
refreshState: () => Promise<SubscriptionStateResponse | null>
/** Emit a transcript system line. */
sys: (text: string) => void
}
export interface SubscriptionOverlayState {
ctx: SubscriptionOverlayCtx
screen: SubscriptionScreen
state: SubscriptionStateResponse
/** The tier the user selected on the overview, summarized on the confirm screen. */
pendingTargetTierId?: string | null
}
export interface OverlayState {
agents: boolean
agentsInitialHistoryIndex: number
@@ -140,6 +181,7 @@ export interface OverlayState {
secret: null | SecretReq
sessions: boolean
skillsHub: boolean
subscription: SubscriptionOverlayState | null
sudo: null | SudoReq
}

View File

@@ -15,6 +15,7 @@ const buildOverlayState = (): OverlayState => ({
secret: null,
sessions: false,
skillsHub: false,
subscription: null,
sudo: null
})
@@ -22,7 +23,7 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ agents, approval, billing, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) =>
({ agents, approval, billing, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, subscription, sudo }) =>
Boolean(
agents ||
approval ||
@@ -35,6 +36,7 @@ export const $isBlocked = computed(
secret ||
sessions ||
skillsHub ||
subscription ||
sudo
)
)

View File

@@ -18,6 +18,8 @@ import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
const USAGE_CTA = 'Run /subscription to change plan · /topup to add credits'
const TUI_SESSION_MODEL_RE = new RegExp(`(?:^|\\s)${TUI_SESSION_MODEL_FLAG}(?:\\s|$)`)
const TUI_SESSION_STRIP_RE = new RegExp(`\\s*${TUI_SESSION_MODEL_FLAG}\\b\\s*`, 'g')
@@ -543,6 +545,8 @@ export const sessionCommands: SlashCommand[] = [
return
}
const sys = ctx.transcript.sys
if (r) {
patchUiState({
usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 }
@@ -553,14 +557,18 @@ export const sessionCommands: SlashCommand[] = [
// even with zero API calls or on a resumed session. Render it whenever
// present, before the token panel.
const creditsLines = r?.credits_lines ?? []
if (creditsLines.length) {
ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }])
}
if (!r?.calls) {
if (!creditsLines.length) {
ctx.transcript.sys('no API calls yet')
sys('no API calls yet')
}
sys(USAGE_CTA)
return
}
@@ -592,6 +600,8 @@ export const sessionCommands: SlashCommand[] = [
}
ctx.transcript.panel('Usage', sections)
sys(USAGE_CTA)
})
}
}

View File

@@ -0,0 +1,108 @@
import type { SubscriptionStateResponse } from '../../../gatewayTypes.js'
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
import type { SubscriptionOverlayCtx } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
type Sys = (text: string) => void
/**
* Build the manage-subscription URL locally from the loaded subscription state.
*
* Uses `portal_url` (the resolved portal base URL carried in the state) and
* `org_id` to construct `{portal_base}/manage-subscription?org_id=<id>`.
* `org_id` pins the page to the correct account in multi-org situations.
* Falls back to bare `/manage-subscription` if org_id is absent.
*/
function buildManageUrl(s: SubscriptionStateResponse): string | null {
// portal_url is already an absolute URL resolved by resolve_portal_base_url()
// on the Python side (e.g. https://portal.nousresearch.com/billing). Strip any
// path so we can attach /manage-subscription cleanly.
const base = s.portal_url
? new URL(s.portal_url).origin
: null
if (!base) {
return null
}
const url = new URL('/manage-subscription', base)
if (s.org_id) {
url.searchParams.set('org_id', s.org_id)
}
return url.toString()
}
/**
* Build the ctx the overlay uses to talk to the gateway + emit transcript
* lines. Mirrors topup.ts's buildOverlayCtx — all RPC + error-mapping logic
* lives here (single source of truth); the overlay only renders + routes keys.
*/
const buildSubscriptionCtx = (
ctx: SlashRunCtx,
sys: Sys,
initialState: SubscriptionStateResponse
): SubscriptionOverlayCtx => ({
openManageLink: () => {
const url = buildManageUrl(initialState)
if (!url) {
sys('Could not build manage URL — is your portal configured?')
return Promise.resolve(false)
}
const opened = openExternalUrl(url)
if (opened) {
sys('Opening your subscription page in the browser — finish the change there; re-run /subscription to confirm.')
} else {
sys('Could not open browser — visit your subscription page manually at ' + url)
}
return Promise.resolve(opened)
},
refreshState: () =>
ctx.gateway
.rpc<SubscriptionStateResponse>('subscription.state', {})
.then(r => r ?? null)
.catch(() => null),
sys
})
export const subscriptionCommands: SlashCommand[] = [
{
help: 'View or change your Nous subscription plan',
name: 'subscription',
aliases: ['upgrade'],
// ZERO sub-commands: bare `/subscription` fetches state and opens the
// overlay (deep-link only — NEVER charges in-terminal).
run: (_arg, ctx) => {
const sys: Sys = ctx.transcript.sys
ctx.gateway
.rpc<SubscriptionStateResponse>('subscription.state', {})
.then(
ctx.guarded<SubscriptionStateResponse>(s => {
if (!s.logged_in) {
sys('Not logged into Nous Portal — run /portal to log in, then /subscription.')
return
}
patchOverlayState({
subscription: {
ctx: buildSubscriptionCtx(ctx, sys, s),
pendingTargetTierId: null,
screen: 'overview',
state: s
}
})
})
)
.catch(ctx.guardedErr)
}
}
]

View File

@@ -6,7 +6,7 @@ import type {
BillingStateResponse
} from '../../../gatewayTypes.js'
import { openExternalUrl } from '../../../lib/openExternalUrl.js'
import type { BillingOverlayCtx } from '../../interfaces.js'
import type { BillingChargeOutcome, BillingOverlayCtx } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
@@ -21,10 +21,13 @@ const renderBillingError = (
sys: Sys,
ctx: SlashRunCtx,
env: {
actor?: string
code?: string
error?: string
message?: string
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
): void => {
@@ -32,9 +35,49 @@ const renderBillingError = (
switch (env.error) {
case 'insufficient_scope':
armStepUp(sys, ctx)
// Reached by non-charge mutations (e.g. auto-reload config) that need the
// Remote-Spending grant. The resumable step-up lives on the buy/charge
// path; point the user there rather than leaking the raw scope name.
sys('💳 This needs Remote Spending authorization. Start a credit purchase to authorize, then retry.')
break
case 'remote_spending_revoked': {
// CF-4: this terminal's spend was revoked. Kill the spend UI NOW (don't
// wait for the token refresh ~15 min away) and tell the user who did it.
patchOverlayState({ billing: null })
const who = env.actor === 'admin'
? 'An admin stopped this terminals spending.'
: 'You stopped this terminals spending.'
sys(`🔴 ${who} Reconnect to restore — run /portal to re-authorize this terminal.`)
return
}
case 'session_revoked':
// Stronger than a spend-revoke: the whole session is gone → full re-login.
patchOverlayState({ billing: null })
sys('🔴 Your session was logged out. Run /portal to log in again.')
return
case 'cli_billing_disabled':
case 'remote_spending_disabled':
// Account-wide switch is OFF (dual-emitted error/code). An admin must flip
// it on the portal; this is NOT a per-terminal revoke.
sys('🔴 Remote Spending is off for this account — an admin must enable it on the portal.')
break
case 'role_required':
sys('🔴 Buying credits needs an org admin/owner. Ask an admin, or manage on the portal.')
break
case 'idempotency_conflict':
sys('🔴 That charge key was already used for a different amount. Start a fresh top-up.')
break
case 'no_payment_method':
sys(
@@ -44,11 +87,6 @@ const renderBillingError = (
break
case 'cli_billing_disabled':
sys('🔴 Terminal billing is turned off for this org — an admin must enable it on the portal.')
break
case 'monthly_cap_exceeded': {
// Surface the remaining headroom the server attaches (parity with the CLI).
const remaining = env.payload?.remainingUsd
@@ -56,7 +94,10 @@ const renderBillingError = (
break
}
case 'rate_limited': {
case 'rate_limited':
case 'temporarily_unavailable': {
// 429 throttle OR 503 gate-fail-closed: NOT a payment failure, NOT a
// revoke. Back off and tell the user to retry.
const mins = env.retry_after ? ` (try again in ~${Math.max(1, Math.round(env.retry_after / 60))} min)` : ''
sys(`🟡 Too many charges right now${mins}. This isn't a payment failure.`)
@@ -72,65 +113,23 @@ const renderBillingError = (
}
}
/** 403 insufficient_scope → arm a ConfirmReq that runs the lazy step-up. */
const armStepUp = (sys: Sys, ctx: SlashRunCtx): void => {
sys('💳 Terminal billing needs an extra permission (billing:manage).')
patchOverlayState({
confirm: {
cancelLabel: 'Not now',
confirmLabel: 'Re-authorize',
detail: 'An org admin/owner must tick "Allow terminal billing" in the portal.',
onConfirm: () => {
// session_id lets the gateway route the billing.step_up.verification
// event (the verification link) back to this session — the device flow
// runs headless in the gateway, so the link can't be printed there.
ctx.gateway
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
.then(
ctx.guarded<BillingMutationResponse>(r => {
if (r.ok && r.granted) {
// Step-up only grants the billing:manage TOKEN scope — the ORG
// kill-switch (cli_billing_enabled) is a separate gate. Re-fetch
// /state so we don't over-promise "enabled" when a charge would
// still hit cli_billing_disabled.
sys('✅ Billing permission granted.')
ctx.gateway
.rpc<BillingStateResponse>('billing.state', {})
.then(
ctx.guarded<BillingStateResponse>(s => {
if (s.cli_billing_enabled) {
sys('Run /billing again to continue.')
} else {
sys(
'🟡 Permission granted, but terminal billing is still turned off ' +
'for this org. Enable it in the portal, then run /billing again.'
)
if (s.portal_url) {
sys(`Portal: ${s.portal_url}`)
}
}
})
)
.catch(() => {
sys('Run /billing again to continue.')
})
} else {
sys('🟡 Terminal billing was not granted (an admin must tick the box).')
}
})
)
.catch(() => {
// The device flow can outlive the RPC's 120s timeout while the user
// is still authorizing in the browser. A reject here is NOT a hard
// failure — the grant (if it lands) is persisted gateway-side; tell
// the user to re-run /billing rather than reporting an error.
sys('🟡 Still waiting on approval — finish in the browser, then run /billing again.')
})
},
title: 'Grant terminal billing access?'
}
})
}
/**
* Run the Remote-Spending device flow and resolve whether the grant landed.
*
* The browser opens via the gateway's out-of-band `billing.step_up.verification`
* event (handled globally in createGatewayEventHandler), so this just kicks the
* blocking `billing.step_up` RPC and awaits its result. A reject (the device
* flow can outlive the RPC's timeout while the user is still authorizing) is
* treated as "not yet granted" non-fatal; the grant persists gateway-side.
*
* NOTE: never surface the raw `billing:manage` scope the user-facing concept
* is "Remote Spending".
*/
const requestRemoteSpending = (ctx: SlashRunCtx): Promise<boolean> =>
ctx.gateway
.rpc<BillingMutationResponse>('billing.step_up', { session_id: ctx.sid ?? undefined })
.then(r => !!(r && r.ok && r.granted))
.catch(() => false)
/** Poll a charge to a terminal state (settled/failed/timeout). Non-blocking. */
const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: string | null): void => {
@@ -147,13 +146,23 @@ const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: st
ctx.guarded<BillingChargeStatusResponse>(r => {
if (!r.ok) {
// 429/503 while polling = retry-after, NOT a failure. Back off + continue.
if (r.error === 'rate_limited') {
if (r.error === 'rate_limited' || r.error === 'temporarily_unavailable') {
const wait = (r.retry_after ?? 5) * 1000
setTimeout(tick, Math.min(wait, 30000))
return
}
// CF-7 rule 4: a post-revoke 403 (or session loss) while polling means
// the prior charge's outcome is AMBIGUOUS — it may have settled. Do not
// call it failed; surface the revoke + tell the user to verify balance.
if (r.error === 'remote_spending_revoked' || r.error === 'session_revoked') {
renderBillingError(sys, ctx, r)
sys('🟡 Your last charges outcome is unconfirmed — check your balance/history before retrying.')
return
}
sys(`🔴 Could not check the charge: ${r.message || r.error || 'error'}`)
return
@@ -274,21 +283,39 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B
return false
}),
charge: (amount: string) => {
charge: (amount: string): Promise<BillingChargeOutcome> => {
sys('💳 Charge submitted — confirming settlement…')
ctx.gateway
return ctx.gateway
.rpc<BillingChargeResponse>('billing.charge', { amount_usd: amount })
.then(
ctx.guarded<BillingChargeResponse>(r => {
if (r.ok && r.charge_id) {
pollCharge(sys, ctx, r.charge_id, s.portal_url)
} else {
renderBillingError(sys, ctx, r)
}
})
)
.catch(ctx.guardedErr)
.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}`)
@@ -297,10 +324,10 @@ const buildOverlayCtx = (ctx: SlashRunCtx, sys: Sys, s: BillingStateResponse): B
validate: (raw: string) => validateAmount(raw, s)
})
export const billingCommands: SlashCommand[] = [
export const topupCommands: SlashCommand[] = [
{
help: 'Manage Nous terminal billing — buy credits, auto-reload, limits',
name: 'billing',
help: 'Top up Nous credits — buy credits, auto-reload, limits',
name: 'topup',
// ZERO sub-commands (plan §0.4): any arg is ignored. Bare `/billing`
// fetches state and opens the interactive overlay (CLI/TUI parity).
run: (_arg, ctx) => {

View File

@@ -1,17 +1,19 @@
import { coreCommands } from './commands/core.js'
import { billingCommands } from './commands/billing.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,
...topupCommands,
...creditsCommands,
...sessionCommands,
...subscriptionCommands,
...opsCommands,
...setupCommands,
...debugCommands

View File

@@ -169,6 +169,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ billing: null })
}
if (overlay.subscription) {
return patchOverlayState({ subscription: null })
}
if (overlay.skillsHub) {
return patchOverlayState({ skillsHub: false })
}
@@ -294,7 +298,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
// answering felt like the prompt had locked the entire UI. Explicitly
// skip the prompt-overlay early-return for scroll keys so they fall
// through to the wheel / PageUp / Shift+arrow handlers below.
const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm
const promptOverlay = overlay.approval || overlay.billing || overlay.clarify || overlay.confirm || overlay.subscription
const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
if (promptOverlay && !fallThroughForScroll) {

View File

@@ -15,6 +15,7 @@ import { OverlayHint } from './overlayControls.js'
import { PluginsHub } from './pluginsHub.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SkillsHub } from './skillsHub.js'
import { SubscriptionOverlay } from './subscriptionOverlay.js'
const COMPLETION_WINDOW = 16
@@ -51,6 +52,21 @@ export function PromptZone({
)
}
if (overlay.subscription) {
const current = overlay.subscription
const onPatch = (next: Partial<typeof current>) =>
patchOverlayState(prev => (prev.subscription ? { ...prev, subscription: { ...prev.subscription, ...next } } : prev))
const onClose = () => patchOverlayState({ subscription: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<SubscriptionOverlay onClose={onClose} onPatch={onPatch} overlay={current} t={theme} />
</Box>
)
}
if (overlay.confirm) {
const req = overlay.confirm

View File

@@ -5,10 +5,9 @@ import type { BillingOverlayState } from '../app/interfaces.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
import type { Theme } from '../theme.js'
import { ActionRow, barCells, footer, MenuRow } from './overlayPrimitives.js'
import { TextInput } from './textInput.js'
const SPEND_BAR_CELLS = 10
interface BillingOverlayProps {
/** Replace the overlay slot (screen transitions + pending data). */
onPatch: (next: Partial<BillingOverlayState>) => void
@@ -18,30 +17,6 @@ interface BillingOverlayProps {
t: Theme
}
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */
function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
return (
<Text>
<Text bold={active} color={active ? t.color.label : t.color.muted} inverse={active}>
{active ? '▸ ' : ' '}
{index}. {label}
</Text>
</Text>
)
}
/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */
function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) {
return (
<Text>
<Text color={active ? t.color.accent : t.color.muted}>{active ? '▸ ' : ' '}</Text>
<Text bold={active} color={active ? (color ?? t.color.text) : t.color.muted}>
{label}
</Text>
</Text>
)
}
/** 10-cell spend bar + percent (omit entirely when there's no usable cap). */
function spendBar(s: BillingStateResponse): null | string {
const cap = s.monthly_cap
@@ -57,10 +32,7 @@ function spendBar(s: BillingStateResponse): null | string {
return null
}
const ratio = Math.max(0, Math.min(1, spent / limit))
const filled = Math.round(ratio * SPEND_BAR_CELLS)
const bar = '█'.repeat(filled) + '░'.repeat(SPEND_BAR_CELLS - filled)
const pct = Math.round(ratio * 100)
const { bar, pct } = barCells(spent / limit)
const ceiling = cap.is_default_ceiling ? ' (default ceiling)' : ''
return `${cap.spent_display} of ${cap.limit_display} used ${bar} ${pct}%${ceiling}`
@@ -76,8 +48,6 @@ function autoReloadLine(s: BillingStateResponse): null | string {
: 'Auto-reload: off'
}
const footer = (extra: string, t: Theme) => <Text color={t.color.muted}>{extra}</Text>
/**
* The /billing modal. A self-contained state machine:
* overview → buy | autoreload | limit (and buy → confirm).
@@ -98,12 +68,21 @@ export function BillingOverlay({ onClose, onPatch, overlay, t }: BillingOverlayP
ctx={ctx}
onBack={() => onPatch({ pendingCharge: null, screen: 'buy' })}
onClose={onClose}
onPatch={onPatch}
s={s}
t={t}
/>
)}
{screen === 'autoreload' && <AutoReloadScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'limit' && <LimitScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'stepup' && (
<StepUpScreen
amount={overlay.pendingCharge?.amount ?? ''}
ctx={ctx}
onClose={onClose}
t={t}
/>
)}
</Box>
)
}
@@ -196,7 +175,7 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Usage credits
Top up credits
</Text>
{bar && <Text color={t.color.text}>{bar}</Text>}
<Text color={t.color.text}>Balance: {s.balance_display}</Text>
@@ -359,6 +338,7 @@ function ConfirmScreen({
ctx,
onBack,
onClose,
onPatch,
s,
t
}: {
@@ -366,16 +346,33 @@ function ConfirmScreen({
ctx: BillingOverlayState['ctx']
onBack: () => void
onClose: () => void
onPatch: (next: Partial<BillingOverlayState>) => void
s: BillingStateResponse
t: Theme
}) {
// rows: Pay $X now / Cancel
const [sel, setSel] = useState(0)
const [submitting, setSubmitting] = useState(false)
const pay = () => {
ctx.charge(amount)
// Settlement is reported via transcript lines; close the overlay now.
onClose()
if (submitting) {
return
}
setSubmitting(true)
void ctx.charge(amount).then(outcome => {
if (outcome === 'needs_remote_spending') {
// Resumable step-up: keep the modal MOUNTED, switch to the stepup
// screen (which holds pendingCharge.amount for the post-grant replay).
onPatch({ screen: 'stepup' })
return
}
// submitted (settlement reported via transcript) or error (already
// surfaced) → close the overlay. The transcript carries the outcome.
onClose()
})
}
const back = () => onBack()
@@ -427,6 +424,118 @@ function ConfirmScreen({
)
}
// ── Screen: Step-up (resumable "Allow Remote Spending") ───────────────
// Reached when a charge returns insufficient_scope. The modal stays MOUNTED
// through the browser device-flow: Allow → await the grant → replay the held
// charge (pendingCharge.amount) without a command re-run. Never leaks the raw
// billing:manage scope — the user-facing concept is "Remote Spending".
function StepUpScreen({
amount,
ctx,
onClose,
t
}: {
amount: string
ctx: BillingOverlayState['ctx']
onClose: () => void
t: Theme
}) {
const [sel, setSel] = useState(0)
const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt')
const allow = () => {
if (phase === 'waiting') {
return
}
setPhase('waiting')
ctx.sys('Opening your browser to authorize Remote Spending…')
void ctx.requestRemoteSpending().then(granted => {
if (!granted) {
ctx.sys('🟡 Remote Spending was not granted (an org admin/owner must approve). Run /topup to try again.')
onClose()
return
}
// Granted → resume the held charge. Replay it; the confirm/poll lines
// report settlement, then close.
ctx.sys('✅ Remote Spending enabled — resuming your purchase.')
void ctx.charge(amount).then(() => onClose())
})
}
const decline = () => onClose()
useInput((ch, key) => {
if (phase === 'waiting') {
// While the device flow runs, only Esc (give up) is live.
if (key.escape) {
onClose()
}
return
}
if (key.escape) {
return decline()
}
const lower = ch.toLowerCase()
if (lower === 'y') {
return allow()
}
if (lower === 'n') {
return decline()
}
if (key.upArrow) {
setSel(0)
}
if (key.downArrow) {
setSel(1)
}
if (key.return) {
return sel === 0 ? allow() : decline()
}
})
if (phase === 'waiting') {
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Allow Remote Spending
</Text>
<Text color={t.color.muted}>Waiting for browser authorization</Text>
<Text color={t.color.muted}>Approve in the page that just opened your purchase resumes here automatically.</Text>
<Text />
{footer('Esc cancel', t)}
</Box>
)
}
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Allow Remote Spending
</Text>
<Text color={t.color.text}>Charging from the terminal needs a one-time browser authorization.</Text>
<Text color={t.color.muted}>We&apos;ll resume your ${amount} purchase right here once it&apos;s granted.</Text>
<Text />
<ActionRow active={sel === 0} color={t.color.ok} label="Allow Remote Spending" t={t} />
<ActionRow active={sel === 1} label="Not now" t={t} />
<Text />
{footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)}
</Box>
)
}
// ── Screen 4: Auto-reload (the 2-field form) ──────────────────────────
function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {

View File

@@ -0,0 +1,40 @@
import { Text } from '@hermes/ink'
import type { Theme } from '../theme.js'
/** A numbered menu row with the ▸ cursor (mirrors ClarifyPrompt). */
export function MenuRow({ active, index, label, t }: { active: boolean; index: number; label: string; t: Theme }) {
return (
<Text>
<Text bold={active} color={active ? t.color.label : t.color.muted} inverse={active}>
{active ? '▸ ' : ' '}
{index}. {label}
</Text>
</Text>
)
}
/** Plain (non-numbered) action row with the ▸ cursor (confirm screens). */
export function ActionRow({ active, label, color, t }: { active: boolean; label: string; color?: string; t: Theme }) {
return (
<Text>
<Text color={active ? t.color.accent : t.color.muted}>{active ? '▸ ' : ' '}</Text>
<Text bold={active} color={active ? (color ?? t.color.text) : t.color.muted}>
{label}
</Text>
</Text>
)
}
export const BAR_CELLS = 10
/** ratio in [0,1] -> { bar: '█…░…', pct: 0-100 } using `cells` cells. */
export function barCells(ratio: number, cells: number = BAR_CELLS): { bar: string; pct: number } {
const r = Math.max(0, Math.min(1, ratio))
const filled = Math.round(r * cells)
return { bar: '█'.repeat(filled) + '░'.repeat(cells - filled), pct: Math.round(r * 100) }
}
export const footer = (extra: string, t: Theme) => <Text color={t.color.muted}>{extra}</Text>

View File

@@ -0,0 +1,385 @@
import { Box, Text, useInput } from '@hermes/ink'
import { useState } from 'react'
import type { SubscriptionOverlayState } from '../app/interfaces.js'
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
import type { Theme } from '../theme.js'
import { ActionRow, barCells, footer, MenuRow } from './overlayPrimitives.js'
interface SubscriptionOverlayProps {
/** Replace the overlay slot (screen transitions + pending data). */
onPatch: (next: Partial<SubscriptionOverlayState>) => void
/** Close the overlay entirely. */
onClose: () => void
overlay: SubscriptionOverlayState
t: Theme
}
/**
* The /subscription modal — deep-link only, NEVER charges in-terminal.
* Mirrors billingOverlay.tsx's structure: a pure-render state machine
* (overview → confirm → handoff) where all RPCs live in subscription.ts and
* are reached through `overlay.ctx`. No step-up here: changing a plan is a
* browser deep-link, which needs no billing scope (the scope gate is on
* /topup's charge, where the resumable step-up lives).
*/
export function SubscriptionOverlay({ onClose, onPatch, overlay, t }: SubscriptionOverlayProps) {
const { ctx, screen, state: s } = overlay
// Team context: no tier picker — teams run on shared credits; redirect to /topup.
if (s.context === 'team') {
return (
<Box borderColor={t.color.accent} borderStyle="round" flexDirection="column" paddingX={1}>
<TeamContextScreen onClose={onClose} s={s} t={t} />
</Box>
)
}
return (
<Box borderColor={t.color.accent} borderStyle="round" flexDirection="column" paddingX={1}>
{screen === 'overview' && <OverviewScreen ctx={ctx} onClose={onClose} onPatch={onPatch} s={s} t={t} />}
{screen === 'confirm' && (
<ConfirmScreen
ctx={ctx}
onBack={() => onPatch({ screen: 'overview' })}
onClose={onClose}
onPatch={onPatch}
overlay={overlay}
s={s}
t={t}
/>
)}
{screen === 'handoff' && <HandoffScreen onClose={onClose} t={t} />}
</Box>
)
}
// ── Screen: Overview (covers states ae: free/mid/top/not-admin/downgrade) ──
interface ScreenProps {
ctx: SubscriptionOverlayState['ctx']
onClose: () => void
onPatch: (next: Partial<SubscriptionOverlayState>) => void
s: SubscriptionStateResponse
t: Theme
}
/** Usage bar from subscription allowance (monthly_credits vs credits_remaining). */
function usageBar(s: SubscriptionStateResponse): null | string {
const c = s.current
if (!c || !c.monthly_credits || !c.credits_remaining) {
return null
}
const monthly = Number(c.monthly_credits)
const remaining = Number(c.credits_remaining)
if (!(monthly > 0) || Number.isNaN(remaining)) {
return null
}
const spent = Math.max(0, monthly - remaining)
const { bar, pct } = barCells(spent / monthly)
return `${remaining} of ${c.monthly_credits} remaining ${bar} ${100 - pct}% left`
}
function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
// (d) not-admin: read-only + note + Manage/portal only.
const canChange = s.can_change_plan && s.is_admin
const c = s.current
const isFree = !c?.tier_id
const hasPendingDowngrade = !!c?.pending_downgrade_tier_name
const isCancelScheduled = !!c?.cancel_at_period_end
// Headline precedence: cancel-scheduled > downgrade-pending > active.
// (Past-due/dunning was removed from the NAS read — a card-failing
// subscriber now returns as a normal plan; no special-casing here.)
const cancellationNote = isCancelScheduled
? c?.cancellation_effective_at
? `Cancels on ${c.cancellation_effective_at} — your plan stays active until then.`
: 'Cancellation scheduled — your plan stays active until the end of the billing period.'
: null
const downgradeNote = !isCancelScheduled && hasPendingDowngrade
? `Scheduled to switch to ${c?.pending_downgrade_tier_name} on ${c?.pending_downgrade_at}.`
: null
const notAdminNote = !canChange ? 'Plan changes need an org admin/owner.' : null
// Build the tier list (only enabled tiers for the menu; current marked).
const enabledTiers = s.tiers.filter(tier => tier.is_enabled)
const currentTierOrder = c?.tier_id ? s.tiers.find(tier => tier.tier_id === c.tier_id)?.tier_order : undefined
const isTopTier = currentTierOrder != null && enabledTiers.every(tier => tier.tier_order <= currentTierOrder)
const topTierNote = isTopTier ? "You're on the top plan." : null
// Menu items: tiers (selectable) + Manage on portal + Close.
// For not-admin: just Manage on portal + Close.
const tierItems: { label: string; tierId?: string }[] = canChange
? [
...enabledTiers.map(tier => ({
label: `${tier.is_current ? '✓ ' : ''}${tier.name}${tier.dollars_per_month_display}/mo (${tier.monthly_credits} credits)`,
tierId: tier.tier_id
})),
{ label: 'Manage on portal' },
{ label: 'Close' }
]
: [{ label: 'Manage on portal' }, { label: 'Close' }]
const [sel, setSel] = useState(0)
const choose = (i: number) => {
const item = tierItems[i]
if (!item) {
return
}
if (item.label === 'Close') {
return onClose()
}
if (item.label === 'Manage on portal') {
if (s.portal_url) {
ctx.sys('Opening portal in your browser…')
void ctx.openManageLink()
}
return onClose()
}
// A tier was selected — go to confirm (deep-link, no in-terminal charge).
if (item.tierId && item.tierId !== c?.tier_id) {
onPatch({ screen: 'confirm', pendingTargetTierId: item.tierId })
}
}
useInput((ch, key) => {
if (key.escape) {
return onClose()
}
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
}
if (key.downArrow && sel < tierItems.length - 1) {
setSel(v => v + 1)
}
if (key.return) {
return choose(sel)
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= tierItems.length) {
return choose(n - 1)
}
})
const header = isFree ? 'Subscribe to a plan' : c?.tier_name ? `Your plan: ${c.tier_name}` : 'Subscription'
const bar = usageBar(s)
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
{header}
</Text>
{bar && <Text color={t.color.text}>{bar}</Text>}
{c?.credits_remaining && !bar && (
<Text color={t.color.text}>Credits remaining: {c.credits_remaining}</Text>
)}
{s.org_name && (
<Text color={t.color.muted}>
Org: {s.org_name}
{s.role ? ` · ${s.role}` : ''}
</Text>
)}
{cancellationNote && (
<Box marginTop={1}>
<Text color={t.color.warn}>{cancellationNote}</Text>
</Box>
)}
{downgradeNote && (
<Box marginTop={1}>
<Text color={t.color.warn}>{downgradeNote}</Text>
</Box>
)}
{notAdminNote && (
<Box marginTop={1}>
<Text color={t.color.warn}>{notAdminNote}</Text>
</Box>
)}
{topTierNote && (
<Box marginTop={1}>
<Text color={t.color.muted}>{topTierNote}</Text>
</Box>
)}
<Text />
{tierItems.map((item, i) => (
<MenuRow active={sel === i} index={i + 1} key={item.label} label={item.label} t={t} />
))}
<Text />
{footer(`↑/↓ select · 1-${tierItems.length} quick pick · Enter confirm · Esc close`, t)}
</Box>
)
}
// ── Screen: Team context (no tier picker — teams use shared credits) ──
interface TeamContextScreenProps {
onClose: () => void
s: SubscriptionStateResponse
t: Theme
}
function TeamContextScreen({ onClose, s, t }: TeamContextScreenProps) {
useInput((_ch, key) => {
if (key.escape || key.return) {
return onClose()
}
})
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Team subscription
</Text>
{s.org_name && (
<Text color={t.color.muted}>
Org: {s.org_name}
{s.role ? ` · ${s.role}` : ''}
</Text>
)}
<Text />
<Text color={t.color.text}>
This terminal is connected to {s.org_name ?? 'a team org'}. Teams run on shared credits
use /topup to add funds.
</Text>
<Text color={t.color.muted}>
Personal subscriptions live on your personal account.
</Text>
<Text />
{footer('Enter/Esc close', t)}
</Box>
)
}
// ── Screen: Confirm (y/n deep-link, NO in-terminal charge) ───────────
interface ConfirmScreenProps extends ScreenProps {
onBack: () => void
onPatch: (next: Partial<SubscriptionOverlayState>) => void
overlay: SubscriptionOverlayState
}
function ConfirmScreen({ ctx, onBack, onClose, onPatch, overlay, s, t }: ConfirmScreenProps) {
const targetTierId = overlay.pendingTargetTierId ?? undefined
const targetTier = s.tiers.find(tier => tier.tier_id === targetTierId)
const isUpgrade = !s.current?.tier_id
const [sel, setSel] = useState(0)
const items = ['Continue to your subscription page', 'Cancel']
const [transitioned, setTransitioned] = useState(false)
const confirm = () => {
if (transitioned) {
return
}
setTransitioned(true)
onPatch({ screen: 'handoff' })
void ctx.openManageLink().then(ok => {
if (!ok) {
// openManageLink only fails if the portal URL can't be built or the
// browser won't open — return to overview (it sys()'d the reason).
onPatch({ screen: 'overview' })
}
})
}
const choose = (i: number) => {
if (i === 0) {
return confirm()
}
return onBack()
}
useInput((ch, key) => {
if (key.escape) {
return onBack()
}
if (key.upArrow && sel > 0) {
setSel(v => v - 1)
}
if (key.downArrow && sel < items.length - 1) {
setSel(v => v + 1)
}
if (key.return) {
return choose(sel)
}
if (ch === 'y') {
return choose(0)
}
if (ch === 'n') {
return choose(1)
}
})
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
{isUpgrade ? 'Confirm subscription' : 'Confirm plan change'}
</Text>
{targetTier && (
<Text color={t.color.text}>
{targetTier.name} {targetTier.dollars_per_month_display}/mo ({targetTier.monthly_credits} credits)
</Text>
)}
<Text color={t.color.muted}>You'll finish this change securely on your subscription page in your browser.</Text>
<Text />
{items.map((label, i) => (
<ActionRow active={sel === i} color={i === 0 ? t.color.ok : undefined} key={label} label={label} t={t} />
))}
<Text />
{footer('y/Enter confirm · n/Esc cancel', t)}
</Box>
)
}
// ── Screen: Handoff (transient) ──────────────────────────────────────
function HandoffScreen({ onClose, t }: { onClose: () => void; t: Theme }) {
useInput((_ch, key) => {
if (key.escape) {
return onClose()
}
})
return (
<Box flexDirection="column">
<Text bold color={t.color.accent}>
Opening your subscription page…
</Text>
<Text color={t.color.muted}>Opening your subscription page in the browser…</Text>
<Text color={t.color.muted}>Finish on the page that just opened. Re-run /subscription to see the change.</Text>
<Text />
{footer('Esc close', t)}
</Box>
)
}

View File

@@ -109,13 +109,16 @@ export interface BillingErrorPayload {
}
export interface BillingChargeResponse {
actor?: string
charge_id?: string
code?: string
error?: string
idempotency_key?: string
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
@@ -133,15 +136,53 @@ 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
dollars_per_month_display: string
monthly_credits: string
is_current: boolean
is_enabled: boolean // false = grandfathered current tier
}
export interface SubscriptionStateResponse {
ok: boolean
logged_in: boolean
is_admin: boolean
can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS
org_name: string | null
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
cancel_at_period_end: boolean // subscription scheduled to cancel at period end
cancellation_effective_at: string | null // ISO when cancellation takes effect
} | null
tiers: SubscriptionTierOption[]
portal_url: string | null
error?: string | null
}
export type CommandDispatchResponse =
| { output?: string; type: 'exec' | 'plugin' }
| { target: string; type: 'alias' }