mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 19:03:33 +08:00
Compare commits
18 Commits
dependabot
...
sid/tui-bi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a83550b5ab | ||
|
|
a75aea8f8a | ||
|
|
37154fa36f | ||
|
|
1a082b780c | ||
|
|
cb8a19ae3a | ||
|
|
e78bf4b7d8 | ||
|
|
a5902cd267 | ||
|
|
3831e78d37 | ||
|
|
2958145f11 | ||
|
|
e4c46be204 | ||
|
|
e854528a85 | ||
|
|
66d22cac97 | ||
|
|
1cecb2fbb9 | ||
|
|
ac8a790b67 | ||
|
|
df4350c5ad | ||
|
|
75e4bfd183 | ||
|
|
aab4ba454f | ||
|
|
30a1254c36 |
353
agent/subscription_view.py
Normal file
353
agent/subscription_view.py
Normal 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
222
cli.py
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
150
tests/agent/test_subscription_view.py
Normal file
150
tests/agent/test_subscription_view.py
Normal 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")
|
||||
120
tests/hermes_cli/test_remote_spending_gate_contract.py
Normal file
120
tests/hermes_cli/test_remote_spending_gate_contract.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for the Remote-Spending gate denial contract (NAS PR #481).
|
||||
|
||||
Behavior contracts: the HTTP→exception mapping in
|
||||
``hermes_cli.nous_billing._raise_for_error`` and the
|
||||
``tui_gateway.server._serialize_billing_error`` envelope the TUI branches on.
|
||||
These assert the wire contract (CF-4) — error code, actor, recovery, retry —
|
||||
not specific copy.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.nous_billing import (
|
||||
BillingError,
|
||||
BillingRateLimited,
|
||||
BillingRemoteSpendingRevoked,
|
||||
BillingScopeRequired,
|
||||
BillingSessionRevoked,
|
||||
_raise_for_error,
|
||||
)
|
||||
|
||||
|
||||
def _raise(status, payload, headers=None):
|
||||
"""Run _raise_for_error and return the exception it raises."""
|
||||
with pytest.raises(BillingError) as ei:
|
||||
_raise_for_error(status, payload, headers)
|
||||
return ei.value
|
||||
|
||||
|
||||
# ── exception mapping (hermes_cli.nous_billing) ──────────────────────
|
||||
|
||||
|
||||
def test_403_remote_spending_revoked_maps_to_typed_exc_with_actor():
|
||||
exc = _raise(403, {"error": "remote_spending_revoked", "recovery": "reconnect", "actor": "admin"})
|
||||
assert isinstance(exc, BillingRemoteSpendingRevoked)
|
||||
assert exc.actor == "admin"
|
||||
assert exc.recovery == "reconnect"
|
||||
|
||||
|
||||
def test_403_revoked_absent_actor_is_none_not_crash():
|
||||
exc = _raise(403, {"error": "remote_spending_revoked"})
|
||||
assert isinstance(exc, BillingRemoteSpendingRevoked)
|
||||
assert exc.actor is None # surface treats absent as "self"
|
||||
|
||||
|
||||
def test_401_session_revoked_is_distinct_from_plain_401():
|
||||
revoked = _raise(401, {"error": "session_revoked", "recovery": "login"})
|
||||
assert isinstance(revoked, BillingSessionRevoked)
|
||||
assert revoked.recovery == "login"
|
||||
|
||||
plain = _raise(401, {"error": "invalid_token"})
|
||||
assert not isinstance(plain, BillingSessionRevoked)
|
||||
|
||||
|
||||
def test_403_insufficient_scope_still_maps_to_scope_required():
|
||||
exc = _raise(403, {"error": "insufficient_scope"})
|
||||
assert isinstance(exc, BillingScopeRequired)
|
||||
# NOT mistaken for a revoke.
|
||||
assert not isinstance(exc, BillingRemoteSpendingRevoked)
|
||||
|
||||
|
||||
def test_503_is_rate_limited_not_revoked_and_carries_retry_after():
|
||||
exc = _raise(503, {"error": "temporarily_unavailable"}, {"Retry-After": "30"})
|
||||
assert isinstance(exc, BillingRateLimited)
|
||||
assert not isinstance(exc, BillingRemoteSpendingRevoked)
|
||||
assert exc.retry_after == 30
|
||||
|
||||
|
||||
def test_403_business_denial_carries_code_and_recovery():
|
||||
exc = _raise(403, {
|
||||
"error": "cli_billing_disabled",
|
||||
"code": "remote_spending_disabled",
|
||||
"recovery": "enable_account_toggle",
|
||||
"portalUrl": "/billing",
|
||||
})
|
||||
# Generic BillingError (not a typed revoke) — the surface maps on code.
|
||||
assert type(exc) is BillingError
|
||||
assert exc.error == "cli_billing_disabled"
|
||||
assert exc.code == "remote_spending_disabled"
|
||||
assert exc.recovery == "enable_account_toggle"
|
||||
|
||||
|
||||
def test_409_idempotency_conflict_passes_through():
|
||||
exc = _raise(409, {"error": "idempotency_conflict", "message": "same key, different amount"})
|
||||
assert exc.error == "idempotency_conflict"
|
||||
|
||||
|
||||
# ── envelope serialization (tui_gateway.server) ──────────────────────
|
||||
|
||||
|
||||
def _serialize(status, payload, headers=None):
|
||||
import tui_gateway.server as srv
|
||||
|
||||
return srv._serialize_billing_error(_raise(status, payload, headers))
|
||||
|
||||
|
||||
def test_envelope_threads_actor_code_recovery():
|
||||
env = _serialize(403, {"error": "remote_spending_revoked", "actor": "admin", "recovery": "reconnect"})
|
||||
assert env["error"] == "remote_spending_revoked"
|
||||
assert env["actor"] == "admin"
|
||||
assert env["recovery"] == "reconnect"
|
||||
assert env["ok"] is False
|
||||
|
||||
|
||||
def test_envelope_session_revoked_kind():
|
||||
env = _serialize(401, {"error": "session_revoked", "recovery": "login"})
|
||||
assert env["error"] == "session_revoked"
|
||||
assert env["recovery"] == "login"
|
||||
|
||||
|
||||
def test_envelope_503_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"
|
||||
@@ -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.
|
||||
|
||||
242
ui-tui/scripts/billing-fixtures.tsx
Normal file
242
ui-tui/scripts/billing-fixtures.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Billing/Subscription TUI fixture harness — renders any single overlay STATE
|
||||
* live in the terminal so it can be screenshotted (tmux) and UX-reviewed.
|
||||
*
|
||||
* This is a DEV/REVIEW tool, not shipped behaviour. It bypasses the gateway and
|
||||
* mounts the real Ink overlay components directly with a hand-built state object,
|
||||
* exactly the way the vitest render tests do — so what you see is pixel-identical
|
||||
* to what `/subscription` and `/topup` draw at runtime.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/billing-fixtures.tsx <fixture-name>
|
||||
* npx tsx scripts/billing-fixtures.tsx --list
|
||||
*
|
||||
* Drive a specific screen of a fixture with SCREEN=<screen>, e.g.:
|
||||
* SCREEN=confirm npx tsx scripts/billing-fixtures.tsx sub-free
|
||||
* SCREEN=handoff npx tsx scripts/billing-fixtures.tsx sub-mid
|
||||
*
|
||||
* The selection cursor can be moved with ↑/↓ once it's live (the components own
|
||||
* their own useInput); Esc/Enter behave as in production. Ctrl-C to exit.
|
||||
*/
|
||||
import { render } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
|
||||
import type { BillingOverlayState, SubscriptionOverlayState, SubscriptionScreen } from '../src/app/interfaces.js'
|
||||
import { BillingOverlay } from '../src/components/billingOverlay.js'
|
||||
import { SubscriptionOverlay } from '../src/components/subscriptionOverlay.js'
|
||||
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from '../src/gatewayTypes.js'
|
||||
import { DEFAULT_THEME } from '../src/theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const tier = (o: Partial<SubscriptionTierOption> = {}): SubscriptionTierOption => ({
|
||||
tier_id: 'free',
|
||||
name: 'Free',
|
||||
tier_order: 0,
|
||||
dollars_per_month_display: '$0',
|
||||
monthly_credits: '0',
|
||||
is_current: false,
|
||||
is_enabled: true,
|
||||
...o
|
||||
})
|
||||
|
||||
const TIERS = {
|
||||
free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }),
|
||||
plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }),
|
||||
super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }),
|
||||
ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' })
|
||||
}
|
||||
|
||||
const tierList = (currentId?: string): SubscriptionTierOption[] =>
|
||||
Object.values(TIERS).map(x => ({ ...x, is_current: x.tier_id === currentId }))
|
||||
|
||||
const subState = (o: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
|
||||
ok: true,
|
||||
logged_in: true,
|
||||
is_admin: true,
|
||||
can_change_plan: true,
|
||||
org_name: 'Acme Inc',
|
||||
org_id: 'org_acme',
|
||||
role: 'OWNER',
|
||||
context: 'personal',
|
||||
current: null,
|
||||
tiers: tierList(),
|
||||
portal_url: 'https://portal.nousresearch.com/billing',
|
||||
...o
|
||||
})
|
||||
|
||||
const cur = (o: Record<string, unknown> = {}) => ({
|
||||
tier_id: 'plus',
|
||||
tier_name: 'Plus',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '420',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null,
|
||||
cancel_at_period_end: false,
|
||||
cancellation_effective_at: null,
|
||||
...o
|
||||
})
|
||||
|
||||
const subCtx: SubscriptionOverlayState['ctx'] = {
|
||||
openManageLink: () => Promise.resolve(true),
|
||||
refreshState: () => Promise.resolve(null),
|
||||
sys: () => {}
|
||||
}
|
||||
|
||||
const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pendingTargetTierId: string | null = null): SubscriptionOverlayState => ({
|
||||
ctx: subCtx,
|
||||
screen,
|
||||
state: s,
|
||||
pendingTargetTierId
|
||||
})
|
||||
|
||||
// ── billing/topup fixtures ───────────────────────────────────────────
|
||||
|
||||
const billState = (o: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
|
||||
ok: true,
|
||||
logged_in: true,
|
||||
is_admin: true,
|
||||
cli_billing_enabled: true,
|
||||
can_charge: true,
|
||||
card: { brand: 'Visa', last4: '4242', masked: 'Visa •••• 4242' },
|
||||
balance_display: '$12.00',
|
||||
balance_usd: '12.00',
|
||||
min_usd: '5',
|
||||
max_usd: '500',
|
||||
monthly_cap: {
|
||||
is_default_ceiling: false,
|
||||
limit_display: '$20',
|
||||
limit_usd: '20',
|
||||
spent_display: '$8.00',
|
||||
spent_this_month_usd: '8'
|
||||
},
|
||||
auto_reload: { enabled: false, reload_to_display: '$25', reload_to_usd: '25', threshold_display: '$5', threshold_usd: '5' },
|
||||
org_name: 'Acme Inc',
|
||||
role: 'OWNER',
|
||||
portal_url: 'https://portal.nousresearch.com/billing',
|
||||
charge_presets: ['10', '25', '50', '100'],
|
||||
charge_presets_display: ['$10', '$25', '$50', '$100'],
|
||||
...o
|
||||
})
|
||||
|
||||
const billCtx = {
|
||||
applyAutoReload: () => Promise.resolve(true),
|
||||
charge: () => Promise.resolve('submitted' as const),
|
||||
openPortal: () => {},
|
||||
requestRemoteSpending: () => Promise.resolve(true),
|
||||
sys: () => {},
|
||||
validate: (raw: string) => ({ amount: raw })
|
||||
}
|
||||
|
||||
const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({
|
||||
ctx: billCtx,
|
||||
pendingCharge: screen === 'confirm' || screen === 'stepup' ? { amount: '100' } : null,
|
||||
screen,
|
||||
state: s
|
||||
})
|
||||
|
||||
// ── fixture registry ─────────────────────────────────────────────────
|
||||
|
||||
type Fixture = { desc: string; node: React.ReactElement }
|
||||
|
||||
const subEl = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pending: string | null = null) =>
|
||||
React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay: sub(s, screen, pending), t })
|
||||
|
||||
const billEl = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview') =>
|
||||
React.createElement(BillingOverlay, { onClose: () => {}, onPatch: () => {}, overlay: bill(s, screen), t })
|
||||
|
||||
const FIXTURES: Record<string, Fixture> = {
|
||||
// /subscription — overview states
|
||||
'sub-free': {
|
||||
desc: 'Free / no sub — upgradeable (primary conversion state)',
|
||||
node: subEl(subState({ current: null }))
|
||||
},
|
||||
'sub-mid': {
|
||||
desc: 'Subscriber mid-tier (Plus) — usage bar + up/downgrade targets',
|
||||
node: subEl(subState({ current: cur(), tiers: tierList('plus') }))
|
||||
},
|
||||
'sub-top': {
|
||||
desc: 'Subscriber top-tier (Ultra) — "on the top plan"',
|
||||
node: subEl(subState({ current: cur({ tier_id: 'ultra', tier_name: 'Ultra', monthly_credits: '7000', credits_remaining: '5000' }), tiers: tierList('ultra') }))
|
||||
},
|
||||
'sub-not-admin': {
|
||||
desc: 'Member (not admin/owner) — read-only, no tier picker',
|
||||
node: subEl(subState({ is_admin: false, can_change_plan: false, role: 'MEMBER', current: cur(), tiers: tierList('plus') }))
|
||||
},
|
||||
'sub-downgrade': {
|
||||
desc: 'Downgrade scheduled — pending-switch banner',
|
||||
node: subEl(subState({ current: cur({ pending_downgrade_tier_name: 'Plus', pending_downgrade_at: '2026-07-15' }), tiers: tierList('super') }))
|
||||
},
|
||||
'sub-cancel': {
|
||||
desc: 'Cancellation scheduled — stays active until effective date',
|
||||
node: subEl(subState({ current: cur({ cancel_at_period_end: true, cancellation_effective_at: '2026-07-01' }), tiers: tierList('plus') }))
|
||||
},
|
||||
'sub-team': {
|
||||
desc: 'Team org context — shared credits, redirect to /topup',
|
||||
node: subEl(subState({ context: 'team', current: null, org_name: 'Acme Engineering' }))
|
||||
},
|
||||
// /subscription — non-overview screens
|
||||
'sub-confirm': {
|
||||
desc: 'Confirm plan change (deep-link, no in-terminal charge)',
|
||||
node: subEl(subState({ current: cur(), tiers: tierList('plus') }), 'confirm', 'super')
|
||||
},
|
||||
'sub-confirm-new': {
|
||||
desc: 'Confirm first subscription (free → paid)',
|
||||
node: subEl(subState({ current: null }), 'confirm', 'plus')
|
||||
},
|
||||
'sub-handoff': {
|
||||
desc: 'Handoff transient — opening subscription page in browser',
|
||||
node: subEl(subState({ current: cur() }), 'handoff')
|
||||
},
|
||||
// /topup (renamed /billing)
|
||||
'topup-overview': {
|
||||
desc: '/topup overview — admin, card on file, full menu',
|
||||
node: billEl(billState())
|
||||
},
|
||||
'topup-no-card': {
|
||||
desc: '/topup overview — admin, NO saved card (card hint)',
|
||||
node: billEl(billState({ card: null }))
|
||||
},
|
||||
'topup-not-admin': {
|
||||
desc: '/topup overview — member, read-only',
|
||||
node: billEl(billState({ is_admin: false }))
|
||||
},
|
||||
'topup-disabled': {
|
||||
desc: '/topup overview — terminal billing OFF for org',
|
||||
node: billEl(billState({ cli_billing_enabled: false }))
|
||||
},
|
||||
'topup-buy': {
|
||||
desc: '/topup buy screen — presets',
|
||||
node: billEl(billState(), 'buy')
|
||||
},
|
||||
'topup-stepup': {
|
||||
desc: '/topup step-up — "Allow Remote Spending" (resumable, holds $100 buy)',
|
||||
node: billEl(billState(), 'stepup')
|
||||
}
|
||||
}
|
||||
|
||||
// ── driver ───────────────────────────────────────────────────────────
|
||||
|
||||
const arg = process.argv[2]
|
||||
|
||||
if (!arg || arg === '--list' || arg === '-l') {
|
||||
const names = Object.keys(FIXTURES)
|
||||
process.stdout.write('Billing/Subscription TUI fixtures:\n\n')
|
||||
for (const name of names) {
|
||||
process.stdout.write(` ${name.padEnd(18)} ${FIXTURES[name]!.desc}\n`)
|
||||
}
|
||||
process.stdout.write(`\n ${names.length} fixtures. Run: npx tsx scripts/billing-fixtures.tsx <name>\n`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const fixture = FIXTURES[arg]
|
||||
|
||||
if (!fixture) {
|
||||
process.stderr.write(`Unknown fixture: ${arg}\nRun with --list to see all.\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
render(fixture.node)
|
||||
107
ui-tui/src/__tests__/billingStepUp.test.tsx
Normal file
107
ui-tui/src/__tests__/billingStepUp.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
104
ui-tui/src/__tests__/subscriptionCommand.test.ts
Normal file
104
ui-tui/src/__tests__/subscriptionCommand.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
241
ui-tui/src/__tests__/subscriptionOverlay.test.tsx
Normal file
241
ui-tui/src/__tests__/subscriptionOverlay.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
100
ui-tui/src/__tests__/usageCommand.test.ts
Normal file
100
ui-tui/src/__tests__/usageCommand.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 a–e 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
108
ui-tui/src/app/slash/commands/subscription.ts
Normal file
108
ui-tui/src/app/slash/commands/subscription.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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 terminal’s spending.'
|
||||
: 'You stopped this terminal’s spending.'
|
||||
sys(`🔴 ${who} Reconnect to restore — run /portal to re-authorize this terminal.`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
case 'session_revoked':
|
||||
// Stronger than a spend-revoke: the whole session is gone → full re-login.
|
||||
patchOverlayState({ billing: null })
|
||||
sys('🔴 Your session was logged out. Run /portal to log in again.')
|
||||
|
||||
return
|
||||
|
||||
case 'cli_billing_disabled':
|
||||
case 'remote_spending_disabled':
|
||||
// Account-wide switch is OFF (dual-emitted error/code). An admin must flip
|
||||
// it on the portal; this is NOT a per-terminal revoke.
|
||||
sys('🔴 Remote Spending is off for this account — an admin must enable it on the portal.')
|
||||
|
||||
break
|
||||
|
||||
case 'role_required':
|
||||
sys('🔴 Buying credits needs an org admin/owner. Ask an admin, or manage on the portal.')
|
||||
|
||||
break
|
||||
|
||||
case 'idempotency_conflict':
|
||||
sys('🔴 That charge key was already used for a different amount. Start a fresh top-up.')
|
||||
|
||||
break
|
||||
|
||||
case 'no_payment_method':
|
||||
sys(
|
||||
@@ -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 charge’s 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) => {
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'll resume your ${amount} purchase right here once it'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) {
|
||||
|
||||
40
ui-tui/src/components/overlayPrimitives.tsx
Normal file
40
ui-tui/src/components/overlayPrimitives.tsx
Normal 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>
|
||||
385
ui-tui/src/components/subscriptionOverlay.tsx
Normal file
385
ui-tui/src/components/subscriptionOverlay.tsx
Normal 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 a–e: 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>
|
||||
)
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user