Epic 005 — Mocking Bird checkout

Status

Ready

Motivation / background

Mocking Bird started as generic local-development mocking tooling (Epic 002, AZ-009) but its first real use became email interception, and the code now treats Mocking Bird as an email-only feature: the instance exists only when MAIL_MODE=mock, and the developer console routes exist only when that instance exists. Meanwhile the application now has a real payment integration (Surfboard, ADR 004) selected purely by CHECKOUT_MODE, and local development has no way to inspect or steer a checkout interactively — local runs use the deterministic fake, which is deliberately non-interactive automation infrastructure.

This epic does two conceptual steps:

  1. Generalize Mocking Bird back into a development-time mocking/debugging surface that can host multiple mocked integrations, with email interception becoming one capability inside it.
  2. Add a Mocking Bird payment mode so a commercial checkout request sent in local development appears inside Mocking Bird, where a developer can inspect it richly and manually choose the payment outcome, driving the application's normal downstream handling.

The intended environment mapping is configuration-driven end to end (see Environment and provider matrix).

Current state

Mocking Bird today

What causes the email-mode coupling

Exactly three coupled points exist; the frontend was never email-coupled:

  1. Instance construction — backend/src/server.ts lines 44–45 create MockingBird only when config.MAIL_MODE === "mock". With MAIL_MODE=disabled (the local default) or MAIL_MODE=real, no instance exists.
  2. Pass-through to the app — backend/src/server.ts lines 86–94 pass mockingBird into buildApp({ auth: ... }) only inside the developerEmail conditional (MAIL_MODE === "mock" || (MAIL_MODE === "real" && MAIL_REAL_TEST_SEND_ENABLED && MAIL_TEST_RECIPIENTS.length > 0)), so even a hypothetical instance would not reach the route layer in normal local runs.
  3. Route registration — backend/src/auth-http.ts line 597 registers all /api/developer/mocking-bird* routes only when options.mockingBird is present, and consoleState() hardcodes mode: "mock" because routes only ever existed in mock mode.

Note what is not coupled: MOCKING_BIRD_ENABLED in backend/src/config.ts (default false) already validates independently of MAIL_MODE (hosted rejects it at lines 204–215; mock mail requires it at lines 194–203). The config layer permits "bird enabled, mail disabled" today — only server.ts collapses the two concepts.

Payment / checkout today

Environment selection today

Goals

Non-goals

Terminology

Repository terminology wins over wording from planning prompts:

Environment and provider matrix

Environment Deployment class Checkout adapter Selected by Credentials
Production DEPLOYMENT_ENV=production (hosted) Surfboard Live CHECKOUT_MODE=surfboard Live values from the secret system
Staging DEPLOYMENT_ENV=staging (hosted) Surfboard Demo (test) CHECKOUT_MODE=surfboard Demo values from the secret system
Local development DEPLOYMENT_ENV=local Mocking Bird CHECKOUT_MODE=mocking-bird (new) none required
Automated tests non-hosted runs driven by test harnesses Deterministic fake CHECKOUT_MODE=fake pinned by harness none

Rules that enforce the matrix (all configuration, no source edits):

Alternative providers later remain pure configuration: another CHECKOUT_MODE value plus another adapter registered in createCheckoutGateway, exactly the extension point ADR 004 describes.

Relevant existing architecture

Proposed architecture

Five separated pieces, each reusing an existing mechanism:

  1. Payment adapter — MockingBirdCheckoutGateway implements CheckoutGateway in backend/src/integrations/mocking-bird-checkout-gateway.ts, selected by createCheckoutGateway when CHECKOUT_MODE=mocking-bird. It is the object CheckoutService calls; it contains no HTTP, no UI, and no storage-layout knowledge beyond the store's API.
  2. Storage / state — MockingBirdCheckoutStore in backend/src/integrations/mocking-bird-checkout.ts: an in-memory, capacity-bounded registry of checkout entries plus the settle mechanism (one parked charge promise per pending entry). It is the single owner of pending/settled state, mirroring how the email module owns capture state.
  3. Developer API — three new routes registered inside the existing if (options.mockingBird) block in backend/src/auth-http.ts, under /api/developer/mocking-bird/checkout*, reusing requireCurrentUser, consoleMutation, the no-store prefix hook, parseJsonBody, and the existing rate-limit groups.
  4. Mocking Bird UI — a new "Checkout" section on the existing frontend/src/pages/MockingBirdConsolePage.tsx plus new operations in frontend/src/app/api/mocking-bird-api.ts (same query-key root, same polling, same card and <details> patterns). No new page and no new navigation entry.
  5. Outcome to result mechanism — charge() registers the entry and returns a promise that settles only when the developer triggers an outcome (or the store is cleared); the resolved CheckoutResult flows back through CheckoutService normalization and OrderService.finalizeCommercial unchanged. Nothing bypasses the port.

Constraint on any restructuring discovered during implementation: the application must keep calling the existing CheckoutGateway port, and the developer API must not reach into application services — it settles entries through the store, and the store resolves the parked charge that the application is already awaiting.

Mocking Bird generalization

Target structure

Current: one class in backend/src/integrations/mocking-bird.ts that is the email interceptor.

Target (flat files, matching the existing flat integrations/ layout so the ESLint boundary pattern ../integrations/* keeps matching every module):

File Exports Responsibility
backend/src/integrations/mocking-bird.ts class MockingBird Facade: owns capability modules of one Mocking Bird instance
backend/src/integrations/mocking-bird-email.ts class MockingBirdEmail The current interceptor, moved verbatim (transport, scenarios, latency, captures, previews, capacity)
backend/src/integrations/mocking-bird-checkout.ts class MockingBirdCheckoutStore Pending/settled checkout entries + settle mechanism
backend/src/integrations/mocking-bird-checkout-gateway.ts class MockingBirdCheckoutGateway CheckoutGateway adapter delegating to the store

What changes in the console state contract

Current: consoleState() in backend/src/auth-http.ts returns { mode: "mock", ... } with the mail mode hardcoded.

Change: the state endpoint returns the actual MAIL_MODE ("mock" | "real" | "disabled") supplied through AuthHttpOptions, plus checkout counters (see Backend / API changes). The frontend already models MockingBirdMode = "mock" | "real" | "disabled" and already disables scenario/test-send actions when mode !== "mock", so no frontend contract change is needed for the mail part.

Removing the email-mode coupling

Current (the three coupled points listed above) versus change:

  1. backend/src/server.ts: construct the facade when Mocking Bird is enabled — const mockingBird = config.MOCKING_BIRD_ENABLED ? new MockingBird(new Set(config.MAIL_TEST_RECIPIENTS)) : undefined; — with no reference to MAIL_MODE. backend/src/config.ts already guarantees hosted deployments never enable Mocking Bird, so enabled implies local/test.
  2. backend/src/server.ts: pass the instance to buildApp through its own option, independent of the developerEmail conditional, as mockingBird: { bird: mockingBird, mailMode: config.MAIL_MODE } (a new AuthHttpOptions.mockingBird shape; mailMode only feeds the console state). The developerEmail option keeps its current condition and still gates POST /api/developer/email-test.
  3. backend/src/server.ts: attach the interceptor only in mock mail mode — config.MAIL_MODE === "mock" ? mockingBird?.email.transport : undefined passed to ResendEmailSender. This guard becomes explicit precisely because the instance now exists in real/disabled local runs; without it, local real mail would be intercepted, which AZ-009 forbids. Config already rejects MAIL_MODE=mock without Mocking Bird enabled, so the mock branch always has an instance.
  4. backend/src/auth-http.ts: keep if (options.mockingBird) as the single route gate (it now means "Mocking Bird exists", not "mail is mocked"), read mailMode from the option instead of hardcoding "mock", and keep every existing protection unchanged.
  5. backend/src/config.ts: derive MOCKING_BIRD_ENABLED when unset — true when DEPLOYMENT_ENV is local/test, false when hosted — so a local start always has Mocking Bird available without any mail-related setting. An explicit MOCKING_BIRD_ENABLED=false remains a valid local opt-out; an explicit true in hosted remains a startup failure (existing validation, path MOCKING_BIRD_ENABLED, message "developer email features are unavailable in hosted mode" — reworded to "developer tooling" once the flag outlives mail).

The scenario PATCH endpoint stays accepted in every mode (the value is inert without an attached interceptor); the UI already disables scenario and test-send actions when mode !== "mock", and AZ-009's "scenario changes apply only in mock mode" is satisfied by that existing effect boundary plus the explicit transport guard in step 3.

Payment integration design

CHECKOUT_MODE=mocking-bird

Current: CHECKOUT_MODE: z.enum(["disabled", "fake", "surfboard"]).default("disabled").

Change (all in backend/src/config.ts + backend/src/integrations/checkout-composition.ts):

The parked charge: how a manual choice becomes a normal payment result

CheckoutGateway.charge(request, context?) is the only seam through which any payment result reaches the application, and OrderService.finalizeCommercial awaits it inside the POST /api/orders/commercial request. The repository offers no other injection point:

Therefore the Mocking Bird adapter implements the charge as an interactive wait:

  1. MockingBirdCheckoutGateway.charge(validatedRequest, context) calls store.register(request, context), which appends a pending entry and returns its entry id plus a promise.
  2. The gateway returns that promise. CheckoutService and finalizeCommercial simply keep awaiting it — the operator's finalize request stays in flight exactly as it does today while Surfboard converges through create-plus-poll (up to SURFBOARD_STATUS_DEADLINE_MS). The pending entry in the console is the Mocking Bird analogue of the provider's non-terminal statuses (PAYMENT_INITIATED/PAYMENT_PROCESSING).
  3. When the developer triggers an outcome, the developer API calls store.settle(entryId, trigger), which resolves the parked promise with the mapped CheckoutResult. The already-running finalize request continues through CheckoutService normalization and finalizeCommercial outcome handling unchanged, then responds (201 or 409).
  4. Settling is immediate from the developer API's perspective (the POST .../outcome response returns the settled entry), while the order-side effect appears when the parked finalize request completes. No clocks, deadlines, or timers decide any outcome: the developer explicitly chooses, including choosing "timeout" (see Supported outcomes). waitMs is recorded only as debug information about how long the entry was parked.

Concurrency safety inside one process is the store's map: each parked promise resolves at most once; a second settle for the same entry is rejected (409) rather than re-resolving.

Abandonment: if a developer never settles a pending entry, the finalize request stays parked indefinitely (no server-side response deadline exists for POST /api/orders/commercial, and this spec deliberately adds none). The mitigation is the checkout clear operation: DELETE /api/developer/mocking-bird/checkout first settles every pending entry as unknown (a decision never arrived — exactly the lost-certainty case) with a settleNote of cleared while pending, then empties the store. The store therefore never silently strands a parked charge. A process restart discards the store together with the in-flight request, which is the documented single-process, memory-only semantics of Mocking Bird (AZ-009); the durable claim row then answers any repeat of that operationId with CHECKOUT_IN_PROGRESS, the same behavior every adapter has after a crash mid-charge.

Reference policy: every settled result carries providerReference = "mocking-bird-" + operationId (new exported constant MOCKING_BIRD_CHECKOUT_REFERENCE_PREFIX). The pending entry is the provider-side artifact, and the neutral contract states that a reference only proves an attempt exists — it never changes the outcome class. This mirrors the Surfboard adapter, whose post-create outcomes all carry the created order id; the deterministic fake's null references for refusals remain its own choice.

Diagnostics: on settle, the gateway emits the same redacted event shape Surfboard uses — {category: "integration", operation: "payment.initiate", outcome, durationMs, requestId, provider: {label: "mocking-bird"}} through the DiagnosticReporter already passed to createCheckoutGateway. No request body, amount, or identifier beyond requestId crosses into logs.

Checkout request lifecycle

Developer UI behavior

The /developer/mocking-bird page gains one Card section, "Checkout requests", alongside the existing scenario, test-send, history, and previews cards. It follows AZ-009's mobile-first card/list pattern with an accessible native <details> disclosure per entry — never a horizontally scrolling desktop table. This UI is for developers: it favors completeness over minimalism and does not hide technical data.

Three display levels per entry:

  1. Concise summary (the <summary> line): status chip (pending / succeeded / rejected / cancelled / unknown), short operationId, gross total via the existing formatGrossPrice(minorUnits, language) helper (frontend/src/app/format/product-format.ts), line count, and relative received time.
  2. Structured details (definition list, mirroring CaptureItem): full operationId, entry id, status, absolute receivedAt/settledAt, trigger, mapped outcome, providerReference, waitMs, correlation requestId, currency, grossAmountMinor, and a per-line table of name, quantity, grossUnitMinor, derived line total (grossUnitMinor * quantity), vatBasisPoints (with percent), plus the reconciliation note that line totals sum to grossAmountMinor (guaranteed by CheckoutService validation).
  3. Raw/debug (escaped <pre>): the complete CheckoutRequest JSON exactly as validated, next to the entry metadata. Rendered as text — never interpreted.

Honesty about absent fields: the neutral contract deliberately excludes customer identity, return/callback URLs, and payment-method parameters (checkout.ts documents this as MVP-TECH-002 discovery discipline), so those sections do not exist and must not be invented or "shown empty" as if data were withheld — the raw layer shows everything the application actually sent. When a future provider discovery widens the contract, the raw layer picks the new fields up automatically.

Controls and behavior:

Supported outcomes (exact set derived from the repository)

The authoritative set is the application's neutral contract, not this prompt: CheckoutOutcome in backend/src/application/checkout.ts is exactly "succeeded" | "rejected" | "cancelled" | "unknown", and CheckoutService/OrderService.finalizeCommercial act on exactly those four. Surfboard's provider vocabulary maps into them inside its adapter (create-plus-poll, terminal statuses PAYMENT_COMPLETED/PAYMENT_FAILED/PAYMENT_CANCELLED, create-time ERROR, and transport/deadline/malformed failures as lost certainty). Mocking Bird therefore offers the four outcome classes; unknown is offered through the two distinct causes the repository already names — a timeout and a provider error/indeterminate response — so the developer explicitly picks between timeout and non-timeout paths without any real clock being involved. The trigger vocabulary deliberately mirrors the existing fixed email scenario names (provider-error, timeout).

UI action (button label) API trigger value Maps to CheckoutOutcome
Payment succeeded succeeded succeeded
Payment declined rejected rejected
Customer cancelled payment cancelled cancelled
Provider timeout timeout unknown
Provider error / no response provider-error unknown

For every trigger: no additional developer input is required (no amount, no reason text — the fixed vocabulary is the input), the settle call is immediate, and the observable application effect appears on the already-parked finalize request:

Deliberately not offered as controls, because they are not provider outcomes the abstraction can produce on demand:

Data model / state representation

In-memory only, owned by MockingBirdCheckoutStore (single backend process, restart clears — the same promise AZ-009 states for email state). Sketch of one stored entry:

type MockingBirdCheckoutTrigger =
  "succeeded" | "rejected" | "cancelled" | "timeout" | "provider-error";

interface MockingBirdCheckoutEntry {
  /** Server-generated UUID used by the developer API. */
  id: string;
  /** Application-owned checkout operation identity, for correlation. */
  operationId: string;
  status: "pending" | "settled";
  /** When the store registered the charge. */
  receivedAt: Date;
  /** When a trigger (or clear) settled the charge; null while pending. */
  settledAt: Date | null;
  /** The complete validated neutral request, stored as received. */
  request: CheckoutRequest;
  /** OperationContext.requestId correlation, when supplied. */
  requestId: string | null;
  /** Developer choice; null while pending. */
  trigger: MockingBirdCheckoutTrigger | null;
  /** Mapped neutral outcome class handed to the application; null while pending. */
  outcome: CheckoutOutcome | null;
  /** Handed back as CheckoutResult.providerReference; null while pending. */
  providerReference: string | null;
  /** receivedAt → settledAt in ms; debug information only, never a decision input. */
  waitMs: number | null;
  /** Extra debug note, e.g. "cleared while pending". */
  settleNote: string | null;
}

State rules (unit-tested at the store level):

Backend / API changes

All routes live inside the existing if (options.mockingBird) block in backend/src/auth-http.ts, inherit the /api/developer/ no-store hook, and require requireCurrentUser (401 otherwise). Mutations reuse the consoleMutation protection object (exact Origin 403, CSRF 403, rate-limit group developer-console-ip).

GET /api/developer/mocking-bird/checkout

POST /api/developer/mocking-bird/checkout/:id/outcome

DELETE /api/developer/mocking-bird/checkout

Console state extension

GET /api/developer/mocking-bird gains three fields alongside the existing mode/scenario/latencyMs/capacity/captureCount/previewCount: checkoutCount, checkoutPendingCount, checkoutCapacity. mode becomes the actual mail mode (see Removing the email-mode coupling).

Contract and wiring files

Frontend / UI changes

Current: frontend/src/app/api/mocking-bird-api.ts exposes state/history/previews/scenario/ clear/test-send operations with hand-written runtime parsers and query keys (mockingBirdQueryRoot = ["authenticated", "developer", "mocking-bird"]); MockingBirdConsolePage.tsx renders four cards; MockingBirdState has no checkout fields.

Change:

Configuration changes

Current versus change:

Admin / development visibility behavior

Current: the console backend is independent of MAIL_MODE (see What causes the email-mode coupling); the frontend page is available in Vite development and local-classified image builds, and omitted from hosted builds.

Change and invariants:

Error handling

Concurrency / multiple pending requests

Multiple checkout requests may be pending simultaneously: claimCheckoutOperation is per operationId, so concurrent finalizes (two browser sessions, or an abandoned draft plus a new one) each reach charge() and each park their own entry. The design therefore:

Security and environment protections

Layered safeguards, each an existing repository mechanism reused rather than a new one:

  1. Startup configuration (strongest) — backend/src/config.ts refuses to start when: hosted (staging/production) selects CHECKOUT_MODE=mocking-bird (or fake); hosted sets MOCKING_BIRD_ENABLED=true (existing check, message reworded to developer tooling); CHECKOUT_MODE=mocking-bird is selected while Mocking Bird is disabled. A hosted deployment therefore cannot even construct the adapter — "Mocking Bird cannot accidentally become the production payment provider" is a startup invariant, not a runtime check.
  2. Composition — createCheckoutGateway only ever constructs MockingBirdCheckoutGateway for CHECKOUT_MODE=mocking-bird, and falls back to UnavailableCheckoutGateway (never success) if the store is missing.
  3. Instance construction — server.ts builds Mocking Bird only when enabled, and hosted enablement is rejected in step 1, so no hosted process holds a Mocking Bird instance.
  4. Routes — without the instance, /api/developer/mocking-bird* routes are not registered and answer 404 (verified by direct HTTP tests independent of any frontend flag, per AZ-009).
  5. Session and request forgery — every route requires an authenticated session (401); mutations additionally require exact Origin (403) and CSRF token (403) and are rate-limited (429); reads/writes are no-store. No role exists or is invented.
  6. Frontend — the page is excluded from static hosted/unclassified builds; it is included only for Vite development or an explicitly enabled local-classified build. The flag is explicitly "never an authorization boundary" (frontend/src/vite-env.d.ts), so all authority stays with points 1–5.
  7. No secrets — Mocking Bird requires no credentials: no SURFBOARD_* value is read for it, none is logged, none enters the repository, and no new secret appears in .env.example, Compose, CI, or the browser. Checkout entry content (amounts, product names) never reaches logs or diagnostics — only the redacted event shape.
  8. Provider boundary hygiene — the new modules live under backend/src/integrations/ (ESLint forbids application/domain imports from ../integrations/*); the static suite in tests/ci/checkout-boundary.test.mjs gains assertions that the Mocking Bird checkout modules are network-free (no fetch, node:http(s), node:net) like the deterministic fake, and that the neutral contract still imports nothing provider-specific. The word "mocking-bird" is not provider vocabulary, so no frontend restriction changes.

Testing strategy

Stable identifiers

Scenarios

ID Scenario Lowest sufficient evidence level
MBC-S01 Local console available with MAIL_MODE disabled/mock/real; absent when Mocking Bird is disabled Backend API (app.inject) tests in auth-http.test.ts
MBC-S02 Email interception regression: mock transport, scenarios, history, previews, test-send, recovery previews unchanged; never intercepts outside mock mode Existing suites stay green plus a new explicit transport-guard test
MBC-S03 Provider selection: local config selects Mocking Bird; hosted rejects mocking-bird at startup; harnesses still select fake config.test.ts + checkout-composition.test.ts
MBC-S04 A commercial finalize in Mocking Bird mode registers a pending entry exposing the full request (lines, amounts, currency, quantities, prices, ids, timestamp, correlation) Application test through CheckoutService + API read
MBC-S05 Trigger succeeded → order Queued, sale counted, attempt row with mocking-bird- reference, 201 Application/integration tests
MBC-S06 Trigger rejected → 409 CHECKOUT_NOT_PAID, no order/sale/unresolved row, client resets operationId Backend API + frontend component assertion
MBC-S07 Trigger cancelled → same application result as MBC-S06; body indistinguishable by design; trigger visible only in Mocking Bird Backend API + store unit test
MBC-S08 Trigger timeout → unresolved attempt recorded, 409 CHECKOUT_UNRESOLVED, visible via GET /api/checkout/unresolved, duplicate suppressed Application/integration + API
MBC-S09 Trigger provider-error → same application result as MBC-S08 with a distinct stored trigger Store unit + application integration
MBC-S10 Two concurrent pending charges settle independently; duplicate finalize while pending → 409 CHECKOUT_IN_PROGRESS, no second entry Application integration (concurrency) + store unit
MBC-S11 Lifecycle: settled entries retained with timestamps/outcome; capacity bounds settled only; clear resolves pending as unknown then empties (email state untouched) Store unit + API tests
MBC-S12 Protections: hosted config rejections; route absence without instance; 401/403/CSRF/rate-limit matrix; standard build excludes the page config.test.ts, auth-http.test.ts, pages.test.tsx, static checks

Suites touched

Migration / refactoring considerations

Open questions

None material. Two judgment calls were resolved from repository evidence rather than left open, and are recorded here so they can be revisited explicitly if challenged:

  1. Parked charge vs. immediate unknown. Parking is the only design that re-enters the normal flow: unresolved attempts are append-only with no resolution path, and the Surfboard webhook is provider-specific, signature-bound, and processing-deferred. An immediate-unknown adapter would strand every local checkout in CHECKOUT_UNRESOLVED and exercise none of the success/decline paths.
  2. Availability defaults. "Always available locally" is implemented by deriving MOCKING_BIRD_ENABLED from the deployment classification when unset (explicit opt-out preserved) rather than by removing the explicit enablement flag entirely — this keeps AZ-009's hosted-rejection semantics and the Compose/CI overrides intact.

Optional follow-up, explicitly not gating this epic: a browser E2E journey in a Mocking Bird checkout mode (see Testing strategy).

Acceptance criteria

Stable IDs are defined in Testing strategy; evidence references are recorded here as tasks land.