feat(tui): show the plan catalog in /subscription on Free (#68357)
* 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
This commit is contained in:
@@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun
|
||||
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
|
||||
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
|
||||
{row.chips?.map(chip => (
|
||||
<Button disabled={chip.disabled} key={chip.label} size="sm" type="button" variant="outline">
|
||||
<Button
|
||||
disabled={chip.disabled}
|
||||
key={chip.label}
|
||||
onClick={chip.url ? () => openExternal(chip.url) : undefined}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -42,11 +42,13 @@ const tier = (o: Partial<SubscriptionTierOption> = {}): 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[] =>
|
||||
|
||||
@@ -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> = {}
|
||||
): 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()))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user