feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689)

* 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.
This commit is contained in:
Siddharth Balyan 2026-07-22 08:11:09 +05:30 committed by GitHub
parent 14f8441009
commit 9baad4e0aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 625 additions and 35 deletions

View file

@ -10,15 +10,32 @@ from decimal import Decimal
import pytest
from agent.subscription_view import (
CurrentSubscription,
SubscriptionState,
SubscriptionTier,
build_subscription_state,
dev_fixture_subscription_state,
format_tier_row,
is_upgrade,
selectable_tiers,
subscription_change_preview_from_payload,
subscription_manage_url,
subscription_state_from_payload,
)
def _tier(tid, order, dpm, mc, *, is_current=False, is_enabled=True):
return SubscriptionTier(
tier_id=tid,
name=tid.title(),
tier_order=order,
dollars_per_month=Decimal(dpm) if dpm is not None else None,
monthly_credits=Decimal(mc) if mc is not None else None,
is_current=is_current,
is_enabled=is_enabled,
)
# ── subscription_manage_url ──────────────────────────────────────────
@ -42,6 +59,75 @@ def test_manage_url_omits_org_when_absent():
assert "org_id" not in url
def test_manage_url_appends_plan_when_tier_picked():
# A picked tier rides along as ?plan=<tier_id>, org_id first, plan second.
s = SubscriptionState(
logged_in=True,
org_id="org_x",
portal_url="https://portal.nousresearch.com/billing",
)
assert (
subscription_manage_url(s, tier_id="plus")
== "https://portal.nousresearch.com/manage-subscription?org_id=org_x&plan=plus"
)
def test_manage_url_plan_without_org():
# No org_id → plan is still appended (the portal validates/ignores it).
s = SubscriptionState(logged_in=True, org_id=None, portal_url="https://p.example.com/")
assert (
subscription_manage_url(s, tier_id="ultra")
== "https://p.example.com/manage-subscription?plan=ultra"
)
def test_manage_url_no_plan_when_tier_absent():
# No tier picked → no plan= param (unchanged legacy shape).
s = SubscriptionState(logged_in=True, org_id="org_x", portal_url="https://p.example.com/")
url = subscription_manage_url(s)
assert url == "https://p.example.com/manage-subscription?org_id=org_x"
assert "plan=" not in url
def test_manage_url_preserves_unrelated_query_params():
# Pre-existing portal query params survive; contract-owned org_id/plan are
# appended after them, org_id before plan.
s = SubscriptionState(
logged_in=True, org_id="org_x", portal_url="https://portal.example/billing?ref=abc"
)
assert (
subscription_manage_url(s, tier_id="plus")
== "https://portal.example/manage-subscription?ref=abc&org_id=org_x&plan=plus"
)
def test_manage_url_overwrites_stale_contract_params():
# A portal_url that already carries org_id/plan gets them replaced, not doubled.
s = SubscriptionState(
logged_in=True, org_id="org_x", portal_url="https://portal.example/x?org_id=old&plan=stale"
)
assert (
subscription_manage_url(s, tier_id="plus")
== "https://portal.example/manage-subscription?org_id=org_x&plan=plus"
)
def test_manage_url_rejects_non_http_scheme():
# Only http(s) is handed to a browser open.
assert (
subscription_manage_url(
SubscriptionState(logged_in=True, org_id="o", portal_url="ftp://portal.example/billing")
)
is None
)
assert (
subscription_manage_url(
SubscriptionState(logged_in=True, portal_url="file:///etc/passwd")
)
is None
)
def test_manage_url_none_without_portal():
assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url=None)) is None
@ -51,6 +137,55 @@ def test_manage_url_none_for_garbage_portal():
assert subscription_manage_url(SubscriptionState(logged_in=True, portal_url="not a url")) is None
# ── shared plan-catalog helpers (format_tier_row / selectable_tiers / is_upgrade) ──
def test_format_tier_row_groups_thousands():
# $1000+ renders thousands-grouped ($1,000), matching the TUI's toLocaleString.
assert format_tier_row(_tier("max", 4, "1000", "3000")) == "Max · $1,000/mo · $3,000 credits/mo"
def test_format_tier_row_hides_absent_or_zero_credits():
# A None / zero-credits tier hides the suffix — never "· — credits/mo" / "· $0 credits/mo".
assert format_tier_row(_tier("plus", 1, "20", "0")) == "Plus · $20/mo"
assert format_tier_row(_tier("plus", 1, "20", None)) == "Plus · $20/mo"
assert "credits/mo" not in format_tier_row(_tier("plus", 1, "20", "0"))
def test_selectable_tiers_excludes_free_current_and_disabled():
state = SubscriptionState(
logged_in=True,
tiers=(
_tier("free", 0, "0", "0"),
_tier("plus", 1, "20", "22"),
_tier("legacy", 2, "40", "50", is_enabled=False),
_tier("ultra", 3, "200", "220", is_current=True),
),
)
# free (order 0), disabled (legacy), and the current tier (ultra) are excluded.
assert [t.tier_id for t in selectable_tiers(state)] == ["plus"]
def test_is_upgrade_by_tier_order():
state = SubscriptionState(
logged_in=True,
current=CurrentSubscription(tier_id="plus", tier_name="Plus"),
tiers=(_tier("plus", 1, "20", "22"), _tier("ultra", 3, "200", "220")),
)
assert is_upgrade(state, "ultra") is True
assert is_upgrade(state, "plus") is False
def test_is_upgrade_falls_back_to_is_current_marker():
# No explicit current subscription → derive the current order from tiers[] is_current.
state = SubscriptionState(
logged_in=True,
current=None,
tiers=(_tier("plus", 1, "20", "22", is_current=True), _tier("ultra", 3, "200", "220")),
)
assert is_upgrade(state, "ultra") is True
# ── payload parser ───────────────────────────────────────────────────

View file

@ -152,6 +152,74 @@ def _scripted(*responses):
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"),

View file

@ -64,6 +64,191 @@ def _no_usage_model(monkeypatch):
monkeypatch.setattr(bu, "build_usage_model", lambda *a, **kw: None, raising=False)
_FREE_TIERS = (
SubscriptionTier(tier_id="free", name="Free", tier_order=0, dollars_per_month=Decimal("0"), monthly_credits=Decimal("0"), is_current=False, is_enabled=True),
SubscriptionTier(tier_id="plus", name="Plus", tier_order=1, dollars_per_month=Decimal("20"), monthly_credits=Decimal("22"), is_current=False, is_enabled=True),
SubscriptionTier(tier_id="ultra", name="Ultra", tier_order=3, dollars_per_month=Decimal("200"), monthly_credits=Decimal("220"), is_current=False, is_enabled=True),
)
def _free_state(tiers=_FREE_TIERS) -> SubscriptionState:
return SubscriptionState(
logged_in=True,
org_name="Acme",
org_id="org_1",
role="OWNER",
context="personal",
current=None, # Free = no plan
tiers=tiers,
portal_url="https://portal.example/billing",
)
def _capture_opener(monkeypatch, *, opened_ok=False):
"""Patch the canonical browser opener to capture the URL; return whether a
real browser 'opened' (default False the printed-URL fallback fires)."""
seen = {}
def _open(self, url):
seen["url"] = url
return opened_ok
monkeypatch.setattr(HermesCLI, "_open_url_in_browser", _open, raising=False)
return seen
def test_free_prints_catalog_and_deep_links_with_plan(cli, monkeypatch, capsys):
# (a)+(b): Free + admin + interactive prints the plan catalog (name · $/mo ·
# $credits/mo) and a pick opens the portal deep-link with plan=<tier_id>.
cli._app = object() # interactive
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _free_state())
# catalog pick → "plus" (single modal; the pick opens the portal directly)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("plus"), raising=False)
opened = _capture_opener(monkeypatch) # returns False → URL also printed
cli._show_subscription()
out = capsys.readouterr().out
# Catalog rows — monthly credits render as DOLLARS ($22 credits/mo), never bare.
assert "Choose a plan" in out
assert "Plus · $20/mo · $22 credits/mo" in out
assert "Ultra · $200/mo · $220 credits/mo" in out
# Free (tier_order 0) is excluded from the paid catalog rows.
assert "Free · $0" not in out
# The pick opens the deep-link directly (no second open/copy menu); the URL
# carries plan=<picked tier>, org_id first, plan second.
assert opened.get("url") == "https://portal.example/manage-subscription?org_id=org_1&plan=plus"
assert "/manage-subscription?org_id=org_1&plan=plus" in out
assert "start Plus" in out
def test_free_numbered_pick_stdin_fallback_opens_tier(cli, monkeypatch, capsys):
# (1): the numbered contract — a bare digit through the modal's stdin fallback
# maps to the Nth printed row and opens THAT tier's deep-link. The shared
# normalizer only knows confirm-dialog digit aliases, so this path is bespoke.
cli._app = object()
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _free_state())
# Row 1 = Plus (cheapest selectable). Feed a bare "1" as the stdin fallback.
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("1"), raising=False)
opened = _capture_opener(monkeypatch)
cli._show_subscription()
out = capsys.readouterr().out
# "1" resolved to Plus (row 1), not to the normalizer's alias → Plus URL opens.
assert opened.get("url") == "https://portal.example/manage-subscription?org_id=org_1&plan=plus"
assert "start Plus" in out
def test_free_catalog_cancel_builds_no_url(cli, monkeypatch, capsys):
cli._app = object()
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _free_state())
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("cancel"), raising=False)
cli._show_subscription()
out = capsys.readouterr().out
assert "No plan started" in out
assert "plan=" not in out # nothing picked → no deep-link built
def test_free_no_paid_tiers_falls_back_to_plain_portal(cli, monkeypatch, capsys):
# Only the Free tier exists → no catalog to show → plain portal hand-off.
cli._app = object()
only_free = (_FREE_TIERS[0],)
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _free_state(only_free))
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("cancel"), raising=False)
cli._show_subscription()
out = capsys.readouterr().out
assert "Choose a plan" not in out # no catalog
assert "plan=" not in out
# ── canonical browser opener (guarded like the device-code auth flows) ──
def test_open_url_in_browser_refuses_remote_session(cli, monkeypatch):
# (4): a remote/SSH session must NOT auto-open — webbrowser.open is never called.
import webbrowser
import hermes_cli.auth as auth
monkeypatch.setattr(auth, "_is_remote_session", lambda: True, raising=False)
called = {"n": 0}
monkeypatch.setattr(webbrowser, "open", lambda url: called.update(n=called["n"] + 1) or True)
assert cli._open_url_in_browser("https://x.example") is False
assert called["n"] == 0 # guard short-circuits before webbrowser.open
def test_open_url_in_browser_refuses_console_browser(cli, monkeypatch):
# (4): a console/text-mode browser (w3m/lynx) must NOT hijack the TTY.
import webbrowser
import hermes_cli.auth as auth
monkeypatch.setattr(auth, "_is_remote_session", lambda: False, raising=False)
monkeypatch.setattr(auth, "_can_open_graphical_browser", lambda: False, raising=False)
called = {"n": 0}
monkeypatch.setattr(webbrowser, "open", lambda url: called.update(n=called["n"] + 1) or True)
assert cli._open_url_in_browser("https://x.example") is False
assert called["n"] == 0
def test_open_url_in_browser_opens_when_graphical(cli, monkeypatch):
# (4): a real graphical browser → open and report True.
import webbrowser
import hermes_cli.auth as auth
monkeypatch.setattr(auth, "_is_remote_session", lambda: False, raising=False)
monkeypatch.setattr(auth, "_can_open_graphical_browser", lambda: True, raising=False)
monkeypatch.setattr(webbrowser, "open", lambda url: True)
assert cli._open_url_in_browser("https://x.example") is True
def test_open_url_in_browser_empty_is_false(cli):
assert cli._open_url_in_browser("") is False
def test_blocked_upgrade_fallback_carries_plan_param(cli, monkeypatch, capsys):
# (b): a blocked UPGRADE preview falls back to the portal with plan=<tier_id>.
cli._app = object()
st = _sub_state(tier_id="plus", tier_name="Plus")
tiers = tuple(
SubscriptionTier(tier_id=t.tier_id, name=t.name, tier_order=t.tier_order, dollars_per_month=t.dollars_per_month, monthly_credits=t.monthly_credits, is_current=(t.tier_id == "plus"), is_enabled=True)
for t in _TIERS
)
object.__setattr__(st, "tiers", tiers)
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "ultra"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "blocked", "reason": "Not available here."})
cli._show_subscription()
out = capsys.readouterr().out
assert "Manage on portal:" in out
assert "plan=ultra" in out # the picked upgrade tier rides along
def test_blocked_downgrade_fallback_stays_generic(cli, monkeypatch, capsys):
# (d): a blocked DOWNGRADE fallback stays native/generic — no plan= deep-link.
cli._app = object()
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: _sub_state()) # current = Ultra
monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted_modal("change", "plus"), raising=False)
monkeypatch.setattr(nb, "post_subscription_preview", lambda **kw: {"effect": "blocked", "reason": "Nope."})
cli._show_subscription()
out = capsys.readouterr().out
assert "Manage on portal:" in out
assert "plan=" not in out # downgrade never carries a portal plan= param
def test_overview_leads_with_scheduled_downgrade_banner(cli, monkeypatch, capsys):
st = _sub_state(pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-28")
monkeypatch.setattr(sv, "build_subscription_state", lambda *a, **kw: st)
@ -95,6 +280,9 @@ def test_change_flow_schedules_a_downgrade(cli, monkeypatch, capsys):
assert seen.get("subscription_type_id") == "plus"
assert "doesn't change today" in out
# (d) Downgrades stay NATIVE — scheduled in-app, never a portal plan= deep-link.
assert "plan=" not in out
assert "manage-subscription" not in out
def test_change_flow_upgrade_charges_now(cli, monkeypatch, capsys):