Skip to content

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 ​

LayerFileProviderProves
Pure logictests/billing.test.mjs, tests/paths.test.mjsboth (unit)Redirect-host validation, _ptxn parsing, entitlement/limit formatting, denial mapping, deep-link login.
Plan picker (in-place upgrade sheet)tests/upgrade-sheet.test.mjsprovider: "stripe" mockPicker 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.mjsprovider: "paddle" mockThe 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 stacktests/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 states freeSummary, paidSummary, noPeriodSummary, unconfiguredSummary, canceledSummary — summary shapes per apps/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; remaining derived as allowance - used - reserved like the server. The free plan is not in billing/plans.json (catalog is paid tiers only) — its billing_plans row values are inlined in the fixture.
  • catalogPlans({available}) — the checked-in billing/plans.json plus the server-added available flag, 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 of https://cdn.paddle.com/paddle/v2/paddle.js; records Environment.set / Initialize / Checkout.open calls and exposes window.__paddle.completed() / .closed() to fire lifecycle events, so the real lib/paddle.ts loader 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) ​

  1. 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.
  2. upgrade-sheet.test.mjs is Stripe-only. Its updated:true and gating coverage does not exercise summary.provider='paddle'; the Paddle runtime half lives in billing-routes.test.mjs, which also exercises the shared upgrade picker under Paddle for missing-email remediation.
  3. 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:true against the real API, because the live stack runs with no payment provider configured.
  4. Webhook → grant path. The Paddle webhook reconciliation (subscription.updated granting 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 the updated:true refetch test.
  5. 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.