Terminal-billing client hardening: shared wire types, wire-layer tests, dead RPC removal (#61067)

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.
This commit is contained in:
Siddharth Balyan 2026-07-18 19:35:58 +05:30 committed by GitHub
parent 8f657b8f74
commit 7668d289d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 862 additions and 301 deletions

View file

@ -4,7 +4,10 @@
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./billing": "./src/billing-types.ts",
"./billing-policy": "./src/billing-policy.ts",
"./charge-settlement": "./src/charge-settlement.ts"
},
"types": "./src/index.ts",
"scripts": {

View file

@ -0,0 +1,46 @@
import type { KnownBillingRefusalCode } from './billing-types.js'
export type BillingRecovery = 'login' | 'none' | 'portal' | 'reconnect' | 'retry' | 'step_up'
export interface BillingRefusalPolicy {
recovery: BillingRecovery
ambiguousMidPoll?: true
reuseIdempotencyKey?: true
}
export const BILLING_REFUSAL_POLICY: Record<KnownBillingRefusalCode, BillingRefusalPolicy> = {
auto_top_up_disabled_failures: { recovery: 'portal' },
cli_billing_disabled: { recovery: 'portal' },
consent_required: { recovery: 'portal' },
endpoint_unavailable: { recovery: 'retry', reuseIdempotencyKey: true },
idempotency_conflict: { recovery: 'none' },
idempotency_key_required: { recovery: 'none' },
// Deliberate: losing scope mid-poll cannot undo an accepted charge, so its outcome is unknown.
insufficient_scope: { recovery: 'step_up', ambiguousMidPoll: true },
internal_error: { recovery: 'retry' },
invalid_charge_id: { recovery: 'none' },
invalid_request: { recovery: 'none' },
monthly_cap_exceeded: { recovery: 'portal' },
network_error: { recovery: 'retry', reuseIdempotencyKey: true },
no_payment_method: { recovery: 'portal' },
org_access_denied: { recovery: 'portal' },
preview_rejected: { recovery: 'none' },
rate_limited: { recovery: 'retry', reuseIdempotencyKey: true },
remote_spending_disabled: { recovery: 'portal' },
remote_spending_revoked: { recovery: 'reconnect', ambiguousMidPoll: true },
role_required: { recovery: 'portal' },
session_revoked: { recovery: 'login', ambiguousMidPoll: true },
stripe_unavailable: { recovery: 'retry', reuseIdempotencyKey: true },
temporarily_unavailable: { recovery: 'retry', reuseIdempotencyKey: true },
upgrade_cap_exceeded: { recovery: 'none' },
validation_failed: { recovery: 'none' }
}
export function refusalPolicy(code: string): BillingRefusalPolicy {
if (Object.hasOwn(BILLING_REFUSAL_POLICY, code)) {
return BILLING_REFUSAL_POLICY[code as KnownBillingRefusalCode]
}
// Unknown codes must still show the server message; no special handling is the safe fallback.
return { recovery: 'none' }
}

View file

@ -0,0 +1,292 @@
/**
* Shared terminal-billing wire contracts.
*
* These shapes round-trip between the Python tui_gateway and TypeScript clients
* such as the TUI and desktop app. Keep rendering state, client logic, and the
* gateway event union out of this runtime-free module.
*/
// ── Terminal billing (Phase 2b) ──────────────────────────────────────
/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */
export interface UsageBarData {
kind: 'plan' | 'topup'
remaining_display: string
total_display: string
spent_display: string
pct_used: null | number
fill_fraction: number
}
/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */
export interface UsageModelData {
available: boolean
status?: string
plan_name?: null | string
renews_at?: null | string
renews_display?: null | string
subscription_remaining_display?: null | string
topup_remaining_display?: null | string
total_spendable_display?: null | string
has_topup?: boolean
plan_bar?: null | UsageBarData
topup_bar?: null | UsageBarData
}
/**
* The closed set of refusal/error codes the gateway serializes today
* (`_serialize_billing_error` preserves the raw NAS code where one exists,
* plus the client-originated transport codes). Closed on purpose: an
* exhaustive `Record<KnownBillingRefusalCode, …>` (classification tables,
* copy maps, tests) gets a compile error when a code is added here but not
* mapped.
*/
export type KnownBillingRefusalCode =
| 'auto_top_up_disabled_failures'
| 'cli_billing_disabled'
| 'consent_required'
| 'endpoint_unavailable'
| 'idempotency_conflict'
| 'idempotency_key_required'
| 'insufficient_scope'
| 'internal_error'
| 'invalid_charge_id'
| 'invalid_request'
| 'monthly_cap_exceeded'
| 'network_error'
| 'no_payment_method'
| 'org_access_denied'
| 'preview_rejected'
| 'rate_limited'
| 'remote_spending_disabled'
| 'remote_spending_revoked'
| 'role_required'
| 'session_revoked'
| 'stripe_unavailable'
| 'temporarily_unavailable'
| 'upgrade_cap_exceeded'
| 'validation_failed'
/**
* What the wire actually carries: a known code, or an unknown future one
* (e.g. the NAS W3 card-health family). The `(string & {})` arm keeps unknown
* codes assignable consumers must keep an unknown-code fallback branch.
*/
export type BillingRefusalCode = KnownBillingRefusalCode | (string & {})
/**
* The closed set of terminal reasons a settled-poll charge can fail with (NAS
* `cli-charge-failure-reason.ts` all four values), plus the raw Stripe code
* NAS pre-#711 leaks for SCA-on-upgrade.
*/
export type KnownChargeFailureReason =
| 'authentication_required'
| 'card_declined'
| 'payment_method_expired'
| 'processing_error'
| 'subscription_payment_intent_requires_action'
/** Wire shape: a known reason or an unknown future one; degrade safely. */
export type ChargeFailureReason = KnownChargeFailureReason | (string & {})
export interface BillingCardInfo {
brand: string
last4: string
masked: string
/** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */
display?: string
/** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */
resolved_via?: null | string
}
export interface BillingMonthlyCap {
is_default_ceiling: boolean
limit_display: string
limit_usd: string | null
spent_display: string
spent_this_month_usd: string | null
}
export interface BillingAutoReload {
card:
| { kind: 'canonical' }
| {
kind: 'distinct'
payment_method_id: string
brand: string | null
last4: string | null
}
| { kind: 'none' }
enabled: boolean
reload_to_display: string
reload_to_usd: string | null
threshold_display: string
threshold_usd: string | null
}
export interface BillingStateResponse {
auto_reload: BillingAutoReload | null
balance_display: string
balance_usd: string | null
// NAS capability (canChangePlan) when the server sends it; legacy role fallback otherwise
can_change_plan?: boolean
can_charge: boolean
card: BillingCardInfo | null
charge_presets: string[]
charge_presets_display: string[]
cli_billing_enabled: boolean
error?: string | null
is_admin: boolean
logged_in: boolean
max_usd: string | null
min_usd: string | null
monthly_cap: BillingMonthlyCap | null
ok: boolean
org_name: string | null
portal_url: string | null
role: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so /topup
// renders the same bars as /usage and /subscription from this single fetch.
usage?: UsageModelData
}
/**
* Raw error payload echoed from the server (`_serialize_billing_error`). Carries
* the extra fields a few error codes attach notably `remainingUsd` on
* `monthly_cap_exceeded` so the client can render the same detail the CLI does.
*/
export interface BillingErrorPayload {
isDefaultCeiling?: boolean
remainingUsd?: string
}
export interface BillingChargeResponse {
actor?: string
charge_id?: string
code?: string
error?: BillingRefusalCode
idempotency_key?: string
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
export interface BillingChargeStatusResponse {
amount_usd?: string | null
error?: BillingRefusalCode
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
reason?: ChargeFailureReason | null
retry_after?: number | null
settled_at?: string | null
status?: string
}
export interface BillingMutationResponse {
actor?: string
code?: string
error?: BillingRefusalCode
granted?: boolean
message?: string
ok: boolean
/**
* On ok:false, the structured error payload. On ok:true the gateway passes
* through the raw NAS success body (e.g. rail, changeType, cancelAtPeriodEnd
* for subscription mutations), which has no stable shape here.
*/
payload?: BillingErrorPayload | Record<string, unknown>
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
export interface SubscriptionTierOption {
tier_id: string
name: string
tier_order: number // sorts the picker + upgrade/downgrade hint
dollars_per_month_display: string // pre-formatted ($X / $X.YY)
monthly_credits: string | null
is_current: boolean // the active plan: shown, not selectable
is_enabled: boolean // false = grandfathered current tier
}
export interface SubscriptionStateResponse {
ok: boolean
logged_in: boolean
is_admin: boolean
can_change_plan: boolean // NAS capability (canChangePlan: OWNER/ADMIN/FINANCE_ADMIN); legacy role fallback when the server omits it
org_name: string | null
org_id: string | null // org.id from the NAS response
role: string | null
context: 'personal' | 'team' // personal account vs team/org terminal
current: {
tier_id: string | null // null = free (no active sub)
tier_name: string | null
monthly_credits: string | null
credits_remaining: string | null
cycle_ends_at: string | null // ISO
pending_downgrade_tier_name: string | null
pending_downgrade_at: string | null
pending_downgrade_display: string | null // formatted pending_downgrade_at
cancel_at_period_end: boolean // subscription scheduled to cancel at period end
cancellation_effective_at: string | null // ISO when cancellation takes effect
cancellation_effective_display: string | null // formatted cancellation_effective_at
} | null
tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker
portal_url: string | null
error?: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so the
// overlay renders the same bars as /usage from this single fetch.
usage?: UsageModelData
}
// A chargeless quote (POST /subscription/preview) of what a change would do.
// `effect` drives the confirm copy; a failed preview reuses the typed-error
// envelope fields (same as the mutations) so a 403 still triggers the step-up.
export interface SubscriptionPreviewResponse {
ok: boolean
effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked'
reason?: string | null
current_tier_id?: string | null
current_tier_name?: string | null
target_tier_id?: string | null
target_tier_name?: string | null
monthly_credits_delta?: string | null
amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade
effective_at?: string | null // ISO, when a scheduled change lands
// typed-error envelope (present when ok=false)
error?: BillingRefusalCode
message?: string
portal_url?: string | null
retry_after?: number | null
payload?: BillingErrorPayload
actor?: string
code?: string
recovery?: string
}
// The single money route (POST /subscription/upgrade). `status` distinguishes a
// completed upgrade from an SCA/decline that must finish in the portal at
// `recovery_url`. `idempotency_key` is echoed so a retry reuses it.
export interface SubscriptionUpgradeResponse {
ok: boolean
status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed'
target_tier_name?: string | null
recovery_url?: string | null
reason?: ChargeFailureReason | null
idempotency_key?: string
// typed-error envelope (present when ok=false)
error?: BillingRefusalCode
message?: string
portal_url?: string | null
retry_after?: number | null
payload?: BillingErrorPayload
actor?: string
code?: string
recovery?: string
}

View file

@ -0,0 +1,89 @@
import { refusalPolicy } from './billing-policy.js'
import type { BillingChargeStatusResponse } from './billing-types.js'
export interface SettlementDeps {
fetchStatus(): Promise<BillingChargeStatusResponse>
sleep(ms: number): Promise<void>
isCancelled(): boolean
now(): number
}
export type SettlementOutcome =
| { kind: 'settled'; status: BillingChargeStatusResponse }
| { kind: 'failed'; status: BillingChargeStatusResponse }
| { kind: 'refused'; error: string; status: BillingChargeStatusResponse }
| {
kind: 'ambiguous'
error: string
status?: BillingChargeStatusResponse
cause?: unknown
}
| { kind: 'timed_out' }
| { kind: 'cancelled' }
export const SETTLEMENT_POLL_INTERVAL_MS = 2000
export const SETTLEMENT_POLL_CAP_MS = 5 * 60 * 1000
export const SETTLEMENT_MAX_RETRY_AFTER_MS = 30000
export async function driveChargeSettlement(deps: SettlementDeps): Promise<SettlementOutcome> {
const start = deps.now()
const timedOut = (): boolean => deps.now() - start >= SETTLEMENT_POLL_CAP_MS
while (true) {
if (deps.isCancelled()) {
return { kind: 'cancelled' }
}
let status: BillingChargeStatusResponse
try {
status = await deps.fetchStatus()
} catch (cause) {
return { kind: 'ambiguous', error: 'transport', cause }
}
if (!status.ok) {
const error = status.error ?? ''
const policy = refusalPolicy(error)
if (policy.ambiguousMidPoll) {
return { kind: 'ambiguous', error: error || 'unknown', status }
}
if (policy.recovery === 'retry') {
if (timedOut()) {
return { kind: 'timed_out' }
}
const wait = Math.min(
(status.retry_after ?? 5) * 1000,
SETTLEMENT_MAX_RETRY_AFTER_MS
)
await deps.sleep(wait)
continue
}
return {
kind: 'refused',
error: status.error ?? status.message ?? 'error',
status
}
}
if (status.status === 'settled') {
return { kind: 'settled', status }
}
if (status.status === 'failed') {
return { kind: 'failed', status }
}
if (timedOut()) {
return { kind: 'timed_out' }
}
await deps.sleep(SETTLEMENT_POLL_INTERVAL_MS)
}
}

View file

@ -19,3 +19,37 @@ export {
type ResolveGatewayWsUrlDeps,
type WebSocketAuthParam
} from './websocket-url'
export type {
BillingCardInfo,
BillingMonthlyCap,
BillingAutoReload,
BillingRefusalCode,
BillingStateResponse,
BillingErrorPayload,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingMutationResponse,
ChargeFailureReason,
KnownBillingRefusalCode,
KnownChargeFailureReason,
SubscriptionTierOption,
SubscriptionStateResponse,
SubscriptionPreviewResponse,
SubscriptionUpgradeResponse,
UsageBarData,
UsageModelData
} from './billing-types'
export {
BILLING_REFUSAL_POLICY,
type BillingRecovery,
type BillingRefusalPolicy,
refusalPolicy
} from './billing-policy'
export {
driveChargeSettlement,
SETTLEMENT_MAX_RETRY_AFTER_MS,
SETTLEMENT_POLL_CAP_MS,
SETTLEMENT_POLL_INTERVAL_MS,
type SettlementDeps,
type SettlementOutcome
} from './charge-settlement'

View file

@ -462,6 +462,13 @@ def _request(
raise BillingError(
f"Could not reach Nous Portal: {exc.reason}", error="network_error"
) from exc
except TimeoutError as exc:
# urlopen() wraps CONNECT-phase timeouts in URLError, but a timeout
# during resp.read() surfaces as a bare TimeoutError — normalize it so
# transport failures always honor the typed-BillingError contract.
raise BillingError(
"Could not reach Nous Portal: timed out", error="network_error"
) from exc
# =============================================================================

1
package-lock.json generated
View file

@ -19508,6 +19508,7 @@
"version": "0.0.1",
"dependencies": {
"@hermes/ink": "file:./packages/hermes-ink",
"@hermes/shared": "file:../apps/shared",
"@nanostores/react": "^1.1.0",
"ink": "^6.8.0",
"ink-text-input": "^6.0.0",

View file

@ -10,6 +10,7 @@ from __future__ import annotations
import io
import json
import socket
from contextlib import contextmanager
import pytest
@ -31,6 +32,47 @@ class _FakeResp(io.BytesIO):
self.close()
def _http_error(status: int, body: bytes | dict[str, object] = b"{}", headers=None):
"""Build the real urllib HTTPError object _request catches."""
if isinstance(body, dict):
body = json.dumps(body).encode()
return nb.urllib.error.HTTPError(
"https://portal.example/api/billing/state",
status,
"HTTP Error",
headers or {},
fp=io.BytesIO(body),
)
def _sequence(monkeypatch, *outcomes, resolver=None):
"""Stub urlopen with ordered outcomes and record each Request."""
seen: list[dict[str, object]] = []
monkeypatch.setattr(nb, "_token_cache", None, raising=False)
monkeypatch.setattr(
nb,
"_resolve_token_and_base",
resolver or (lambda **kw: ("tok", "https://portal.example")),
)
def _fake_urlopen(req, timeout=None):
seen.append(
{
"method": req.get_method(),
"url": req.full_url,
"data": json.loads(req.data.decode()) if req.data else None,
"headers": {k.lower(): v for k, v in req.header_items()},
}
)
outcome = outcomes[len(seen) - 1]
if isinstance(outcome, BaseException):
raise outcome
return outcome
monkeypatch.setattr(nb.urllib.request, "urlopen", _fake_urlopen)
return seen
@contextmanager
def _stub(monkeypatch, body: bytes, status: int = 200):
# Bypass auth/token resolution entirely — we only exercise response parsing.
@ -159,3 +201,273 @@ def test_post_subscription_upgrade_blank_key_raises():
subscription_type_id="nous-chat-plan-40", idempotency_key=" "
)
assert getattr(ei.value, "error", None) == "idempotency_key_required"
# ---------------------------------------------------------------------------
# Wire-layer HTTP error mapping through _request.
# ---------------------------------------------------------------------------
def test_401_refreshes_token_and_retries_successfully(monkeypatch):
# A cached-token 401 is retried once with a freshly resolved token.
def _resolver(*, use_cache=True):
token = "tok-fresh" if not use_cache else "tok-stale"
return token, "https://portal.example"
seen = _sequence(
monkeypatch,
_http_error(401),
_FakeResp(b'{"ok": true}'),
resolver=_resolver,
)
assert nb.get_billing_state() == {"ok": True}
assert len(seen) == 2
assert seen[0]["headers"]["authorization"] == "Bearer tok-stale"
assert seen[1]["headers"]["authorization"] == "Bearer tok-fresh"
def test_401_retries_once_then_plain_401_is_terminal(monkeypatch):
# A second plain 401 maps to auth failure, not another recursive retry.
seen = _sequence(monkeypatch, _http_error(401), _http_error(401))
with pytest.raises(nb.BillingAuthError):
nb.get_billing_state()
assert len(seen) == 2
def test_401_retry_terminal_session_revoked_preserves_recovery(monkeypatch):
# session_revoked is only surfaced after the one refresh attempt is exhausted.
seen = _sequence(
monkeypatch,
_http_error(401),
_http_error(401, {"error": "session_revoked", "recovery": "login"}),
)
with pytest.raises(nb.BillingSessionRevoked) as ei:
nb.get_billing_state()
assert len(seen) == 2
assert ei.value.recovery == "login"
def test_post_charge_preserves_idempotency_key_across_401_retry(monkeypatch):
# Money requests must retry with the exact same Idempotency-Key.
seen = _sequence(
monkeypatch,
_http_error(401),
_FakeResp(b'{"chargeId": "ch_1"}', status=202),
)
assert nb.post_charge(amount_usd="10", idempotency_key="k1") == {
"chargeId": "ch_1"
}
assert len(seen) == 2
assert seen[0]["headers"]["idempotency-key"] == "k1"
assert seen[1]["headers"]["idempotency-key"] == "k1"
def test_403_insufficient_scope_maps_through_request(monkeypatch):
# Wire 403 insufficient_scope drives the lazy billing:manage step-up path.
_sequence(monkeypatch, _http_error(403, {"error": "insufficient_scope"}))
with pytest.raises(nb.BillingScopeRequired):
nb.get_billing_state()
def test_403_remote_spending_revoked_maps_through_request(monkeypatch):
# The wire discriminator keeps spend-revocation distinct from missing scope.
_sequence(
monkeypatch,
_http_error(
403,
{
"error": "remote_spending_revoked",
"actor": "self",
"recovery": "reconnect",
},
),
)
with pytest.raises(nb.BillingRemoteSpendingRevoked) as ei:
nb.get_billing_state()
assert ei.value.actor == "self"
assert ei.value.recovery == "reconnect"
def test_403_cli_billing_disabled_stays_generic_with_portal_url(monkeypatch):
# Business denials stay generic so surfaces can branch on code/recovery.
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
_sequence(
monkeypatch,
_http_error(
403,
{
"error": "cli_billing_disabled",
"code": "remote_spending_disabled",
"recovery": "enable_account_toggle",
"portalUrl": "/billing",
},
),
)
with pytest.raises(nb.BillingError) as ei:
nb.get_billing_state()
assert type(ei.value) is nb.BillingError
assert ei.value.code == "remote_spending_disabled"
assert ei.value.recovery == "enable_account_toggle"
assert ei.value.portal_url == "https://portal.nousresearch.com/billing"
def test_429_retry_after_header_maps_to_rate_limited(monkeypatch):
# 429 is rate-limited and reads Retry-After from headers.
_sequence(monkeypatch, _http_error(429, headers={"Retry-After": "15"}))
with pytest.raises(nb.BillingRateLimited) as ei:
nb.get_billing_state()
assert ei.value.retry_after == 15
def test_429_body_retry_after_hint_is_ignored_without_header(monkeypatch):
# Pins current behavior: the client reads only the Retry-After header.
# Whether it should also honor retryAfter in JSON is an open product question.
_sequence(
monkeypatch,
_http_error(429, {"error": "rate_limited", "retryAfter": 20}),
)
with pytest.raises(nb.BillingRateLimited) as ei:
nb.get_billing_state()
assert ei.value.retry_after is None
def test_503_without_headers_is_rate_limited_without_retry_after(monkeypatch):
# Missing headers must not crash retry-after parsing.
_sequence(monkeypatch, _http_error(503, headers=None))
with pytest.raises(nb.BillingRateLimited) as ei:
nb.get_billing_state()
assert ei.value.retry_after is None
def test_non_json_403_body_maps_to_generic_billing_denied(monkeypatch):
# An unparseable 403 has no discriminator, so no subtype is selected.
_sequence(monkeypatch, _http_error(403, b"<html>Forbidden</html>"))
with pytest.raises(nb.BillingError) as ei:
nb.get_billing_state()
assert type(ei.value) is nb.BillingError
assert str(ei.value) == "Billing request denied."
def test_non_json_second_401_maps_to_auth_error_not_session_revoked(monkeypatch):
# The terminal 401 must not become session_revoked without a JSON discriminator.
_sequence(
monkeypatch,
_http_error(401),
_http_error(401, b"<html>Unauthorized</html>"),
)
with pytest.raises(nb.BillingAuthError) as ei:
nb.get_billing_state()
assert not isinstance(ei.value, nb.BillingSessionRevoked)
def test_non_json_500_body_maps_to_generic_failure(monkeypatch):
# Generic server failures keep the status and default failure message.
_sequence(monkeypatch, _http_error(500, b"<html>Oops</html>"))
with pytest.raises(nb.BillingError) as ei:
nb.get_billing_state()
assert ei.value.status == 500
assert str(ei.value) == "Billing request failed (500)."
def test_404_get_charge_status_maps_to_generic_billing_error(monkeypatch):
# get_charge_status should surface unexpected 404s as typed generic errors.
_sequence(monkeypatch, _http_error(404))
with pytest.raises(nb.BillingError) as ei:
nb.get_charge_status("ch_404")
assert type(ei.value) is nb.BillingError
assert ei.value.status == 404
def test_502_empty_json_body_maps_to_generic_error_without_error_code(monkeypatch):
# Empty JSON error bodies preserve the HTTP status and no error discriminator.
_sequence(monkeypatch, _http_error(502, {}))
with pytest.raises(nb.BillingError) as ei:
nb.get_billing_state()
assert type(ei.value) is nb.BillingError
assert ei.value.status == 502
assert ei.value.error is None
def test_urlerror_dns_maps_to_network_error(monkeypatch):
# urllib transport failures are normalized to BillingError(network_error).
_sequence(
monkeypatch,
nb.urllib.error.URLError("Name or service not known"),
)
with pytest.raises(nb.BillingError) as ei:
nb.get_billing_state()
assert ei.value.error == "network_error"
assert "Could not reach Nous Portal" in str(ei.value)
def test_urlerror_wrapped_timeout_maps_to_network_error(monkeypatch):
# Real urllib timeouts arrive wrapped in URLError at this layer.
_sequence(monkeypatch, nb.urllib.error.URLError(TimeoutError("timed out")))
with pytest.raises(nb.BillingError) as ei:
nb.get_billing_state()
assert ei.value.error == "network_error"
assert "Could not reach Nous Portal" in str(ei.value)
def test_bare_socket_timeout_normalizes_to_network_error(monkeypatch):
# urlopen wraps connect-phase timeouts in URLError, but a read-phase timeout
# is a bare TimeoutError — it must still honor the typed-BillingError contract.
_sequence(monkeypatch, socket.timeout())
with pytest.raises(nb.BillingError) as ei:
nb.get_billing_state()
assert ei.value.error == "network_error"
assert "timed out" in str(ei.value)
def test_401_retry_re_resolves_base_url(monkeypatch):
# The auth retry re-runs base resolution too, so preview/prod flips are honored.
def _resolver(*, use_cache=True):
if use_cache:
return "tok-stale", "https://old.example"
return "tok-fresh", "https://new.example"
seen = _sequence(
monkeypatch,
_http_error(401),
_FakeResp(b'{"ok": true}'),
resolver=_resolver,
)
assert nb.get_billing_state() == {"ok": True}
assert len(seen) == 2
assert seen[0]["url"] == "https://old.example/api/billing/state"
assert seen[1]["url"] == "https://new.example/api/billing/state"

View file

@ -19,6 +19,7 @@
},
"dependencies": {
"@hermes/ink": "file:./packages/hermes-ink",
"@hermes/shared": "file:../apps/shared",
"@nanostores/react": "^1.1.0",
"ink": "^6.8.0",
"ink-text-input": "^6.0.0",

View file

@ -1,3 +1,8 @@
import {
driveChargeSettlement,
type SettlementOutcome
} from '@hermes/shared/charge-settlement'
import type {
BillingChargeResponse,
BillingChargeStatusResponse,
@ -10,10 +15,6 @@ import type { BillingChargeOutcome, BillingOverlayCtx } from '../../interfaces.j
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand, SlashRunCtx } from '../types.js'
// Poll cadence (plan §5, frozen): 2s interval, 5-minute cap.
const POLL_INTERVAL_MS = 2000
const POLL_CAP_MS = 5 * 60 * 1000
const UNCONFIRMED_CHARGE_MESSAGE =
'🟡 Your last charges outcome is unconfirmed — check your balance/history before retrying.'
@ -174,97 +175,82 @@ const requestRemoteSpending = (ctx: SlashRunCtx): Promise<boolean> =>
/** Poll a charge to a terminal state (settled/failed/timeout). Non-blocking. */
const pollCharge = (sys: Sys, ctx: SlashRunCtx, chargeId: string, portalUrl?: string | null): void => {
const start = Date.now()
const renderOutcome = (outcome: SettlementOutcome): void => {
switch (outcome.kind) {
case 'settled':
sys(`${outcome.status.amount_usd ? `$${outcome.status.amount_usd}` : 'Credits'} added.`)
// The 5-min cap, honored on EVERY non-terminal path (pending AND throttled)
// so a sustained 429/503 can't keep the poll alive forever.
const timedOut = (): boolean => {
if (Date.now() - start < POLL_CAP_MS) {
return false
}
return
sys(
'🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' + 'Check /topup or the portal shortly.'
)
case 'failed':
renderChargeFailed(sys, outcome.status.reason, portalUrl)
if (portalUrl) {
sys(`Portal: ${portalUrl}`)
}
return
return true
}
case 'refused':
sys(`🔴 Could not check the charge: ${outcome.status.message || outcome.status.error || 'error'}`)
const tick = (): void => {
if (ctx.stale()) {
return
}
return
ctx.gateway
.rpc<BillingChargeStatusResponse>('billing.charge_status', { charge_id: chargeId })
.then(
ctx.guarded<BillingChargeStatusResponse>(r => {
if (!r.ok) {
// 429/503 while polling = retry-after, NOT a failure. Back off + continue.
if (
r.error === 'rate_limited' ||
r.error === 'temporarily_unavailable' ||
r.error === 'stripe_unavailable'
) {
if (timedOut()) {
return
}
case 'ambiguous':
if (outcome.status) {
renderBillingError(sys, ctx, outcome.status)
sys(UNCONFIRMED_CHARGE_MESSAGE)
const wait = (r.retry_after ?? 5) * 1000
setTimeout(tick, Math.min(wait, 30000))
return
}
return
}
// CF-7 rule 4: a post-revoke 403 (or session loss) while polling means
// the prior charge's outcome is AMBIGUOUS — it may have settled. Do not
// call it failed; surface the revoke + tell the user to verify balance.
if (r.error === 'remote_spending_revoked' || r.error === 'session_revoked') {
renderBillingError(sys, ctx, r)
sys(UNCONFIRMED_CHARGE_MESSAGE)
return
}
sys(`🔴 Could not check the charge: ${r.message || r.error || 'error'}`)
return
}
if (r.status === 'settled') {
sys(`${r.amount_usd ? `$${r.amount_usd}` : 'Credits'} added.`)
return
}
if (r.status === 'failed') {
renderChargeFailed(sys, r.reason, portalUrl)
return
}
// pending → keep polling until the 5-min cap, then call it a timeout.
if (timedOut()) {
return
}
setTimeout(tick, POLL_INTERVAL_MS)
})
)
.catch(e => {
ctx.guardedErr(e)
if ('cause' in outcome) {
ctx.guardedErr(outcome.cause)
}
if (!ctx.stale()) {
sys(UNCONFIRMED_CHARGE_MESSAGE)
}
})
return
case 'timed_out':
sys(
'🟡 Still processing after 5 minutes — this is a timeout, not a failure. ' +
'Check /topup or the portal shortly.'
)
if (portalUrl) {
sys(`Portal: ${portalUrl}`)
}
return
case 'cancelled':
return
}
}
tick()
void driveChargeSettlement({
fetchStatus: async () => {
const status = await ctx.gateway.rpc<BillingChargeStatusResponse>('billing.charge_status', {
charge_id: chargeId
})
if (!status) {
throw new Error('billing.charge_status returned no response')
}
return status
},
isCancelled: () => ctx.stale(),
now: () => Date.now(),
sleep: ms => new Promise(resolve => setTimeout(resolve, ms))
}).then(outcome => {
if (outcome.kind === 'ambiguous' && !outcome.status) {
renderOutcome(outcome)
return
}
ctx.guarded<SettlementOutcome>(renderOutcome)(outcome)
})
}
const renderChargeFailed = (sys: Sys, reason?: string | null, portalUrl?: string | null): void => {

View file

@ -1,3 +1,4 @@
import type { UsageModelData } from '@hermes/shared/billing'
import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js'
export interface GatewaySkin {
@ -45,200 +46,14 @@ export interface SlashExecResponse {
// ── Terminal billing (Phase 2b) ──────────────────────────────────────
export interface BillingCardInfo {
brand: string
last4: string
masked: string
/** "Visa ····4242 — the card on your subscription" (= masked when provenance unknown). */
display?: string
/** Raw card-resolution rung ("subPin" | "customerDefault" | "autoRefill") or null on older NAS. */
resolved_via?: null | string
}
export interface BillingMonthlyCap {
is_default_ceiling: boolean
limit_display: string
limit_usd: string | null
spent_display: string
spent_this_month_usd: string | null
}
export interface BillingAutoReload {
card:
| { kind: 'canonical' }
| {
kind: 'distinct'
payment_method_id: string
brand: string | null
last4: string | null
}
| { kind: 'none' }
enabled: boolean
reload_to_display: string
reload_to_usd: string | null
threshold_display: string
threshold_usd: string | null
}
export interface BillingStateResponse {
auto_reload: BillingAutoReload | null
balance_display: string
balance_usd: string | null
can_charge: boolean
card: BillingCardInfo | null
charge_presets: string[]
charge_presets_display: string[]
cli_billing_enabled: boolean
error?: string | null
is_admin: boolean
logged_in: boolean
max_usd: string | null
min_usd: string | null
monthly_cap: BillingMonthlyCap | null
ok: boolean
org_name: string | null
portal_url: string | null
role: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so /topup
// renders the same bars as /usage and /subscription from this single fetch.
usage?: UsageModelData
}
/**
* Raw error payload echoed from the server (`_serialize_billing_error`). Carries
* the extra fields a few error codes attach notably `remainingUsd` on
* `monthly_cap_exceeded` so the client can render the same detail the CLI does.
*/
export interface BillingErrorPayload {
isDefaultCeiling?: boolean
remainingUsd?: string
}
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
}
export interface BillingChargeStatusResponse {
amount_usd?: string | null
error?: string
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
reason?: string | null
retry_after?: number | null
settled_at?: string | null
status?: string
}
export interface BillingMutationResponse {
actor?: string
code?: string
error?: string
granted?: boolean
message?: string
ok: boolean
payload?: BillingErrorPayload
portal_url?: string | null
recovery?: string
retry_after?: number | null
}
export interface SubscriptionTierOption {
tier_id: string
name: string
tier_order: number // sorts the picker + upgrade/downgrade hint
dollars_per_month_display: string // pre-formatted ($X / $X.YY)
monthly_credits: string | null
is_current: boolean // the active plan: shown, not selectable
is_enabled: boolean // false = grandfathered current tier
}
export interface SubscriptionStateResponse {
ok: boolean
logged_in: boolean
is_admin: boolean
can_change_plan: boolean // role gate (ADMIN/OWNER), from NAS
org_name: string | null
org_id: string | null // org.id from the NAS response
role: string | null
context: 'personal' | 'team' // personal account vs team/org terminal
current: {
tier_id: string | null // null = free (no active sub)
tier_name: string | null
monthly_credits: string | null
credits_remaining: string | null
cycle_ends_at: string | null // ISO
pending_downgrade_tier_name: string | null
pending_downgrade_at: string | null
pending_downgrade_display: string | null // formatted pending_downgrade_at
cancel_at_period_end: boolean // subscription scheduled to cancel at period end
cancellation_effective_at: string | null // ISO when cancellation takes effect
cancellation_effective_display: string | null // formatted cancellation_effective_at
} | null
tiers: SubscriptionTierOption[] // selectable catalog for the in-terminal picker
portal_url: string | null
error?: string | null
// Shared dollar usage model (two-bar view), embedded by the gateway so the
// overlay renders the same bars as /usage from this single fetch.
usage?: UsageModelData
}
// A chargeless quote (POST /subscription/preview) of what a change would do.
// `effect` drives the confirm copy; a failed preview reuses the typed-error
// envelope fields (same as the mutations) so a 403 still triggers the step-up.
export interface SubscriptionPreviewResponse {
ok: boolean
effect?: 'charge_now' | 'scheduled' | 'no_op' | 'blocked'
reason?: string | null
current_tier_id?: string | null
current_tier_name?: string | null
target_tier_id?: string | null
target_tier_name?: string | null
monthly_credits_delta?: string | null
amount_due_now_cents?: number | null // the prorated upfront charge for an upgrade
effective_at?: string | null // ISO, when a scheduled change lands
// typed-error envelope (present when ok=false)
error?: string
message?: string
portal_url?: string | null
retry_after?: number | null
payload?: BillingErrorPayload
actor?: string
code?: string
recovery?: string
}
// The single money route (POST /subscription/upgrade). `status` distinguishes a
// completed upgrade from an SCA/decline that must finish in the portal at
// `recovery_url`. `idempotency_key` is echoed so a retry reuses it.
export interface SubscriptionUpgradeResponse {
ok: boolean
status?: 'upgraded' | 'already_on_tier' | 'requires_action' | 'payment_failed'
target_tier_name?: string | null
recovery_url?: string | null
reason?: string | null
idempotency_key?: string
// typed-error envelope (present when ok=false)
error?: string
message?: string
portal_url?: string | null
retry_after?: number | null
payload?: BillingErrorPayload
actor?: string
code?: string
recovery?: string
}
// Wire shapes now live in @hermes/shared for reuse by TypeScript clients.
export type {
BillingCardInfo, BillingMonthlyCap, BillingAutoReload, BillingStateResponse,
BillingErrorPayload, BillingChargeResponse, BillingChargeStatusResponse,
BillingMutationResponse, SubscriptionTierOption, SubscriptionStateResponse,
SubscriptionPreviewResponse, SubscriptionUpgradeResponse,
UsageBarData, UsageModelData,
} from '@hermes/shared/billing'
export type CommandDispatchResponse =
| { output?: string; type: 'exec' | 'plugin' }
@ -433,31 +248,6 @@ export interface SessionUsageResponse {
usage?: UsageModelData
}
/** One serialized usage bar (mirrors server `_serialize_usage_bar`). */
export interface UsageBarData {
kind: 'plan' | 'topup'
remaining_display: string
total_display: string
spent_display: string
pct_used: null | number
fill_fraction: number
}
/** The shared dollar usage model (mirrors server `_serialize_usage_model`). */
export interface UsageModelData {
available: boolean
status?: string
plan_name?: null | string
renews_at?: null | string
renews_display?: null | string
subscription_remaining_display?: null | string
topup_remaining_display?: null | string
total_spendable_display?: null | string
has_topup?: boolean
plan_bar?: null | UsageBarData
topup_bar?: null | UsageBarData
}
export interface SessionStatusResponse {
output?: string
}