Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
30a1254
feat(tui): rename /billing slash command to /topup
alt-glitch Jun 23, 2026
aab4ba4
refactor(tui): extract overlay primitives to shared module
alt-glitch Jun 23, 2026
75e4bfd
feat(tui): add /subscription + /topup CTAs to /usage output
alt-glitch Jun 23, 2026
df4350c
feat(tui): add subscription wire types
alt-glitch Jun 23, 2026
ac8a790
feat(gateway): add subscription.state + subscription.manage_link RPCs
alt-glitch Jun 23, 2026
1cecb2f
feat(tui): add subscription overlay state types + store slot
alt-glitch Jun 23, 2026
66d22ca
feat(tui): build SubscriptionOverlay — overview + confirm + handoff
alt-glitch Jun 23, 2026
e854528
feat(tui): add /subscription command + overlay wiring
alt-glitch Jun 23, 2026
e4c46be
fix(tui/subscription): stop saying Stripe in deep-link copy + fix man…
alt-glitch Jun 24, 2026
2958145
feat(tui/subscription): render cancellation-scheduled note with headl…
alt-glitch Jun 24, 2026
3831e78
feat(tui/subscription): team-context screen — redirect to /topup for …
alt-glitch Jun 24, 2026
a5902cd
fix(subscription): drop manage-link gateway RPC, build URL locally
alt-glitch Jun 24, 2026
e78bf4b
chore(subscription): drop unused format_money import
alt-glitch Jun 24, 2026
cb8a19a
feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs
alt-glitch Jun 24, 2026
1a082b7
feat(subscription): CLI /subscription handler, drop dunning, current:…
alt-glitch Jun 24, 2026
37154fa
feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)
alt-glitch Jun 24, 2026
a75aea8
refactor(subscription): remove dead step-up scaffolding from /subscri…
alt-glitch Jun 24, 2026
a83550b
feat(tui/topup): resumable 'Allow Remote Spending' step-up on the cha…
alt-glitch Jun 24, 2026
1a15c4c
feat(billing): shared dollar usage model + two-bar view (drop "credits")
alt-glitch Jun 24, 2026
46176e7
feat(tui): dollar usage bars on /usage + /subscription, drop tier picker
alt-glitch Jun 24, 2026
1429574
feat(cli): mirror dollar usage bars on /usage + /subscription
alt-glitch Jun 24, 2026
cd0c662
feat(billing): embed dollar usage model into billing.state for /topup
alt-glitch Jun 24, 2026
a15e7b7
feat(tui/topup): reorder overview + in-flight reauth with press-Enter…
alt-glitch Jun 24, 2026
70b9a72
feat(cli/topup): mirror overview reorder + in-flight reauth resume
alt-glitch Jun 24, 2026
35de78c
fix(billing): guard non-JSON 2xx responses in the billing HTTP client
alt-glitch Jun 25, 2026
9eee09f
feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/sc…
alt-glitch Jun 25, 2026
beb2c5f
refactor(billing): fold /credits into /topup
alt-glitch Jun 25, 2026
13a4355
fix(billing): card-on-file heads-up, no-card portal gate, /usage bar …
alt-glitch Jun 25, 2026
6de4899
refactor(billing): apply safe simplify-pass fixes
alt-glitch Jun 25, 2026
1cb65a5
fix(billing): reactive charge gating — drop card preflight, react to …
alt-glitch Jun 25, 2026
7dae104
refactor(billing): drop the /credits alias entirely
alt-glitch Jun 25, 2026
90081ba
docs(billing): fix stale comment in _billing_overview — describe reac…
alt-glitch Jun 25, 2026
5ec5a25
refactor(billing): simplify-pass — share usage-payload helper, drop d…
alt-glitch Jun 25, 2026
722299b
refactor(billing): drop the /billing alias too — /topup is the only b…
alt-glitch Jun 25, 2026
25d7497
fix(billing): code-review fixes — money-path + parity bugs
alt-glitch Jun 25, 2026
9e1ade1
fix(billing): cross-surface bar direction, formatted cancel/downgrade…
alt-glitch Jun 25, 2026
f8c6da2
fix(billing): thread idempotency key through the TUI step-up replay (#2)
alt-glitch Jun 25, 2026
61b5d97
refactor(billing): remove dead /subscription tier-picker scaffolding …
alt-glitch Jun 25, 2026
0fccd09
test(billing): parametrize usage-model tests; drop dead is_low/is_fre…
alt-glitch Jun 25, 2026
943389d
fix(billing): revert dead 'billing' Slack-via-hermes entry — the alia…
alt-glitch Jun 25, 2026
9fa33ef
test(billing): cull redundant TUI billing tests (parametrize, merge d…
alt-glitch Jun 25, 2026
27a5c64
refactor(billing): extract _usage_bar_lines — one source of truth for…
alt-glitch Jun 25, 2026
e636d2b
feat(billing): NAS V3 subscription-change HTTP client wrappers
alt-glitch Jun 30, 2026
3d385ce
feat(billing): subscription tier catalog + change-preview models
alt-glitch Jun 30, 2026
311eee6
feat(billing): gateway RPCs for the V3 subscription change flow
alt-glitch Jun 30, 2026
e2422ce
feat(billing): in-terminal subscription change flow (TUI)
alt-glitch Jun 30, 2026
7274bdf
merge: integrate origin/main into sid/tui-billing (unblock CI)
alt-glitch Jun 30, 2026
30067b4
feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI)
alt-glitch Jul 1, 2026
06fa84c
feat(billing): full in-terminal subscription change flow in the class…
alt-glitch Jul 1, 2026
4e32743
merge: integrate origin/main into sid/tui-billing (re-sync, unblock CI)
alt-glitch Jul 1, 2026
199ebee
fix(billing): close TUI subscription money-path holes (ultracode review)
alt-glitch Jul 1, 2026
ee93fc8
fix(billing): close CLI subscription money-path holes (ultracode review)
alt-glitch Jul 1, 2026
8af47c6
merge: integrate origin/main into sid/tui-billing (re-sync)
alt-glitch Jul 1, 2026
96ff097
fix(billing): guard the step-up resume against double-fire (2nd ultra…
alt-glitch Jul 1, 2026
3a5cd02
fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode…
alt-glitch Jul 1, 2026
93c50da
merge: integrate origin/main (re-sync for 2nd-pass fixes)
alt-glitch Jul 1, 2026
3b77f43
fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (…
alt-glitch Jul 1, 2026
d0e5a90
fix(billing): narrow the CLI ambiguous-charge catch to indeterminate …
alt-glitch Jul 1, 2026
2a520b1
merge: integrate origin/main (final-pass fixes re-sync)
alt-glitch Jul 1, 2026
752ccf1
feat(billing): card visibility + guided add-card path in /topup and /…
alt-glitch Jul 1, 2026
f8a0764
merge: integrate origin/main (card-visibility re-sync)
alt-glitch Jul 1, 2026
907a091
Merge remote-tracking branch 'origin/main' into sid/tui-billing
alt-glitch Jul 8, 2026
9f6fcd7
Merge remote-tracking branch 'origin/main' into sid/tui-billing
alt-glitch Jul 17, 2026
5661a3f
fix(billing): consume server canChangePlan, preserve distinct refusal…
alt-glitch Jul 17, 2026
442919d
feat(tui): render the full NAS billing refusal surface
alt-glitch Jul 17, 2026
8ce6d91
docs(billing): client-side billing state and refusal lifecycle table
alt-glitch Jul 17, 2026
fb0f4a5
refactor(billing): explicit BillingTransient trait, drop broken credi…
alt-glitch Jul 17, 2026
ce6f14d
refactor(cli): extract CLIBillingMixin; charge gates follow the serve…
alt-glitch Jul 17, 2026
81d3482
refactor(tui): promote useMenu to overlay primitives, type pendingTie…
alt-glitch Jul 17, 2026
31f989a
docs(billing): correct the CLI-parity row — the CLI has the full in-t…
alt-glitch Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 205 additions & 0 deletions agent/subscription_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""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
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 fields = free / no active sub)."""

tier_id: Optional[str] = None
tier_name: Optional[str] = None
monthly_credits: Optional[Decimal] = None
credits_remaining: Optional[Decimal] = None
cycle_ends_at: Optional[str] = None # ISO
pending_downgrade_tier_name: Optional[str] = None
pending_downgrade_at: Optional[str] = None # ISO
is_past_due: bool = False
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]:
if not isinstance(raw, dict):
return None
return CurrentSubscription(
tier_id=raw.get("tierId") or raw.get("id"),
tier_name=raw.get("tierName") or raw.get("name"),
monthly_credits=parse_money(raw.get("monthlyCredits")),
credits_remaining=parse_money(raw.get("creditsRemaining")),
cycle_ends_at=raw.get("cycleEndsAt"),
pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"),
pending_downgrade_at=raw.get("pendingDowngradeAt"),
is_past_due=bool(raw.get("isPastDue")),
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.
"""
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)


12 changes: 12 additions & 0 deletions hermes_cli/nous_billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,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)


66 changes: 66 additions & 0 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5580,6 +5580,72 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"ok": True, "logged_in": False, "error": "could not load billing state"})


def _serialize_subscription_state(state) -> dict:
"""Serialize a SubscriptionState for the wire (Decimals → strings)."""
from agent.billing_view import format_money

def _s(value):
return None if value is None else str(value)

current = None
if state.current is not None:
c = state.current
current = {
"tier_id": c.tier_id,
"tier_name": c.tier_name,
"monthly_credits": _s(c.monthly_credits),
"credits_remaining": _s(c.credits_remaining),
"cycle_ends_at": c.cycle_ends_at,
"pending_downgrade_tier_name": c.pending_downgrade_tier_name,
"pending_downgrade_at": c.pending_downgrade_at,
"is_past_due": c.is_past_due,
"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.
Expand Down
104 changes: 104 additions & 0 deletions ui-tui/src/__tests__/subscriptionCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading