diff --git a/apps/shared/package.json b/apps/shared/package.json index 3d3c921e14dc..d678748333f4 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -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": { diff --git a/apps/shared/src/billing-policy.ts b/apps/shared/src/billing-policy.ts new file mode 100644 index 000000000000..359abe3a794f --- /dev/null +++ b/apps/shared/src/billing-policy.ts @@ -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 = { + 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' } +} diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts new file mode 100644 index 000000000000..d06e24bcba50 --- /dev/null +++ b/apps/shared/src/billing-types.ts @@ -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` (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 + 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 +} diff --git a/apps/shared/src/charge-settlement.ts b/apps/shared/src/charge-settlement.ts new file mode 100644 index 000000000000..6e23252a7189 --- /dev/null +++ b/apps/shared/src/charge-settlement.ts @@ -0,0 +1,89 @@ +import { refusalPolicy } from './billing-policy.js' +import type { BillingChargeStatusResponse } from './billing-types.js' + +export interface SettlementDeps { + fetchStatus(): Promise + sleep(ms: number): Promise + 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 { + 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) + } +} diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 83f4fee7344b..3b9aff5d4578 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -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' diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index 83c65f70de74..a2c59a317f8d 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -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 # ============================================================================= diff --git a/package-lock.json b/package-lock.json index 7f4c1e5f2eb0..0b5dfdb169d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/tests/hermes_cli/test_nous_billing_request.py b/tests/hermes_cli/test_nous_billing_request.py index 92471ee2b131..6ea965dd9090 100644 --- a/tests/hermes_cli/test_nous_billing_request.py +++ b/tests/hermes_cli/test_nous_billing_request.py @@ -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"Forbidden")) + + 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"Unauthorized"), + ) + + 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"Oops")) + + 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" diff --git a/ui-tui/package.json b/ui-tui/package.json index 7c83ad71e6b3..34fa3c31db4f 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -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", diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 2f1fa6d1e9f0..7bbbad027134 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -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 charge’s outcome is unconfirmed — check your balance/history before retrying.' @@ -174,97 +175,82 @@ const requestRemoteSpending = (ctx: SlashRunCtx): Promise => /** 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('billing.charge_status', { charge_id: chargeId }) - .then( - ctx.guarded(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('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(renderOutcome)(outcome) + }) } const renderChargeFailed = (sys: Sys, reason?: string | null, portalUrl?: string | null): void => { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index b79de83222d6..1b87eb696ac3 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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 }