From 0bd5d4c90088b5c90f9c3132ad13e13cdc3a546d Mon Sep 17 00:00:00 2001 From: isra el Date: Mon, 20 Jul 2026 21:47:17 +0300 Subject: [PATCH] fix(billing): send the plan name from route params under Next 16 /checkout/pro and /checkout/scale failed with "Plan cannot be purchased" for every user since the Next 16 upgrade (ea1b00c). The checkout page still typed `params` as a plain object and read `params.planName` synchronously. Next 16 removed the sync compat shim, so the value was undefined, axios dropped the key, and the API ran findOne({ name: undefined }). Mongoose 9 returns null for that where older versions stripped the key, so the guard threw a message about a misconfigured plan for a request that never named one. Plan data and Polar product ids were correct throughout. - unwrap params with React's use() and drop the `as string` cast that suppressed the type error - split the conflated guard into PLAN_NAME_REQUIRED, PLAN_NOT_FOUND and the existing unpurchasable case, each logged server side - stop retrying a rejected checkout twice: a 400 is not transient - mark the Polar product id indexes sparse, they have been failing to build with E11000 on every boot because most plans leave them unset Checkout now defaults to yearly. A URL that names an interval still redirects straight through, so the marketing funnel is unchanged; the in-app CTAs that name none get an interval chooser with yearly preselected instead of a silent monthly default. Also redesigns the checkout states, which were the only place in the dashboard hardcoding gray-100/white and so rendering a light slab in dark mode, and the plan picker, which painted the accent on every tier. Tests: a checkout e2e spec asserting the request body carries planName, which is what would have caught this, plus API cases pinning the three guard failures apart. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/src/billing/billing.service.spec.ts | 75 ++++ api/src/billing/billing.service.ts | 26 +- api/src/billing/schemas/plan.schema.ts | 7 +- web/app/(app)/checkout/[planName]/page.tsx | 386 +++++++++++++----- .../get-started/plan-picker.test.tsx | 26 +- .../(components)/get-started/plan-picker.tsx | 81 ++-- web/e2e/checkout.spec.ts | 149 +++++++ web/e2e/mock-api.ts | 10 + web/lib/plans.test.ts | 75 +++- web/lib/plans.ts | 33 ++ 10 files changed, 729 insertions(+), 139 deletions(-) create mode 100644 web/e2e/checkout.spec.ts diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index 2c5a806..f73101f 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -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) + 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') + }) +}) diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index b7486ff..7780156 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -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') } diff --git a/api/src/billing/schemas/plan.schema.ts b/api/src/billing/schemas/plan.schema.ts index ff18240..7b2e6d2 100644 --- a/api/src/billing/schemas/plan.schema.ts +++ b/api/src/billing/schemas/plan.schema.ts @@ -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 }) diff --git a/web/app/(app)/checkout/[planName]/page.tsx b/web/app/(app)/checkout/[planName]/page.tsx index 4dc07f0..8c51fdf 100644 --- a/web/app/(app)/checkout/[planName]/page.tsx +++ b/web/app/(app)/checkout/[planName]/page.tsx @@ -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 ( +
+
+ {children} +
+
+ ) +} + export default function CheckoutPage({ params, }: { - params: { planName: string } + params: Promise<{ planName: string }> }) { const [error, setError] = useState(null) const [planChange, setPlanChange] = useState(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('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 ( + +
+ +

Loading

+
+
+ ) } if (error) { return ( -
-
{error}
- -
+ +
+
+

We could not start checkout

+

{error}

+
+
+ + +
+
+
) } if (planChange) { return ( -
-
-

- Confirm your plan change -

-
- - {formatPlan(planChange.currentPlan, planChange.currentInterval)} - - - {formatPlan(planChange.newPlan, planChange.newInterval)} -
-

- 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.'} -

- {planChange.cancelAtPeriodEnd && ( -

- Your subscription is currently scheduled to cancel at the end of - the billing period. Changing your plan will remove the scheduled - cancellation. -

- )} -
- - -
+ +

Confirm your plan change

+ +
+ + {formatPlan(planChange.currentPlan, planChange.currentInterval)} + + + {formatPlan(planChange.newPlan, planChange.newInterval)}
-
+ +

+ 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.'} +

+ + {planChange.cancelAtPeriodEnd && ( +

+ Your subscription is currently scheduled to cancel at the end of the + billing period. Changing your plan will remove the scheduled + cancellation. +

+ )} + +
+ + +
+ ) } + // 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 ( + +
+ + +
+

+ {tier + ? `Setting up your ${tier.name} ${intervalLabel} checkout` + : 'Setting up your checkout'} +

+

+ Redirecting you to Polar, our payment provider. This only takes a + moment. +

+
+ + {tier && price !== undefined && ( +

+ {tier.name}, {intervalLabel}, {formatPlanPrice(price)} +

+ )} + + {isSlow && ( +

+ Still working, hang on. +

+ )} +
+
+ ) + } + + // 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 ( -
- -

Hang Tight!

-

- We're processing your order. This won't take long! + +

+ {tier ? `Upgrade to ${tier.name}` : 'Choose your billing'} +

+

+ Pick how you would like to be billed.

- - - Thank you for your patience! - -
+ +
+ Billing interval +
+ {tier?.yearlyPrice !== undefined && ( + + )} + + +
+
+ + + +

+ Cancel anytime, keep access until the end of your billing period. +

+ ) } diff --git a/web/app/(app)/dashboard/(components)/get-started/plan-picker.test.tsx b/web/app/(app)/dashboard/(components)/get-started/plan-picker.test.tsx index c3a4aa3..6b2d795 100644 --- a/web/app/(app)/dashboard/(components)/get-started/plan-picker.test.tsx +++ b/web/app/(app)/dashboard/(components)/get-started/plan-picker.test.tsx @@ -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', () => { diff --git a/web/app/(app)/dashboard/(components)/get-started/plan-picker.tsx b/web/app/(app)/dashboard/(components)/get-started/plan-picker.tsx index 6528e92..ab101ec 100644 --- a/web/app/(app)/dashboard/(components)/get-started/plan-picker.tsx +++ b/web/app/(app)/dashboard/(components)/get-started/plan-picker.tsx @@ -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 ( - {(highlight || isCurrent) && ( - - {isCurrent ? 'Current' : 'Most popular'} - - )} + +
+ {tier.name} + {(highlight || isCurrent) && ( + + {isCurrent ? 'Current' : 'Most popular'} + + )} +
- - {tier.name} - - - {formatPlanPrice(tier.monthlyPrice)} - - {' / month'} - +
+
+ + {formatPlanPrice(perMonth ?? tier.monthlyPrice)} + + /month +
+ + {formatPriceCaption(tier)} + + {saving !== undefined && ( + + Save {saving}% yearly + + )} +
@@ -79,10 +101,7 @@ function PlanCard({ )} > {feature} @@ -91,7 +110,7 @@ function PlanCard({ - + {isCurrent ? ( ) : ( - + <> + {/* above the button so every card's CTA lands on one baseline */} +

+ Cancel anytime, keep access until the end of your billing period. +

+ + )}
diff --git a/web/e2e/checkout.spec.ts b/web/e2e/checkout.spec.ts new file mode 100644 index 0000000..100fe68 --- /dev/null +++ b/web/e2e/checkout.spec.ts @@ -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) + }) +}) diff --git a/web/e2e/mock-api.ts b/web/e2e/mock-api.ts index c4c72b3..67419e3 100644 --- a/web/e2e/mock-api.ts +++ b/web/e2e/mock-api.ts @@ -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) diff --git a/web/lib/plans.test.ts b/web/lib/plans.test.ts index 614ffdf..42b67e6 100644 --- a/web/lib/plans.test.ts +++ b/web/lib/plans.test.ts @@ -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') diff --git a/web/lib/plans.ts b/web/lib/plans.ts index 69a247c..83b8236 100644 --- a/web/lib/plans.ts +++ b/web/lib/plans.ts @@ -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` +}