From 9baad4e0aa93cbc77326ce1d9cd3fc6ae73ece76 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:11:09 +0530 Subject: [PATCH] feat(cli): plan catalog on Free + plan= deep link + top-up/auto-refill copy split (#68689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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= appended. - subscription_manage_url(state, tier_id=...) appends plan= (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= — selected-tier deep-links are reserved for new subscriptions and upgrades. --- agent/subscription_view.py | 94 ++++++++++- docs/billing-lifecycle.md | 7 + hermes_cli/cli_billing_mixin.py | 168 +++++++++++++++---- tests/agent/test_subscription_view.py | 135 ++++++++++++++++ tests/hermes_cli/test_billing_cli.py | 68 ++++++++ tests/hermes_cli/test_subscription_cli.py | 188 ++++++++++++++++++++++ 6 files changed, 625 insertions(+), 35 deletions(-) diff --git a/agent/subscription_view.py b/agent/subscription_view.py index f78fcb283d1c..c2c55e0ee20b 100644 --- a/agent/subscription_view.py +++ b/agent/subscription_view.py @@ -300,14 +300,22 @@ def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState: return subscription_state_from_payload(payload, portal_url=portal_url) -def subscription_manage_url(state: SubscriptionState) -> Optional[str]: - """Build ``{portal_origin}/manage-subscription?org_id=`` from a state. +def subscription_manage_url( + state: SubscriptionState, tier_id: Optional[str] = None +) -> Optional[str]: + """Build ``{portal_origin}/manage-subscription?org_id=[&plan=]``. Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing Portal — decided Jun 23), which routes upgrade→Checkout / downgrade→scheduled internally. ``org_id`` pins the page to the right account in multi-org situations. Returns ``None`` when no portal URL is resolvable. + + ``tier_id`` (the stable ``tiers[]`` id, never a name/slug) is appended as + ``plan=`` so the portal preselects the picked plan — only for a NEW + subscription / upgrade the user chose. The portal validates it and simply + ignores an unknown tier, so the CLI appends unconditionally when a tier was + picked (parity with the TUI's ``?plan=``). """ from urllib.parse import urlencode, urlsplit, urlunsplit @@ -319,13 +327,91 @@ def subscription_manage_url(state: SubscriptionState) -> Optional[str]: except Exception: return None - if not parts.scheme or not parts.netloc: + if parts.scheme not in ("http", "https") or not parts.netloc: return None - query = urlencode({"org_id": state.org_id}) if state.org_id else "" + from urllib.parse import parse_qsl + + # Preserve unrelated portal query params; org_id / plan are contract-owned + # (org_id before plan — insertion order is the emitted query order). + params = dict(parse_qsl(parts.query, keep_blank_values=True)) + params.pop("org_id", None) + params.pop("plan", None) + if state.org_id: + params["org_id"] = state.org_id + if tier_id: + params["plan"] = tier_id + query = urlencode(params) return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, "")) +# ============================================================================= +# Shared plan-catalog helpers (consumed by the CLI Free catalog + paid picker) +# ============================================================================= + + +def _format_dollars_grouped(value: Optional[Decimal]) -> str: + """``$1,000`` / ``$1,234.50`` — the whole-vs-fractional rule of + ``billing_view.format_money`` but thousands-grouped, matching the TUI's + ``toLocaleString('en-US')``. + + The shared ``format_money`` is intentionally ungrouped (and asserted so across + other surfaces), so plan-catalog rows group locally to mirror the TUI. + """ + if value is None: + return "—" + if value == value.to_integral_value(): + return f"${format(value.to_integral_value(), ',f')}" + return f"${format(value.quantize(Decimal('0.01')), ',f')}" + + +def selectable_tiers(state: SubscriptionState) -> list[SubscriptionTier]: + """Enabled paid tiers other than the current plan, cheapest first. + + One derivation shared by the CLI Free catalog and the paid change picker: + ``is_enabled and not is_current and tier_order > 0`` (free / no-sub excluded — + dropping to free is a cancellation), sorted by ``tier_order``. + """ + return sorted( + ( + t + for t in (state.tiers or ()) + if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0 + ), + key=lambda t: t.tier_order or 0, + ) + + +def format_tier_row(tier: SubscriptionTier) -> str: + """``name · $X/mo[ · $Y credits/mo]`` — the shared plan-catalog row. + + Mirrors the TUI Free rows (``subscriptionOverlay.tsx``): thousands-grouped + money, and the ``$Y credits/mo`` suffix ONLY when monthly credits are present + and > 0 (a ``None`` / zero-credits tier hides it — never ``· — credits/mo`` or + ``· $0 credits/mo``). + """ + row = f"{tier.name} · {_format_dollars_grouped(tier.dollars_per_month)}/mo" + mc = tier.monthly_credits + if mc is not None and mc > 0: + row += f" · {_format_dollars_grouped(mc)} credits/mo" + return row + + +def is_upgrade(state: SubscriptionState, tier_id: str) -> bool: + """True when ``tier_id`` ranks above the current plan by ``tier_order``. + + Prefers the active subscription's tier; falls back to the ``tiers[]`` + ``is_current`` marker (what the picker derives from), else 0 (free). + """ + orders = {t.tier_id: (t.tier_order or 0) for t in (state.tiers or ())} + cur_id = state.current.tier_id if state.current else None + if cur_id is not None and cur_id in orders: + cur_order = orders[cur_id] + else: + cur_order = next((t.tier_order or 0 for t in (state.tiers or ()) if t.is_current), 0) + return orders.get(tier_id, 0) > cur_order + + # ============================================================================= # Dev fixtures (throwaway scaffolding — env-var driven, no live portal) # ============================================================================= diff --git a/docs/billing-lifecycle.md b/docs/billing-lifecycle.md index 8562c90e8368..761cdbe0f4a6 100644 --- a/docs/billing-lifecycle.md +++ b/docs/billing-lifecycle.md @@ -160,6 +160,13 @@ interactive modal (prompt_toolkit) mirrors the TUI overlay the same way, and non-interactive contexts fall back to the same text + portal-link rendering, never prompting. +| CLI surface / state | Behavior (parity with TUI / desktop) | +| --- | --- | +| `/subscription` on **Free** + admin/owner + interactive | `_subscription_free_catalog` prints the plan catalog from the same `tiers[]` data the TUI uses — one row per enabled paid tier, cheapest first, `name · $/mo · $credits/mo` (monthly credits are DOLLARS → `$22 credits/mo`, never a bare number). A numbered pick opens the `/manage-subscription` deep-link with `plan=` appended so the portal preselects the chosen plan. Starting a new subscription needs a fresh card, so the only action is the portal hand-off (the terminal never charges here). | +| Any CLI-built manage/subscribe URL | `subscription_manage_url(state, tier_id=…)` appends `plan=` (the stable `tiers[]` id, never a name/slug) **only when a tier was picked** (the Free catalog). The portal validates it server-side and ignores an unknown tier, so the CLI appends unconditionally on a pick, mirroring the TUI's `?plan=`. `org_id` is emitted first, `plan` second. | +| **Downgrades** in the CLI | Stay **native / in-app** for normal changes (chargeless scheduling via `put_subscription_pending_change`). A blocked downgrade may still print the generic manage URL, but it never carries `plan=` — selected-tier deep-links are reserved for new subscriptions and upgrades. | +| `/topup` overview action copy | Splits one-time top-up from automatic refill, the distinction stated up front in each first sentence: `Add funds now — a single charge, added to your balance today.` vs `Refill when low — charges $X automatically when your balance falls below $Y.` ("credits" stays out of the dollars-only `/topup` surface — "Add funds now" carries the one-time meaning without it). When auto-reload is off, the automatic line omits concrete amounts. | + ## Forward compatibility Any `error`/`status`/`reason` code not in the tables above lands on the diff --git a/hermes_cli/cli_billing_mixin.py b/hermes_cli/cli_billing_mixin.py index bb9edd95267a..15e2088a42b7 100644 --- a/hermes_cli/cli_billing_mixin.py +++ b/hermes_cli/cli_billing_mixin.py @@ -254,12 +254,107 @@ class CLIBillingMixin: if is_free: # Starting a NEW subscription needs a fresh card — deep-link only. - self._subscription_open_portal(state, manage_url, verb="Start a subscription") + # Show the plan catalog, let the user pick, and carry ``plan=`` + # into the portal deep-link so it preselects the chosen plan. + self._subscription_free_catalog(state, manage_url) return # Paid + admin/owner + interactive → the in-terminal change flow. self._subscription_change_menu(state, manage_url) + def _open_url_in_browser(self, url: str) -> bool: + """Open ``url`` in a REAL graphical browser; return whether one opened. + + The one opener behind every "open the portal" path in this mixin. Applies + the same console-browser / remote-session guard the device-code auth flows + use (``hermes_cli.auth``): ``webbrowser.open()`` returns ``True`` even when + it launched a text-mode browser (w3m/lynx over SSH) that hijacks the TTY, + so we refuse those and let the caller print the URL instead. Returns + ``False`` on any guard refusal or open failure, ``True`` only when a real + graphical browser launched. + """ + if not url: + return False + try: + from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session + + if _is_remote_session() or not _can_open_graphical_browser(): + return False + except Exception: + # Guard unavailable → fall through to a plain best-effort open. + pass + try: + import webbrowser + + return bool(webbrowser.open(url)) + except Exception: + return False + + def _subscription_free_catalog(self, state, manage_url): + """Free + admin/owner + interactive: print the plan catalog, pick one, then + open the portal manage-subscription deep-link with ``plan=``. + + The catalog mirrors the TUI Free rows (name · $/mo · $credits/mo, from the + same ``tiers[]`` data via the shared ``selectable_tiers`` / ``format_tier_row`` + helpers). Monthly credits are DOLLARS — rendered ``$X credits/mo`` (hidden + when absent/zero). Starting a NEW subscription needs a fresh card, so the + only action is the portal hand-off (the terminal never charges here); the + picked tier rides along as ``plan=`` so the portal preselects it. + """ + from cli import _cprint, _b, _d + + from agent.subscription_view import ( + format_tier_row, + selectable_tiers, + subscription_manage_url, + ) + + tiers = selectable_tiers(state) + if not tiers: + # No catalog to show → the plain portal hand-off (no plan= to append). + self._subscription_open_portal(state, manage_url, verb="Start a subscription") + return + + print() + _cprint(f" ⚕ {_b('Choose a plan')}") + print(f" {'─' * 41}") + for i, t in enumerate(tiers, 1): + print(f" {i}. {format_tier_row(t)}") + _cprint(f" {_d('Starting a subscription opens the portal to add your card.')}") + + choices = [(t.tier_id, format_tier_row(t), f"start {t.name} on the portal") for t in tiers] + choices.append(("cancel", "Cancel", "do nothing")) + raw = self._prompt_text_input_modal( + title="Start a subscription", + detail="Pick a plan to open it on the portal.", + choices=choices, + ) + # The rows are printed numbered, so accept a bare number as a pick (the + # shared normalizer only knows the confirm-dialog digit aliases). + _digit = (raw or "").strip() + if _digit.isdigit() and 1 <= int(_digit) <= len(tiers): + choice = tiers[int(_digit) - 1].tier_id + else: + choice = self._normalize_slash_confirm_choice(raw, choices) + if not choice or choice == "cancel": + print(" 🟡 Cancelled. No plan started.") + return + # Numbered pick → open the portal deep-link directly, with the picked tier's + # plan= param so the portal preselects it (spec: pick → opens the portal). + tier_url = subscription_manage_url(state, tier_id=choice) or manage_url + if not tier_url: + _cprint(f" {_d('No manage URL available — is your portal configured?')}") + return + picked = next((t for t in tiers if t.tier_id == choice), None) + label = picked.name if picked else "your plan" + if self._open_url_in_browser(tier_url): + print(f" Opening the portal to start {label}…") + else: + # No graphical browser (headless / SSH / console browser): print the + # link so it stays actionable. + print(f" Open this URL to start {label}: {tier_url}") + print(" Finish in your browser, then re-run /subscription.") + def _subscription_open_portal(self, state, manage_url, *, verb="Manage your subscription"): """Open / copy the manage-subscription URL — the portal hand-off.""" from cli import _cprint, _d @@ -277,14 +372,7 @@ class CLIBillingMixin: raw = self._prompt_text_input_modal(title=verb, detail="", choices=choices) choice = self._normalize_slash_confirm_choice(raw, choices) if choice == "open": - opened = False - try: - import webbrowser - - opened = webbrowser.open(manage_url) - except Exception: - opened = False - if not opened: + if not self._open_url_in_browser(manage_url): print(f" Open this URL: {manage_url}") print() print(" Finish in your browser, then re-run /subscription.") @@ -333,24 +421,20 @@ class CLIBillingMixin: def _subscription_pick_tier(self, state): """Tier picker → preview → confirm (mirrors the TUI picker screen).""" - from agent.billing_view import format_money + from agent.subscription_view import format_tier_row, is_upgrade, selectable_tiers c = state.current - tiers = tuple(state.tiers or ()) - cur_order = next((t.tier_order for t in tiers if t.is_current), 0) # Selectable = enabled paid tiers other than current (free/no-sub excluded; # dropping to free is a cancellation, on the change menu). Sorted by price. - selectable = sorted( - [t for t in tiers if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0], - key=lambda t: t.tier_order or 0, - ) + # Shared with the Free catalog + blocked-preview branch (one derivation). + selectable = selectable_tiers(state) if not selectable: print(" No other plans are available to switch to right now.") return choices = [] for t in selectable: - direction = "upgrade" if (t.tier_order or 0) > cur_order else "downgrade" - choices.append((t.tier_id, f"{t.name} · {format_money(t.dollars_per_month)}/mo · {direction}", f"switch to {t.name}")) + direction = "upgrade" if is_upgrade(state, t.tier_id) else "downgrade" + choices.append((t.tier_id, f"{format_tier_row(t)} · {direction}", f"switch to {t.name}")) choices.append(("cancel", "Back", "do nothing")) raw = self._prompt_text_input_modal( title="Change plan", @@ -397,11 +481,15 @@ class CLIBillingMixin: if effect not in ("charge_now", "scheduled"): # blocked OR an unknown/unexpected effect → fail SAFE (never schedule a # real change on an unrecognized string, unlike a bare `else`), and - # re-offer the portal hand-off like the TUI's blocked branch. - from agent.subscription_view import subscription_manage_url + # re-offer the portal hand-off like the TUI's blocked branch. The picked + # tier rides along as plan= only for an UPGRADE hand-off — new-sub / + # upgrade deep-links carry the plan; downgrades stay native (binding + # ruling), so a blocked downgrade keeps the generic manage link. + from agent.subscription_view import is_upgrade, subscription_manage_url + _plan = tier_id if is_upgrade(state, tier_id) else None _cprint(f" 🟡 {p.reason or 'This change cannot be confirmed here — manage it on the portal.'}") - _mu = subscription_manage_url(state) + _mu = subscription_manage_url(state, tier_id=_plan) if _mu: print(f" Manage on portal: {_mu}") return @@ -780,6 +868,31 @@ class CLIBillingMixin: self._billing_portal_hint(state) return + # One-time vs automatic — the two ways to add funds, the distinction stated + # up front in each first sentence (parity with the desktop revamp's split + # copy). "credits" stays out of the dollars-only /topup surface: "Add funds + # now" carries the one-time meaning without it. + _cprint(f" {_d('Add funds now — a single charge, added to your balance today.')}") + if ( + ar is not None + and ar.enabled + and ar.reload_to_usd is not None + and ar.reload_to_usd.is_finite() + and ar.threshold_usd is not None + and ar.threshold_usd.is_finite() + ): + _auto_line = ( + f"Refill when low — charges {format_money(ar.reload_to_usd)} automatically " + f"when your balance falls below {format_money(ar.threshold_usd)}." + ) + else: + _auto_line = ( + "Refill when low — charges your card automatically when your balance " + "falls below the amount you set." + ) + _cprint(f" {_d(_auto_line)}") + print(f" {'─' * 41}") + # Add funds first, then settings, then the scopeless browser handoff. # No "Allow Remote Spending" item — that's discovered at pay time. # "Add funds" charges in-terminal against the org's portal-saved card @@ -787,8 +900,8 @@ class CLIBillingMixin: # missing card is NOT gated here: the buy flow reacts to the server's # no_payment_method 403 and hands off to the portal at charge time. choices = [ - ("buy", "Add funds", "add money to your balance"), - ("auto", "Auto-reload", "configure automatic top-ups"), + ("buy", "Add funds", "a single charge, added to your balance today"), + ("auto", "Auto-reload", "refill automatically when your balance runs low"), ("limit", "Monthly limit", "show the monthly spend cap (read-only)"), ("portal", "Manage on portal", "open the billing page in your browser"), ("cancel", "Cancel", "do nothing"), @@ -837,14 +950,7 @@ class CLIBillingMixin: if not url: print(" No portal URL available.") return - opened = False - try: - import webbrowser - - opened = webbrowser.open(url) - except Exception: - opened = False - if not opened: + if not self._open_url_in_browser(url): print(f" Open this URL: {url}") print(" Complete billing changes in the browser.") diff --git a/tests/agent/test_subscription_view.py b/tests/agent/test_subscription_view.py index 6c80c1ef52fa..5c9ed251721a 100644 --- a/tests/agent/test_subscription_view.py +++ b/tests/agent/test_subscription_view.py @@ -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=, 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 ─────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_billing_cli.py b/tests/hermes_cli/test_billing_cli.py index d90dfb9c26bc..85d4c5407ff5 100644 --- a/tests/hermes_cli/test_billing_cli.py +++ b/tests/hermes_cli/test_billing_cli.py @@ -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"), diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py index deb13648a881..f7a5845a75d3 100644 --- a/tests/hermes_cli/test_subscription_cli.py +++ b/tests/hermes_cli/test_subscription_cli.py @@ -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=. + 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=, 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=. + 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):