From 94c944363c10405e3544b0aeeaac1d00f0b85a54 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:20:59 +0530 Subject: [PATCH] feat(tui): show the plan catalog in /subscription on Free (#68357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tui): show the plan catalog in /subscription on Free The server returns the tier list even with no subscription, but the overlay hid the picker behind can_change_plan && !isFree, so a Free account got only "Start a subscription" with no idea what the plans cost. Now: - Overview on Free offers "Choose a plan" whenever the catalog has enabled paid tiers. - The picker on Free lists each plan as name · price · monthly credits (no upgrade/downgrade hints — there is nothing to move from), and picking one opens the portal, where starting a subscription actually happens (card capture + checkout live there; the upgrade RPC requires an existing subscription). - Paid-plan behavior (preview → confirm → apply) is unchanged. * refactor(tui): compute the picker row suffix once Review feedback: the isFree fork duplicated the label template and run handler; only the suffix differs. * fix(tui): arm the busy guard before the Free portal handoff Adversarial review: the Free branch returned before setting busyRef, so a double-Enter could open the portal twice; and the picker narrated a handoff that openManageLink already narrates (duplicate on success, contradictory on failure). Guard first, let the helper do the talking. * fix(tui): monthly credits are dollars — label them as such The Free picker showed "1000 credits/mo" for what is $1,000 of monthly credit — render "$1,000 credits/mo" (grouped, dollar-signed). * feat(tui): render the Free-plan catalog inline in the /subscription overview Sid ruling: the upsell belongs where the user already is — no intermediate "Choose a plan" hop. On Free the overview lists each paid plan (name · $/mo · $credits/mo) as a pickable row; picking opens the portal (openManageLink narrates). The generic "Start a subscription" row survives only when the catalog is empty. The picker reverts to its original change-only form (Free never reaches it). * feat(desktop): tier catalog chips on the Subscription row Desktop parity with the TUI inline catalog (Sid ruling): accounts that can act see the plans where they already are — Free gets the upsell list (every chip opens the portal), a subscriber sees all tiers with the current one marked inert. Members and team contexts see no chips. Chips learn an optional url (portal handoff) in the shared row model. * chore(tui): fixture harness mirrors the live tier catalog The dev screenshot fixtures showed invented plans ($50 Super / $99 Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200 with $22/$110/$220 monthly credits) so fixture renders cannot be mistaken for product truth. The overlay itself always reads tiers from the subscription API. * chore: trim narration comments --- .../src/app/settings/billing/index.tsx | 9 +- .../billing/use-billing-state.test.ts | 94 +++++++++++++++++++ .../app/settings/billing/use-billing-state.ts | 40 +++++++- ui-tui/scripts/billing-fixtures.tsx | 8 +- .../__tests__/subscriptionOverlay.test.tsx | 41 ++++++++ ui-tui/src/components/subscriptionOverlay.tsx | 31 +++++- 6 files changed, 217 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 94ffda3b7c..a564047575 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun {row.pill && {row.pill.label}} {row.secondaryPill && {row.secondaryPill}} {row.chips?.map(chip => ( - ))} 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 f87d9f4fe1..8a71b51f4e 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 @@ -184,6 +184,100 @@ describe('deriveBillingView', () => { }) }) + 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 }, + { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url } + ]) + }) + + 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 } + ]) + }) + + 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', () => { const view = deriveBillingView( okBilling(todayBillingState), 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 870af16367..6fda6cc730 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -41,6 +41,8 @@ export interface BillingRowActionView { export interface BillingChipView { disabled: boolean label: string + /** When set, clicking the chip opens this URL externally. */ + url?: string } export interface BillingAccountRowView { @@ -272,6 +274,37 @@ 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. + */ +function subscriptionTierChips( + subscription: null | SubscriptionStateResponse, + manageUrl: 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: manageUrl } + }) +} + function subscriptionRow( billing: BillingStateResponse, subscription: null | SubscriptionStateResponse, @@ -283,13 +316,18 @@ function subscriptionRow( 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, manageUrl) return { action: { label: 'Adjust plan ↗', url: manageUrl }, caption: unavailable ? 'Subscription details are unavailable; opening the portal is still available.' : `Renews ${renewal}`, - description: 'Review your plan and change it from the billing portal.', + 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', diff --git a/ui-tui/scripts/billing-fixtures.tsx b/ui-tui/scripts/billing-fixtures.tsx index 7f6edac012..53bcbeb9a8 100644 --- a/ui-tui/scripts/billing-fixtures.tsx +++ b/ui-tui/scripts/billing-fixtures.tsx @@ -42,11 +42,13 @@ const tier = (o: Partial = {}): SubscriptionTierOption = ...o }) +// Mirrors the live portal catalog so fixtures don't drift; the real overlay +// reads tiers from GET /api/billing/subscription, never from here. const TIERS = { free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }), - plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }), - super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }), - ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' }) + plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '22' }), + super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$100', monthly_credits: '110' }), + ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$200', monthly_credits: '220' }) } const tierList = (currentId?: string): SubscriptionTierOption[] => diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index 0569f64164..4a5a5ed21a 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -149,6 +149,39 @@ describe('SubscriptionOverlay — overview', () => { expect(out.toLowerCase()).not.toContain('credits') }) + it('free with catalog: plans render inline; the generic portal row disappears', () => { + const out = render(overlay(freeWithCatalog())) + + expect(out).toContain('Plus · $20/mo · $1,000 credits/mo') + expect(out).toContain('Ultra · $40/mo · $3,000 credits/mo') + expect(out).not.toContain('upgrade') // a start, not a move + expect(out).not.toContain('$0/mo') // free tier is not an option + expect(out).not.toContain('Choose a plan') + expect(out).not.toContain('Start a subscription') + }) + + it('free with catalog: picking a plan opens the portal once, even on double-Enter', async () => { + const openManageLink = vi.fn(() => Promise.resolve(true)) + const preview = vi.fn(() => Promise.resolve(null)) + const sys = vi.fn() + + const mounted = mount({ + ctx: { ...ctx, openManageLink, preview, sys } as SubscriptionOverlayState['ctx'], + screen: 'overview', + state: freeWithCatalog() + }) + + inputHarness.handler?.('', { return: true }) // first row = Plus + inputHarness.handler?.('', { return: true }) + await vi.waitFor(() => expect(openManageLink).toHaveBeenCalled()) + mounted.cleanup() + + expect(openManageLink).toHaveBeenCalledTimes(1) + expect(preview).not.toHaveBeenCalled() + // openManageLink narrates the handoff itself. + expect(sys).not.toHaveBeenCalled() + }) + it('subscriber: status line + plan bar + top-up bar, no "credits"', () => { const out = render( overlay( @@ -318,6 +351,14 @@ const at = ( extra: Partial = {} ): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra }) +// Free account (no current sub) where NAS still returns the tier catalog. +const freeWithCatalog = (): SubscriptionStateResponse => + state({ + current: null, + tiers: TIERS.map(tier => ({ ...tier, is_current: false })), + usage: { available: true, plan_name: null, status: 'free' } + }) + describe('SubscriptionOverlay — overview actions', () => { it('admin subscriber: offers Change plan + Cancel subscription', () => { const out = render(overlay(subscriber())) diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 3a75ddab21..5e0ae4af48 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -374,6 +374,11 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { // Admin/owner on a personal paid plan can change it in-terminal; otherwise the // portal enforces who can act (members) / starting a new sub needs a card. const canChange = s.can_change_plan && !isFree + // On Free the catalog renders inline; picking a plan hands off to the portal, + // where starting a subscription needs card capture + checkout. + const freePlans = isFree + ? s.tiers.filter(tier => tier.is_enabled && tier.tier_order > 0).sort((a, b) => a.tier_order - b.tier_order) + : [] // Guard the async resume so a double-press cannot fire two DELETEs mid-await. const busyRef = useRef(false) @@ -422,7 +427,31 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { } } - rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage }) + for (const tier of freePlans) { + // NAS sends a bare decimal string; tolerate pre-grouped ("1,000") too. + const credits = Number((tier.monthly_credits ?? '').replace(/,/g, '')) + const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : '' + + rows.push({ + label: `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}`, + run: () => { + if (busyRef.current) { + return + } + + busyRef.current = true + void ctx.openManageLink() + onClose() + } + }) + } + + // The inline plan rows are the subscribe path; only a catalog-less free state + // still needs the generic portal row. + if (!isFree || freePlans.length === 0) { + rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage }) + } + rows.push({ label: 'Close', run: onClose }) const sel = useMenu(rows, onClose)