Merge pull request #245 from vernu/fix/checkout-plan-params-next16

fix(billing): send the plan name from route params under Next 16
This commit is contained in:
vernu
2026-07-20 22:12:25 +03:00
committed by GitHub
10 changed files with 729 additions and 139 deletions

View File

@@ -139,3 +139,78 @@ describe('BillingService - cancellation handling', () => {
})
})
})
// Pins apart three failures that used to share one misleading message.
describe('BillingService - checkout guards', () => {
let service: BillingService
const user = { _id: new Types.ObjectId('507f1f77bcf86cd799439011') }
const req = { ip: '127.0.0.1' }
const mockPlanModel = {
findOne: jest.fn(),
}
const emptyModel = {}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
BillingService,
{ provide: getModelToken(Plan.name), useValue: mockPlanModel },
{ provide: getModelToken(Subscription.name), useValue: emptyModel },
{ provide: getModelToken(User.name), useValue: emptyModel },
{ provide: getModelToken(SMS.name), useValue: emptyModel },
{
provide: getModelToken(PolarWebhookPayload.name),
useValue: emptyModel,
},
{ provide: getModelToken(CheckoutSession.name), useValue: emptyModel },
{ provide: BillingNotificationsService, useValue: {} },
],
}).compile()
service = module.get<BillingService>(BillingService)
jest.clearAllMocks()
})
it('names the real problem when the request carries no plan name', async () => {
await expect(
service.getCheckoutUrl({
user,
payload: { billingInterval: 'monthly' },
req,
}),
).rejects.toMatchObject({
response: { code: 'PLAN_NAME_REQUIRED' },
})
// the plan is never looked up, so it can never be blamed
expect(mockPlanModel.findOne).not.toHaveBeenCalled()
})
it('reports an unknown plan as not found, not as unpurchasable', async () => {
mockPlanModel.findOne.mockResolvedValue(null)
await expect(
service.getCheckoutUrl({
user,
payload: { planName: 'enterprise', billingInterval: 'monthly' },
req,
}),
).rejects.toMatchObject({
response: { code: 'PLAN_NOT_FOUND' },
})
})
it('still rejects a real plan that has no Polar products', async () => {
mockPlanModel.findOne.mockResolvedValue({ name: 'pro' })
await expect(
service.getCheckoutUrl({
user,
payload: { planName: 'pro', billingInterval: 'monthly' },
req,
}),
).rejects.toThrow('Plan cannot be purchased')
})
})

View File

@@ -400,12 +400,34 @@ export class BillingService {
polarSubscription?: any
targetProductId?: string
}> {
// a missing plan name is a client bug, not an unpurchasable plan
if (!planName || typeof planName !== 'string') {
console.error(
`Checkout requested without a plan name (received: ${JSON.stringify(planName)})`,
)
throw new BadRequestException({
message: 'No plan was selected. Please pick a plan and try again.',
code: 'PLAN_NAME_REQUIRED',
})
}
const selectedPlan = await this.planModel.findOne({ name: planName })
if (!selectedPlan) {
console.error(`Checkout requested for unknown plan "${planName}"`)
throw new BadRequestException({
message: `Plan "${planName}" was not found.`,
code: 'PLAN_NOT_FOUND',
})
}
if (
!selectedPlan?.polarMonthlyProductId &&
!selectedPlan?.polarYearlyProductId
!selectedPlan.polarMonthlyProductId &&
!selectedPlan.polarYearlyProductId
) {
console.error(
`Plan "${planName}" has no Polar product ids and cannot be purchased`,
)
throw new BadRequestException('Plan cannot be purchased')
}

View File

@@ -27,13 +27,14 @@ export class Plan {
@Prop({})
yearlyPrice: number // in cents
@Prop({ type: String, unique: true })
// sparse: without it, a second plan leaving these unset collides on null
@Prop({ type: String, index: { unique: true, sparse: true } })
polarProductId?: string
@Prop({ type: String, unique: true })
@Prop({ type: String, index: { unique: true, sparse: true } })
polarMonthlyProductId?: string
@Prop({ type: String, unique: true })
@Prop({ type: String, index: { unique: true, sparse: true } })
polarYearlyProductId?: string
@Prop({ type: Boolean, default: true })

View File

@@ -1,12 +1,25 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, use } from 'react'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { apiErrorMessage } from '@/lib/utils/errorHandler'
import { useSession } from 'next-auth/react'
import { redirect } from 'next/navigation'
import { Loader, CheckCircle, ArrowRight } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
import { Badge } from '@/components/ui/badge'
import {
findPlanTier,
formatPlanPrice,
monthlyEquivalent,
yearlySavingPercent,
} from '@/lib/plans'
import { cn } from '@/lib/utils'
type BillingInterval = 'monthly' | 'yearly'
interface PlanChangePreview {
currentPlan: string
@@ -20,37 +33,58 @@ interface PlanChangePreview {
const formatPlan = (plan: string, interval: string) =>
`${plan.charAt(0).toUpperCase() + plan.slice(1)} (${interval})`
/** Frames every state on this page so they share one width and one elevation. */
function CheckoutShell({ children }: { children: React.ReactNode }) {
return (
<div className='flex min-h-[80vh] items-center justify-center p-6'>
<div className='w-full max-w-md rounded-lg border border-border bg-card p-8 text-card-foreground shadow-sm'>
{children}
</div>
</div>
)
}
export default function CheckoutPage({
params,
}: {
params: { planName: string }
params: Promise<{ planName: string }>
}) {
const [error, setError] = useState<string | null>(null)
const [planChange, setPlanChange] = useState<PlanChangePreview | null>(null)
const [isConfirming, setIsConfirming] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSlow, setIsSlow] = useState(false)
const planName = params.planName as string
// params is a promise in Next 16, reading it synchronously yields undefined
const { planName } = use(params)
const tier = findPlanTier(planName)
const { data: session } = useSession()
const { status } = useSession()
const router = useRouter()
const getBillingInterval = () => {
// marketing site and pricing pages link here with ?billingInterval=monthly|yearly
// (legacy ?billing= fallback until the marketing site redeploys)
const searchParams = new URLSearchParams(window.location.search)
const billingInterval =
searchParams.get('billingInterval') ?? searchParams.get('billing')
return billingInterval === 'yearly' ? 'yearly' : 'monthly'
}
const [selected, setSelected] = useState<BillingInterval>('yearly')
// marketing and pricing pages link here with ?billingInterval=monthly|yearly
// (legacy ?billing= fallback until the marketing site redeploys)
const searchParams = useSearchParams()
const rawInterval =
searchParams.get('billingInterval') ?? searchParams.get('billing')
const urlInterval: BillingInterval | null =
rawInterval === 'yearly'
? 'yearly'
: rawInterval === 'monthly'
? 'monthly'
: null
const initiateCheckout = useCallback(
async (retries = 2) => {
async (billingInterval: BillingInterval) => {
setIsSubmitting(true)
setIsSlow(false)
setError(null)
try {
const response = await httpBrowserClient.post(
ApiEndpoints.billing.checkout(),
{
planName,
billingInterval: getBillingInterval(),
},
{ planName, billingInterval },
)
if (response.data?.redirectUrl) {
@@ -58,23 +92,22 @@ export default function CheckoutPage({
} else if (response.data?.planChange) {
// user already has a paid subscription: confirm before updating it
setPlanChange(response.data.planChange)
setIsSubmitting(false)
} else {
throw new Error('No redirect URL found')
}
} catch (error) {
if (retries > 0) {
initiateCheckout(retries - 1)
} else {
const serverMessage = apiErrorMessage(error)
setError(
serverMessage ||
'Failed to create checkout session. Please try again or contact billing@textbee.dev.'
)
console.error(serverMessage)
}
// a 400 is not transient, so surface it rather than retrying blindly
const serverMessage = apiErrorMessage(error)
setError(
serverMessage ||
'Failed to create checkout session. Please try again or contact billing@textbee.dev.',
)
console.error(serverMessage)
setIsSubmitting(false)
}
},
[planName]
[planName],
)
const confirmPlanChange = async () => {
@@ -82,7 +115,7 @@ export default function CheckoutPage({
try {
await httpBrowserClient.post(ApiEndpoints.billing.changePlan(), {
planName,
billingInterval: getBillingInterval(),
billingInterval: urlInterval ?? selected,
})
window.location.href = '/dashboard/account?plan-change-success=1'
} catch (error) {
@@ -98,93 +131,242 @@ export default function CheckoutPage({
}
}
// an interval in the URL means the choice was already made on the pricing
// page, so do not ask again
useEffect(() => {
initiateCheckout()
}, [initiateCheckout])
if (urlInterval === 'monthly' || urlInterval === 'yearly') {
initiateCheckout(urlInterval)
}
}, [urlInterval, initiateCheckout])
if (!session?.user) {
return redirect(`/login?redirect=${window.location.href}`)
useEffect(() => {
if (!isSubmitting) return
const timer = setTimeout(() => setIsSlow(true), 5000)
return () => clearTimeout(timer)
}, [isSubmitting])
useEffect(() => {
if (status === 'unauthenticated') {
router.replace(`/login?redirect=${encodeURIComponent(window.location.href)}`)
}
}, [status, router])
if (status === 'loading' || status === 'unauthenticated') {
return (
<CheckoutShell>
<div className='flex flex-col items-center gap-4 text-center'>
<Spinner size='lg' className='motion-reduce:animate-none' />
<p className='text-sm text-muted-foreground'>Loading</p>
</div>
</CheckoutShell>
)
}
if (error) {
return (
<div className='flex flex-col items-center justify-center h-screen'>
<div className='text-red-500'>{error}</div>
<button
onClick={() => {
setError(null)
initiateCheckout()
}}
className='mt-4 px-4 py-2 bg-brand-500 text-white rounded hover:bg-brand-600'
>
Try Again
</button>
</div>
<CheckoutShell>
<div className='flex flex-col gap-4'>
<div>
<h1 className='text-lg font-semibold'>We could not start checkout</h1>
<p className='mt-2 text-sm text-destructive'>{error}</p>
</div>
<div className='flex flex-col gap-2'>
<Button onClick={() => initiateCheckout(urlInterval ?? selected)}>
Try again
</Button>
<Button variant='ghost' asChild>
<Link href='/dashboard/account'>Back to your account</Link>
</Button>
</div>
</div>
</CheckoutShell>
)
}
if (planChange) {
return (
<div className='flex flex-col items-center justify-center min-h-[80vh] p-6'>
<div className='max-w-md w-full bg-white rounded-lg shadow-lg p-8'>
<h2 className='text-2xl font-bold text-gray-800 mb-4'>
Confirm your plan change
</h2>
<div className='flex items-center justify-center gap-3 mb-6 text-lg font-semibold text-gray-700'>
<span>
{formatPlan(planChange.currentPlan, planChange.currentInterval)}
</span>
<ArrowRight className='text-brand-500' size={20} />
<span>{formatPlan(planChange.newPlan, planChange.newInterval)}</span>
</div>
<p className='text-gray-600 mb-2'>
The change takes effect immediately. The price difference for the
remainder of your billing period is prorated by our payment
provider
{planChange.isUpgrade
? ' and may be charged to your payment method right away.'
: ' and credited towards your upcoming invoices.'}
</p>
{planChange.cancelAtPeriodEnd && (
<p className='text-amber-600 mb-2'>
Your subscription is currently scheduled to cancel at the end of
the billing period. Changing your plan will remove the scheduled
cancellation.
</p>
)}
<div className='flex gap-3 mt-6'>
<button
onClick={confirmPlanChange}
disabled={isConfirming}
className='flex-1 px-4 py-2 bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-60 flex items-center justify-center gap-2'
>
{isConfirming && <Loader className='animate-spin' size={16} />}
{isConfirming ? 'Updating...' : 'Confirm change'}
</button>
<button
onClick={() => (window.location.href = '/dashboard/account')}
disabled={isConfirming}
className='flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-60'
>
Cancel
</button>
</div>
<CheckoutShell>
<h1 className='text-lg font-semibold'>Confirm your plan change</h1>
<div className='my-6 flex items-center justify-center gap-3 text-sm font-medium'>
<span>
{formatPlan(planChange.currentPlan, planChange.currentInterval)}
</span>
<ArrowRight className='h-4 w-4 text-primary' aria-hidden />
<span>{formatPlan(planChange.newPlan, planChange.newInterval)}</span>
</div>
</div>
<p className='text-sm text-muted-foreground'>
The change takes effect immediately. The price difference for the
remainder of your billing period is prorated by our payment provider
{planChange.isUpgrade
? ' and may be charged to your payment method right away.'
: ' and credited towards your upcoming invoices.'}
</p>
{planChange.cancelAtPeriodEnd && (
<p className='mt-3 text-sm text-amber-600 dark:text-amber-500'>
Your subscription is currently scheduled to cancel at the end of the
billing period. Changing your plan will remove the scheduled
cancellation.
</p>
)}
<div className='mt-6 flex gap-3'>
<Button
onClick={confirmPlanChange}
disabled={isConfirming}
className='flex-1'
>
{isConfirming && (
<Spinner
size='sm'
color='white'
className='mr-2 motion-reduce:animate-none'
/>
)}
{isConfirming ? 'Updating' : 'Confirm change'}
</Button>
<Button
variant='outline'
className='flex-1'
disabled={isConfirming}
asChild
>
<Link href='/dashboard/account'>Cancel</Link>
</Button>
</div>
</CheckoutShell>
)
}
// an interval in the URL always redirects, so show progress rather than
// flashing the chooser before the effect fires
if (isSubmitting || urlInterval !== null) {
const intervalLabel = (urlInterval ?? selected) === 'yearly' ? 'yearly' : 'monthly'
const price =
tier &&
((urlInterval ?? selected) === 'yearly'
? tier.yearlyPrice
: tier.monthlyPrice)
return (
<CheckoutShell>
<div className='flex flex-col items-center gap-4 text-center'>
<Spinner size='lg' className='motion-reduce:animate-none' />
<div>
<h1 className='text-lg font-semibold'>
{tier
? `Setting up your ${tier.name} ${intervalLabel} checkout`
: 'Setting up your checkout'}
</h1>
<p className='mt-2 text-sm text-muted-foreground'>
Redirecting you to Polar, our payment provider. This only takes a
moment.
</p>
</div>
{tier && price !== undefined && (
<p className='text-sm font-medium tabular-nums'>
{tier.name}, {intervalLabel}, {formatPlanPrice(price)}
</p>
)}
{isSlow && (
<p className='text-sm text-muted-foreground'>
Still working, hang on.
</p>
)}
</div>
</CheckoutShell>
)
}
// no interval in the URL: ask rather than guess, with yearly preselected
const perMonth = tier ? monthlyEquivalent(tier) : undefined
const saving = tier ? yearlySavingPercent(tier) : undefined
return (
<div className='flex flex-col items-center justify-center min-h-[80vh] bg-gray-100 p-6 rounded-lg shadow-lg'>
<Loader className='animate-spin mb-4 text-brand-500' size={48} />
<h2 className='text-2xl font-bold text-gray-800 mb-2'>Hang Tight!</h2>
<p className='text-lg text-gray-600 mb-4'>
We're processing your order. This won't take long!
<CheckoutShell>
<h1 className='text-lg font-semibold'>
{tier ? `Upgrade to ${tier.name}` : 'Choose your billing'}
</h1>
<p className='mt-1 text-sm text-muted-foreground'>
Pick how you would like to be billed.
</p>
<CheckCircle className='text-green-500 mb-2' size={32} />
<span className='text-lg font-semibold'>
Thank you for your patience!
</span>
</div>
<fieldset className='mt-6'>
<legend className='sr-only'>Billing interval</legend>
<div className='flex flex-col gap-3'>
{tier?.yearlyPrice !== undefined && (
<label
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors',
selected === 'yearly'
? 'border-primary ring-1 ring-primary'
: 'border-border hover:bg-muted/50',
)}
>
<input
type='radio'
name='billingInterval'
value='yearly'
checked={selected === 'yearly'}
onChange={() => setSelected('yearly')}
className='mt-1 accent-primary'
/>
<span className='flex-1'>
<span className='flex items-center justify-between gap-2'>
<span className='font-medium'>Yearly</span>
{saving !== undefined && (
<Badge variant='secondary'>Save {saving}%</Badge>
)}
</span>
<span className='mt-1 block text-sm text-muted-foreground tabular-nums'>
{perMonth !== undefined &&
`${formatPlanPrice(perMonth)}/month, billed yearly at ${formatPlanPrice(tier.yearlyPrice)}`}
</span>
</span>
</label>
)}
<label
className={cn(
'flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition-colors',
selected === 'monthly'
? 'border-primary ring-1 ring-primary'
: 'border-border hover:bg-muted/50',
)}
>
<input
type='radio'
name='billingInterval'
value='monthly'
checked={selected === 'monthly'}
onChange={() => setSelected('monthly')}
className='mt-1 accent-primary'
/>
<span className='flex-1'>
<span className='font-medium'>Monthly</span>
<span className='mt-1 block text-sm text-muted-foreground tabular-nums'>
{tier && `${formatPlanPrice(tier.monthlyPrice)}/month`}
</span>
</span>
</label>
</div>
</fieldset>
<Button
className='mt-6 w-full'
onClick={() => initiateCheckout(selected)}
>
Continue to checkout
</Button>
<p className='mt-3 text-center text-xs text-muted-foreground'>
Cancel anytime, keep access until the end of your billing period.
</p>
</CheckoutShell>
)
}

View File

@@ -31,11 +31,31 @@ describe('PlanPicker', () => {
).toEqual(['Free', 'Pro', 'Scale'])
})
it('shows the marketing prices', () => {
// The headline figure is the yearly per-month equivalent, so both it and the
// month-to-month price are pinned. A customer must never see a price we do
// not charge.
it('leads with the yearly per-month price', () => {
renderPicker()
expect(screen.getByText('$9.99')).toBeInTheDocument()
expect(screen.getByText('$29.99')).toBeInTheDocument()
expect(screen.getByText('$8.33')).toBeInTheDocument()
expect(screen.getByText('$25.00')).toBeInTheDocument()
})
it('still shows what the monthly option costs', () => {
renderPicker()
expect(
screen.getByText('billed yearly at $99.99, or $9.99 monthly')
).toBeInTheDocument()
expect(
screen.getByText('billed yearly at $299.99, or $29.99 monthly')
).toBeInTheDocument()
})
it('quotes the yearly saving on each paid tier', () => {
renderPicker()
expect(screen.getAllByText('Save 17% yearly')).toHaveLength(2)
})
it('links each paid tier at its own checkout route', () => {

View File

@@ -15,7 +15,14 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Check, ExternalLink } from 'lucide-react'
import { Routes } from '@/config/routes'
import { useSubscription } from '@/lib/api'
import { PLAN_TIERS, formatPlanPrice, type PlanTier } from '@/lib/plans'
import {
PLAN_TIERS,
formatPlanPrice,
formatPriceCaption,
monthlyEquivalent,
yearlySavingPercent,
type PlanTier,
} from '@/lib/plans'
import { cn } from '@/lib/utils'
type PlanPickerProps = {
@@ -38,34 +45,49 @@ function PlanCard({
}) {
const free = tier.monthlyPrice <= 0
const highlight = tier.isPopular && !isCurrent
const perMonth = monthlyEquivalent(tier)
const saving = yearlySavingPercent(tier)
return (
<Card
className={cn(
'relative flex flex-col',
'flex h-full flex-col',
// the accent marks one card only, so it still means something
highlight
? 'border-2 border-primary shadow-md'
? 'border-primary shadow-md ring-1 ring-primary'
: 'border-border shadow-none',
isCurrent && 'border-2 border-primary/40'
isCurrent && 'border-primary/40'
)}
>
{(highlight || isCurrent) && (
<Badge
variant={isCurrent ? 'secondary' : 'default'}
className='absolute right-3 top-3 text-[10px]'
>
{isCurrent ? 'Current' : 'Most popular'}
</Badge>
)}
<CardHeader className='pb-3 pt-5'>
<div className='flex items-center justify-between gap-2'>
<CardTitle className='text-base'>{tier.name}</CardTitle>
{(highlight || isCurrent) && (
<Badge
variant={isCurrent ? 'secondary' : 'default'}
className='text-[10px]'
>
{isCurrent ? 'Current' : 'Most popular'}
</Badge>
)}
</div>
<CardHeader className='pb-2 pr-24 pt-4'>
<CardTitle className='text-base'>{tier.name}</CardTitle>
<CardDescription>
<span className='text-foreground'>
{formatPlanPrice(tier.monthlyPrice)}
</span>
{' / month'}
</CardDescription>
<div className='pt-2'>
<div className='flex items-baseline gap-1'>
<span className='text-3xl font-semibold tabular-nums'>
{formatPlanPrice(perMonth ?? tier.monthlyPrice)}
</span>
<span className='text-sm text-muted-foreground'>/month</span>
</div>
<CardDescription className='mt-1 tabular-nums'>
{formatPriceCaption(tier)}
</CardDescription>
{saving !== undefined && (
<Badge variant='secondary' className='mt-2 text-[10px]'>
Save {saving}% yearly
</Badge>
)}
</div>
</CardHeader>
<CardContent className='flex-1 pb-4'>
@@ -79,10 +101,7 @@ function PlanCard({
)}
>
<Check
className={cn(
'mt-0.5 h-4 w-4 shrink-0',
free ? 'text-muted-foreground' : 'text-primary'
)}
className='mt-0.5 h-4 w-4 shrink-0 text-muted-foreground'
aria-hidden
/>
{feature}
@@ -91,7 +110,7 @@ function PlanCard({
</ul>
</CardContent>
<CardFooter className='pb-4 pt-0'>
<CardFooter className='mt-auto flex-col items-stretch gap-2 pb-5 pt-0'>
{isCurrent ? (
<Button variant='outline' className='w-full' disabled>
Your current plan
@@ -106,9 +125,15 @@ function PlanCard({
Continue with Free
</Button>
) : (
<Button className='w-full' asChild>
<Link href={`/checkout/${tier.id}`}>Upgrade to {tier.name}</Link>
</Button>
<>
{/* above the button so every card's CTA lands on one baseline */}
<p className='text-center text-xs text-muted-foreground'>
Cancel anytime, keep access until the end of your billing period.
</p>
<Button className='w-full' asChild>
<Link href={`/checkout/${tier.id}`}>Upgrade to {tier.name}</Link>
</Button>
</>
)}
</CardFooter>
</Card>

149
web/e2e/checkout.spec.ts Normal file
View File

@@ -0,0 +1,149 @@
import { expect, test } from '@playwright/test'
import { authenticate } from './session'
import { mockApi } from './mock-api'
const checkoutRequest = (page: import('@playwright/test').Page) =>
page.waitForRequest(
(r) => r.url().includes('/billing/checkout') && r.method() === 'POST'
)
test.describe('checkout (mocked API, no real backend)', () => {
// The regression this file exists for: a Next 16 upgrade left the page
// reading `params` synchronously, so planName reached the API as undefined
// and every paid checkout failed. Asserting on the rendered page would not
// have caught it, only asserting on the request body does.
test('sends the plan from the URL segment, not undefined', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
const request = checkoutRequest(page)
await page.goto('/checkout/pro?billingInterval=monthly')
expect((await request).postDataJSON()).toMatchObject({
planName: 'pro',
billingInterval: 'monthly',
})
})
test('carries the yearly interval through to the API', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
const request = checkoutRequest(page)
await page.goto('/checkout/scale?billingInterval=yearly')
expect((await request).postDataJSON()).toMatchObject({
planName: 'scale',
billingInterval: 'yearly',
})
})
// Marketing links already carry the interval, so that funnel must not gain
// an extra confirmation step.
test('redirects straight through when the URL names an interval', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/checkout/pro?billingInterval=yearly')
await expect(page).toHaveURL(/polar-checkout-mock=1/)
})
test.describe('when the URL names no interval', () => {
test('asks instead of guessing, with yearly preselected', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page)
await page.goto('/checkout/pro')
await expect(page.getByRole('radio', { name: /Yearly/ })).toBeChecked()
await expect(page.getByRole('radio', { name: /Monthly/ })).not.toBeChecked()
await expect(
page.getByRole('button', { name: 'Continue to checkout' })
).toBeVisible()
})
test('posts nothing until the user confirms', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
let posted = false
page.on('request', (r) => {
if (r.url().includes('/billing/checkout') && r.method() === 'POST') {
posted = true
}
})
await page.goto('/checkout/pro')
await expect(
page.getByRole('button', { name: 'Continue to checkout' })
).toBeVisible()
expect(posted).toBe(false)
})
test('defaults to yearly on confirm', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/checkout/pro')
const request = checkoutRequest(page)
await page.getByRole('button', { name: 'Continue to checkout' }).click()
expect((await request).postDataJSON()).toMatchObject({
planName: 'pro',
billingInterval: 'yearly',
})
})
// The case where a wrong default would charge 10x.
test('honours an explicit switch to monthly', async ({ page, context }) => {
await authenticate(context)
await mockApi(page)
await page.goto('/checkout/pro')
await page.getByRole('radio', { name: /Monthly/ }).check()
const request = checkoutRequest(page)
await page.getByRole('button', { name: 'Continue to checkout' }).click()
expect((await request).postDataJSON()).toMatchObject({
planName: 'pro',
billingInterval: 'monthly',
})
})
})
test('surfaces a failure once, without retrying a rejected request', async ({
page,
context,
}) => {
await authenticate(context)
await mockApi(page, { checkoutError: 'Plan "nope" was not found.' })
let posts = 0
page.on('request', (r) => {
if (r.url().includes('/billing/checkout') && r.method() === 'POST') {
posts += 1
}
})
await page.goto('/checkout/nope?billingInterval=monthly')
await expect(page.getByText('Plan "nope" was not found.')).toBeVisible()
await expect(
page.getByRole('link', { name: 'Back to your account' })
).toBeVisible()
expect(posts).toBe(1)
})
})

View File

@@ -24,12 +24,22 @@ const json = (route: Route, body: unknown, status = 200) =>
export type MockApiOverrides = {
/** Serve a different subscription payload, e.g. the free-user shape. */
subscription?: unknown
/** Fail POST /billing/checkout with this message, to exercise the error state. */
checkoutError?: string
}
export async function mockApi(page: Page, overrides: MockApiOverrides = {}) {
await page.route('**/api/v1/**', (route) => {
const path = new URL(route.request().url()).pathname.replace('/api/v1', '')
// Kept in-origin so following the redirect does not leave the test app.
if (path === '/billing/checkout') {
if (overrides.checkoutError) {
return json(route, { message: overrides.checkoutError }, 400)
}
return json(route, { redirectUrl: '/dashboard?polar-checkout-mock=1' })
}
if (path === '/auth/who-am-i') return json(route, { data: mockUser })
if (path === '/billing/current-subscription')
return json(route, overrides.subscription ?? mockSubscription)

View File

@@ -1,5 +1,16 @@
import { describe, expect, it } from 'vitest'
import { PLAN_TIERS, findPlanTier, formatPlanPrice } from './plans'
import {
PLAN_TIERS,
findPlanTier,
formatPlanPrice,
formatPriceCaption,
monthlyEquivalent,
yearlySavingPercent,
} from './plans'
const pro = PLAN_TIERS.find((t) => t.id === 'pro')!
const scale = PLAN_TIERS.find((t) => t.id === 'scale')!
const free = PLAN_TIERS.find((t) => t.id === 'free')!
// These values mirror the marketing pricing page. They are pinned here so that
// if the two drift apart, this fails instead of quietly showing a customer a
@@ -52,6 +63,68 @@ describe('formatPlanPrice', () => {
})
})
// The yearly saving is quoted to customers, so it is derived from PLAN_TIERS
// and asserted here rather than written into the markup as a literal.
describe('yearly pricing', () => {
it('derives the per-month equivalent of a yearly plan', () => {
expect(monthlyEquivalent(pro)).toBeCloseTo(8.3325, 4)
expect(monthlyEquivalent(scale)).toBeCloseTo(24.99917, 4)
})
it('has no yearly equivalent for a tier without a yearly price', () => {
expect(monthlyEquivalent(free)).toBeUndefined()
expect(yearlySavingPercent(free)).toBeUndefined()
})
// 12 x $9.99 = $119.88 against $99.99 is $19.89 saved, or 16.6%.
it('quotes a saving the arithmetic actually supports', () => {
expect(yearlySavingPercent(pro)).toBe(17)
expect(yearlySavingPercent(scale)).toBe(17)
})
// Guards the claim itself: paying yearly must never cost more than monthly.
it('never quotes a saving on a plan that is not cheaper yearly', () => {
for (const tier of PLAN_TIERS) {
if (!tier.yearlyPrice) continue
expect(tier.yearlyPrice).toBeLessThan(tier.monthlyPrice * 12)
}
})
// The picker prints a headline per-month figure, so its caption must not
// repeat it.
it('captions a headline price without repeating it', () => {
expect(formatPriceCaption(pro)).toBe(
'billed yearly at $99.99, or $9.99 monthly'
)
expect(formatPriceCaption(scale)).toBe(
'billed yearly at $299.99, or $29.99 monthly'
)
})
it('captions the free tier without quoting a price', () => {
expect(formatPriceCaption(free)).toBe('no card required')
})
it('captions a paid tier that has no yearly option', () => {
expect(formatPriceCaption({ ...pro, yearlyPrice: undefined })).toBe(
'$9.99 billed monthly'
)
})
// The caption is the only place the yearly total is quoted, so it has to
// carry both figures.
it('quotes both the yearly total and the monthly price', () => {
for (const tier of [pro, scale]) {
expect(formatPriceCaption(tier)).toContain(
formatPlanPrice(tier.monthlyPrice)
)
expect(formatPriceCaption(tier)).toContain(
formatPlanPrice(tier.yearlyPrice!)
)
}
})
})
describe('findPlanTier', () => {
it('is forgiving about casing and whitespace', () => {
expect(findPlanTier('Pro')?.id).toBe('pro')

View File

@@ -92,3 +92,36 @@ export function findPlanTier(name: string | undefined | null) {
const key = name.trim().toLowerCase()
return PLAN_TIERS.find((tier) => tier.id === key)
}
/**
* What a yearly plan works out to per month, so the saving is legible without
* making the reader divide. Derived rather than stored: a hardcoded figure
* silently goes wrong the moment a price changes.
*/
export function monthlyEquivalent(tier: PlanTier): number | undefined {
if (!tier.yearlyPrice) return undefined
return tier.yearlyPrice / 12
}
/** Percentage saved by paying yearly, rounded to a whole number. */
export function yearlySavingPercent(tier: PlanTier): number | undefined {
if (!tier.yearlyPrice || tier.monthlyPrice <= 0) return undefined
const yearOfMonthly = tier.monthlyPrice * 12
return Math.round(((yearOfMonthly - tier.yearlyPrice) / yearOfMonthly) * 100)
}
/**
* The caption under a headline per-month figure, e.g. "billed yearly at
* $99.99, or $9.99 monthly". Assumes the per-month figure is already shown
* above it, so it does not repeat it.
*/
export function formatPriceCaption(tier: PlanTier): string {
const perMonth = monthlyEquivalent(tier)
if (perMonth !== undefined && tier.yearlyPrice !== undefined) {
return `billed yearly at ${formatPlanPrice(tier.yearlyPrice)}, or ${formatPlanPrice(
tier.monthlyPrice,
)} monthly`
}
if (tier.monthlyPrice <= 0) return 'no card required'
return `${formatPlanPrice(tier.monthlyPrice)} billed monthly`
}