mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
* fix(billing): rename user-facing "terminal billing" copy to Remote Spending
The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.
- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
admin can turn it on from the portal's Hermes Agent page") instead of
the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
unchanged; copy, comments, docs, and test expectations only.
* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename
Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).
458 lines
17 KiB
Python
458 lines
17 KiB
Python
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
|
|
|
|
One fetch/parse per concern, consumed identically by the CLI handler
|
|
(``cli.py::_show_billing``), the TUI JSON-RPC methods
|
|
(``tui_gateway/server.py``), and any other surface. Mirrors the proven
|
|
``agent/account_usage.py::build_credits_view`` pattern: parse the server payload
|
|
into a frozen dataclass; **fail open** — 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 discipline: the server emits decimal STRINGS (``"142.5"``, not fixed 2dp).
|
|
We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Decimal money helpers
|
|
# =============================================================================
|
|
|
|
|
|
def parse_money(value: Any) -> Optional[Decimal]:
|
|
"""Parse a server money value (decimal string) into :class:`Decimal`.
|
|
|
|
Returns None for missing/invalid input. Never raises. Accepts str/int (and,
|
|
defensively, float — though the server always sends strings).
|
|
"""
|
|
if value is None:
|
|
return None
|
|
try:
|
|
# Decimal(str(...)) avoids binary-float artifacts if a float ever sneaks in.
|
|
return Decimal(str(value).strip())
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
return None
|
|
|
|
|
|
def format_money(value: Optional[Decimal]) -> str:
|
|
"""Format a Decimal as ``$X`` / ``$X.YY`` for display.
|
|
|
|
Whole dollars show no decimals; any fractional amount shows exactly 2dp:
|
|
``Decimal("142.5")`` → ``"$142.50"``, ``Decimal("100")`` → ``"$100"``,
|
|
``Decimal("0.01")`` → ``"$0.01"``.
|
|
"""
|
|
if value is None:
|
|
return "—"
|
|
if value == value.to_integral_value():
|
|
# Whole dollars — no decimal point. format(..., "f") avoids 1E+3 for 1000.
|
|
return f"${format(value.to_integral_value(), 'f')}"
|
|
# Fractional — always show 2dp.
|
|
return f"${format(value.quantize(Decimal('0.01')), 'f')}"
|
|
|
|
|
|
# =============================================================================
|
|
# Parsed sub-structures
|
|
# =============================================================================
|
|
|
|
|
|
# resolvedVia → the human answer to "why THIS card?". Keys are the server's card
|
|
# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label
|
|
# so the display degrades cleanly on servers that don't send resolvedVia yet.
|
|
_CARD_PROVENANCE_LABELS = {
|
|
"subPin": "the card on your subscription",
|
|
"customerDefault": "your default card saved on the portal",
|
|
"autoRefill": "your auto-reload card",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CardInfo:
|
|
brand: str
|
|
last4: str
|
|
# NAS card-on-file field (post card-resolver): which ladder rung found the
|
|
# card. Defaults off so pre-resolver payloads parse unchanged.
|
|
resolved_via: Optional[str] = None
|
|
|
|
@property
|
|
def masked(self) -> str:
|
|
# A Link payment method has no card number (last4 = "") — render the
|
|
# brand alone, not "Link ····".
|
|
if not self.last4:
|
|
return self.brand
|
|
return f"{self.brand} ····{self.last4}"
|
|
|
|
@property
|
|
def provenance(self) -> Optional[str]:
|
|
"""Human label for why this card was picked, or None (unknown rung /
|
|
server too old to say)."""
|
|
if self.resolved_via is None:
|
|
return None
|
|
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
|
|
|
|
@property
|
|
def display(self) -> str:
|
|
"""The one-line card display: ``Visa ····4242 — the card on your
|
|
subscription`` (or just the masked card when provenance is unknown)."""
|
|
label = self.provenance
|
|
return f"{self.masked} — {label}" if label else self.masked
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MonthlyCap:
|
|
limit_usd: Optional[Decimal] = None
|
|
spent_this_month_usd: Optional[Decimal] = None
|
|
is_default_ceiling: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AutoReloadCard:
|
|
kind: str # "canonical" | "distinct" | "none"
|
|
payment_method_id: Optional[str] = None
|
|
brand: Optional[str] = None
|
|
last4: Optional[str] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AutoReload:
|
|
enabled: bool = False
|
|
threshold_usd: Optional[Decimal] = None
|
|
reload_to_usd: Optional[Decimal] = None
|
|
card: Optional[AutoReloadCard] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BillingState:
|
|
"""Parsed ``GET /api/billing/state`` — 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_id: Optional[str] = None
|
|
org_slug: Optional[str] = None
|
|
org_name: Optional[str] = None
|
|
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
|
|
can_change_plan_raw: Optional[bool] = None
|
|
balance_usd: Optional[Decimal] = None
|
|
cli_billing_enabled: bool = False
|
|
charge_presets: tuple[Decimal, ...] = ()
|
|
min_usd: Optional[Decimal] = None
|
|
max_usd: Optional[Decimal] = None
|
|
card: Optional[CardInfo] = None
|
|
monthly_cap: Optional[MonthlyCap] = None
|
|
auto_reload: Optional[AutoReload] = None
|
|
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:
|
|
"""Deprecated/display only — a legacy OWNER/ADMIN check.
|
|
|
|
NOT a capability check; use :attr:`can_change_plan` for gating billing
|
|
plan-change actions.
|
|
"""
|
|
return (self.role or "").upper() in ("OWNER", "ADMIN")
|
|
|
|
@property
|
|
def can_change_plan(self) -> bool:
|
|
"""Server capability when supplied; otherwise the legacy role fallback."""
|
|
if self.can_change_plan_raw is not None:
|
|
return self.can_change_plan_raw
|
|
return self.is_admin
|
|
|
|
@property
|
|
def can_charge(self) -> bool:
|
|
"""True when the UI should offer charge/auto-reload actions.
|
|
|
|
Uses the server-granted plan-change capability (``can_change_plan``,
|
|
which itself falls back to the legacy OWNER/ADMIN role check when the
|
|
server omits ``canChangePlan``) AND the per-org kill-switch. This lets
|
|
the server grant charge capability to non-OWNER/ADMIN roles (e.g.
|
|
FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the
|
|
deprecated 3-role admin check. (The server still enforces; this is
|
|
just for graying out actions the user can't take.)
|
|
"""
|
|
return self.can_change_plan and self.cli_billing_enabled
|
|
|
|
|
|
def _parse_card(raw: Any) -> Optional[CardInfo]:
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
brand = raw.get("brand")
|
|
last4 = raw.get("last4")
|
|
if not (isinstance(brand, str) and isinstance(last4, str)):
|
|
return None
|
|
# Post-resolver fields — all optional so both payload generations parse.
|
|
resolved_via = raw.get("resolvedVia")
|
|
if not isinstance(resolved_via, str):
|
|
resolved_via = None
|
|
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
|
|
|
|
|
|
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
return MonthlyCap(
|
|
limit_usd=parse_money(raw.get("limitUsd")),
|
|
spent_this_month_usd=parse_money(raw.get("spentThisMonthUsd")),
|
|
is_default_ceiling=bool(raw.get("isDefaultCeiling")),
|
|
)
|
|
|
|
|
|
def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
return AutoReload(
|
|
enabled=bool(raw.get("enabled")),
|
|
threshold_usd=parse_money(raw.get("thresholdUsd")),
|
|
reload_to_usd=parse_money(raw.get("reloadToUsd")),
|
|
card=_parse_auto_reload_card(raw.get("card")),
|
|
)
|
|
|
|
|
|
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
kind = raw.get("kind")
|
|
if kind not in ("canonical", "distinct", "none"):
|
|
return None
|
|
if kind in ("canonical", "none"):
|
|
return AutoReloadCard(kind=kind)
|
|
|
|
payment_method_id = raw.get("paymentMethodId")
|
|
brand = raw.get("brand")
|
|
last4 = raw.get("last4")
|
|
return AutoReloadCard(
|
|
kind=kind,
|
|
payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None,
|
|
brand=brand if isinstance(brand, str) else None,
|
|
last4=last4 if isinstance(last4, str) else None,
|
|
)
|
|
|
|
|
|
def billing_state_from_payload(
|
|
payload: dict[str, Any], *, portal_url: Optional[str] = None
|
|
) -> BillingState:
|
|
"""Map a raw ``/api/billing/state`` JSON dict into :class:`BillingState`."""
|
|
raw_org = payload.get("org")
|
|
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
|
|
raw_bounds = payload.get("bounds")
|
|
bounds: dict[str, Any] = raw_bounds if isinstance(raw_bounds, dict) else {}
|
|
|
|
presets: list[Decimal] = []
|
|
for item in payload.get("chargePresets") or ():
|
|
parsed = parse_money(item)
|
|
if parsed is not None:
|
|
presets.append(parsed)
|
|
|
|
return BillingState(
|
|
logged_in=True,
|
|
org_id=org.get("id"),
|
|
org_slug=org.get("slug"),
|
|
org_name=org.get("name"),
|
|
role=org.get("role"),
|
|
can_change_plan_raw=(
|
|
payload.get("canChangePlan")
|
|
if isinstance(payload.get("canChangePlan"), bool)
|
|
else None
|
|
),
|
|
balance_usd=parse_money(payload.get("balanceUsd")),
|
|
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
|
|
charge_presets=tuple(presets),
|
|
min_usd=parse_money(bounds.get("minUsd")),
|
|
max_usd=parse_money(bounds.get("maxUsd")),
|
|
card=_parse_card(payload.get("card")),
|
|
monthly_cap=_parse_monthly_cap(payload.get("monthlyCap")),
|
|
auto_reload=_parse_auto_reload(payload.get("autoReload")),
|
|
portal_url=portal_url,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Fail-open builders (the surface front doors)
|
|
# =============================================================================
|
|
|
|
|
|
def build_billing_state(*, timeout: float = 15.0) -> BillingState:
|
|
"""Fetch + parse ``/api/billing/state``. Fail-open.
|
|
|
|
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
|
|
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
|
|
a clear message rather than crashing.
|
|
|
|
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
|
|
card-on-file / admin / scope states are testable offline (mirrors
|
|
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
|
|
"""
|
|
fixture = _dev_fixture_billing_state()
|
|
if fixture is not None:
|
|
return fixture
|
|
|
|
try:
|
|
from hermes_cli.nous_billing import (
|
|
BillingAuthError,
|
|
BillingError,
|
|
_absolutize_portal_url,
|
|
get_billing_state,
|
|
resolve_portal_base_url,
|
|
)
|
|
except Exception:
|
|
return BillingState(logged_in=False, error="billing client unavailable")
|
|
|
|
try:
|
|
payload = get_billing_state(timeout=timeout)
|
|
except BillingAuthError:
|
|
return BillingState(logged_in=False)
|
|
except BillingError as exc:
|
|
logger.debug("billing ▸ /state fetch failed (fail-open)", exc_info=True)
|
|
return BillingState(logged_in=False, error=str(exc))
|
|
except Exception:
|
|
logger.debug("billing ▸ /state unexpected error (fail-open)", exc_info=True)
|
|
return BillingState(logged_in=False, error="could not load billing state")
|
|
|
|
# Prefer a server-supplied portalUrl if present (resolved to absolute in case
|
|
# it's relative); else build the standard one.
|
|
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 = _fallback_portal_url(resolve_portal_base_url())
|
|
except Exception:
|
|
portal_url = None
|
|
|
|
return billing_state_from_payload(payload, portal_url=portal_url)
|
|
|
|
|
|
def _fallback_portal_url(base: str) -> str:
|
|
"""Standard billing deep-link when the server omits ``portalUrl``."""
|
|
return f"{base.rstrip('/')}/billing?topup=open"
|
|
|
|
|
|
# =============================================================================
|
|
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
|
|
# =============================================================================
|
|
|
|
|
|
def _dev_fixture_billing_state() -> Optional[BillingState]:
|
|
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
|
|
|
|
Recognized names::
|
|
|
|
nocard logged in · billing on · admin · NO card on file
|
|
card card on file · auto-reload off
|
|
card-autoreload card on file · auto-reload on
|
|
notadmin logged in · MEMBER role (billing actions disabled)
|
|
billing-off logged in · admin · per-org kill-switch OFF
|
|
logged-out not logged in
|
|
|
|
Returns ``None`` when the env var is unset (the real portal path runs).
|
|
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
|
|
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
|
|
"""
|
|
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
|
|
if not name:
|
|
return None
|
|
|
|
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
|
|
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
|
|
portal = "https://portal.nousresearch.com/billing?topup=open"
|
|
common: dict[str, Any] = dict(
|
|
org_id="org_acme",
|
|
org_slug="acme",
|
|
org_name="Acme Inc",
|
|
role="OWNER",
|
|
balance_usd=Decimal("3.40"),
|
|
cli_billing_enabled=True,
|
|
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
|
|
min_usd=Decimal("5"),
|
|
max_usd=Decimal("500"),
|
|
portal_url=portal,
|
|
)
|
|
card = CardInfo(brand="Visa", last4="4242")
|
|
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
|
|
|
|
if name in ("logged-out", "logged_out", "loggedout"):
|
|
return BillingState(logged_in=False)
|
|
if name == "nocard":
|
|
return BillingState(logged_in=True, card=None, **common)
|
|
if name == "card":
|
|
return BillingState(logged_in=True, card=card, **common)
|
|
if name in ("card-sub", "card_sub"):
|
|
# Post-resolver: the card came from the subscription (provenance label).
|
|
_sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin")
|
|
return BillingState(logged_in=True, card=_sub_card, **common)
|
|
if name in ("card-autoreload", "card_autoreload", "autoreload"):
|
|
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
|
|
if name in ("notadmin", "not-admin", "member"):
|
|
opts = {**common, "role": "MEMBER"}
|
|
return BillingState(logged_in=True, card=card, **opts)
|
|
if name in ("billing-off", "billing_off", "off"):
|
|
opts = {**common, "cli_billing_enabled": False}
|
|
return BillingState(logged_in=True, card=None, **opts)
|
|
|
|
# Unknown name → logged-out so the misconfiguration is visible.
|
|
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
|
|
|
|
|
|
# =============================================================================
|
|
# Idempotency
|
|
# =============================================================================
|
|
|
|
|
|
def new_idempotency_key() -> str:
|
|
"""Fresh UUID for a user-confirmed purchase (reuse on retry of the SAME buy).
|
|
|
|
The ``Idempotency-Key`` header is mandatory on ``POST /charge``; generate one
|
|
per confirmed purchase and reuse it across retries so a double-submit collapses
|
|
to a single charge. Never reuse a key across different amounts (the server
|
|
returns 409 idempotency_conflict).
|
|
"""
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
# =============================================================================
|
|
# Amount validation (Screen 3 custom input)
|
|
# =============================================================================
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AmountValidation:
|
|
ok: bool
|
|
amount: Optional[Decimal] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
def validate_charge_amount(
|
|
raw: str, *, min_usd: Optional[Decimal], max_usd: Optional[Decimal]
|
|
) -> AmountValidation:
|
|
"""Validate a custom charge amount against bounds + 2dp (multipleOf 0.01).
|
|
|
|
Mirrors the server's accept/reject so the UI can give instant feedback rather
|
|
than round-tripping a sure-to-fail charge. The server is still authoritative.
|
|
"""
|
|
cleaned = (raw or "").strip().lstrip("$").strip()
|
|
amount = parse_money(cleaned)
|
|
if amount is None:
|
|
return AmountValidation(ok=False, error="Enter a dollar amount, e.g. 100")
|
|
if amount <= 0:
|
|
return AmountValidation(ok=False, error="Amount must be greater than $0")
|
|
# multipleOf 0.01 — reject sub-cent precision.
|
|
if amount != amount.quantize(Decimal("0.01")):
|
|
return AmountValidation(ok=False, error="Amount can't be smaller than a cent")
|
|
if min_usd is not None and amount < min_usd:
|
|
return AmountValidation(ok=False, error=f"Minimum is {format_money(min_usd)}")
|
|
if max_usd is not None and amount > max_usd:
|
|
return AmountValidation(ok=False, error=f"Maximum is {format_money(max_usd)}")
|
|
return AmountValidation(ok=True, amount=amount)
|