mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
* feat(billing): add payment_method union to the billing-state wire type
* feat(billing): carry the payment-method union through the gateway
NAS now sends a typed `paymentMethod` union on /api/billing/state alongside
the legacy `card` field. The gateway parses payloads field-by-field, so the
new field was dropped on the floor before reaching TUI/Desktop.
Parse it into PaymentMethodInfo and re-emit it as snake_case `payment_method`,
matching the translation the rest of this payload already does. The payment
method id is deliberately not carried through — clients have no use for it.
No client rendering changes: `card` stays populated for cards, so every
existing consumer behaves exactly as before and the new field is inert until
a surface opts into reading it.
* fix(billing): send only the fields each payment-method kind declares
The serializer emitted every key for every kind, so a Link method went out
carrying brand, last4 and wallet set to null. That contradicts the shared
type, where each kind declares its own fields: a client testing `'brand' in
pm` would read every Link method as a card, and one trusting the declared
non-null `brand` could crash on it.
Send each kind's own fields, and forward an unrecognized kind by name alone
so a client that predates it can still say something honest. The shared type
gains the matching fallback arm its own comment already promised.
Tests now follow a payload from the server response through to the client
wire for each kind, rather than checking parsing and serializing separately —
which is why the old expectation locked in the wrong shape without noticing.
* fix(billing): keep the payment-method kind narrowable
Typing the fallback arm's kind as `string & {}` borrowed a trick that only
works on unions of plain strings. On a union of objects it makes the
discriminant non-literal, so TypeScript stops narrowing on every arm — even
`if (pm.kind === 'card')` no longer gives you `brand`. The first client to
use this would have hit a compile error and reached for a cast.
An unrecognised kind now arrives as `unknown`, carrying the real name
alongside it, so every arm has a literal discriminant. A type-level test
pins this: it fails to compile if the discriminant stops narrowing.
The parser settles which kind it is, the way the card parser already does,
so the record cannot hold fields that do not belong to its kind and the
serializer no longer re-checks. The type comment also stops claiming `card`
is a safe signal — it is null for Link, so `!card` does not mean "nothing on
file".
293 lines
9.9 KiB
Python
293 lines
9.9 KiB
Python
"""Tests for the Phase 2b billing JSON-RPC methods (tui_gateway/server.py).
|
|
|
|
Verifies the structured envelope contract the Ink side branches on:
|
|
- billing.state serializes BillingState (Decimals → strings) + fails open.
|
|
- billing.charge / charge_status / auto_reload return typed error envelopes
|
|
(result.ok=false, result.error=<code>) instead of JSON-RPC errors.
|
|
- billing.charge mints + echoes an idempotency_key for retry reuse.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
import tui_gateway.server as srv
|
|
import hermes_cli.nous_billing as nb
|
|
import agent.billing_view as bv
|
|
from agent.billing_view import BillingState, CardInfo, MonthlyCap, PaymentMethodInfo
|
|
|
|
|
|
def _call(method: str, params: dict) -> dict:
|
|
"""Invoke a registered RPC method and return its result dict."""
|
|
envelope = srv._methods[method](1, params)
|
|
return envelope["result"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# billing.state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_billing_state_serializes_decimals_as_strings(monkeypatch):
|
|
state = BillingState(
|
|
logged_in=True,
|
|
org_name="Acme",
|
|
role="OWNER",
|
|
balance_usd=Decimal("142.5"),
|
|
cli_billing_enabled=True,
|
|
charge_presets=(Decimal("100"), Decimal("250")),
|
|
min_usd=Decimal("10"),
|
|
max_usd=Decimal("10000"),
|
|
card=CardInfo(brand="visa", last4="4242"),
|
|
payment_method=PaymentMethodInfo(
|
|
kind="link",
|
|
email="billing@example.com",
|
|
resolved_via="customerDefault",
|
|
),
|
|
monthly_cap=MonthlyCap(
|
|
limit_usd=Decimal("1000"), spent_this_month_usd=Decimal("180"), is_default_ceiling=True
|
|
),
|
|
portal_url="https://portal/billing?topup=open",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
res = _call("billing.state", {})
|
|
assert res["ok"] is True and res["logged_in"] is True
|
|
# Money on the wire is STRING, not float/number.
|
|
assert res["balance_usd"] == "142.5"
|
|
assert res["balance_display"] == "$142.50"
|
|
assert res["charge_presets"] == ["100", "250"]
|
|
assert res["card"] == {
|
|
"brand": "visa",
|
|
"last4": "4242",
|
|
"masked": "visa ····4242",
|
|
"display": "visa ····4242",
|
|
"resolved_via": None,
|
|
}
|
|
assert res["payment_method"] == {
|
|
"kind": "link",
|
|
"email": "billing@example.com",
|
|
"resolved_via": "customerDefault",
|
|
}
|
|
assert res["monthly_cap"]["is_default_ceiling"] is True
|
|
assert res["is_admin"] is True and res["can_charge"] is True
|
|
|
|
|
|
def test_billing_state_fail_open(monkeypatch):
|
|
def _boom(*a, **kw):
|
|
raise RuntimeError("portal down")
|
|
|
|
monkeypatch.setattr(bv, "build_billing_state", _boom)
|
|
res = _call("billing.state", {})
|
|
assert res["ok"] is True and res["logged_in"] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw_payment_method,expected",
|
|
[
|
|
(
|
|
{
|
|
"kind": "card",
|
|
"brand": "visa",
|
|
"last4": "4242",
|
|
"wallet": "apple_pay",
|
|
"paymentMethodId": "pm_card",
|
|
"resolvedVia": "subPin",
|
|
},
|
|
{
|
|
"kind": "card",
|
|
"brand": "visa",
|
|
"last4": "4242",
|
|
"wallet": "apple_pay",
|
|
"resolved_via": "subPin",
|
|
},
|
|
),
|
|
(
|
|
{
|
|
"kind": "link",
|
|
"email": "billing@example.com",
|
|
"resolvedVia": "customerDefault",
|
|
},
|
|
{
|
|
"kind": "link",
|
|
"email": "billing@example.com",
|
|
"resolved_via": "customerDefault",
|
|
},
|
|
),
|
|
# A kind added after this gateway shipped: normalised to "unknown",
|
|
# keeping the real name for logs and neutral copy.
|
|
(
|
|
{"kind": "future_method", "resolvedVia": "subPin"},
|
|
{"kind": "unknown", "raw_kind": "future_method", "resolved_via": "subPin"},
|
|
),
|
|
],
|
|
)
|
|
def test_billing_state_carries_payment_method_from_server_to_client(
|
|
monkeypatch, raw_payment_method, expected
|
|
):
|
|
payload = {
|
|
"org": {"id": "org_1", "name": "Acme", "role": "OWNER"},
|
|
"balanceUsd": "10",
|
|
"paymentMethod": raw_payment_method,
|
|
}
|
|
monkeypatch.setattr(
|
|
bv,
|
|
"build_billing_state",
|
|
lambda *a, **kw: bv.billing_state_from_payload(payload),
|
|
)
|
|
|
|
res = _call("billing.state", {})
|
|
|
|
assert res["payment_method"] == expected
|
|
|
|
|
|
def test_billing_state_serializes_absent_payment_method(monkeypatch):
|
|
monkeypatch.setattr(
|
|
bv,
|
|
"build_billing_state",
|
|
lambda *a, **kw: BillingState(logged_in=False),
|
|
)
|
|
|
|
res = _call("billing.state", {})
|
|
|
|
assert res["payment_method"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# billing.charge — typed error envelopes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_billing_charge_success_echoes_charge_id(monkeypatch):
|
|
monkeypatch.setattr(nb, "post_charge", lambda **kw: {"chargeId": "ch_123"})
|
|
res = _call("billing.charge", {"amount_usd": "100", "idempotency_key": "key-1"})
|
|
assert res["ok"] is True
|
|
assert res["charge_id"] == "ch_123"
|
|
assert res["idempotency_key"] == "key-1"
|
|
|
|
|
|
def test_billing_charge_mints_key_when_absent(monkeypatch):
|
|
seen = {}
|
|
|
|
def _post(**kw):
|
|
seen["key"] = kw["idempotency_key"]
|
|
return {"chargeId": "ch_x"}
|
|
|
|
monkeypatch.setattr(nb, "post_charge", _post)
|
|
res = _call("billing.charge", {"amount_usd": "50"})
|
|
assert res["ok"] is True
|
|
assert res["idempotency_key"] == seen["key"] # minted key echoed back
|
|
assert len(res["idempotency_key"]) == 36
|
|
|
|
|
|
def test_billing_charge_insufficient_scope_envelope(monkeypatch):
|
|
def _post(**kw):
|
|
raise nb.BillingScopeRequired("need scope", status=403, error="insufficient_scope")
|
|
|
|
monkeypatch.setattr(nb, "post_charge", _post)
|
|
res = _call("billing.charge", {"amount_usd": "100", "idempotency_key": "k"})
|
|
assert res["ok"] is False
|
|
assert res["error"] == "insufficient_scope"
|
|
assert res["idempotency_key"] == "k" # preserved for reuse post-stepup
|
|
|
|
|
|
def test_billing_charge_no_payment_method_envelope(monkeypatch):
|
|
def _post(**kw):
|
|
raise nb.BillingError(
|
|
"no reusable card", status=403, error="no_payment_method",
|
|
portal_url="/billing?topup=open",
|
|
)
|
|
|
|
monkeypatch.setattr(nb, "post_charge", _post)
|
|
res = _call("billing.charge", {"amount_usd": "100", "idempotency_key": "k"})
|
|
assert res["ok"] is False
|
|
assert res["error"] == "no_payment_method"
|
|
assert res["portal_url"] == "/billing?topup=open"
|
|
|
|
|
|
def test_billing_charge_rate_limited_envelope(monkeypatch):
|
|
def _post(**kw):
|
|
raise nb.BillingRateLimited("slow down", status=429, error="rate_limited", retry_after=60)
|
|
|
|
monkeypatch.setattr(nb, "post_charge", _post)
|
|
res = _call("billing.charge", {"amount_usd": "100", "idempotency_key": "k"})
|
|
assert res["error"] == "rate_limited"
|
|
assert res["retry_after"] == 60
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# billing.charge_status — the poll
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"server_resp,expected",
|
|
[
|
|
({"status": "pending"}, {"status": "pending"}),
|
|
(
|
|
{"status": "settled", "amountUsd": "50", "settledAt": "2026-06-13T00:00:00Z"},
|
|
{"status": "settled", "amount_usd": "50"},
|
|
),
|
|
({"status": "failed", "reason": "card_declined"}, {"status": "failed", "reason": "card_declined"}),
|
|
],
|
|
)
|
|
def test_billing_charge_status_maps_fields(monkeypatch, server_resp, expected):
|
|
monkeypatch.setattr(nb, "get_charge_status", lambda cid, **kw: server_resp)
|
|
res = _call("billing.charge_status", {"charge_id": "ch_1"})
|
|
assert res["ok"] is True
|
|
for k, v in expected.items():
|
|
assert res[k] == v
|
|
|
|
|
|
def test_billing_charge_status_requires_id():
|
|
res = _call("billing.charge_status", {})
|
|
assert res["ok"] is False and res["error"] == "invalid_charge_id"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# billing.auto_reload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_billing_auto_reload_success(monkeypatch):
|
|
seen = {}
|
|
monkeypatch.setattr(nb, "patch_auto_top_up", lambda **kw: seen.update(kw) or {"ok": True})
|
|
res = _call("billing.auto_reload", {"enabled": True, "threshold": 20, "top_up_amount": 100})
|
|
assert res["ok"] is True
|
|
assert seen == {"enabled": True, "threshold": 20, "top_up_amount": 100}
|
|
|
|
|
|
def test_billing_auto_reload_validation_error_envelope(monkeypatch):
|
|
def _patch(**kw):
|
|
raise nb.BillingError("bad", status=400, error="validation_failed")
|
|
|
|
monkeypatch.setattr(nb, "patch_auto_top_up", _patch)
|
|
res = _call("billing.auto_reload", {"enabled": True, "threshold": 20, "top_up_amount": 100})
|
|
assert res["ok"] is False and res["error"] == "validation_failed"
|
|
|
|
|
|
def test_billing_auto_reload_requires_fields():
|
|
res = _call("billing.auto_reload", {"enabled": True})
|
|
assert res["ok"] is False and res["error"] == "invalid_request"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# billing.step_up
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_billing_step_up_granted(monkeypatch):
|
|
import hermes_cli.auth as auth
|
|
|
|
monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: True)
|
|
res = _call("billing.step_up", {})
|
|
assert res["ok"] is True and res["granted"] is True
|
|
|
|
|
|
def test_billing_step_up_downscoped(monkeypatch):
|
|
import hermes_cli.auth as auth
|
|
|
|
monkeypatch.setattr(auth, "step_up_nous_billing_scope", lambda **kw: False)
|
|
res = _call("billing.step_up", {})
|
|
assert res["ok"] is True and res["granted"] is False
|