mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
149
web/e2e/checkout.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user