mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
* feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split
Bring the plain (non-TUI) CLI billing surface to parity with the desktop/TUI
billing changes:
- /subscription on Free (admin/owner, interactive) prints the plan catalog
(name · $/mo · $credits/mo, from the same tiers[] data the TUI uses; monthly
credits render as dollars). A numbered pick opens the manage-subscription
deep-link directly with plan=<tier_id> appended.
- subscription_manage_url(state, tier_id=...) appends plan=<tier_id> (the stable
tiers[] id) when a tier was picked, org_id first — mirrors the TUI's ?plan=.
The paid change flow's blocked/unknown-preview portal fallback carries plan=
for upgrades only; downgrades stay generic/native.
- /topup overview splits one-time top-up from automatic refill, the distinction
stated in each first sentence ("Add funds now — a single charge…" vs "Refill
when low — charges … automatically …"), keeping "credits" out of the
dollars-only surface.
- Downgrades remain native (chargeless scheduled change), unchanged.
Updates the CLI-parity section of docs/billing-lifecycle.md and tests under
tests/hermes_cli + tests/agent.
* refactor(billing): share plan-catalog helpers + harden manage-url builder
- subscription_manage_url now preserves unrelated portal query params (parse_qsl,
popping only the contract-owned org_id/plan) and restricts to http/https schemes,
matching the desktop URL builder — the function owns the contract.
- Lift the plan-catalog derivation into agent/subscription_view.py so the CLI Free
catalog and the paid picker/blocked-preview branch share one implementation:
selectable_tiers (enabled paid, not current, sorted), format_tier_row (name · $/mo
· $credits/mo — thousands-grouped like the TUI's toLocaleString, credits suffix
hidden when absent/zero), and is_upgrade(state, tier_id).
* fix(cli): numbered pick, canonical guarded browser opener, partial auto-refill copy
- Free catalog: accept a bare digit as a pick (the shared normalizer only knows the
confirm-dialog digit aliases, so `1` used to resolve to None → "Cancelled"). The
Nth digit maps to the Nth printed row.
- Extract one _open_url_in_browser used by every "open the portal" path, applying the
device-code flows' console-browser / remote-session guard (webbrowser.open returns
True even for lynx/w3m over SSH) and returning whether a real browser opened.
- Consume the shared selectable_tiers / format_tier_row / is_upgrade helpers from the
Free catalog, the paid picker, and the blocked-preview branch.
- /topup auto-refill copy: the concrete "charges $X … below $Y." sentence only when
both amounts are present and finite; otherwise the generic sentence.
* docs(billing): correct CLI-parity rows (drop cross-repo ref, downgrade invariant)
Remove the other-repo PR reference from the manage-URL row, and state the real
downgrade invariant: a blocked downgrade may print the generic manage URL but never
carries plan=<tier_id> — selected-tier deep-links are reserved for new subscriptions
and upgrades.
308 lines
13 KiB
Python
308 lines
13 KiB
Python
"""Tests for the /billing CLI handler (cli.py::_show_billing).
|
|
|
|
Focus on the non-interactive (no live prompt_toolkit app) path — the same
|
|
discipline as the /credits non-interactive test: it must render text, never
|
|
invoke the modal (which would read the slash-worker's JSON-RPC stdin and hang).
|
|
Plus role/kill-switch gating and logged-out handling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
import agent.billing_view as bv
|
|
from agent.billing_view import BillingState, CardInfo, MonthlyCap
|
|
from cli import HermesCLI
|
|
|
|
|
|
@pytest.fixture
|
|
def cli():
|
|
obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed)
|
|
obj._app = None # non-interactive: forces the text path
|
|
return obj
|
|
|
|
|
|
def _boom_modal(*a, **kw):
|
|
raise AssertionError("modal must NOT be called in non-interactive mode")
|
|
|
|
|
|
def test_billing_logged_out(cli, monkeypatch, capsys):
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: BillingState(logged_in=False))
|
|
cli._show_billing("/billing")
|
|
out = capsys.readouterr().out
|
|
assert "Not logged into Nous Portal" in out
|
|
assert "hermes portal" in out
|
|
|
|
|
|
def test_billing_overview_non_interactive_renders_text_not_modal(cli, monkeypatch, capsys):
|
|
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
|
|
state = BillingState(
|
|
logged_in=True,
|
|
org_name="Acme",
|
|
role="OWNER",
|
|
balance_usd=Decimal("142.5"),
|
|
cli_billing_enabled=True,
|
|
charge_presets=(Decimal("100"),),
|
|
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)
|
|
cli._show_billing("/billing")
|
|
out = capsys.readouterr().out
|
|
# Balance now leads in the title; dollars, never "credits".
|
|
assert "Top up · balance $142.50" in out
|
|
assert "credits" not in out.lower()
|
|
# ZERO sub-commands: no /billing buy|auto-reload|limit advertising.
|
|
assert "/billing buy" not in out
|
|
assert "Actions:" not in out
|
|
# Non-interactive funnels to the portal (the URL is the affordance).
|
|
assert "Manage on portal:" in out
|
|
|
|
|
|
def test_billing_member_cannot_charge(cli, monkeypatch, capsys):
|
|
state = BillingState(
|
|
logged_in=True, role="MEMBER", balance_usd=Decimal("10"),
|
|
cli_billing_enabled=True, portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
cli._show_billing("/billing")
|
|
out = capsys.readouterr().out
|
|
assert "require an org admin/owner" in out
|
|
|
|
|
|
def test_billing_killswitch_off_blocks(cli, monkeypatch, capsys):
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
|
|
cli_billing_enabled=False, portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
cli._show_billing("/billing")
|
|
out = capsys.readouterr().out
|
|
assert "Remote spending is off for this org." in out
|
|
assert (
|
|
"A billing admin can turn it on from the portal's Hermes Agent page "
|
|
"to add funds here."
|
|
) in out
|
|
|
|
|
|
def test_billing_limit_screen_readonly(cli, monkeypatch, capsys):
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", cli_billing_enabled=True,
|
|
monthly_cap=MonthlyCap(limit_usd=Decimal("1000"), spent_this_month_usd=Decimal("250"),
|
|
is_default_ceiling=True),
|
|
portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
# ZERO sub-commands: the limit screen is reached via the menu, never a
|
|
# sub-command — call it directly the way the overview menu would.
|
|
cli._billing_limit_screen(state)
|
|
out = capsys.readouterr().out
|
|
assert "Monthly spend limit" in out
|
|
assert "$250 of $1000 used" in out
|
|
assert "read-only" in out
|
|
|
|
|
|
def test_billing_sub_arg_ignored_opens_overview(cli, monkeypatch, capsys):
|
|
# A stray sub-arg must NOT error and must NOT dispatch to a sub-screen —
|
|
# it just opens the overview (spec §0.4: zero sub-commands).
|
|
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", balance_usd=Decimal("142.5"),
|
|
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
|
portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
cli._show_billing("/billing buy") # arg is ignored
|
|
out = capsys.readouterr().out
|
|
assert "Top up · balance" in out # overview, NOT the buy screen
|
|
# The buy screen's preset list isn't shown. (The overview's no-card hint may
|
|
# legitimately mention the "Add funds" menu item, so key on presets instead.)
|
|
assert "Presets:" not in out
|
|
|
|
|
|
def test_billing_buy_non_interactive_defers_to_portal(cli, monkeypatch, capsys):
|
|
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _boom_modal, raising=False)
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", cli_billing_enabled=True,
|
|
charge_presets=(Decimal("25"), Decimal("50"), Decimal("100")),
|
|
card=CardInfo(brand="visa", last4="4242"),
|
|
portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
# Reached via the menu in real use; non-interactively it defers to the portal.
|
|
cli._billing_buy_flow(state)
|
|
out = capsys.readouterr().out
|
|
assert "Add funds" in out
|
|
assert "$25" in out and "$50" in out and "$100" in out
|
|
assert "interactive CLI" in out # defers; no charge attempted non-interactively
|
|
|
|
|
|
# ── Card visibility + the add-card path (inline w/ NAS card-resolver) ──
|
|
|
|
|
|
def _scripted(*responses):
|
|
it = iter(responses)
|
|
|
|
def _modal(self, **kw):
|
|
return next(it)
|
|
|
|
return _modal
|
|
|
|
|
|
def test_topup_overview_splits_onetime_from_automatic_copy(cli, monkeypatch, capsys):
|
|
# (c): the interactive /topup overview states the one-time-vs-automatic
|
|
# distinction up front in each first sentence, and keeps "credits" out of the
|
|
# dollars-only surface. Auto-reload OFF → the automatic line omits amounts.
|
|
cli._app = object() # interactive → reaches the split-copy explainer
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", balance_usd=Decimal("50"),
|
|
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
|
card=CardInfo(brand="Visa", last4="4242"),
|
|
portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
# Overview prints the explainer, then the action modal → back out with "cancel".
|
|
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
|
|
cli._show_billing("/topup")
|
|
out = capsys.readouterr().out
|
|
|
|
assert "Add funds now — a single charge, added to your balance today." in out
|
|
assert "Refill when low — charges your card automatically when your balance falls below" in out
|
|
# Dollars-only surface: no "credits" word leaks into /topup.
|
|
assert "credits" not in out.lower()
|
|
|
|
|
|
def test_topup_overview_automatic_copy_names_amounts_when_on(cli, monkeypatch, capsys):
|
|
# Auto-reload ON → the automatic first sentence names $X (reload-to) and $Y (threshold).
|
|
from agent.billing_view import AutoReload
|
|
|
|
cli._app = object()
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", balance_usd=Decimal("50"),
|
|
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
|
card=CardInfo(brand="Visa", last4="4242"),
|
|
auto_reload=AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("20")),
|
|
portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
|
|
cli._show_billing("/topup")
|
|
out = capsys.readouterr().out
|
|
|
|
assert "Refill when low — charges $20 automatically when your balance falls below $5." in out
|
|
|
|
|
|
def test_topup_automatic_copy_generic_when_amounts_missing(cli, monkeypatch, capsys):
|
|
# (5): auto-reload "enabled" but amounts absent (partial response) → generic
|
|
# copy, never "charges — automatically … below —.".
|
|
from agent.billing_view import AutoReload
|
|
|
|
cli._app = object()
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", balance_usd=Decimal("50"),
|
|
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
|
card=CardInfo(brand="Visa", last4="4242"),
|
|
auto_reload=AutoReload(enabled=True, threshold_usd=None, reload_to_usd=None),
|
|
portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
|
|
cli._show_billing("/topup")
|
|
out = capsys.readouterr().out
|
|
|
|
assert (
|
|
"Refill when low — charges your card automatically when your balance "
|
|
"falls below the amount you set."
|
|
) in out
|
|
assert "charges — automatically" not in out
|
|
|
|
|
|
def test_overview_shows_card_with_provenance(cli, monkeypatch, capsys):
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
|
|
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
|
card=CardInfo(brand="Visa", last4="4242", resolved_via="subPin"),
|
|
portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
cli._show_billing("/topup")
|
|
out = capsys.readouterr().out
|
|
assert "Card: Visa ····4242 — the card on your subscription" in out
|
|
|
|
|
|
def test_overview_shows_no_card_hint(cli, monkeypatch, capsys):
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
|
|
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
|
card=None, portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
cli._show_billing("/topup")
|
|
out = capsys.readouterr().out
|
|
assert "No saved card on file" in out
|
|
assert "Add funds" in out # the hint names the path
|
|
|
|
|
|
def test_link_card_renders_brand_alone(cli, monkeypatch, capsys):
|
|
# A Link payment method has no card number (last4 = "") — never "Link ····".
|
|
state = BillingState(
|
|
logged_in=True, role="OWNER", balance_usd=Decimal("10"),
|
|
cli_billing_enabled=True, charge_presets=(Decimal("25"),),
|
|
card=CardInfo(brand="Link", last4=""), portal_url="https://portal/billing",
|
|
)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state)
|
|
cli._show_billing("/topup")
|
|
out = capsys.readouterr().out
|
|
assert "Card: Link" in out
|
|
assert "Link ····" not in out
|
|
|
|
|
|
def test_buy_flow_no_card_guides_then_continues_after_recheck(cli, monkeypatch, capsys):
|
|
# No card → the guided add-card path; "check again" re-fetches state and,
|
|
# once the card exists, continues straight into the preset menu.
|
|
cli._app = object()
|
|
common = dict(
|
|
logged_in=True, role="OWNER", cli_billing_enabled=True,
|
|
charge_presets=(Decimal("25"), Decimal("50")),
|
|
min_usd=Decimal("5"), max_usd=Decimal("500"),
|
|
portal_url="https://portal/billing",
|
|
)
|
|
nocard = BillingState(card=None, **common)
|
|
withcard = BillingState(card=CardInfo(brand="Visa", last4="4242", resolved_via="customerDefault"), **common)
|
|
monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: withcard)
|
|
# add-card modal → "recheck"; preset modal → "cancel" (we only test the routing)
|
|
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("recheck", "cancel"), raising=False)
|
|
|
|
cli._billing_buy_flow(nocard)
|
|
out = capsys.readouterr().out
|
|
|
|
assert "Add a card first" in out
|
|
assert "Card found: Visa ····4242 — your default card saved on the portal" in out
|
|
assert "Cancelled. No funds added." in out # reached the preset menu, then bailed
|
|
|
|
|
|
def test_buy_flow_no_card_back_abandons(cli, monkeypatch, capsys):
|
|
cli._app = object()
|
|
nocard = BillingState(
|
|
logged_in=True, role="OWNER", cli_billing_enabled=True,
|
|
charge_presets=(Decimal("25"),), card=None,
|
|
portal_url="https://portal/billing",
|
|
)
|
|
calls = {"n": 0}
|
|
|
|
def _no_fetch(*a, **kw):
|
|
calls["n"] += 1
|
|
return nocard
|
|
|
|
monkeypatch.setattr(bv, "build_billing_state", _no_fetch)
|
|
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False)
|
|
|
|
cli._billing_buy_flow(nocard)
|
|
out = capsys.readouterr().out
|
|
|
|
assert "Add a card first" in out
|
|
assert "Cancelled. No funds added." in out
|
|
assert calls["n"] == 0 # backed out before any re-check
|