# Checkout loader lifecycle

Implementation scope: the route-local Paddle payment-link page and the shared
plan button's pending treatment. No provider switch, server changes, deployment,
production payment, subscription mutation, credentials or CSP widening.

## Authoritative Paddle references

Verified against the Paddle developer documentation during implementation:

- https://developer.paddle.com/paddle-js/events/checkout-loaded
  `checkout.loaded` is emitted when a checkout is created and loaded on the page,
  once per checkout. This is the overlay-open/loading settlement signal. There is
  no `checkout.opened` listener in this implementation; the browser regression
  deliberately fires that invented name as a negative control. A `ready` value
  in `data.status` describes payment-field completeness, not initial UI readiness.
- https://developer.paddle.com/paddle-js/events/checkout-error
  `checkout.error` is a fatal checkout startup error. A warning or failed payment
  attempt is not equivalent: neither should tear down a customer's usable checkout.
- https://developer.paddle.com/paddle-js/events/checkout-completed
  Completion may mean instant capture or authorization pending capture. The page
  says "Checkout completed", not "Payment received". Backend webhooks, not this
  browser event, remain authoritative for account/credit state.
- https://developer.paddle.com/paddle-js/events/checkout-closed
  Both user dismissal and `Paddle.Checkout.close()` may emit this event. Mark a
  startup failure terminal BEFORE closing so it cannot become a canceled redirect.
- https://developer.paddle.com/paddle-js/methods/paddle-initialize
  Initialize is allowed only once per page. A page ref and module-level guard
  protect against repeated effects, refetches, concurrent calls and SPA revisits.
- https://developer.paddle.com/build/transactions/default-payment-link
  Explicit `Checkout.open({transactionId})` takes priority over automatic `_ptxn`
  handling. Transaction validation and trusted same-origin URL checks are unchanged.

## State and recovery decisions

- Loading spans summary, CDN script and the interval between `Checkout.open`
  returning and `checkout.loaded`. Resolving `openPaddleCheckout` is NOT readiness.
- The checkout summary uses its own `["billing", "checkout", transaction]` snapshot
  rather than sharing the sidebar credits query's in-flight promise. Otherwise the
  sidebar's request can bypass the checkout timeout and network-error handling.
  The sidebar still makes its ordinary summary request even for an invalid link.
- Summary fetch, script load and overlay startup each have a 20-second bound.
  These are local UX limits, not Paddle's SLA or a payment deadline. There is no
  deadline on a loaded overlay while the customer is entering payment details.
- HTTP, network, SDK-global/script, invalid transaction, disabled/wrong provider,
  missing/invalid configuration, thrown startup and `checkout.error` failures
  render an alert instead of an animation. No arbitrary provider event detail is
  rendered. The SDK is never requested with an invalid transaction/config/provider.
- Loaded events are idempotent; completed/closed/failure are terminal. Duplicates
  and late events cannot restart the animation, cancel a completed checkout, or
  schedule repeated redirects. Completion retains the existing 1.5-second delay;
  cancellation retains the full navigation to `/billing?checkout=canceled`.
- Retry is an ordinary same-origin link to the validated payment-link URL. It
  reloads the document instead of calling Initialize a second time. Back to
  billing is also a full navigation and is available during loading and errors.
  Leaving via the SPA navigation during SDK load suppresses late initialization;
  revisiting without a reload offers the same safe recovery rather than re-init.
- Existing sandbox selection, trusted URL/transaction helpers, missing-email
  remediation, route-local SDK loading and nginx CSP are preserved.

## Accessibility and presentation

A lime-accent ring and lock mark match the existing dark dashboard; animation is
purely decorative (`aria-hidden`). A stable polite `role=status`, atomic text and
screen-reader-only neutral label announce "Loading checkout…" once, not again for
each asynchronous stage. Loading errors use an alert and clear that status. The
spinner is removed at `checkout.loaded`/terminal/failure, not simply hidden behind
the provider. The card keeps the same bounding box between loading and ready.
`motion-safe:animate-spin` with `motion-reduce:animate-none` leaves a static mark
under reduced motion. Selected plan buttons show a small decorative spinner plus
"Processing…", retain their plan-specific accessible name, expose `aria-busy`
and disable all plan actions while pending. No full-page loader is used in buttons.
Those buttons render inside the shared Billing Upgrade picker, so the two
`/billing` plan-control regressions open that picker first; the pending
treatment itself is unchanged by that composition.

## Scoped verification

Run inside the isolated worktree with Node 24 (system Node 12 is insufficient):

    export PATH=/home/nyanpasu/.local/share/fnm/node-versions/v24.22.1/installation/bin:$PATH
    corepack pnpm install --frozen-lockfile
    corepack pnpm --filter @scrapingcow/ui typecheck
    corepack pnpm --filter @scrapingcow/ui build
    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/checkout-loader.test.mjs apps/ui/tests/upgrade-sheet.test.mjs

`checkout-loader.test.mjs` drives the actual built route and SDK adapter in Chrome.
Only API and CDN boundaries are mocked; the SDK mock is intentionally inert until
tests fire documented events. Unexpected external requests are aborted, service
workers are blocked, and no provider is contacted. Set `CHECKOUT_EVIDENCE_DIR` to
save desktop, mobile, reduced-motion, ready, completed and failure screenshots.
The "ready" screenshot shows our background state, NOT a real Paddle overlay.
Tests also measure bounding boxes, live-region text mutations, reduced-motion
computed styles, safe reload recovery, one-time initialization and terminal events.

Full UI suites and independent integrated-commit desktop/mobile/accessibility and
local live-stack verification belong to reviewer t_93d09268. A local mocked pass
does not prove provider availability, real overlay rendering, production behavior,
webhook delivery or deployment. Manual assistive-technology listening and real
sandbox provider compatibility remain distinct from DOM/announcement evidence.
