From 7c68b4eae1a1de14a569d6df371c55a1a2dc06da Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:07:52 +0530 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Billing=20page=20revamp=20?= =?UTF-8?q?=E2=80=94=20current-plan=20card,=20in-app=20plans=20view,=20tie?= =?UTF-8?q?r=20art=20(#68722)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): revamp Billing page — plan card, in-app plans view, tier art Reshape the desktop Billing settings per wayfinder ticket 09. New page order: Plan → Payment → One-time top-up → Automatic refill → Usage, with the at-a-glance summary strip unchanged at the top. - CurrentPlanCard replaces the old Subscription row: tier name + price + renewal and at most one button — "View plans" (free/no-sub + can_change_plan), "Change plan" (subscriber + can_change_plan), or none for teams / non-changers. Teams keep the portal "Adjust plan ↗" link so they are not stranded. The button navigates in-app to the plans sub-view. - bview=plans sub-view mirrors the settings pview/kview pattern (useRouteEnumParam, default overview). BillingPlansView renders a grid of PlanCard from live tiers[] (is_enabled, sorted by tier_order, free tier included). - PlanCard: tier art + name + $/mo + monthly credits as dollars ("$110 credits/mo"). Current tier is highlighted + inert; higher/no-current tiers get "Choose ↗" (opens portal with plan=); lower tiers are a DISABLED "Downgrade" with a caption — downgrades move in-app in ticket 11 (gateway pending-change flow), so this PR intentionally links them out/disabled rather than wiring the money path. - buildManageSubscriptionUrl gains an optional third arg (tierId) → appends plan=. Signature kept identical to draft PR #68666 for a trivial rebase; NAS #748 validates the param server-side. - Tier art: four NAS hero webps rendered as ~40px thumbnails over a Nous-blue well with per-tier blend modes (the only place Nous blue appears). Keyed by lowercase tier NAME (free/starter→connect, plus→memory, super→automation, ultra→sandbox); unknown name → text-only card. Imported via vite static imports for packaged file:// + webSecurity. - Top-up vs auto-refill disambiguated by section label + first sentence: "One-time top-up" / "Buy credits now" vs "Automatic refill" / "Refill when low" (configured copy reads "Charges $X automatically when your balance falls below $Y."). - Variant-A auto-refill editing: Manage swaps the row's left side (caption → the two $ fields with a pre-allocated error line) and the action column (Manage → Save/ Cancel) in place, with the row height reserved for the tallest state so the Usage section never shifts. Fixes the spurious on-open validation error (errors now show only after an edit or a save attempt). Save/disable API calls + confirm-disable flow unchanged. - Remove subscriptionTierChips and the subscription-row chips; reshape (not delete) deriveBillingView to expose plan + tiers. Buy-credits row keeps the chips seam. - Dev fixtures: add free-personal and subscriber-personal (personal orgs, full 4-tier Free/Plus/Super/Ultra catalog) so the plans view is exercisable. Tests: update/extend index.test.tsx + use-billing-state.test.ts, add tier-art.test.ts; delete the old chips tests. Desktop billing suite 70/70 green, typecheck clean. * fix(desktop): mark the free/lowest tier current (not an upgrade) when there is no subscription Visual verification caught a spec-fidelity bug: in the plans grid, an account with no active subscription rendered the Free tier ($0/mo, tier_order 0) as a "Choose ↗" upgrade — clicking would deep-link the portal to "subscribe to Free". Ruling: current-card = tier.is_current OR (subscription.current == null AND the tier is the lowest-order / $0 tier). derivePlanTiers now falls back to the lowest-order tier as the stand-in current plan when there is no subscription, so the free card renders exactly like is_current (inert, "Current plan") and — being the lowest order — no tier can be a downgrade; every paid tier is a "Choose ↗" upgrade. CurrentPlanCard is unaffected (still "Free" + "View plans"); subscriber-personal is unchanged (Free stays a disabled Downgrade below the current Plus tier). Tests: free-personal grid now asserts Free = current/inert, no downgrade state, three Choose buttons; text-only unknown-tier test gains a free tier so the unknown paid tier is unambiguously an upgrade. Billing suite 70/70 green, typecheck + lint clean. * chore(desktop): shrink bundled tier art to 128px thumbnails The plan-card wells render the art at ~40px; shipping the full landing images added 2.7 MB to the repo for no visible difference. 128px covers 2x displays; total is now 26 KB. * fix(desktop): address 6 adversarial-review findings on the Billing revamp 1. Grandfathered current tier (BLOCKER). NAS marks a grandfathered current tier is_enabled:false; the enabled-only filter dropped it, leaving currentOrder undefined so every lower tier rendered as an actionable "Choose ↗". derivePlanTiers now resolves current identity/ordering against the UNFILTERED tiers and keeps the grandfathered current tier in the grid as the inert "Current plan" card; downgrades classify against its tier_order. (Non-current disabled tiers are still dropped.) 2. Dead plan-card button. derivePlanCard offered "View plans"/"Change plan" purely on can_change_plan, but the grid could be empty / current-only and showPlans refused, so the button no-oped. It now offers the in-app action ONLY when the grid has ≥1 actionable (non-current) tier; otherwise it falls back to the portal link. 3. Deep-link bypass. showPlans now gates on the same capability that renders the button (view.plan?.action), so a team / non-changer deep-linking bview=plans always falls back to overview instead of a grid of live Choose buttons. 4. Lost portal escape hatch. Whenever the card has no in-app action (teams, non-changers, refused subscription, empty catalog) it now ALWAYS carries the "Adjust plan ↗" portal link built from subscription?.portal_url ?? billing.portal_url — the refusal caption no longer promises a portal the UI didn't render. 5. Choose URLs dropping org_id/plan. (a) derivePlanTiers now threads billing.portal_url as the fallback base for the Choose URL. (b) buildManageSubscriptionUrl treats the hard-coded FALLBACK_PORTAL_BILLING_URL as a last-resort ORIGIN (applying org_id/plan) instead of a bare return, so a null portal_url never strips the routing params. 6. Zero-shift on narrow panes. Replaced the magic min-h-28 (under-reserved once the two inputs stack below @2xl) with exact reservation: the edit form is always rendered and both states share one grid cell ([grid-template-areas:'stack']), invisible+aria-hidden when not editing — the row equals the tallest state at every width, no breakpoint math. The refusal stays inside the reserved layer. Tests: +12 (grandfathered current, no-dead-button + empty-catalog portal link, team & personal deep-link fallback to overview, billing.portal_url-backed Choose URL, fallback org_id/plan, reserved-form-mounted); updated the two portal-link expectations for §4. Billing suite 78/78 green; typecheck (app/electron/e2e) + lint clean. * refactor(desktop): reuse the shared openExternalLink helper in the plans view * fix(desktop): honor the auto_reload wire contract — null card + disable amounts A full-stack contract sweep (desktop ↔ shared types ↔ gateway ↔ NAS) surfaced two real desktop bugs in the auto-refill row: A. auto_reload.card can be null. The gateway's _parse_auto_reload_card returns None for a missing/unknown-kind card and _serialize_billing_state emits `card: null`, but the shared BillingAutoReload.card union had no null arm and use-billing-state dereferenced `autoReload.card.kind` bare — a crash on the enabled path. Add `| null` to the shared union (contract honesty) and guard the read (`card?.kind`); null now falls through to the default enabled path, same as a canonical card. B. Disable was rejected by the gateway. billing.auto_reload unconditionally requires threshold + top_up_amount, so `updateAutoReload({ enabled: false })` came back invalid_request. (The TUI always sends both; desktop fixture mode stubbed it.) disable() now sends the current threshold_usd/reload_to_usd from the autoReload prop alongside enabled: false, matching the TUI. Tests: enabled auto_reload with card:null renders the normal enabled row (derivation + render, no crash); disable call carries both current amounts. Billing suite 80/80 green; typecheck (app/electron/e2e) + lint clean. * fix(tui): guard the nullable auto_reload card in the auto-reload screen The shared BillingAutoReload.card union gained its honest null arm (the gateway emits card: null for a missing/unknown card); the TUI's only bare dereference follows the same default path as a canonical card. * fix(desktop): align billing inputs to the sm control height The three billing inputs used an ad-hoc h-8 (32px) next to size=sm buttons (24px). They now use the control system's size=sm with a py-[3px] compensation for the input's real 1px border — buttons draw theirs as an inset shadow, so sm alone still sits 2px taller. All five controls in the buy row now measure 24px. * fix(desktop): plan-card actionability + billing view-model hardening Code-quality review of the Billing revamp (PR #68722). BLOCKING — a top-tier subscriber (only downgrades/current below them) opened a plans grid with zero enabled actions AND no portal link. The plan card gated its in-app button on `tiers.some(state !== 'current')`, which counts the (disabled) downgrade tiles. It now gates on an actual UPGRADE being present (`capable && tiers.some(state === 'upgrade')`); with no upgrade the card falls back to its "Adjust plan ↗" portal link, and the bview=plans deep link (gated on the same plan.action) falls back to overview. Reviewer structural items: - One "plans capability" verdict (personal + can_change_plan + subscription ok) is derived once in deriveBillingView and threaded to BOTH derivePlanCard and derivePlanTiers; the grid only mints upgrade actions when capable, so the invariant lives in one place. - BillingPlanTierView is now a discriminated union (`current` | `downgrade` w/ disabledCaption | `upgrade` w/ required action), and BillingPlanCardView is an action-XOR-link union — deleting the `tier.action?.url ?? ''` and `plan.link?.url` defensive branches in the consumers. - `findCurrentTier(subscription)` replaces the repeated is_current||id predicate at its three sites (plan card price, grid ordering, summary plan line). - BillingView exposes named `paymentRow` / `topupRow` / `refillRow` instead of an `accountRows[]` + three `.find(id)` lookups. - The auto-refill row that edits in place carries an explicit `manageInApp: true`; AutoReloadRow keys off it instead of sniffing the action label/url. - tier-art header comment no longer cites an internal repo path; dead `?.` removed from RowValue (via a destructured const) and the plan-card link handler. Behavior is identical except the blocking fix. Billing suite 82/82 green; typecheck (app/electron/e2e) + lint clean. * refactor(desktop): adopt inline-review nits on the billing plan card Resolves the inline suggestion threads: - plan-card gate reads a named `hasActionableTier` = "a tile carries an action" (union-safe `'action' in tier`, equivalent to the old upgrade-only check). - re-narrow link/action inside the click callbacks (`plan.link && …`, `tier.action && …`) rather than relying on outer narrowing. Behavior unchanged; billing suite 82/82 green, typecheck + lint clean. --- .../src/app/settings/billing/dev-fixtures.ts | 114 +++- .../src/app/settings/billing/index.test.tsx | 163 +++++- .../src/app/settings/billing/index.tsx | 395 +++++++++---- .../src/app/settings/billing/plans-view.tsx | 94 ++++ .../src/app/settings/billing/tier-art.test.ts | 25 + .../src/app/settings/billing/tier-art.tsx | 69 +++ .../billing/use-billing-state.test.ts | 530 +++++++++++++----- .../app/settings/billing/use-billing-state.ts | 346 ++++++++---- .../src/assets/tiers/feature-automation.webp | Bin 0 -> 5410 bytes .../src/assets/tiers/feature-connect.webp | Bin 0 -> 7670 bytes .../src/assets/tiers/feature-memory.webp | Bin 0 -> 5214 bytes .../src/assets/tiers/feature-sandbox.webp | Bin 0 -> 7854 bytes apps/shared/src/billing-types.ts | 4 + ui-tui/src/components/billingOverlay.tsx | 2 +- 14 files changed, 1368 insertions(+), 374 deletions(-) create mode 100644 apps/desktop/src/app/settings/billing/plans-view.tsx create mode 100644 apps/desktop/src/app/settings/billing/tier-art.test.ts create mode 100644 apps/desktop/src/app/settings/billing/tier-art.tsx create mode 100644 apps/desktop/src/assets/tiers/feature-automation.webp create mode 100644 apps/desktop/src/assets/tiers/feature-connect.webp create mode 100644 apps/desktop/src/assets/tiers/feature-memory.webp create mode 100644 apps/desktop/src/assets/tiers/feature-sandbox.webp diff --git a/apps/desktop/src/app/settings/billing/dev-fixtures.ts b/apps/desktop/src/app/settings/billing/dev-fixtures.ts index 8f69cf687a1e..d5ed680586f2 100644 --- a/apps/desktop/src/app/settings/billing/dev-fixtures.ts +++ b/apps/desktop/src/app/settings/billing/dev-fixtures.ts @@ -1,5 +1,5 @@ import type { BillingResult } from './api' -import type { BillingStateResponse, SubscriptionStateResponse } from './types' +import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from './types' const current = ( overrides: Partial> = {} @@ -207,6 +207,110 @@ export const loggedOutSubscriptionState = { usage: undefined } satisfies SubscriptionStateResponse +// Full four-tier personal catalog. tier_ids are cuid-like (Prisma) on purpose: +// tier art keys off the lowercase NAME, never the id (ids differ per env). Dollar +// credits are 0.1 / 22 / 110 / 220 to exercise the "$X credits/mo" formatting. +const personalTierCatalog: SubscriptionTierOption[] = [ + { + dollars_per_month_display: '$0', + is_current: false, + is_enabled: true, + monthly_credits: '0.1', + name: 'Free', + tier_id: 'cltier000free0000personal', + tier_order: 0 + }, + { + dollars_per_month_display: '$20', + is_current: false, + is_enabled: true, + monthly_credits: '22', + name: 'Plus', + tier_id: 'cltier111plus1111personal', + tier_order: 1 + }, + { + dollars_per_month_display: '$100', + is_current: false, + is_enabled: true, + monthly_credits: '110', + name: 'Super', + tier_id: 'cltier222super222personal', + tier_order: 2 + }, + { + dollars_per_month_display: '$200', + is_current: false, + is_enabled: true, + monthly_credits: '220', + name: 'Ultra', + tier_id: 'cltier333ultra333personal', + tier_order: 3 + } +] + +const catalogWithCurrent = (currentTierId: null | string): SubscriptionTierOption[] => + personalTierCatalog.map(tier => ({ ...tier, is_current: tier.tier_id === currentTierId })) + +// Logged-in personal org, no subscription: exercises the "View plans" plan card +// and the full plans grid where every tier is an upgrade. +export const freePersonalBillingState = { + ...postTrainBillingState, + balance_display: '$12.00', + balance_usd: '12.00', + org_name: 'Personal', + usage: { + available: true, + has_topup: true, + plan_name: 'Free', + renews_at: null, + renews_display: null, + status: 'active', + subscription_remaining_display: '$0', + topup_remaining_display: '$12.00', + total_spendable_display: '$12.00' + } +} satisfies BillingStateResponse + +export const freePersonalSubscriptionState = { + ...todaySubscriptionState, + can_change_plan: true, + context: 'personal', + current: null, + org_id: 'org_personal_free', + org_name: 'Personal', + tiers: catalogWithCurrent(null), + usage: freePersonalBillingState.usage +} satisfies SubscriptionStateResponse + +// Personal subscriber on Plus: exercises the "Change plan" plan card, the current +// marker, upgrades (Super/Ultra), and the disabled downgrade (Free). +export const subscriberPersonalBillingState = { + ...postTrainBillingState, + org_name: 'Personal', + usage: { + ...postTrainBillingState.usage, + plan_name: 'Plus' + } +} satisfies BillingStateResponse + +export const subscriberPersonalSubscriptionState = { + ...todaySubscriptionState, + can_change_plan: true, + context: 'personal', + current: current({ + credits_remaining: '12', + cycle_ends_at: '2026-08-15T00:00:00Z', + monthly_credits: '22', + tier_id: 'cltier111plus1111personal', + tier_name: 'Plus' + }), + org_id: 'org_personal_plus', + org_name: 'Personal', + tiers: catalogWithCurrent('cltier111plus1111personal'), + usage: subscriberPersonalBillingState.usage +} satisfies SubscriptionStateResponse + const okBilling = (data: BillingStateResponse): BillingResult => ({ data, ok: true }) const okSubscription = (data: SubscriptionStateResponse): BillingResult => ({ @@ -270,6 +374,14 @@ function withUsage( export const billingDevFixtures = { healthy: withUsage('Healthy', { monthlyCapSpent: '89', remaining: '132' }), + 'free-personal': { + billing: okBilling(freePersonalBillingState), + subscription: okSubscription(freePersonalSubscriptionState) + }, + 'subscriber-personal': { + billing: okBilling(subscriberPersonalBillingState), + subscription: okSubscription(subscriberPersonalSubscriptionState) + }, 'auto-refill-divergent': withUsage('Auto Refill Divergent', { autoReload: { ...postTrainBillingState.auto_reload, diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 29b8f95e82ab..ad12438cbbcb 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -38,13 +39,15 @@ vi.mock('./api', () => ({ }) })) -function renderBilling() { +function renderBilling(initialEntries: string[] = ['/settings?tab=billing']) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( - - - + + + + + ) return client @@ -79,7 +82,7 @@ describe('BillingSettings', () => { ) ).toBeTruthy() expect(screen.queryByRole('button', { name: '$100' })).toBeNull() - expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy() + expect(screen.getByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy() expect(screen.getByText('$120 of $220 left')).toBeTruthy() expect(screen.getByText('$876.47')).toBeTruthy() expect(screen.getByText('$10 of $100 used').classList.contains('tabular-nums')).toBe(true) @@ -163,6 +166,17 @@ describe('BillingSettings', () => { expect(apiMocks.updateAutoReload).not.toHaveBeenCalled() }) + it('renders the enabled auto-refill row without crashing when the card is null', async () => { + apiMocks.fetchBillingState.mockResolvedValue( + okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } }) + ) + + renderBilling() + + expect(await screen.findByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Manage' })).toBeTruthy() + }) + it('requires inline confirmation before disabling auto-refill', async () => { renderBilling() @@ -176,7 +190,144 @@ describe('BillingSettings', () => { fireEvent.click(screen.getByRole('button', { name: 'Turn off' })) - await waitFor(() => expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: false })) + // The gateway requires threshold + top_up_amount even to disable, so the current + // amounts ride along (todayBillingState: threshold $5, reload-to $10). + await waitFor(() => + expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ + enabled: false, + reload_to_usd: '10', + threshold_usd: '5' + }) + ) + }) + + it('opens auto-refill edit without a validation error even when the saved config is below the minimum', async () => { + // todayBillingState: threshold $5 with min_usd $10 — invalid, but opening + // Manage must stay silent until the user edits or attempts to save (spec §9). + renderBilling() + + fireEvent.click(await screen.findByRole('button', { name: 'Manage' })) + + expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy() + expect(screen.queryByText('Threshold: minimum is $10.')).toBeNull() + // Save is disabled because the prefilled config is invalid — but no error yet. + expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true) + }) + + it('navigates to the in-app plans grid from the plan card and back', async () => { + const fixture = billingDevFixtures['free-personal'] + + apiMocks.fetchBillingState.mockResolvedValue(fixture.billing) + apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription) + + renderBilling() + + fireEvent.click(await screen.findByRole('button', { name: 'View plans' })) + + expect(await screen.findByText('Plans')).toBeTruthy() + // No subscription → the free tier is the inert current plan, the three paid + // tiers are "Choose ↗" upgrades (no "subscribe to Free"). + expect(screen.getByText('Current plan')).toBeTruthy() + expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(3) + expect(screen.queryByRole('button', { name: 'Downgrade' })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Back to billing' })) + + expect(await screen.findByRole('button', { name: 'View plans' })).toBeTruthy() + }) + + it('renders the current marker and disabled downgrade when deep-linked to the plans grid', async () => { + const fixture = billingDevFixtures['subscriber-personal'] + + apiMocks.fetchBillingState.mockResolvedValue(fixture.billing) + apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription) + + renderBilling(['/settings?tab=billing&bview=plans']) + + expect(await screen.findByText('Current plan')).toBeTruthy() + // Free sits below Plus → disabled downgrade with the ticket-11 caption. + expect(screen.getByRole('button', { name: 'Downgrade' }).hasAttribute('disabled')).toBe(true) + expect(screen.getByText('Downgrades are moving in-app — coming soon.')).toBeTruthy() + // Super + Ultra are upgrades. + expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(2) + }) + + it('falls back to overview (no live Choose grid) when a team deep-links bview=plans', async () => { + // Default beforeEach uses todaySubscriptionState (context: 'team') — no in-app + // plans capability, so the URL must not surface a grid of Choose buttons. + renderBilling(['/settings?tab=billing&bview=plans']) + + expect(await screen.findByText('Payment')).toBeTruthy() + expect(screen.queryByText('Plans')).toBeNull() + expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull() + }) + + it('falls back to overview when a non-changer personal account deep-links bview=plans', async () => { + apiMocks.fetchSubscriptionState.mockResolvedValue( + okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' }) + ) + + renderBilling(['/settings?tab=billing&bview=plans']) + + expect(await screen.findByText('Payment')).toBeTruthy() + expect(screen.queryByText('Plans')).toBeNull() + expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull() + }) + + it('falls back to overview when a top-tier subscriber deep-links bview=plans', async () => { + // Capable, but on the highest tier → no upgrade → no in-app button → the deep + // link must not open a grid whose only actions are inert downgrades. + apiMocks.fetchSubscriptionState.mockResolvedValue( + okSubscription({ + ...todaySubscriptionState, + can_change_plan: true, + context: 'personal', + current: { ...todaySubscriptionState.current, tier_id: 'top', tier_name: 'Ultra' }, + tiers: [ + { + dollars_per_month_display: '$0', + is_current: false, + is_enabled: true, + monthly_credits: '0.1', + name: 'Free', + tier_id: 't_free', + tier_order: 0 + }, + { + dollars_per_month_display: '$200', + is_current: true, + is_enabled: true, + monthly_credits: '220', + name: 'Ultra', + tier_id: 'top', + tier_order: 1 + } + ] + }) + ) + + renderBilling(['/settings?tab=billing&bview=plans']) + + expect(await screen.findByText('Payment')).toBeTruthy() + expect(screen.queryByText('Plans')).toBeNull() + // The plan card's portal link is present instead of an in-app button. + expect(screen.getByRole('button', { name: /Adjust plan/ })).toBeTruthy() + }) + + it('keeps the auto-refill edit form mounted so the row height is reserved before editing', async () => { + renderBilling() + + await screen.findByRole('button', { name: 'Manage' }) + + // Not editing: the inputs are already in the DOM (height reserved) but aria-hidden, + // so the accessible query finds nothing while the hidden-inclusive query does. + expect(screen.queryByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeNull() + expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold', hidden: true })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Manage' })) + + // Editing reveals the same reserved input. + expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy() }) it('renders auto-refill mutation refusals and step-up affordance', async () => { diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index a56404757545..814585c49d8e 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -5,19 +5,23 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip } from '@/components/ui/tooltip' -import { BarChart3, ExternalLink, RefreshCw } from '@/lib/icons' +import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons' import { cn } from '@/lib/utils' +import { useRouteEnumParam } from '../../hooks/use-route-enum-param' import { ListRow, Pill, SectionHeading, SettingsContent } from '../primitives' import type { BillingRefusal } from './api' import { useBillingApi } from './api' import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures' import { resolveRefusal } from './errors' +import { BillingPlansView } from './plans-view' +import { TierArt } from './tier-art' import type { BillingAutoReload, BillingStateResponse } from './types' import { type BillingAccountRowView, type BillingNoticeView, + type BillingPlanCardView, type BillingUsageRowView, deriveBillingView, EMPTY_BILLING_VALUE, @@ -25,6 +29,11 @@ import { useBillingState, useSubscriptionState } from './use-billing-state' + +// `bview` mirrors the settings pview/kview sub-view pattern (deep-linkable, replace +// navigation). `overview` is the default landing; `plans` is the in-app catalog. +const BILLING_VIEWS = ['overview', 'plans'] as const +type BillingSubView = (typeof BILLING_VIEWS)[number] import { useChargeFlow } from './use-charge-poller' import { useStepUpFlow } from './use-step-up' @@ -84,6 +93,9 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) { } function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) { + // Destructure to a const so narrowing survives into the onClick closure below. + const { action } = row + return (
{row.value && ( @@ -105,16 +117,16 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun {chip.label} ))} - {row.action && ( + {action && ( )}
@@ -147,6 +159,46 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil ) } +function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) { + return ( +
+
+
+ +
+
+ + {plan.tierName} + + {plan.price && ( + + {plan.price}/mo + + )} +
+
+ {plan.caption} +
+
+
+
+ {plan.action && ( + + )} + {plan.link && ( + + )} +
+
+
+ ) +} + function AutoReloadRow({ autoReload, bounds, @@ -160,6 +212,10 @@ function AutoReloadRow({ const queryClient = useQueryClient() const [confirmDisable, setConfirmDisable] = useState(false) const [editing, setEditing] = useState(false) + // Validation errors are silent until the user edits a field or attempts a + // save — opening Manage on a prefilled (possibly below-min) config must not + // flash an error (spec §9). + const [showErrors, setShowErrors] = useState(false) const [message, setMessage] = useState(null) const [refusal, setRefusal] = useState(null) @@ -178,12 +234,28 @@ function AutoReloadRow({ const maxBound = bounds.max_usd ?? undefined const minBound = bounds.min_usd ?? undefined + // Only the canonical-card enabled state edits in place (flagged in the view model). + // Off / divergent-card rows have no Manage affordance (or a portal link) and render + // read-only. + const editable = row.manageInApp === true + const resetFeedback = () => { setConfirmDisable(false) setMessage(null) setRefusal(null) } + const openEdit = () => { + resetFeedback() + setShowErrors(false) + setEditing(true) + } + + const cancelEdit = () => { + resetFeedback() + setEditing(false) + } + const save = async () => { if (!validation.values || busy) { return @@ -219,7 +291,14 @@ function AutoReloadRow({ resetFeedback() setSaving(true) - const result = await api.updateAutoReload({ enabled: false }) + // The gateway's billing.auto_reload handler unconditionally requires threshold + // + top_up_amount (invalid_request otherwise), so a disable must still carry the + // current amounts — mirroring the TUI, which always sends both. + const result = await api.updateAutoReload({ + enabled: false, + reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display), + threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display) + }) setSaving(false) @@ -234,115 +313,149 @@ function AutoReloadRow({ setEditing(false) } - const below = editing ? ( -
-
- - -
- {validation.error && ( -
{validation.error}
- )} -
- - - -
- {confirmDisable && ( -
- Turn off auto-refill? - - -
- )} - - {message && {message.text}} -
- ) : ( - <> - {row.caption ? ( -
- {row.caption} -
- ) : null} - - {message && {message.text}} - - ) + // Read-only states (off / divergent card) keep the original ListRow shape. + if (!editable) { + return ( + } + below={ + <> + {row.caption ? ( +
+ {row.caption} +
+ ) : null} + + {message && {message.text}} + + } + description={row.description} + key={row.id} + title={row.title} + /> + ) + } + const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => { + resetFeedback() + setShowErrors(true) + setter(event.target.value) + } + + // Zero-shift by exact reservation, not a magic min-height: the edit form is + // ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`), + // so the row's height always equals the tallest state at EVERY container width — + // no breakpoint math that under-reserves when the two inputs stack on narrow + // panes. The form is `invisible` + `aria-hidden` when not editing. return ( - { - resetFeedback() - setEditing(true) - } - } - row={row} - /> - } - below={below} - description={row.description} - key={row.id} - title={row.title} - /> +
+
+
+
+ {row.title} +
+
+ {row.description} +
+
+ {/* EDIT layer — always in layout (reserves exact height); hidden until editing. */} +
+
+ + +
+ {/* Pre-allocated error line — occupies height whether or not shown. */} +
+ {showErrors && validation.error ? validation.error : ''} +
+ {confirmDisable ? ( +
+ Turn off auto-refill? + + +
+ ) : ( + + )} + {/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */} + +
+ {/* VIEW layer — success feedback overlaid in the same cell when not editing. */} + {!editing && message && ( +
+ {message.text} +
+ )} +
+
+ {/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */} +
+ {row.pill && {row.pill.label}} + {editing ? ( + <> + + + + ) : ( + + )} +
+
+
) } @@ -392,7 +505,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B ))} ('bview', BILLING_VIEWS, 'overview') + const billingState = useBillingState(!fixture) const subscriptionState = useSubscriptionState(!fixture) const billingResult = fixture?.billing ?? billingState.data @@ -778,6 +894,22 @@ function BillingSettingsContent({ void Promise.all([billingState.refetch(), subscriptionState.refetch()]) } + const { paymentRow, refillRow, topupRow } = view + + // Gate the plans sub-view on the SAME capability that renders the in-app button + // (`plan.action`): a team / non-changer deep-linking `bview=plans` must never + // reach a grid of live Choose buttons — it falls back to the overview. + const showPlans = subView === 'plans' && view.status === 'normal' && Boolean(view.plan?.action) + + if (showPlans) { + return ( + + + setSubView('overview')} tiers={view.tiers} /> + + ) + } + return ( @@ -792,13 +924,32 @@ function BillingSettingsContent({ {view.notice && } - {view.accountRows.length > 0 && ( - <> - - {view.accountRows.map(row => ( - - ))} - + {view.plan && ( +
+ + setSubView('plans')} plan={view.plan} /> +
+ )} + + {paymentRow && ( +
+ + +
+ )} + + {topupRow && ( +
+ + +
+ )} + + {refillRow && ( +
+ + +
)} {view.usageRows.length > 0 && ( diff --git a/apps/desktop/src/app/settings/billing/plans-view.tsx b/apps/desktop/src/app/settings/billing/plans-view.tsx new file mode 100644 index 000000000000..30ca37accf9c --- /dev/null +++ b/apps/desktop/src/app/settings/billing/plans-view.tsx @@ -0,0 +1,94 @@ +import { Button } from '@/components/ui/button' +import { openExternalLink } from '@/lib/external-link' +import { ChevronLeft, ExternalLink } from '@/lib/icons' +import { cn } from '@/lib/utils' + +import { Pill } from '../primitives' + +import { TierArt } from './tier-art' +import type { BillingPlanTierView } from './use-billing-state' + +function PlanCard({ tier }: { tier: BillingPlanTierView }) { + const isCurrent = tier.state === 'current' + + return ( +
+
+ +
+
+ {tier.name} +
+
+ {tier.priceDisplay}/mo +
+
+
+ + {tier.creditsDisplay && ( +
+ {tier.creditsDisplay} +
+ )} + +
+ {isCurrent && Current plan} + + {tier.state === 'upgrade' && ( + + )} + + {tier.state === 'downgrade' && ( +
+ + + {tier.disabledCaption} + +
+ )} +
+
+ ) +} + +export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) { + return ( +
+
+ + Plans +
+ + {tiers.length > 0 ? ( +
+ {tiers.map(tier => ( + + ))} +
+ ) : ( +
+ No plans are available to change to right now. +
+ )} +
+ ) +} diff --git a/apps/desktop/src/app/settings/billing/tier-art.test.ts b/apps/desktop/src/app/settings/billing/tier-art.test.ts new file mode 100644 index 000000000000..60d8d4291bab --- /dev/null +++ b/apps/desktop/src/app/settings/billing/tier-art.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { resolveTierArt } from './tier-art' + +describe('resolveTierArt', () => { + it('keys art by lowercase tier name, case-insensitively', () => { + for (const name of ['Free', 'starter', 'Plus', 'SUPER', 'ultra']) { + expect(resolveTierArt(name)).not.toBeNull() + } + }) + + it('maps each named tier to its NAS blend mode', () => { + expect(resolveTierArt('free')?.blend).toBe('screen') + expect(resolveTierArt('plus')?.blend).toBe('screen') + expect(resolveTierArt('super')?.blend).toBe('lighten') + expect(resolveTierArt('ultra')?.blend).toBe('normal') + }) + + it('returns null for unknown or missing names so the card renders text-only', () => { + expect(resolveTierArt('Mystery')).toBeNull() + expect(resolveTierArt('')).toBeNull() + expect(resolveTierArt(null)).toBeNull() + expect(resolveTierArt(undefined)).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/settings/billing/tier-art.tsx b/apps/desktop/src/app/settings/billing/tier-art.tsx new file mode 100644 index 000000000000..9d34a048a834 --- /dev/null +++ b/apps/desktop/src/app/settings/billing/tier-art.tsx @@ -0,0 +1,69 @@ +import automationArt from '@/assets/tiers/feature-automation.webp' +import connectArt from '@/assets/tiers/feature-connect.webp' +import memoryArt from '@/assets/tiers/feature-memory.webp' +import sandboxArt from '@/assets/tiers/feature-sandbox.webp' +import { cn } from '@/lib/utils' + +// Reproduces the portal's tier-card hero treatment at thumbnail size: each webp sits +// over a solid Nous-blue well and blends into it. This blue well is the ONLY place +// Nous blue appears in the billing page — everything else stays on the app's own tokens. +const NOUS_BLUE = '#0000f2' + +const BLEND_CLASS = { + lighten: 'mix-blend-lighten', + normal: '', + screen: 'mix-blend-screen' +} as const + +type TierBlend = keyof typeof BLEND_CLASS + +interface TierArtSpec { + blend: TierBlend + src: string +} + +// Keyed by lowercase tier NAME, not tier_id: real tier_ids are Prisma cuids that +// differ per environment, while names are stable. `free`/`starter` share the +// entry-tier art. An unknown name resolves to null → the card renders text-only. +const TIER_ART: Record = { + free: { blend: 'screen', src: connectArt }, + plus: { blend: 'screen', src: memoryArt }, + starter: { blend: 'screen', src: connectArt }, + super: { blend: 'lighten', src: automationArt }, + ultra: { blend: 'normal', src: sandboxArt } +} + +export function resolveTierArt(tierName?: null | string): null | TierArtSpec { + if (!tierName) { + return null + } + + return TIER_ART[tierName.trim().toLowerCase()] ?? null +} + +/** + * Small rounded thumbnail (~40px) rendering the tier art over a Nous-blue well. + * Returns null for unknown tiers so the caller lays out a text-only card without + * reserving empty art space. Imported via vite static imports so the URLs resolve + * under a packaged `file://` origin with webSecurity on. + */ +export function TierArt({ className, name, size = 40 }: { className?: string; name?: null | string; size?: number }) { + const art = resolveTierArt(name) + + if (!art) { + return null + } + + return ( +
+ +
+ ) +} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index 90bb728990d9..710ef3ed8c13 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -62,15 +62,14 @@ describe('deriveBillingView', () => { expect(view.status).toBe('normal') expect(view.summary).toContainEqual({ label: 'Balance', value: '$996.47' }) expect(view.summary).toContainEqual({ label: 'Plan', value: 'Ultra · $200/mo' }) - const buyCredits = view.accountRows.find(row => row.id === 'buy_credits') - - expect(buyCredits?.description).toBe( + expect(view.topupRow?.description).toBe( "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page." ) - expect(buyCredits?.chips).toBeUndefined() - expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({ + expect(view.topupRow?.chips).toBeUndefined() + expect(view.refillRow).toMatchObject({ action: { label: 'Manage' }, - caption: 'Refill $10 when balance falls below $5', + description: 'Charges $10 automatically when your balance falls below $5.', + manageInApp: true, pill: { label: 'Enabled', tone: 'primary' } }) expect(view.usageRows.map(row => row.id)).toEqual(['subscription_credits', 'topup_credits', 'monthly_cap']) @@ -80,15 +79,9 @@ describe('deriveBillingView', () => { const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState)) expect(view.status).toBe('normal') - expect(view.accountRows.find(row => row.id === 'payment_method')?.value).toBe('Visa •••• 4242 - subscription card') - expect(view.accountRows.find(row => row.id === 'buy_credits')?.chips?.map(chip => chip.label)).toEqual([ - '$25', - '$50', - '$100' - ]) - expect(view.accountRows.find(row => row.id === 'subscription')?.action?.url).toBe( - 'https://portal.nousresearch.com/manage-subscription?org_id=org_123' - ) + expect(view.paymentRow?.value).toBe('Visa •••• 4242 - subscription card') + expect(view.topupRow?.chips?.map(chip => chip.label)).toEqual(['$25', '$50', '$100']) + expect(view.plan?.link?.url).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123') expect(view.usageRows.find(row => row.id === 'subscription_credits')).toMatchObject({ bar: { value: 0.4 }, value: '$40 of $100 left' @@ -107,11 +100,9 @@ describe('deriveBillingView', () => { okSubscription(todaySubscriptionState) ) - const autoReload = view.accountRows.find(row => row.id === 'auto_reload') - - expect(autoReload?.caption).toContain('Mastercard ••4444') - expect(autoReload?.caption).toContain('reconcile') - expect(autoReload?.action).toEqual({ + expect(view.refillRow?.caption).toContain('Mastercard ••4444') + expect(view.refillRow?.caption).toContain('reconcile') + expect(view.refillRow?.action).toEqual({ label: 'Reconcile ↗', url: 'https://portal.nousresearch.com/billing' }) @@ -129,17 +120,30 @@ describe('deriveBillingView', () => { okSubscription(todaySubscriptionState) ) - const autoReload = view.accountRows.find(row => row.id === 'auto_reload') + expect(view.refillRow?.caption).toContain('a different card') + expect(view.refillRow?.caption).not.toContain('null') + expect(view.refillRow?.action?.url).toBe('https://portal.nousresearch.com/billing') + }) - expect(autoReload?.caption).toContain('a different card') - expect(autoReload?.caption).not.toContain('null') - expect(autoReload?.action?.url).toBe('https://portal.nousresearch.com/billing') + it('renders the normal enabled auto-refill row when the card is null (no crash)', () => { + // The gateway emits auto_reload.card: null for a missing/unknown-kind card. + const view = deriveBillingView( + okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } }), + okSubscription(todaySubscriptionState) + ) + + expect(view.refillRow).toMatchObject({ + action: { label: 'Manage' }, + description: 'Charges $10 automatically when your balance falls below $5.', + manageInApp: true, + pill: { label: 'Enabled', tone: 'primary' } + }) }) it('keeps buy credit controls visible but disabled when no card is on file', () => { const fixture = billingDevFixtures['no-card'] const view = deriveBillingView(fixture.billing, fixture.subscription) - const buyCredits = view.accountRows.find(row => row.id === 'buy_credits') + const buyCredits = view.topupRow expect(buyCredits).toMatchObject({ action: { disabled: true, label: 'Buy' }, @@ -158,7 +162,9 @@ describe('deriveBillingView', () => { expect(view.notice).toMatchObject({ title: 'Connect your Nous account' }) - expect(view.accountRows).toEqual([]) + expect(view.paymentRow).toBeUndefined() + expect(view.topupRow).toBeUndefined() + expect(view.refillRow).toBeUndefined() expect(view.usageRows).toEqual([]) }) @@ -170,115 +176,25 @@ describe('deriveBillingView', () => { expect(view.notice).toMatchObject({ title: 'Billing endpoint unavailable' }) - expect(view.accountRows).toEqual([]) + expect(view.paymentRow).toBeUndefined() + expect(view.topupRow).toBeUndefined() + expect(view.refillRow).toBeUndefined() }) - it('keeps subscription unavailable as a row-level degradation when billing.state succeeds', () => { + it('keeps subscription unavailable as a plan-card degradation with a live portal link', () => { const view = deriveBillingView(okBilling(todayBillingState), endpointUnavailableSubscription) - const subscription = view.accountRows.find(row => row.id === 'subscription') expect(view.status).toBe('normal') - expect(subscription).toMatchObject({ + expect(view.plan).toMatchObject({ caption: 'Subscription details are unavailable; opening the portal is still available.', - value: 'Ultra' + tierName: 'Ultra' + }) + expect(view.plan?.action).toBeUndefined() + // The caption promises the portal is still reachable — so the link must exist. + expect(view.plan?.link).toMatchObject({ + label: 'Adjust plan ↗', + url: 'https://portal.nousresearch.com/manage-subscription' }) - }) - - it('free with catalog: tier chips render inline and open the portal', () => { - const view = deriveBillingView( - okBilling(todayBillingState), - okSubscription({ - ...todaySubscriptionState, - context: 'personal', - current: null, - tiers: [ - { - dollars_per_month_display: '$0', - is_current: false, - is_enabled: true, - monthly_credits: '0', - name: 'Free', - tier_id: 'free', - tier_order: 0 - }, - { - dollars_per_month_display: '$40', - is_current: false, - is_enabled: true, - monthly_credits: '3000', - name: 'Ultra', - tier_id: 'ultra', - tier_order: 2 - }, - { - dollars_per_month_display: '$20', - is_current: false, - is_enabled: true, - monthly_credits: '1000', - name: 'Plus', - tier_id: 'plus', - tier_order: 1 - } - ] - }) - ) - - const subscription = view.accountRows.find(row => row.id === 'subscription') - - expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.') - expect(subscription?.chips).toEqual([ - { disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: `${subscription?.action?.url}&plan=plus` }, - { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` } - ]) - }) - - it('subscriber who can change plans: current tier marked inert, others open the portal', () => { - const view = deriveBillingView( - okBilling(todayBillingState), - okSubscription({ - ...todaySubscriptionState, - context: 'personal', - tiers: [ - { - dollars_per_month_display: '$20', - is_current: true, - is_enabled: true, - monthly_credits: '1000', - name: 'Plus', - tier_id: 'plus', - tier_order: 1 - }, - { - dollars_per_month_display: '$40', - is_current: false, - is_enabled: true, - monthly_credits: '3000', - name: 'Ultra', - tier_id: 'ultra', - tier_order: 2 - } - ] - }) - ) - - const subscription = view.accountRows.find(row => row.id === 'subscription') - - expect(subscription?.chips).toEqual([ - { disabled: true, label: '✓ Plus · $20/mo · $1,000 credits/mo' }, - { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` } - ]) - }) - - it('members and team contexts get no tier chips', () => { - const member = deriveBillingView( - okBilling(todayBillingState), - okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' }) - ) - - const team = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState)) - - expect(member.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined() - expect(team.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined() }) it('clamps overdrawn subscription credits to $0 and names the overage', () => { @@ -380,6 +296,333 @@ describe('deriveBillingView', () => { }) }) +describe('derivePlanCard (current-plan card)', () => { + it('offers an in-app "View plans" button for a free personal account that can change plans', () => { + const fixture = billingDevFixtures['free-personal'] + const view = deriveBillingView(fixture.billing, fixture.subscription) + + expect(view.plan).toMatchObject({ action: { label: 'View plans' }, tierName: 'Free' }) + expect(view.plan?.link).toBeUndefined() + }) + + it('offers an in-app "Change plan" button for a personal subscriber', () => { + const fixture = billingDevFixtures['subscriber-personal'] + const view = deriveBillingView(fixture.billing, fixture.subscription) + + expect(view.plan).toMatchObject({ action: { label: 'Change plan' }, price: '$20', tierName: 'Plus' }) + expect(view.plan?.link).toBeUndefined() + }) + + it('gives teams a portal escape hatch and no in-app button', () => { + // todaySubscriptionState is context: 'team'. + const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState)) + + expect(view.plan?.action).toBeUndefined() + expect(view.plan?.link).toMatchObject({ + label: 'Adjust plan ↗', + url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5' + }) + }) + + it('gives non-changing members a portal link but no in-app button', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' }) + ) + + expect(view.plan?.action).toBeUndefined() + expect(view.plan?.link).toMatchObject({ + label: 'Adjust plan ↗', + url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5' + }) + }) + + it('withholds the in-app button (no dead click) and offers the portal link when nothing is actionable', () => { + // A subscriber whose only enabled tier is the one they are already on: the grid + // would show a single inert card, so the "Change plan" button must not appear. + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + can_change_plan: true, + context: 'personal', + current: { ...todaySubscriptionState.current, tier_id: 'solo', tier_name: 'Solo' }, + tiers: [ + { + dollars_per_month_display: '$10', + is_current: true, + is_enabled: true, + monthly_credits: '10', + name: 'Solo', + tier_id: 'solo', + tier_order: 0 + } + ] + }) + ) + + expect(view.tiers.map(tier => tier.state)).toEqual(['current']) + expect(view.plan?.action).toBeUndefined() + expect(view.plan?.link?.label).toBe('Adjust plan ↗') + }) + + it('gives a top-tier subscriber a portal link, not a dead in-app button', () => { + // The subscriber is on the highest enabled tier: the grid holds only downgrades + + // current — nothing to upgrade to — so the card must fall back to the portal link + // rather than open a grid with no enabled action. + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + can_change_plan: true, + context: 'personal', + current: { ...todaySubscriptionState.current, tier_id: 'top', tier_name: 'Ultra' }, + tiers: [ + { + dollars_per_month_display: '$0', + is_current: false, + is_enabled: true, + monthly_credits: '0.1', + name: 'Free', + tier_id: 't_free', + tier_order: 0 + }, + { + dollars_per_month_display: '$200', + is_current: true, + is_enabled: true, + monthly_credits: '220', + name: 'Ultra', + tier_id: 'top', + tier_order: 1 + } + ] + }) + ) + + expect(view.tiers.some(tier => tier.state === 'upgrade')).toBe(false) + expect(view.plan?.action).toBeUndefined() + expect(view.plan?.link?.label).toBe('Adjust plan ↗') + }) + + it('offers only the portal link when the tier catalog is empty', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + can_change_plan: true, + context: 'personal', + current: null, + tiers: [] + }) + ) + + expect(view.tiers).toEqual([]) + expect(view.plan?.action).toBeUndefined() + expect(view.plan?.link?.label).toBe('Adjust plan ↗') + }) +}) + +describe('derivePlanTiers (plans grid)', () => { + it('marks the current tier, upgrades, and disabled downgrades for a subscriber', () => { + const fixture = billingDevFixtures['subscriber-personal'] + const view = deriveBillingView(fixture.billing, fixture.subscription) + const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier])) + + expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Plus', 'Super', 'Ultra']) + expect(byName.Free).toMatchObject({ + disabledCaption: 'Downgrades are moving in-app — coming soon.', + state: 'downgrade' + }) + expect('action' in byName.Free).toBe(false) + expect(byName.Plus.state).toBe('current') + expect('action' in byName.Plus).toBe(false) + expect(byName.Super).toMatchObject({ + action: { + url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_plus&plan=cltier222super222personal' + }, + creditsDisplay: '$110 credits/mo', + state: 'upgrade' + }) + expect(byName.Ultra.state).toBe('upgrade') + }) + + it('marks the free/lowest tier current (inert) and every paid tier an upgrade when there is no subscription', () => { + const fixture = billingDevFixtures['free-personal'] + const view = deriveBillingView(fixture.billing, fixture.subscription) + const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier])) + + // No "subscribe to Free" — the $0 tier is the current plan, not a choice. + expect(view.tiers.map(tier => tier.state)).toEqual(['current', 'upgrade', 'upgrade', 'upgrade']) + expect('action' in byName.Free).toBe(false) + expect('disabledCaption' in byName.Free).toBe(false) + // No downgrade state can exist without a subscription. + expect(view.tiers.some(tier => tier.state === 'downgrade')).toBe(false) + expect(byName.Plus).toMatchObject({ + action: { + url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_free&plan=cltier111plus1111personal' + } + }) + }) + + it('still lists a tier whose name has no art mapping (text-only card, no layout break)', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + context: 'personal', + current: null, + tiers: [ + { + dollars_per_month_display: '$0', + is_current: false, + is_enabled: true, + monthly_credits: '0.1', + name: 'Free', + tier_id: 'cltier_free_0000', + tier_order: 0 + }, + { + dollars_per_month_display: '$5', + is_current: false, + is_enabled: true, + monthly_credits: '5', + name: 'Mystery', + tier_id: 'cltier_mystery_0000', + tier_order: 5 + } + ] + }) + ) + + // The unknown-named paid tier still lists (art resolves to null → text-only). + expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Mystery']) + expect(view.tiers.find(tier => tier.name === 'Mystery')?.state).toBe('upgrade') + }) + + it('keeps a grandfathered (is_enabled:false) CURRENT tier inert and orders downgrades against it', () => { + // NAS marks a grandfathered current tier is_enabled:false. It must still appear + // (inert "Current plan") and define the order boundary — lower enabled tiers are + // downgrades, higher ones are Choose. + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + context: 'personal', + current: { ...todaySubscriptionState.current, tier_id: 'legacy_mid', tier_name: 'Legacy' }, + tiers: [ + { + dollars_per_month_display: '$5', + is_current: false, + is_enabled: true, + monthly_credits: '5', + name: 'Basic', + tier_id: 'basic', + tier_order: 0 + }, + { + dollars_per_month_display: '$15', + is_current: true, + is_enabled: false, + monthly_credits: '15', + name: 'Legacy', + tier_id: 'legacy_mid', + tier_order: 1 + }, + { + dollars_per_month_display: '$40', + is_current: false, + is_enabled: true, + monthly_credits: '40', + name: 'Ultra', + tier_id: 'ultra', + tier_order: 2 + } + ] + }) + ) + + const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier])) + + expect(view.tiers.map(tier => tier.name)).toEqual(['Basic', 'Legacy', 'Ultra']) + expect(byName.Legacy.state).toBe('current') + expect('action' in byName.Legacy).toBe(false) + expect(byName.Basic.state).toBe('downgrade') + expect('action' in byName.Basic).toBe(false) + expect(byName.Ultra).toMatchObject({ action: { label: 'Choose ↗' }, state: 'upgrade' }) + }) + + it('backs Choose URLs with billing.portal_url (org_id + plan intact) when the subscription has no portal_url', () => { + const view = deriveBillingView( + okBilling({ ...todayBillingState, portal_url: 'https://billing.example.com/x' }), + okSubscription({ + ...todaySubscriptionState, + can_change_plan: true, + context: 'personal', + current: null, + portal_url: null, + tiers: [ + { + dollars_per_month_display: '$0', + is_current: false, + is_enabled: true, + monthly_credits: '0.1', + name: 'Free', + tier_id: 'free0', + tier_order: 0 + }, + { + dollars_per_month_display: '$20', + is_current: false, + is_enabled: true, + monthly_credits: '22', + name: 'Plus', + tier_id: 'plus1', + tier_order: 1 + } + ] + }) + ) + + expect(view.tiers.find(tier => tier.name === 'Plus')).toMatchObject({ + action: { url: 'https://billing.example.com/manage-subscription?org_id=sid-5&plan=plus1' } + }) + }) + + it('drops grandfathered (is_enabled: false) tiers from the grid', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + context: 'personal', + current: null, + tiers: [ + { + dollars_per_month_display: '$20', + is_current: false, + is_enabled: true, + monthly_credits: '22', + name: 'Plus', + tier_id: 'plus', + tier_order: 1 + }, + { + dollars_per_month_display: '$9', + is_current: false, + is_enabled: false, + monthly_credits: '9', + name: 'Legacy', + tier_id: 'legacy', + tier_order: 1 + } + ] + }) + ) + + expect(view.tiers.map(tier => tier.name)).toEqual(['Plus']) + }) +}) + describe('buildManageSubscriptionUrl', () => { it('mirrors the TUI manage-subscription URL construction', () => { expect( @@ -390,26 +633,27 @@ describe('buildManageSubscriptionUrl', () => { ).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123') }) - it('appends the tier as a plan query param when provided', () => { + it('appends plan= when a tier is chosen', () => { expect( buildManageSubscriptionUrl( - { - org_id: 'org_123', - portal_url: 'https://portal.nousresearch.com/billing' - }, - undefined, - 'ultra' + { org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' }, + null, + 'tier_abc' ) - ).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=ultra') + ).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=tier_abc') }) - it('omits the plan param when no tierId is given', () => { + it('omits the plan param when no tier is given', () => { expect( - buildManageSubscriptionUrl( - { org_id: null, portal_url: 'https://portal.nousresearch.com/billing' }, - undefined, - undefined - ) - ).toBe('https://portal.nousresearch.com/manage-subscription') + buildManageSubscriptionUrl({ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' }, null) + ).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123') + }) + + it('applies org_id + plan to the hard-coded portal fallback when no portal_url resolves', () => { + // Regression: the fallback must be the last-resort ORIGIN, not a bare return that + // silently drops org_id/plan. + expect(buildManageSubscriptionUrl({ org_id: 'org_z', portal_url: null }, null, 'tier_q')).toBe( + 'https://portal.nousresearch.com/manage-subscription?org_id=org_z&plan=tier_q' + ) }) }) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 8fbc481b1b8f..d55af57c7fcf 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -5,7 +5,12 @@ import { fmtDate } from '@/lib/time' import type { BillingRefusal, BillingResult } from './api' import { useBillingApi } from './api' import { resolveRefusal } from './errors' -import type { BillingStateResponse, SubscriptionStateResponse, UsageModelData } from './types' +import type { + BillingStateResponse, + SubscriptionStateResponse, + SubscriptionTierOption, + UsageModelData +} from './types' export const EMPTY_BILLING_VALUE = '—' export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing' @@ -50,7 +55,9 @@ export interface BillingAccountRowView { caption?: string chips?: BillingChipView[] description: string - id: 'auto_reload' | 'buy_credits' | 'payment_method' | 'subscription' + id: 'auto_reload' | 'buy_credits' | 'payment_method' + /** The auto-refill row that edits its amounts in place (canonical-card enabled). */ + manageInApp?: true pill?: { label: string tone: 'muted' | 'primary' @@ -60,6 +67,36 @@ export interface BillingAccountRowView { value?: string } +/** + * The current-plan summary that replaces the old subscription row. Carries EITHER + * one in-app `action` (View plans / Change plan) OR a portal `link` ("Adjust plan + * ↗"), never both — a discriminated pair so consumers don't guard for the impossible + * "both present" / "neither present" cases. + */ +export type BillingPlanCardView = { + caption: string + price?: string + tierName: string +} & ({ action: { label: string }; link?: undefined } | { action?: undefined; link: { label: string; url: string } }) + +interface BillingPlanTierBase { + creditsDisplay?: string + name: string + priceDisplay: string + tierId: string +} + +/** + * One card in the `bview=plans` grid, discriminated by `state`: an `upgrade` always + * carries its portal `action`; a `downgrade` always carries a `disabledCaption` + * (ticket 11 wires these in-app); `current` is inert. The union lets consumers read + * `action` / `disabledCaption` without defensive `?.`. + */ +export type BillingPlanTierView = + | (BillingPlanTierBase & { state: 'current' }) + | (BillingPlanTierBase & { disabledCaption: string; state: 'downgrade' }) + | (BillingPlanTierBase & { action: { label: string; url: string }; state: 'upgrade' }) + export interface BillingUsageRowView { bar?: { label: string @@ -75,10 +112,19 @@ export interface BillingUsageRowView { } export interface BillingView { - accountRows: BillingAccountRowView[] notice?: BillingNoticeView + /** Payment section row. Absent outside the normal (logged-in) state. */ + paymentRow?: BillingAccountRowView + /** Current-plan card (Plan section). Absent until billing.state resolves. */ + plan?: BillingPlanCardView + /** Automatic-refill section row. */ + refillRow?: BillingAccountRowView status: 'loading' | 'logged_out' | 'normal' | 'refusal' summary: BillingSummaryItemView[] + /** Live tier catalog for the plans sub-view (empty when unavailable). */ + tiers: BillingPlanTierView[] + /** One-time top-up section row. */ + topupRow?: BillingAccountRowView usageRows: BillingUsageRowView[] } @@ -110,19 +156,19 @@ export function deriveBillingView( ): BillingView { if (!stateResult) { return { - accountRows: [], status: 'loading', summary: emptySummary(), + tiers: [], usageRows: [] } } if (!stateResult.ok) { return { - accountRows: [], notice: refusalNotice(stateResult.refusal), status: 'refusal', summary: emptySummary(), + tiers: [], usageRows: [] } } @@ -132,7 +178,6 @@ export function deriveBillingView( if (!billing.logged_in || subscription?.logged_in === false) { return { - accountRows: [], notice: { action: { label: 'Open portal ↗', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL }, message: 'Run /portal in the TUI or open the Nous portal to connect your account.', @@ -140,12 +185,22 @@ export function deriveBillingView( }, status: 'logged_out', summary: emptySummary(), + tiers: [], usageRows: [] } } + // One "can change plans in-app" verdict, shared by the plan card (button vs portal + // link) and the grid (whether upgrade tiles are actionable) so the invariant lives + // in one place. + const capable = plansCapable(subscription, subscriptionResult) + const tiers = derivePlanTiers(subscription, billing.portal_url, capable) + return { - accountRows: deriveAccountRows(billing, subscription, subscriptionResult), + notice: undefined, + paymentRow: paymentMethodRow(billing), + plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable), + refillRow: autoReloadRow(billing), status: 'normal', summary: [ { label: 'Balance', value: displayBalance(billing) }, @@ -156,6 +211,8 @@ export function deriveBillingView( value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE } ], + tiers, + topupRow: buyCreditsRow(billing), usageRows: deriveUsageRows(billing, subscription) } } @@ -163,9 +220,14 @@ export function deriveBillingView( export function buildManageSubscriptionUrl( subscription?: null | Pick, fallbackPortalUrl?: null | string, - tierId?: string + // Optional tier to pre-select on the portal, appended as `plan=` + // (validated server-side by the NAS reader, draft #748). + tierId?: null | string ): string { - const portalUrls = [subscription?.portal_url, fallbackPortalUrl].filter( + // The hard-coded portal is the LAST-RESORT origin, not a bare early return: + // org_id / plan must still be applied to it so a null portal_url never silently + // strips the params that route the user to the right org + pre-selected tier. + const portalUrls = [subscription?.portal_url, fallbackPortalUrl, FALLBACK_PORTAL_BILLING_URL].filter( (url): url is string => typeof url === 'string' && url.length > 0 ) @@ -243,17 +305,153 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView { } } -function deriveAccountRows( +// The active tier from the UNFILTERED catalog — a grandfathered current tier is +// is_enabled:false, so it must still resolve here (by is_current or matching id). +function findCurrentTier(subscription: null | SubscriptionStateResponse): SubscriptionTierOption | undefined { + const current = subscription?.current + + return subscription?.tiers?.find(tier => tier.is_current || tier.tier_id === current?.tier_id) +} + +// Whether this account can change plans in-app: a personal (non-team) subscription +// the server says the user can change, whose payload actually loaded. +function plansCapable( + subscription: null | SubscriptionStateResponse, + subscriptionResult: BillingResult | undefined +): boolean { + if (!subscription || (subscriptionResult && !subscriptionResult.ok)) { + return false + } + + return subscription.context !== 'team' && Boolean(subscription.can_change_plan) +} + +// Monthly credits are dollars; NAS sends a bare decimal string. Never render a +// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips). +function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefined { + const credits = Number((monthlyCredits ?? '').replace(/,/g, '')) + + return Number.isFinite(credits) && credits > 0 ? `$${credits.toLocaleString('en-US')} credits/mo` : undefined +} + +/** + * The current-plan card. It offers the in-app "View plans" / "Change plan" button + * ONLY when the account is plans-capable AND the grid has an actual UPGRADE to offer + * — a top-tier subscriber (only downgrades / current below them) would otherwise open + * a grid with nothing to do. In every no-button case (teams, non-changers, refused + * subscription, top tier, empty catalog) the card ALWAYS carries the portal + * escape-hatch link so the user is never stranded on an info-only card. + */ +function derivePlanCard( billing: BillingStateResponse, subscription: null | SubscriptionStateResponse, - subscriptionResult?: BillingResult -): BillingAccountRowView[] { - return [ - paymentMethodRow(billing), - subscriptionRow(billing, subscription, subscriptionResult), - buyCreditsRow(billing), - autoReloadRow(billing) - ] + subscriptionResult: BillingResult | undefined, + tiers: BillingPlanTierView[], + capable: boolean +): BillingPlanCardView { + const current = subscription?.current + const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free' + // Price resolves against the UNFILTERED catalog so a grandfathered current tier + // still shows its price. + const price = findCurrentTier(subscription)?.dollars_per_month_display + const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at) + const unavailable = subscriptionResult ? !subscriptionResult.ok : false + + const caption = unavailable + ? 'Subscription details are unavailable; opening the portal is still available.' + : current + ? `Renews ${renewal}` + : 'No active subscription — paid models draw down top-up credits.' + + // Actionable = a tile the user can act on. At ticket 09 only upgrades carry an + // `action` (downgrades are inert), so "has an action" ⟺ an upgrade exists. (The + // discriminated union means `'action' in tier` rather than `tier.action != null`.) + const hasActionableTier = tiers.some(tier => 'action' in tier) + + if (capable && hasActionableTier) { + return { action: { label: current ? 'Change plan' : 'View plans' }, caption, price, tierName } + } + + return { + caption, + // No in-app action → always hand off to the portal so the user isn't stranded. + link: { + label: 'Adjust plan ↗', + url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url) + }, + price, + tierName + } +} + +/** + * The plans-grid catalog. Each card's state depends on its order relative to the + * current tier: current = inert marker; higher = "Choose ↗" opening the portal with + * the tier pre-selected; lower = disabled with a caption noting downgrades are + * moving in-app (ticket 11 wires the gateway pending-change flow). With no active + * subscription the lowest-order ($0 / free) tier stands in as the current plan, so + * there is no "subscribe to Free" upgrade and no downgrade state. + * + * Empty unless `capable`: only a plans-capable account gets actionable tiles, and the + * plan card / deep-link gate on the same verdict — so the grid never mints an + * upgrade action nobody may take. `fallbackPortalUrl` (billing.portal_url) backs the + * Choose URLs when the subscription payload has no portal_url, so org_id + plan are + * never dropped. + */ +function derivePlanTiers( + subscription: null | SubscriptionStateResponse, + fallbackPortalUrl: null | string, + capable: boolean +): BillingPlanTierView[] { + if (!capable || !subscription) { + return [] + } + + const allTiers = subscription.tiers ?? [] + const current = subscription.current + const explicitCurrent = findCurrentTier(subscription) + + // The grid shows the enabled catalog plus the grandfathered current tier (so it + // still renders as the inert "Current plan" card), sorted low→high. + const gridTiers = allTiers + .filter(tier => tier.is_enabled || tier.tier_id === explicitCurrent?.tier_id) + .slice() + .sort((a, b) => a.tier_order - b.tier_order) + + if (gridTiers.length === 0) { + return [] + } + + // No active subscription → the lowest-order ($0 / free) tier stands in as the + // current plan: inert, never a "subscribe to Free" upgrade, and (being lowest) + // never leaving room for a downgrade. + const currentTier = explicitCurrent ?? (current == null ? gridTiers[0] : undefined) + const currentOrder = currentTier?.tier_order + const manageBase = subscription.portal_url ?? fallbackPortalUrl + + return gridTiers.map((tier): BillingPlanTierView => { + const base: BillingPlanTierBase = { + creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits), + name: tier.name, + priceDisplay: tier.dollars_per_month_display, + tierId: tier.tier_id + } + + if (currentTier && tier.tier_id === currentTier.tier_id) { + return { ...base, state: 'current' } + } + + // Downgrade = strictly below the current tier's order. + if (currentOrder != null && tier.tier_order < currentOrder) { + return { ...base, disabledCaption: 'Downgrades are moving in-app — coming soon.', state: 'downgrade' } + } + + return { + ...base, + action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) }, + state: 'upgrade' + } + }) } function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView { @@ -279,71 +477,6 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView } } -/** - * Tier catalog as chips for accounts that can change plans; the current plan is - * inert, every other opens the portal where the change/start happens, deep-linked - * to that tier via `?plan=`. - */ -function subscriptionTierChips( - subscription: null | SubscriptionStateResponse, - fallbackPortalUrl?: null | string -): BillingChipView[] | undefined { - // Teams have no personal subscription to sell into. - if (!subscription?.can_change_plan || subscription.context === 'team') { - return undefined - } - - const tiers = (subscription.tiers ?? []) - .filter(tier => tier.is_enabled && tier.tier_order > 0) - .sort((a, b) => a.tier_order - b.tier_order) - - if (tiers.length === 0) { - return undefined - } - - return tiers.map(tier => { - // Monthly credits are dollars; NAS sends a bare decimal string. - const credits = Number((tier.monthly_credits ?? '').replace(/,/g, '')) - const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : '' - const label = `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}` - - return tier.is_current - ? { disabled: true, label: `✓ ${label}` } - : { disabled: false, label, url: buildManageSubscriptionUrl(subscription, fallbackPortalUrl, tier.tier_id) } - }) -} - -function subscriptionRow( - billing: BillingStateResponse, - subscription: null | SubscriptionStateResponse, - subscriptionResult?: BillingResult -): BillingAccountRowView { - const fallbackPortalUrl = subscription?.portal_url ?? billing.portal_url - const manageUrl = buildManageSubscriptionUrl(subscription, fallbackPortalUrl) - const current = subscription?.current - const fallbackPlan = billing.usage?.plan_name ?? EMPTY_BILLING_VALUE - const value = current?.tier_name ?? fallbackPlan - const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at) - const unavailable = subscriptionResult && !subscriptionResult.ok - const chips = subscriptionTierChips(subscription, fallbackPortalUrl) - - return { - action: { label: 'Adjust plan ↗', url: manageUrl }, - caption: unavailable - ? 'Subscription details are unavailable; opening the portal is still available.' - : `Renews ${renewal}`, - chips, - description: - !current && chips - ? 'Paid models need a subscription — pick a plan to start it on the portal.' - : 'Review your plan and change it from the billing portal.', - id: 'subscription', - secondaryPill: 'opens portal', - title: 'Subscription', - value - } -} - function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView { if (!billing.card) { return { @@ -355,7 +488,7 @@ function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView { portalUrl: billing.portal_url ?? undefined }).message, id: 'buy_credits', - title: 'Buy credits' + title: 'Buy credits now' } } @@ -365,19 +498,24 @@ function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView { return { description: disabledReason, id: 'buy_credits', - title: 'Buy credits' + title: 'Buy credits now' } } return { action: { disabled: true, label: 'Buy' }, chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })), - description: 'Add top-up credits for agent runs outside your plan.', + description: 'A single charge on your card, added to your balance today.', id: 'buy_credits', - title: 'Buy credits' + title: 'Buy credits now' } } +// The generic first sentence shared by the off / absent / divergent states, +// where the concrete amounts aren't the headline. The configured state overrides +// this with the disambiguating "Charges $X … below $Y." sentence (spec §8). +const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below your threshold.' + function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView { const autoReload = billing.auto_reload @@ -385,24 +523,26 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView { return { action: { disabled: true, label: 'Manage' }, caption: 'Manage auto-refill from the portal.', - description: 'Keep your balance topped up when it drops below your threshold.', + description: AUTO_REFILL_GENERIC, id: 'auto_reload', pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' }, - title: 'Auto-refill' + title: 'Refill when low' } } if (!autoReload.enabled) { return { caption: 'Turn on auto-refill from the portal', - description: 'Keep your balance topped up when it drops below your threshold.', + description: AUTO_REFILL_GENERIC, id: 'auto_reload', pill: { label: 'Off', tone: 'muted' }, - title: 'Auto-refill' + title: 'Refill when low' } } - if (autoReload.card.kind === 'distinct') { + // A null card (gateway emits it for a missing/unknown-kind card) falls through to + // the default enabled path below — the same treatment as a canonical card. + if (autoReload.card?.kind === 'distinct') { const { brand, last4 } = autoReload.card const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card' const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL @@ -410,22 +550,27 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView { return { action: { label: 'Reconcile ↗', url: portalUrl }, caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`, - description: 'Keep your balance topped up when it drops below your threshold.', + description: AUTO_REFILL_GENERIC, id: 'auto_reload', pill: { label: 'Enabled', tone: 'primary' }, - title: 'Auto-refill' + title: 'Refill when low' } } + const reloadTo = autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd) + const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd) + return { action: { label: 'Manage' }, - caption: `Refill ${autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd)} when balance falls below ${ - autoReload.threshold_display || formatMoney(autoReload.threshold_usd) - }`, - description: 'Keep your balance topped up when it drops below your threshold.', + // Numbers live in the first sentence (spec §8); the swap region below carries + // the editable fields, so no redundant caption here. + description: `Charges ${reloadTo} automatically when your balance falls below ${threshold}.`, id: 'auto_reload', + // The only row that edits in place — AutoReloadRow keys its swap layout off this + // flag rather than sniffing the action label. + manageInApp: true, pill: { label: 'Enabled', tone: 'primary' }, - title: 'Auto-refill' + title: 'Refill when low' } } @@ -519,8 +664,7 @@ function displayPlan(subscription: null | SubscriptionStateResponse, usage?: Usa return EMPTY_BILLING_VALUE } - const currentTier = subscription?.tiers.find(t => t.is_current || t.tier_id === current?.tier_id) - const price = currentTier?.dollars_per_month_display + const price = findCurrentTier(subscription)?.dollars_per_month_display return price ? `${tier} · ${price}/mo` : tier } diff --git a/apps/desktop/src/assets/tiers/feature-automation.webp b/apps/desktop/src/assets/tiers/feature-automation.webp new file mode 100644 index 0000000000000000000000000000000000000000..7db7c551bbdceb57ca6b06c15195b5be0cd24c33 GIT binary patch literal 5410 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 MzOAd2gCGrn0E4=rY5)KL literal 0 HcmV?d00001 diff --git a/apps/desktop/src/assets/tiers/feature-connect.webp b/apps/desktop/src/assets/tiers/feature-connect.webp new file mode 100644 index 0000000000000000000000000000000000000000..36941196bbdb7fd86b2b56c4d5085511d6406383 GIT binary patch literal 7670 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 literal 0 HcmV?d00001 diff --git a/apps/desktop/src/assets/tiers/feature-memory.webp b/apps/desktop/src/assets/tiers/feature-memory.webp new file mode 100644 index 0000000000000000000000000000000000000000..b70bf6a2476e906427f74f546cea2ebe38473a76 GIT binary patch literal 5214 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 YOvY!^uDfWvMqJM#Y-wtUK1qN80MwZ#Hvj+t literal 0 HcmV?d00001 diff --git a/apps/desktop/src/assets/tiers/feature-sandbox.webp b/apps/desktop/src/assets/tiers/feature-sandbox.webp new file mode 100644 index 0000000000000000000000000000000000000000..08a9d6e3f26dcc1abcbfb451f888f1ec199bd610 GIT binary patch literal 7854 zcmV;f9#P>^Nk&Gd9smGWMM6+kP&go(9smF^Q~;d;Du4ih06tM1ibW%#p&fniFgOJS zv;bb@3@MsT9JAfuvvPgfPg!{?bBCcXM804iI3A^Z!v5j_>iJh?>)$g0uu{ zgTFO*5WDs3*R;?=e2}~rep3|WR+viGs}jFPcv0a?uexQ`jkA5!(QFloT#sb6(X7{< zv{I+SIh*MF|BkLp%GRNg=5;Ivv(i|?{`&vw&-&~5jr7sC$2lMD*X#bK+K8vq*OG~Q zIG%;q+v?K#998yVX$(jNz+Y6ZducC4hlq+-JgIlwaLef~m-+ddZ`PMVB;{aM@CG8$ z5dP%6Tc1}KDHK0wQGA6e2EFAFyD%!Jf@CtAEB`V%7r_El#u6F1Oln(=DVcB~I-f}t zB|*T`WB|G+6boR?bu#p@QOU);z(YE8QVfq*!n*U}hLUGU!$yix2ji4pmE>p1-5dEi zkUxXgOOukAgj(*j&p$cM_B0wMvHYq-JO7>d<5RJ`0092-?(i|635E0{>y8E1L7?z# z{Q6tm@4ZP3UCdGLG-Rii#2xpKo}7 zpH~y+J!9R1QQAuU0u0QP<1q>hY2C!YC^M{x1!>&z?u$Zlr(%o~XlY>EQs3z*Fpe~Y zbR236AaO8qgbG`0V;get#IQHhE|orV%3|A%vf37-p~JmsVoG@C^Z2ZgXsdwj=Qh*_ z+>Bi*&6W!i&E~H9khZsUTvzdHy9rYwp?B!{opYg_3A{=iaoa}s*q!@w0V%fx^*o>K z5ZT1E!%(a=XSi=Q>(5~Eb>b^9oKE^M=VN0zX0HwSI@Bz;wQR*PsotFlPYN=#;au-1 z6#F0)$bIxbFIZb`P@OuIHla^cXrMvua+IB~!CkyQO(z5ku@&jCbI{kyAYI`W!11pOi zu&g))3&%&y{Xw1o>m=y^2Ce;KT#%y-{Xu}w5m`S8IM+nki|9&Vi?h^}dL>OS>J~+6 z*I&6y@qCHM&WDh#=^?piV}etNhHekdN)H1@a>@g5+{;g+!<DJcQ$Atq>Ui~$4idet%zqC^@Y>AgMMw&L+?>-vC=`V6IHSAyK{vG@8U9;!C61y1#?~V9fKwpn8>C*T*15Bt54MN}Q zT`BR_T-7~=#eD{d7uZf&Ys5Cmmi1E0zi>XT`Z-_cW4~^aI$yl4TfPS4&$%JVWg3wG zPGSC=)eQ3KQ546DY3SDV?il_5#Ub=g?!Q|&U+wV+?s@wB^fR`TZV;NenT4u0Rt^%O z@QfAIfP=z0D5g1LLu&{65Hsd)lL3VII>^3ql9#YKJ@M_@~zUE%6^Sa zQAR$*krv#GENa0(%GEYrMlKi z>1{%-+2u*^7w6p}Kg74n5E%z^d5FrrQcC?DHyV1%>KjJ4bnSE4{0I4P!iVd{`&OchZvOluI@W=L|0rY5jB2cH*e`09zm5n6F&QTYh8dY} znYaPWnBPK<#@yX%)D-|aGJRV3!GmZtJPAwQFp#s+_I`5CqUU(}_Y+lxkR>Uw&74=C zWuB`^@`n_I?GT-^lJ%9cy-~q2p-uXK$LXEjreK^rteNy-h6LPzW1}OPPZp>rDvxkT zw@ASuvEAdJNgg{j=Nk~F1`;YSupTi=!Dx?+hmW5x+D9KE>7<-LMf%sNt!KgPnhy<5 z3y0N^o8H`DUyroHvf3;_(sUuqi+XgR3u7FeeKyv$caAvhceTn9L0j$ z9Cn@UpoJrX#{dY;Rj*X5UyQ+iqB)aynijQt5+B3wf7Uak5MR21ec5Z31mON+rqLdo zyO)Fih&Mv$T-T)cZDrAYE`C^WVe*jHQf`g>dAdl^2*Si) zaMvO^|5z6$Uf|h_1)C9B&)fo)VzR zD}SqwyCASA>>9O9IYYc(%-pvip3d85@x9<6-OehDgjXwy93B3ET+J^!2DKJIeF$5& zFNG&0TB82!xbSxkoXkWU*_%!Oj-{h`?eH7YdY;sVaPz2PY5BMcVdyhg3mGmg5P8Ts zILTvLmAhoG6uTAy<%EEq+{wiwBlJ2MIV0bZUZtCF*>YQ)fBcKrluEpL4ICfe+JjDt zPyCA7jZ_X>Vps@yEiJulD^#2?822tCP8uigjh5Ep8MEUz#^AP{Cy~GODxRCcQHq*k zFz4Qq>cUFZe&ya=-t4nz1^yO|M4iF>RB!P`0za6-^X344^MLa=@3E2T5$Ev+Ow@=| zOLs(8&OKk83l6+;PG&oMc%G*}X)4uEaaak^%ad zNUPQBLQ~x`-7Yv%meY9{#yF$sw(aPuLR^*hr;l{o%>!_0g z=H{yr6nrl4oz8YQ)RFFQHrdt>#4`PPQiusRF?OaEI;^u`1Gc$#O@rxO==nyVQSLRf z)UlFF6x3>{-r`M}!tsBPnoMqc91HS}iA?iDG4Ev!oNp?b-pj;O4G}r}X8D;P!z<-q z^NZ3b&$$cse_T;x30v$~ak=phxVQi`cS}H0u2;eo<+j6d0%0ut3dgrR`Yo09hvF8e zC^b!zxo;3Vf)n(3omWUZCK8U29p&YJmUC7kzC#Eq)AB*kj8VMnUNau z!8fhkognn(!S2aXJkOF!`gTX^Xo7Cx+O!QR>bS&uS(sGh^8oTcGLrHtacP#7;_gw zFq>O6a4N_qg>-FSu7_PEx%qj_oVqm(c|Xx0)pE2nhWc71e$=yJP~Vz=I-Z!xsc<%T zO_KoD+&O-2E$kb_gC~x2kBMr}R8Xt)uPq$S(A?HEEJQV*;f=xDp{;Ea&m4QuV~C#+ ziqtuSz%Y?VnfCFVKwnb6w(kRj{f>)nfcvGxoT8t3iyaxjC*fG8vWxjjuoUL*~0-1XTPtG<;sW5@GkO(&D`dXF;I^e3Wo*Kt2 zf@|+`@R0@X*ZeH$l>?9tM*KUMPk^tJT@6uWknipGB0)5bo@i=+`wb`9P1T) z7vQb)bW(;~(Vfr>sLN(RMUh(1iWLai^5SD*CNyCJ^Cc8rSWJ<%Uy+uCZM#v9vFBDr zEl5nmOu8XsqNMrF2jk3^d8lfb`{J)IXs(L-dRa?sOlBZ^8@4xv=?}^}5E~6YOKPj6 z)VcrlIv56s$Nss?MWm#@YEqNdwu!WwB0-nHx9WrR;u*IZ_7lwIDHRu1y zZW@sc&gmZ@e6>SONz~X?@*U)w6D|+a?Wbxb(EW|;?5&t<>9@jCsLiOZcJkhiBOB%m zi_Gki=kajyFh*`nXl7WvPZ?o+!%WlY1bwySwuVM|pV~h$v6k@m!Xjh5JJIFFYfe2F zHA`+B)dCol8R?2TKp#j6wQ^1Yi&eE8t93S|xE$;-@%(yA^b|-JRDfZe4I9Zrv9NhT z{V`z;k%%}VD?zrWp{k$LX{Kr9x-I(*?A7Ud#(0ZXrGL*srj{Zrj-Rb4DFnF0ENBL-^1#5DyN+1hx~3!{W4@3QqNmn zvVY;0k2%i4C^AqIBemoTD^Hea2dK;MDt9LzMGbK9zcXSryuaVRxq%@!B3SvmfVWRs9V%K=O--}iuSK2pDruB)SgC1VQ?T1Adfl5=z(yD zs}cF@AOqwyGqlF!nA&F4DoY`kQ#fYB_6gyVWvPy%oE(eKOSk)FeFz%GcPwjMH*@cz z?p80V>JGXNs?CQY;em*vJ&KDsqU|JGTCRMX>Hprg_Rb2V$zSoOv~fUf-x%oqP$Ksp z`GdmD3PiO7nq_KrrHEA20l>W$dPsp|N{)Co7&2Qtiqb)g-TAJYw~4c>rLqQv6Y=ye zh_%aUlqS2%k6{7Y>JFgb_+5(xU9m%fz`MxZKk=Sraw?TzlV4N7g=9u??|O8n%m0oa zQEy_RT{%a-v3tqhF5NgQMu zJyT)&Cwj|a-Umd%bBH{3>^2u9%gMN+r+~tA%K!xtX9vC@kxVhb@UBoggwBWA@Lox^ zSVmy`nm#AORs%_%E|9{SW)}vm{g9pRSL4oMa@!IFQU9*A z_7S_;Jd&cAwT(}I;=+h$0AQ;jNTAM5Rf)sM%8_ZlPOIe(Nuae>S*{FxNhYP=YuNlX z__1HMJe~L+o&pGRxCLfoq`7?)_26?VCb=zN!{P`4x4xM!N#qb-YAI4F9f6Fhnw8aw zmT!RtSzb=@s(Zn}dsIDsIyP%~juS&xaERcvi@XJlt+7E>0~-Bp=1bxesEKFT)EhD= zj4&>4dN+MJPHsTG;x|r~oGd1Ac#1AH3=AuT^A;?g$z$GIWK^to97&KX^+#^E_yOxS zD#XJb0f~3%&T#0oF7_8yj0*G~$uY#MLs*It=20}dkuIA^TnEcGlSuAaj|?)*%L#pG zL3c88Y;4#sLMMnF3no0;fD1%jk9Ccj&?wZsvEiAP#%*-+xy44(E7njgF zQfshj$TS*(t3*g+CDGy_F%ofMGF3onvpf5D=O6e3_~w1zT!J&h27m*sW^4K^8q z;xtp>6EMK2$pVxfWG6>Uy(>;?@`p8EvByhdiAj2gk;JnE@`=$A67q8nTFluA|JTts22a7zcD(T~g) z5}Te)=tZAKPgtsWsJjBoNF|FOU7xF@~`cD!+zl8oeiQS(ro?!qWhxNxdA? z=vT;|j~Vv`I;p6mF!Hg7IU6mw?>&VNl?v>yi`+*V1(#_@;WB9vqxU2c2cxOIdQHh? zJsB3Dwi|EqF%$a&1J`Gtl45Tie&eiLdOiMG1d^UX_s=x!kjD33l+I5+^mL)$D0n?_XRZp4w zmsAytw^`q1RkQh)e4Ef~p+TuJcJXh$Tu<<0#H*bjJQ0+DtDx9O?M9}FBo*`Z-co(O z5@0!3x@!aCvBZG{QM}z2_r(^{_FVki_mJ|}k@BXTwgRHyszvi0GQG3GMM}M9I zIP(-`Ql^uTsEoOpj#_ASCHAy&(`X?AQVVl1j%a#P)(;=visH*a-sjQK?sLvaJ+8uqg`LwnpH}`cH`vY>2h~WPl|nwDz2cE~XQ4%o3sUqP z7pgr=81N_Xm&n>n&}MqSw>FV!KO2va-nMh=q?s>cpJ z)>^yRwf~&q2AJU3pDb?1s6zrWMUEuIK-pcjfh!+al$ zs-@H6#5y-d%*Wi_ZY=)+x3845DSqf7v$fG*S&x-|2HX9&dGQEB8fhsolN>XEjCxe4g%le3R={VZzM4b`;jiH`}SbHS?6R z&q%Fki*1r&Gte}#RD2_~(durxl)y<)N&(c^Q{CH>gFtbFfjOvF<`1x{YXxdHMurv!S`T9c_iSq)uxzq+QSKHs)C9ZO)p$Tv_c?h2z}egpxbg@s&1%a0ipDO0#Izg)`U&o?d`c8_SD}Z%w1TSU?F*UiA2VRu0x}1 zL4DKorOEBMBUmHE-wl7MGG2}bbQo?yV5In_P0 z^Q)iK6q{!FP^$5|Zltv^EjPYB{$kGhT#c+@pzBRVs*pkx>vzmNPFhO9TXuEaShq-_ Mun}H6^H=}?02dpDX#fBK literal 0 HcmV?d00001 diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts index 33004746c81b..d8d7415c7bb3 100644 --- a/apps/shared/src/billing-types.ts +++ b/apps/shared/src/billing-types.ts @@ -108,6 +108,9 @@ export interface BillingMonthlyCap { } export interface BillingAutoReload { + // The gateway's _parse_auto_reload_card returns None for a missing/unknown-kind + // card, and _serialize_billing_state emits `card: null` — so the wire really can + // carry null. Consumers must keep a null branch (treat it like the canonical card). card: | { kind: 'canonical' } | { @@ -117,6 +120,7 @@ export interface BillingAutoReload { last4: string | null } | { kind: 'none' } + | null enabled: boolean reload_to_display: string reload_to_usd: string | null diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index c5577a5c6cf1..52cf5529c550 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -685,7 +685,7 @@ function StepUpScreen({ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const ar = s.auto_reload const enabled = Boolean(ar?.enabled) - const distinctCard = ar?.card.kind === 'distinct' ? ar.card : null + const distinctCard = ar?.card?.kind === 'distinct' ? ar.card : null const distinctCardName = distinctCard ? [distinctCard.brand, distinctCard.last4 ? `••${distinctCard.last4}` : null].filter(Boolean).join(' ') ||