Files
textbee/web/e2e/checkout.spec.ts
isra el 0bd5d4c900 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) <noreply@anthropic.com>
2026-07-20 21:47:17 +03:00

150 lines
4.5 KiB
TypeScript

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)
})
})