# 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 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.
