Billing regression coverage
How the dashboard's Billing surface is regression-tested, what each layer proves, and what is deliberately out of scope. Written for the Billing regression infrastructure task (t_cf42b93d); the defect-specific reproduction work builds on this.
Baseline (verified 2026-11-03, commit 8cf06cd)
node --test apps/ui/tests/billing.test.mjs apps/ui/tests/paths.test.mjs
→ 17 pass / 0 fail / 0 skipped, exit 0 (node v26.7.0, ~108 ms). These cover pure presentation/URL logic in lib/billing.ts only — no page renders, no network, no Paddle runtime. A green run here never proves checkout or the rendered page work.
Layers
| Layer | File | Provider | Proves |
|---|---|---|---|
| Pure logic | tests/billing.test.mjs, tests/paths.test.mjs | both (unit) | Redirect-host validation, _ptxn parsing, entitlement/limit formatting, denial mapping, deep-link login. |
| Plan picker (in-place upgrade sheet) | tests/upgrade-sheet.test.mjs | provider: "stripe" mock | Picker opens from every route, focus trap, catalog retry, non-owner gating, updated:true in-place notice. Does not verify Paddle runtime behavior — the summary is mocked with provider='stripe', so nothing here loads Paddle.js or follows a Paddle link. |
| Billing routes (provider-mocked) | tests/billing-routes.test.mjs | provider: "paddle" mock | The real /billing and /billing/checkout pages in a real browser: plan/status/stat rendering, entitlement table values, unconfigured + non-owner gating, checkout redirect to <origin>/billing/checkout?_ptxn=…, Paddle.js load + Initialize/Checkout.open args, completed/closed returns, updated:true, foreign-host refusal, missing-config errors, portal navigation to the provider host. |
| Live stack | tests/e2e-live.mjs ("billing" describe) | unconfigured (no provider) | The authenticated live page against the real API/DB (Business period seeded via enableSchedules()): plan card, account status, four credit metrics, all five entitlement rows with their enforced values. No payment flow — Stripe/Paddle are deliberately out of scope in the live stack. |
Reusable fixtures — tests/billing-fixtures.mjs
Plain data factories for page.route() mocking, no network I/O:
billingSummary({...})and the named statesfreeSummary,paidSummary,noPeriodSummary,unconfiguredSummary,canceledSummary— summary shapes perapps/api-rust/src/billing/mod.rs::summary/billing/openapi.rs(account,period,configured,live_mode,provider,paddle_js).periodFor(planId)/entitlementsFor(planId)— period + capability envelope per plan;remainingderived asallowance - used - reservedlike the server. The free plan is not inbilling/plans.json(catalog is paid tiers only) — itsbilling_plansrow values are inlined in the fixture.catalogPlans({available})— the checked-inbilling/plans.jsonplus the server-addedavailableflag, wrapped as{plans, currency:"usd", interval:"month"}.paddleCheckoutUrl(origin)({url: "<origin>/billing/checkout?_ptxn=txn_…"}),UPDATED_IN_PLACE({updated:true}),PORTAL_RESPONSE({url}on the provider's own https host).PADDLE_STUB_JS— served in place ofhttps://cdn.paddle.com/paddle/v2/paddle.js; recordsEnvironment.set/Initialize/Checkout.opencalls and exposeswindow.__paddle.completed()/.closed()to fire lifecycle events, so the reallib/paddle.tsloader runs against a deterministic provider.assertSummaryContract/assertCatalogContract— key-set + derived-value pins so a server contract change breaks every consumer loudly.
tests/browser-sweep.mjs::serveDashboard gained an opt-in { https: true } mode (throwaway openssl self-signed cert). The billing controller refuses any checkout/portal URL that is not https: on a trusted provider host, so the redirect paths can only be exercised over a genuine https origin. Callers must accept the certificate (ignoreHTTPSErrors). The default http mode and every existing caller are unchanged.
Known gaps (deliberate, for the later defect regression)
- No live provider. Nothing here contacts Paddle/Stripe; checkout, webhook confirmation (
subscription.updated→ plan grant) and portal are provider-mocked. A real charge/subscription test needs sandbox credentials and separate authorization. upgrade-sheet.test.mjsis Stripe-only. Itsupdated:trueand gating coverage does not exercisesummary.provider='paddle'; the Paddle runtime half lives inbilling-routes.test.mjs, which also exercises the shared upgrade picker under Paddle for missing-email remediation.- The live-stack billing test is render-only. It seeds a Business period directly in SQL and asserts the rendered page; it cannot see checkout, portal, webhook reconciliation, or
updated:trueagainst the real API, because the live stack runs with no payment provider configured. - Webhook → grant path. The Paddle webhook reconciliation (
subscription.updatedgranting the paid period) is covered by Rust tests (apps/api-rust/src/billing/{upgrade_tests,webhook_tests}.rs), not by any UI-level test; the UI side of "plan updates after the provider confirms" (10 s refetch) is only implied by theupdated:truerefetch test. - Deployed-revision skew. Production CSP/nginx config (
deploy/nginx/scrapingcow-https.conf, route-specific CSP for/billing/checkout) is not exercised by any local fixture; a production Paddle failure that smells like a blocked CDN script must be checked against the served revision, not these tests.
Confirmed missing-email remediation (t_4287e2c2)
The upstream live investigation reproduced POST /platform/v1/billing/checkout returning 400 for a username-only owner with email = NULL and no provider customer. The page displayed the error but offered no path to fix it. This is an actionable-error UX fix, not proof of the reporter's unspecified payment failure or a change to payment-provider behavior. The reported portal 400 ("Subscribe before opening the billing portal") is a separate expected no-customer state, not an email-verification failure.
Source trace: billing/mod.rs::checkout reads dashboard_session_profile.email and calls provider.create_customer only when customer_id is absent; paddle.rs::create_customer refuses None. Migration 0042's session profile returns email and email_verified separately. Despite the 400's wording ("verified billing email"), the current API checks address presence, not the verification flag. This UI change deliberately does not add a verification policy or change the backend contract.
useBilling exposes the requirement only for owner + configured Paddle + no customer + no linked email. Shared BillingPlans explains it, links to /settings/security, and disables checkout until an email is linked. The native link also leaves any open upgrade sheet so it cannot hide the form. Existing customers (including no-active-period subscriptions), linked emails, Stripe, non-owners and unconfigured deployments keep their existing behavior. Strict redirect validation, summary.provider, updated:true, API owner permissions and production configuration are untouched.
Six browser regressions extend billing-routes.test.mjs. They drive the real Billing/upgrade/settings routes, submit the real request/confirm forms through mocked auth endpoints, patch the session identity, return to Billing, and resume checkout. Adjacent cases cover absent/existing customers with no active period, paid in-place updates and portal navigation without a linked email, Stripe, linked-but-unverified email, member/viewer roles, disabled billing and summary errors. No provider or email service is contacted; API owner rejection remains a Rust/API review responsibility, not a claim from these UI mocks.
Red/green (Node v26.7.0, Chromium, BROWSER_SWEEP_REQUIRED=1):
# Build each isolated checkout's actual source (pnpm is not on this worker's PATH):
(cd apps/ui && node node_modules/typescript/bin/tsc --noEmit && node node_modules/vite/bin/vite.js build)
# Run the same final regression file copied into the unfixed e27e486 baseline:
BROWSER_SWEEP_REQUIRED=1 node --test --test-name-pattern='missing billing email:' apps/ui/tests/billing-routes.test.mjs
Unfixed e27e486: exit 1, 3 fail / 3 pass / 0 skipped. Missing remediation fails on the free/new-account page, absent-customer/no-period page, and shared upgrade picker; adjacent unaffected cases pass.
BROWSER_SWEEP_REQUIRED=1 node --test apps/ui/tests/billing.test.mjs apps/ui/tests/paths.test.mjs apps/ui/tests/billing-routes.test.mjs apps/ui/tests/upgrade-sheet.test.mjs
Fixed source: exit 0, 64 pass / 0 fail / 0 skipped; dashboard TypeScript check and Vite build also pass. Full UI glob, live-stack/API billing suites and any deployed/sandbox payment acceptance remain with the downstream QA card. This worker makes no deployment, real payment, webhook-delivery or sandbox-to-live configuration claim.
Running
pnpm --filter @scrapingcow/ui build # dashboard bundle (needed by the browser layers)
node --test apps/ui/tests/*.test.mjs # pure logic + provider-mocked routes + picker
DATABASE_URL=… SCRAPINGCOW_SECRET_KEY=… node --test apps/ui/tests/e2e-live.mjs # live stack (see file header)
The browser layers skip (or hard-fail with BROWSER_SWEEP_REQUIRED=1) when the build, Playwright or Chrome is missing — same policy as the existing sweep.