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:
- 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.
- 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
backend/src/integrations/mocking-bird.tsexports the classMockingBird: a capped (100 entries), closed-set Resend transport interceptor. It owns the emailtransport(EmailHttpTransport), the fixed scenariosMockEmailScenario = "success" | "provider-error" | "timeout", bounded artificial latency (0–5000 ms), redactedEmailCapturehistory, andSyntheticEmailPreviewpreviews for allowlistedMAIL_TEST_RECIPIENTS.backend/src/server.tswires it:const mockingBird = config.MAIL_MODE === "mock" ? new MockingBird(new Set(config.MAIL_TEST_RECIPIENTS)) : undefined;(lines 44–45). The interceptor is attached toResendEmailSenderthroughmockingBird?.transport, and the instance is passed tobuildApponly inside thedeveloperEmailconditional (lines 86–94), which itself depends onMAIL_MODE.backend/src/auth-http.tsgates every developer console route behindif (options.mockingBird)(line 597):GET /api/developer/mocking-bird(console state;mode: "mock"is hardcoded at lines 600–607),GET .../history,GET .../previews,PATCH .../scenario,DELETE .../history. Reads userequireCurrentUser; mutations useconsoleMutation(exact Origin + CSRF + rate-limit groupdeveloper-console-ip).POST /api/developer/email-testis separately gated byAuthHttpOptions.developerEmail.- Frontend:
frontend/src/app/pages.tsxregisters/developer/mocking-birdin Vite development and in local-classified image builds withVITE_ENABLE_DEVELOPER_TOOLS=true, viafrontend/src/app/developer/pages.tsx(access: "authenticated",developerOnly: true, labelnavigation.developerTools). The hosted exclusion and default local visibility are tracked by STANDALONE-002.frontend/src/pages/MockingBirdConsolePage.tsxpolls every 5 s (POLL_INTERVAL_MS), renders the scenario form (Apply disabled whenmode !== "mock"), test-send, history, and previews.frontend/src/app/api/mocking-bird-api.tsowns operations, runtime response validation, and TanStack Query keys below["authenticated", "developer", "mocking-bird"]. - Enablement is documented in
docs/development.md(enablement matrix: frontend bundle gate, backend deployment classification, mail transport, synthetic identity) and specified by AZ-009 (docs/specs/002-application-zero/009-mocking-bird-console.md), which explicitly forbids adding an admin role for this tool and requires backend absence in hosted deployments.
What causes the email-mode coupling
Exactly three coupled points exist; the frontend was never email-coupled:
- Instance construction —
backend/src/server.tslines 44–45 createMockingBirdonly whenconfig.MAIL_MODE === "mock". WithMAIL_MODE=disabled(the local default) orMAIL_MODE=real, no instance exists. - Pass-through to the app —
backend/src/server.tslines 86–94 passmockingBirdintobuildApp({ auth: ... })only inside thedeveloperEmailconditional (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. - Route registration —
backend/src/auth-http.tsline 597 registers all/api/developer/mocking-bird*routes only whenoptions.mockingBirdis present, andconsoleState()hardcodesmode: "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
- Neutral port —
backend/src/application/checkout.tsdefinesCheckoutRequest(operationId,grossAmountMinor,currency: "SEK", optionallinesofCheckoutLine{name, quantity, grossUnitMinor, vatBasisPoints}),CheckoutOutcome = "succeeded" | "rejected" | "cancelled" | "unknown",DefinitiveCheckoutRefusal = "rejected" | "cancelled",CheckoutResult {outcome, providerReference}, andCheckoutGateway.charge(request, context?). The contract deliberately carries only what a charge needs; customer data, return URLs, and payment-method parameters are excluded until a provider discovery proves them (MVP-TECH-002). - Application use case —
backend/src/application/checkout-service.ts(CheckoutService) validates the request, normalizes adapter results, and converts any thrown adapter fault intounknown. - Order flow —
OrderService.finalizeCommercial(backend/src/application/order-service.ts): price → durable claim (claimCheckoutOperation) →checkout.charge(...)→ outcome handling:succeeded→repository.create(operationId, input, OrderCheckoutAttempt)→201; a null create raisesOrderCheckoutIncompleteError(charged but unrecorded).rejected/cancelled→OrderCheckoutDeclinedError→409 CHECKOUT_NOT_PAID(the body deliberately does not distinguish rejection from cancellation — same certainty).unknown→ durable append-onlyrecordUnresolvedCheckoutAttempt→OrderCheckoutUnresolvedError→409 CHECKOUT_UNRESOLVED.- duplicate while pending →
OrderCheckoutInProgressError→409 CHECKOUT_IN_PROGRESS; mismatched fingerprint →OrderFinalizationError; a completed operation replays its order; an unresolved operation replaysCHECKOUT_UNRESOLVED.
- HTTP —
backend/src/order-http.ts:POST /api/orders/commercial,GET /api/orders/active,GET /api/sales/current-day,GET /api/checkout/unresolved. - Adapters — all in
backend/src/integrations/, selected only bycreateCheckoutGateway(config, report)incheckout-composition.ts:FakeCheckoutGateway(fake-checkout-gateway.ts): deterministic, network-free;FakeCheckoutScenario = "succeeded" | "rejected" | "cancelled" | "unknown"plus amount-keyed overrides (CHECKOUT_FAKE_*_AMOUNT_MINOR); referencefake-checkout-<operationId>only forsucceeded/unknown.SurfboardCheckoutGateway(surfboard-checkout-gateway.ts):POST /orders(create + initiate in one call) then pollsGET /orders/{orderId}/statusto a terminal state. Mapping:PAYMENT_COMPLETED→succeeded,PAYMENT_FAILED→rejected,PAYMENT_CANCELLED→cancelled, create-time envelopeERROR→rejected, and transport timeout/reset, HTTP 5xx, malformed envelope, or deadline with a non-terminal status →unknown. Non-terminal provider statuses (PAYMENT_INITIATED,PAYMENT_PROCESSING,PAYMENT_PROCESSED) are converged by polling withinSURFBOARD_STATUS_DEADLINE_MS.UnavailableCheckoutGateway(checkout-composition.ts): alwaysunknown, used when no adapter is configured or Surfboard credentials are incomplete.
- Webhook —
POST /webhooks/surfboard(surfboard-webhook-http.ts,surfboard-webhook.ts) is HMAC-SHA512 signature-authenticated, registered only whenSURFBOARD_WEBHOOK_SECRETis set, and currently verifies/acknowledges only — processing is deferred (documented limitation indocs/development.md). It plays no role in outcome delivery. - Frontend flow —
frontend/src/pages/KdsPage.tsxrunsfinalizeCommercialMutation, keeps a browser-ownedoperationId(frontend/src/app/checkout-operation-id.ts), resets it whenisDefinitiveNotPaidreportsCHECKOUT_NOT_PAID, and polls the queue, sales, and unresolved attempts every 15 s. - Enforcement —
tests/ci/checkout-boundary.test.mjskeeps provider vocabulary out ofbackend/src/domain,backend/src/application, andfrontend/src, keeps the neutral port free of imports except../diagnostics.js, and proves the fake is network-free. ESLint (eslint.config.js) forbids application/domain imports from../integrations/*and forbids pages/components from callingfetchdirectly.
Environment selection today
DEPLOYMENT_ENV(local | test | staging | production, derived fromNODE_ENVwhen unset) classifies the deployment;hosted = staging || productiondrives every safety validation inbackend/src/config.ts.CHECKOUT_MODE(disableddefault |fake|surfboard) is the adapter switch and nothing else (ADR 004,docs/decisions/004-surfboard-integration-discovery.md). Hosted rejectsfake;surfboardrequires the completeSURFBOARD_*credential set. Automated harnesses pinfakeexplicitly (tests/e2e/playwright.config.ts,scripts/docker-validation.mjs), anddocker-compose.ymlforwardsCHECKOUT_MODEwith defaultdisabled.- Surfboard Demo (staging) vs Live (production) is purely credential configuration; the repository
never sets values toward Surfboard (
.env.exampleentries stay empty).
Goals
- A logged-in user running the application in the local development environment can always reach the
Mocking Bird console, independently of
MAIL_MODE. - Mocking Bird is structurally a generic development mocking surface with at least two capabilities: email interception (existing) and payment checkout interception (new), so a third mocked integration can be added later without another generalization.
CHECKOUT_MODE=mocking-birdselects a Mocking Bird checkout adapter behind the existingCheckoutGatewayport; local configuration points at it, hosted stays on Surfboard, automated tests stay on the deterministic fake.- A commercial checkout request in Mocking Bird mode appears as a pending entry with complete, debugging-oriented visibility into everything the neutral request carries.
- The developer can manually trigger every outcome class the application acts on, and each trigger
flows through the normal downstream handling (
CheckoutService→OrderService.finalizeCommercial→ persistence → HTTP codes). - Multiple concurrently pending checkout requests are supported and can be settled independently.
- Mocking Bird can never become the checkout adapter of a hosted (staging/production) deployment.
Non-goals
- No production, staging, or internet-exposed Mocking Bird functionality of any kind.
- No replacement of the deterministic fake; it remains the automation adapter, and no acceptance harness is switched to Mocking Bird.
- No change to Surfboard behavior, credentials, webhook semantics, or the fiscal/ECR path (MVP-TASK-034 remains blocked on external prerequisites).
- No change to the neutral
CheckoutRequest/CheckoutResultcontract: the Mocking Bird adapter consumes the port as it exists. No customer data, redirect/return URLs, or provider parameters are added to the contract just to display them. - No persistence of Mocking Bird state in PostgreSQL; no migrations, no new tables.
- No websockets, event bus, or new push mechanism; polling stays the sync mechanism.
- No roles/RBAC; no new authorization model (see Terminology).
- No scripted or screenshot-based payment provider emulation, no card data handling of any kind, and no arbitrary response scripting — only the fixed outcome vocabulary below.
- No retry policy, refund, capture, or split-payment simulation; those provider concepts do not exist in the application's abstraction today.
Terminology
Repository terminology wins over wording from planning prompts:
- Mocking Bird — the development-time mocking/debugging surface: backend instance, developer API
under
/api/developer/mocking-bird*, and the/developer/mocking-birdconsole page. It hosts capabilities (email, checkout); it is not "the email tool". - Email interception / console scenario — the existing mail capability: transport
interception with fixed scenarios
success | provider-error | timeoutand bounded latency. - Checkout operation — one application-owned charge attempt identified by
operationId(client-generated UUID, reused across transport attempts of the same operation). - Charge —
CheckoutGateway.charge(...), the single provider-boundary call. - Outcome classes — the neutral
CheckoutOutcomevalues the application acts on:succeeded,rejected,cancelled,unknown.rejected/cancelledareDefinitiveCheckoutRefusals (certainly unpaid);unknownis lost certainty, durably recorded as an unresolved attempt. - Trigger — the manual selection in the Mocking Bird UI/API that produces one outcome class (see Supported outcomes).
- Pending / settled — a Mocking Bird checkout entry whose charge has not / has received a trigger.
- Operator — the person using the KDS/order tablet; developer — the person using the Mocking Bird console.
- "Admin" — this repository has no roles (
docs/specs/003-mvp/scope.md: any authenticated user may perform admin/operator actions because roles do not yet exist; AZ-009: do not add an admin role solely for this dev tool; ADR 002: the user is not an implicit administrator). Requirements phrased as "logged-in admin" map to an authenticated local user — session plus Origin/CSRF checks — exactly as every other developer endpoint works today.
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):
backend/src/config.tsrejectsCHECKOUT_MODE=mocking-bird(and the existingfakerejection) whenhostedis true, so staging/production cannot select either non-provider adapter at startup. Surfboard remains the only hosted-selectable adapter.backend/src/config.tsrejectsCHECKOUT_MODE=mocking-birdwhen Mocking Bird is disabled, so the mode and the instance it needs cannot diverge.- Automated harnesses keep their explicit
CHECKOUT_MODE=fakepins (tests/e2e/playwright.config.ts,scripts/docker-validation.mjs); CI's Docker jobs inherit them. Mocking Bird is never set by those harnesses — interactive tooling and deterministic automation stay separate by configuration. - Local configuration (
.env.example,docker-compose.yml) is edited to selectmocking-bird; nothing in the repository is set toward Surfboard (.env.exampleSURFBOARD_*values stay empty).
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
- Ports and adapters — application code depends on
CheckoutGateway; adapters live inbackend/src/integrations/and are chosen only bycreateCheckoutGateway(the single configuration-to-adapter map). This epic adds one adapter; it creates no parallel payment architecture. - Composition root —
backend/src/server.tsbuilds repositories, services, the checkout gateway, Mocking Bird, andbuildAppoptions. The developer console is wired throughAuthHttpOptions. - HTTP conventions — Fastify route registration inside the authenticated session scope
(
backend/src/auth-http.ts,backend/src/order-http.ts,backend/src/product-http.ts);apiErrorCodes+sendApiErrorinbackend/src/api-errors.ts; strict Zod bodies viaparseJsonBody; exact-Origin + CSRF on mutations; theCache-Control: no-storehook already covers every/api/developer/path; every route must appear indocs/api/openapi.yaml(enforced bybackend/src/openapi-contract.test.ts, includingx-deployment-environments: [local, test]for developer paths). - Developer UI conventions — one page registry (
frontend/src/app/pages.tsx+frontend/src/app/developer/pages.tsx), feature API modules underfrontend/src/app/api/with runtime response parsing and TanStack Query keys, MUI cards/lists with native<details>for accessible disclosure (AZ-009), bounded polling active only while the page is visible, localized strings foren/sv/huinfrontend/src/app/i18n/resources.ts. - State management — TanStack Query everywhere; polling intervals are named constants (console 5 s, KDS 15 s); no global client store for server data.
- In-memory infrastructure —
MockingBirdalready owns capped ring buffers (push/capacity, default 100) for a single backend process, with the documented promise that one process owns the state and restart clears it (AZ-009). This is the precedent the checkout store reuses; no database table exists or is needed for dev tooling. - Diagnostics —
DiagnosticReporter+OperationContext {requestId}inbackend/src/diagnostics.ts; adapters report redactedintegrationevents (for examplepayment.initiatewithprovider.label). - Testing conventions — Vitest unit/component tests colocated (
*.test.ts(x)), real-PostgreSQL integration tests (*.integration.test.ts), Fastifyapp.injectAPI tests, Playwright journeys intests/e2e/, static boundary tests intests/ci/, scenario IDs in bracketed test titles.
Proposed architecture
Five separated pieces, each reusing an existing mechanism:
- Payment adapter —
MockingBirdCheckoutGateway implements CheckoutGatewayinbackend/src/integrations/mocking-bird-checkout-gateway.ts, selected bycreateCheckoutGatewaywhenCHECKOUT_MODE=mocking-bird. It is the objectCheckoutServicecalls; it contains no HTTP, no UI, and no storage-layout knowledge beyond the store's API. - Storage / state —
MockingBirdCheckoutStoreinbackend/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. - Developer API — three new routes registered inside the existing
if (options.mockingBird)block inbackend/src/auth-http.ts, under/api/developer/mocking-bird/checkout*, reusingrequireCurrentUser,consoleMutation, theno-storeprefix hook,parseJsonBody, and the existing rate-limit groups. - Mocking Bird UI — a new "Checkout" section on the existing
frontend/src/pages/MockingBirdConsolePage.tsxplus new operations infrontend/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. - 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 resolvedCheckoutResultflows back throughCheckoutServicenormalization andOrderService.finalizeCommercialunchanged. 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 |
MockingBirdexposesreadonly email: MockingBirdEmailandreadonly checkout: MockingBirdCheckoutStore(the checkout capability lands with the payment tasks; the facade exists from the first refactoring task). Its constructor keeps the current arguments (syntheticRecipients,capacity) and passes them to the email module; the checkout store receives its own capacity (same default 100) from the same constructor.- Email-specific members move behind
bird.email.*. Production call sites (server.tstransport attachment,auth-http.tsconsole routes) and the email tests inpassword-recovery-service.test.ts, the console describe block inauth-http.test.ts, andopenapi-contract.test.tsupdate frombird.history()-style access to the explicit module — a mechanical rename with unchanged assertions. - Adding a third mocked integration later means: one new
mocking-bird-<integration>.tsmodule, one facade property, one capability section in the console, one route group under/api/developer/mocking-bird/<integration>. No further generalization of the surface is required. This is deliberately not a plugin framework: no registries, no dynamic capability loading — just named modules on one instance.
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:
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 toMAIL_MODE.backend/src/config.tsalready guarantees hosted deployments never enable Mocking Bird, so enabled implies local/test.backend/src/server.ts: pass the instance tobuildAppthrough its own option, independent of thedeveloperEmailconditional, asmockingBird: { bird: mockingBird, mailMode: config.MAIL_MODE }(a newAuthHttpOptions.mockingBirdshape;mailModeonly feeds the console state). ThedeveloperEmailoption keeps its current condition and still gatesPOST /api/developer/email-test.backend/src/server.ts: attach the interceptor only in mock mail mode —config.MAIL_MODE === "mock" ? mockingBird?.email.transport : undefinedpassed toResendEmailSender. This guard becomes explicit precisely because the instance now exists inreal/disabledlocal runs; without it, local real mail would be intercepted, which AZ-009 forbids. Config already rejectsMAIL_MODE=mockwithout Mocking Bird enabled, so the mock branch always has an instance.backend/src/auth-http.ts: keepif (options.mockingBird)as the single route gate (it now means "Mocking Bird exists", not "mail is mocked"), readmailModefrom the option instead of hardcoding"mock", and keep every existing protection unchanged.backend/src/config.ts: deriveMOCKING_BIRD_ENABLEDwhen unset —truewhenDEPLOYMENT_ENVislocal/test,falsewhen hosted — so a local start always has Mocking Bird available without any mail-related setting. An explicitMOCKING_BIRD_ENABLED=falseremains a valid local opt-out; an explicittruein hosted remains a startup failure (existing validation, pathMOCKING_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):
- Extend the enum with
"mocking-bird". Keep the schema defaultdisabled: explicit absence of an adapter stays the conservative default for runs that do not configure one; the discoverable local configuration (.env.example,docker-compose.yml) selectsmocking-bird(see Configuration changes). - Extend the hosted rejection
hosted && CHECKOUT_MODE === "fake"to also rejectmocking-bird, with its own message ("the Mocking Bird checkout adapter is unavailable in hosted mode"). - Add a cross-field rule:
CHECKOUT_MODE=mocking-birdrequires Mocking Bird to be enabled, otherwise fail startup (same style as the Surfboard credential requirement). createCheckoutGateway(config, report?, mockingBirdCheckoutStore?): whenCHECKOUT_MODE === "mocking-bird", returnnew MockingBirdCheckoutGateway(store, report). If the store is somehow absent (wiring the config loader cannot see), fall back toUnavailableCheckoutGateway— the same defense-in-depth precedent used for incomplete Surfboard credentials — so a mis-wired run can never fabricate a payment result.- Update the
createCheckoutGatewaydoc comment: the mode map becomes localmocking-bird(interactive), local/testfake(deterministic), hostedsurfboard, withdisabledas the fallback when no adapter is configured.
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:
- unresolved attempts are append-only and have no resolution path (the
order_checkout_unresolved_attemptstable rejects updates and deletes by trigger), so an "answer now, resolve later" design cannot re-enter the normal flow; - the Surfboard webhook ingress is signature-authenticated with
SURFBOARD_WEBHOOK_SECRET, Surfboard-specific, and defers processing anyway — it is not a generic result channel.
Therefore the Mocking Bird adapter implements the charge as an interactive wait:
MockingBirdCheckoutGateway.charge(validatedRequest, context)callsstore.register(request, context), which appends a pending entry and returns its entry id plus a promise.- The gateway returns that promise.
CheckoutServiceandfinalizeCommercialsimply keep awaiting it — the operator's finalize request stays in flight exactly as it does today while Surfboard converges through create-plus-poll (up toSURFBOARD_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). - When the developer triggers an outcome, the developer API calls
store.settle(entryId, trigger), which resolves the parked promise with the mappedCheckoutResult. The already-running finalize request continues throughCheckoutServicenormalization andfinalizeCommercialoutcome handling unchanged, then responds (201or409). - Settling is immediate from the developer API's perspective (the
POST .../outcomeresponse 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).waitMsis 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
- Identification — each entry gets a server-generated UUID (
id, minted by the store, independent ofoperationId) used by the developer API. The application-ownedoperationIdis stored as a field for correlation; in practice oneoperationIdproduces at most one entry because the durable claim decides duplicates before any adapter is called. - Arrival —
charge()registers the entry synchronously before returning the parked promise, so it is visible in the console as soon as the operator's finalize request reaches the provider boundary. Status:pending.receivedAtis captured at registration. - Ordering — the list endpoint returns entries newest-first by
receivedAt; the UI renders pending entries above settled ones, each group newest-first. - While pending — the console shows status
pending, the received timestamp (absolute and relative), the full request details, and the outcome controls. KDS keeps showing its in-flight finalize state exactly as it does during a slow Surfboard convergence. - Settling — a trigger sets
settledAt,trigger, mappedoutcome,providerReference, andwaitMs; status becomessettled. The finalize response then reaches the operator with the matching HTTP result. - After settlement — entries remain visible (favored development history) with their outcome, until the checkout clear operation, a restart, or capacity pressure. Capacity bounds settled entries only: pending entries are never evicted, and the oldest settled entry is evicted only when the settled count exceeds the store capacity (default 100).
- Retained for debugging — everything in the data model below: ids, timestamps, trigger, mapped
outcome, reference, wait duration, correlation
requestId, and the complete validatedCheckoutRequestsnapshot.
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:
- Concise summary (the
<summary>line): status chip (pending/succeeded/rejected/cancelled/unknown), shortoperationId, gross total via the existingformatGrossPrice(minorUnits, language)helper (frontend/src/app/format/product-format.ts), line count, and relative received time. - Structured details (definition list, mirroring
CaptureItem): fulloperationId, entryid, status, absolutereceivedAt/settledAt,trigger, mappedoutcome,providerReference,waitMs, correlationrequestId,currency,grossAmountMinor, and a per-line table ofname,quantity,grossUnitMinor, derived line total (grossUnitMinor * quantity),vatBasisPoints(with percent), plus the reconciliation note that line totals sum togrossAmountMinor(guaranteed byCheckoutServicevalidation). - Raw/debug (escaped
<pre>): the completeCheckoutRequestJSON 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:
- Pending entries expose five outcome buttons (see Supported outcomes); only the clicked entry's buttons disable while its settle mutation is in flight, then the entry rerenders as settled.
- A section header shows pending/total counters (from the extended console state) and a "Clear
checkout history" button bound to the checkout
DELETEroute (separate from the existing email "Clear history" button, which keeps clearing only email captures, previews, scenario, and latency). - Polling reuses
POLL_INTERVAL_MS(5 s, paused when the tab is hidden) for the new checkout query; after any settle or clear, the checkout queries are invalidated immediately. Leaving the page or logging out cancels them through the existingmockingBirdQueryRootcleanup. - New strings are added for
en,sv, andhuunder thedeveloper.*namespace infrontend/src/app/i18n/resources.ts(pnpm i18n:checkenforces locale parity). - Loading, empty, and error states reuse
LoadingState/EmptyState/ErrorStateandapiErrorMessage, exactly like the existing cards.
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:
succeeded— fake provider response: a terminal success, the analogue of SurfboardPAYMENT_COMPLETED(and of the fake'ssucceeded). Code path: parkedcharge()resolves{outcome: "succeeded", providerReference: "mocking-bird-<operationId>"}→CheckoutService.normalizeCheckoutResult→OrderService.finalizeCommercial→repository.create(operationId, input, OrderCheckoutAttempt)→201 {order}. Expected state: the order entersQueued, contributes an item snapshot to current-day commercial sales, the confirmed attempt row stores the reference, and KDS refreshes its queue/sales queries — MVP-PAY-001-S01 behavior.rejected— fake provider response: a definitive refusal, the analogue of SurfboardPAYMENT_FAILEDor a create-time envelopeERROR(and of the fake'srejected). Code path:finalizeCommercialraisesOrderCheckoutDeclinedError→backend/src/order-http.tsanswers409withcode: "CHECKOUT_NOT_PAID". Expected state: no order, no sale, no unresolved row; the KDS client resets itsoperationIdviaisDefinitiveNotPaidso a retry starts a new operation — MVP-PAY-001-S02 behavior. The HTTP body does not distinguish rejection from cancellation by design (MVP-TASK-028); only the Mocking Bird entry records which trigger was used.cancelled— fake provider response: user cancellation, the analogue of SurfboardPAYMENT_CANCELLED(and of the fake'scancelled). Code path and expected state: identical torejected(sameDefinitiveCheckoutRefusalcertainty, same409 CHECKOUT_NOT_PAID) — MVP-PAY-001-S02 behavior. Kept as a separate trigger because the provider has a distinct cancellation concept and the console must show which happened.timeout— fake provider response: lost certainty through timeout, the analogue of Surfboard's transport timeout/deadline-with-non-terminal-status branches (and of the email scenariotimeout). Code path:charge()resolves{outcome: "unknown", providerReference: "mocking-bird-<operationId>"}→finalizeCommercialdurably recordsrecordUnresolvedCheckoutAttempt(append-only) →OrderCheckoutUnresolvedError→409withcode: "CHECKOUT_UNRESOLVED". Expected state: no order, no sale; the attempt is visible inGET /api/checkout/unresolvedand the KDS unresolved-attempts panel; repeating the sameoperationIdreplaysCHECKOUT_UNRESOLVEDinstead of charging again — MVP-PAY-001-S03 behavior.provider-error— fake provider response: lost certainty through an error/indeterminate response, the analogue of Surfboard's HTTP 5xx or malformed-envelope branches (and of the email scenarioprovider-error). Code path and expected state: identical totimeout— MVP-PAY-001-S03 behavior. The distinction lives only in the Mocking Bird entry'striggerfield for debugging; the application correctly sees one thing:unknown.
Deliberately not offered as controls, because they are not provider outcomes the abstraction can produce on demand:
OrderCheckoutIncompleteError(409 CONFLICT): a confirmed charge whose order insert failed — a persistence fault, not a payment decision.OrderCheckoutInProgressError(409 CHECKOUT_IN_PROGRESS): not a trigger but an emergent result when the operator repeats the finalize request while an entry is pending (MVP-PAY-001-S04 duplicate protection); it occurs naturally and is exercised as-is.- Surfboard's non-terminal statuses (
PAYMENT_INITIATED,PAYMENT_PROCESSING,PAYMENT_PROCESSED): represented by the pending state itself; there is no "advance to processing" button because there is no clock-driven convergence in Mocking Bird. UnavailableCheckoutGateway: unreachable whileCHECKOUT_MODE=mocking-birdis validly wired.
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):
register(request, context)→ appendspending, returns{id, result}whereresultis the parked promise; never throws for a valid request (the gateway receives requests already validated byCheckoutService).settle(id, trigger)→ one of: settled (first call wins), already-settled (rejected, entry returned for the error body), or not-found (malformed or unknown id).list()→ defensive copies, newest-first;counts()→{total, pending, capacity}.clear()→ settles pending entries asunknown(triggerleft null,settleNote: "cleared while pending") and then empties everything; email state is untouched.- Capacity applies to settled entries only; pending entries are never evicted. Capacity comes from the facade constructor (default 100, same as today's email ring).
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
- Response
200:{ requests: MockingBirdCheckoutEntryView[] }, newest-first, pending entries included;idas UUID string, dates as ISOdate-timestrings (Fastify's defaultDateserialization, matchingRecordedUnresolvedCheckoutAttempt.recordedAtover HTTP). Unthrottled read, like the other console reads. - Errors:
401unauthenticated;400when query parameters are present (explicit-input convention of the existing developer routes).
POST /api/developer/mocking-bird/checkout/:id/outcome
- Body (strict Zod,
parseJsonBody):{ "outcome": "succeeded" | "rejected" | "cancelled" | "timeout" | "provider-error" }. - Response
200:{ request: MockingBirdCheckoutEntryView }— the settled entry. - Errors:
400invalid/unknown body fields;401;403origin/CSRF;404malformed or unknownid(no existence distinction);409entry already settled (codeCONFLICT);429rate limited. Unexpected query parameters are rejected with400.
DELETE /api/developer/mocking-bird/checkout
- Behavior: settle all pending as
unknown("cleared while pending"), then clear. Response204. Rejects any body or query with400, exactly like the existingDELETE /api/developer/mocking-bird/history. - Errors:
401;403;429.
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
docs/api/openapi.yaml: add the three paths (with:idpath parameter), the entry/trigger schemas, the extended state schema, andx-deployment-environments: [local, test]on each developer path —backend/src/openapi-contract.test.tscompares implemented routes against documented routes and asserts that extension for every developer path it lists, so its developer-path array gains the new paths.AuthHttpOptions.mockingBirdchanges fromMockingBirdto{ bird: MockingBird; mailMode: "disabled" | "mock" | "real" }.backend/src/server.tsordering: constructmockingBirdbeforecreateCheckoutGatewayso the store can be passed in;createCheckoutGateway(config, report, mockingBird?.checkout).- Regenerate the documentation site after the contract change (
pnpm docs:build).
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:
- API module (
frontend/src/app/api/mocking-bird-api.ts):- types
MockingBirdCheckoutTrigger,MockingBirdCheckoutEntry(the wire view), and extendedMockingBirdState(checkoutCount,checkoutPendingCount,checkoutCapacity); mockingBirdCheckoutQueryKey = [...mockingBirdQueryRoot, "checkout"];- operations
getMockingBirdCheckoutRequests(signal),settleMockingBirdCheckoutOutcome(id, trigger)(CSRF viaacquireCsrfToken, POST), andclearMockingBirdCheckoutHistory()(CSRF, DELETE) — all using the existingapiJsonRequest/apiAcceptedRequest/apiNoContentRequesthelpers, never rawfetch(enforced by ESLint on pages/components); - parsers validating every field exactly like the OpenAPI schema (the established convention in
this module), throwing
ApiResponseErroron mismatch.
- types
- Page (
frontend/src/pages/MockingBirdConsolePage.tsx): the new "Checkout requests" card as specified in Developer UI behavior, composed from small local components in the same file (matchingHistoryCard/CaptureItemstyle), auseQuerywithrefetchInterval: POLL_INTERVAL_MS, and twouseMutations (settle, clear) withretry: false, success notifications viauseNotification, and immediate invalidation ofmockingBirdCheckoutQueryKeyplus the state key (counters). - Localization (
frontend/src/app/i18n/resources.ts): newdeveloper.checkout.*keys in all three locales (en,sv,hu); no key may exist in fewer locales (pnpm i18n:check). - No changes to
frontend/src/app/developer/pages.tsx,pages.tsx, the route path, the navigation label, or theVITE_ENABLE_DEVELOPER_TOOLSflag mechanics. - Tests: extend
frontend/src/pages/MockingBirdConsolePage.test.tsx(render, pending/settled grouping, detail disclosure, each trigger's mutation call and disabled states, clear flow, error/loading/empty states, state fixtures with checkout counters) andfrontend/src/app/api/mocking-bird-api.test.ts(query keys below the shared root, parser acceptance/rejection, CSRF headers) — the existing fixture helpers in those files are reused.
Configuration changes
Current versus change:
backend/src/config.tsCHECKOUT_MODE: enum gainsmocking-bird(schema default staysdisabled).- Hosted guard: extend
hosted && CHECKOUT_MODE === "fake"to also rejectmocking-bird(distinct message per mode). - New cross-field guard:
CHECKOUT_MODE=mocking-birdwithout Mocking Bird enabled fails startup. MOCKING_BIRD_ENABLED: empty string treated as absent; when absent, derivetrueforDEPLOYMENT_ENV=local|testandfalsefor hosted. Explicit values win; hostedtruestays a startup failure (existing check). TheMAIL_MODE=mockrequirements are unchanged.
.env.exampleCHECKOUT_MODE=mocking-birdwith the explanatory comment updated: local development uses the interactive Mocking Bird adapter; automated harnesses pinfake; hosted deployments supplysurfboardthrough the deployment secret system (values stay empty here — nothing is set toward Surfboard).MOCKING_BIRD_ENABLED=trueshown explicitly (documentation of the now-defaulted local value; the schema default also derives totruelocally).
docker-compose.yml(backend service):CHECKOUT_MODE: ${CHECKOUT_MODE:-mocking-bird}andMOCKING_BIRD_ENABLED: ${MOCKING_BIRD_ENABLED:-true}(Compose is explicitly local-only —DEPLOYMENT_ENVis pinned local — so the hosted guards are unaffected). Compose still never sets anySURFBOARD_*value.- Unchanged pins (verified, must stay):
tests/e2e/playwright.config.tsandscripts/docker-validation.mjskeepCHECKOUT_MODE: "fake"(plus their fake scenario and amount overrides);.github/workflows/ci.ymlkeeps its explicitMOCKING_BIRD_ENABLED: truemail fixtures;backend/src/config.test.tskeeps asserting the schema defaultdisabled.tests/ci/artifacts.test.mjshas noCHECKOUT_MODE/MOCKING_BIRD_ENABLEDassertions that conflict with these defaults — re-run it after the compose edit to confirm. - Hosted deployment contracts (
.env-less staging/production,docs/development.md§6.1, CI release flows) are untouched: they never set these keys, and the derived hosted default forMOCKING_BIRD_ENABLEDisfalse.
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:
- Backend gate becomes environment-plus-flag, never mail mode: routes are registered exactly
when
AuthHttpOptions.mockingBirdis present, whichserver.tsderives fromMOCKING_BIRD_ENABLED(locally defaultedtrue, hosted impossible). WithDEPLOYMENT_ENV=localand default configuration, every authenticated user reaches/api/developer/mocking-birdand all subroutes regardless ofMAIL_MODEbeingdisabled,mock, orreal— MBC-S01. - "Logged-in admin" = authenticated user: no role check is added (AZ-009, ADR 002, Epic 003
scope).
requireCurrentUser(401), exact-Origin (403), and CSRF (403) on mutations are the complete authorization model, identical to every other developer route. - Frontend bundle gate: Vite development registers the console without an opt-in. A static local
image requires
VITE_DEPLOYMENT_ENV=localandVITE_ENABLE_DEVELOPER_TOOLS=true; local Compose supplies both by default. Hosted and unclassified production builds omit the page, even if the tools flag is accidentally true. AZ-009 still defines the flag as visibility-only, never authorization. See STANDALONE-002. - Development tooling, not a product feature: the console keeps its
developerOnlymetadata, stays out of standard builds, and remains absent (404) on any deployment where the backend was not started with Mocking Bird enabled — tested by direct HTTP regardless of the frontend flag, as AZ-009 requires.
Error handling
- Adapter faults never fabricate outcomes.
CheckoutServicealready converts a throwncharge()intounknown; the Mocking Bird gateway additionally never throws for a valid request — registration is synchronous in-memory work, and the parked promise only ever resolves with a normalized{outcome, providerReference}. A programming error that did throw would still surface as an honestunknownattempt through the existing boundary, never as success. - Settle races: first settle wins; a concurrent or repeated settle receives
409with the entry's current state in the error path, and the parked promise is resolved exactly once (no double resolution). - Unknown/again-settled/cleared entries:
404for malformed or unknown ids (no oracle distinction),409for settling a settled entry,400for invalid bodies/queries — all through the existingsendApiError/apiErrorCodesvocabulary; no new error codes are introduced. - Developer-facing failures reuse
apiErrorMessagein the console withretry: falsemutations; a failed settle leaves the entry pending and the finalize request parked, so the developer can retry the settle — the UI must not imply the outcome was delivered. - Operator-facing behavior is unchanged: whatever the settled outcome maps to, the operator sees
exactly the responses the application already defines (
201,409 CHECKOUT_NOT_PAID,409 CHECKOUT_UNRESOLVED,409 CHECKOUT_IN_PROGRESS,409 CONFLICT), with bodies that carry no Mocking Bird or provider detail (existing contract). - Diagnostics never log amounts, request bodies, or entry content; only the redacted
payment.initiateevent shape above, consistent withbackend/src/diagnostics.tsrules.
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:
- keys entries by their own UUID, never by
operationId, and settles them independently; - relies on the existing duplicate protection for the same operation: a repeated finalize while
pending is answered
409 CHECKOUT_IN_PROGRESSbefore any adapter is called, so no second entry and no second parked charge can appear for one operation (MVP-PAY-001-S04); - keeps
list()newest-first so several pending entries are all individually actionable — there is no "settle all" control; clearing is the explicit bulk operation and it resolves each parked charge asunknown; - assumes the single backend process Mocking Bird has always assumed (AZ-009): no cross-instance coordination, no cluster support, state lost on restart. Local development and tests run one process.
Security and environment protections
Layered safeguards, each an existing repository mechanism reused rather than a new one:
- Startup configuration (strongest) —
backend/src/config.tsrefuses to start when:hosted(staging/production) selectsCHECKOUT_MODE=mocking-bird(orfake); hosted setsMOCKING_BIRD_ENABLED=true(existing check, message reworded to developer tooling);CHECKOUT_MODE=mocking-birdis 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. - Composition —
createCheckoutGatewayonly ever constructsMockingBirdCheckoutGatewayforCHECKOUT_MODE=mocking-bird, and falls back toUnavailableCheckoutGateway(never success) if the store is missing. - Instance construction —
server.tsbuilds Mocking Bird only when enabled, and hosted enablement is rejected in step 1, so no hosted process holds a Mocking Bird instance. - Routes — without the instance,
/api/developer/mocking-bird*routes are not registered and answer404(verified by direct HTTP tests independent of any frontend flag, per AZ-009). - 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 areno-store. No role exists or is invented. - 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. - 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. - Provider boundary hygiene — the new modules live under
backend/src/integrations/(ESLint forbids application/domain imports from../integrations/*); the static suite intests/ci/checkout-boundary.test.mjsgains assertions that the Mocking Bird checkout modules are network-free (nofetch,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
- Acceptance scenarios use
MBC-SNN; implementation tasks useMBC-TASK-NNN(declared intasks.md). Once committed, identifiers are never renumbered. - Executable evidence carries the exact bracketed scenario ID in its test title or describe
metadata, for example
it("[MBC-S06] a declined trigger answers 409 CHECKOUT_NOT_PAID", ...). Evidence for the underlying payment behavior may carry both anMBC-SNNand the existingMVP-PAY-001-SNNID when it proves both contracts. pnpm traceability:checkonly parsesMVP-*IDs anddocs/specs/*/stories,journeys, ortraceability.mdinputs; this epic records evidence in this README's acceptance table instead of those Epic 003 files, so the checker is unaffected. If the project later generalizes the checker, migrating this epic's IDs is a separate, explicit change.
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
- Unit/store:
backend/src/integrations/mocking-bird-checkout.test.ts,backend/src/integrations/mocking-bird-checkout-gateway.test.ts, and the email module's tests (backend/src/integrations/mocking-bird.test.ts, renamed alongside its module if the class moves). - Composition/config:
backend/src/integrations/checkout-composition.test.ts,backend/src/config.test.ts. - Application/API:
backend/src/application/order-service.test.ts(existing outcome tests keep theirMVP-PAY-*IDs),backend/src/auth-http.test.ts,backend/src/openapi-contract.test.ts. - Frontend:
frontend/src/pages/MockingBirdConsolePage.test.tsx,frontend/src/app/api/mocking-bird-api.test.ts,frontend/src/app/pages.test.tsx(registry behavior unchanged). - Static:
tests/ci/checkout-boundary.test.mjs,tests/ci/artifacts.test.mjs(compose edits),tests/ci/documentation-gates.test.mjs(if the spec is registered in the manifest). - Browser E2E: not required for these scenarios — the parked-charge behavior and every outcome path
are provable at application/API level, which
docs/test-strategy.mdranks above browser evidence for such flows. An optional Playwright journey may be added only if it can run withCHECKOUT_MODE=mocking-birdwithout changing the fake-pinned acceptance environment (default: no new E2E project; see tasks). - Regression: the existing email/console suites and the local E2E mail journey must keep passing in
intent;
pnpm qualityis mandatory before completion, plus the focused suites above and — because Compose defaults change —pnpm validate:docker.
Migration / refactoring considerations
- Mechanical first: extract
MockingBirdEmailand introduce the facade with zero behavior change, run the full email/console suites, then change wiring. This follows the Epic 004 execution contract (mechanical move first, semantic change second; seedocs/specs/004-refactor/README.md). - Option-shape ripple:
AuthHttpOptions.mockingBirdbecomes{ bird, mailMode }, touchingserver.ts, the console block inauth-http.ts, and the fixtures inauth-http.test.tsandopenapi-contract.test.ts. If Epic 004's REF-TASK-009 (splittingauth-http.tsroute families) has landed first, register the new checkout routes in the extracted developer-console module instead of growing the original file; otherwise extend the existingif (options.mockingBird)block. Do not do both refactors in one commit. - Console state
mode: becomes dynamic (real/disabledreachable locally for the first time). The frontend already handles those values; backend tests that hardcodemode: "mock"must construct their fixture with the mode they intend to assert. - Config default derivation changes
MOCKING_BIRD_ENABLED's effective default locally;config.test.tscases that previously relied on the implicitfalsemust stateMOCKING_BIRD_ENABLED=falseexplicitly where they want the disabled behavior. - Composition doc comments in
checkout-composition.ts(which currently narrate "local/test keepdisabled/fake") must be rewritten in the same change that alters the map — comments and code must not disagree. - No migrations, no API removals: existing endpoints, error codes, and the neutral checkout
contract are untouched; the only HTTP changes are additive developer routes plus the extended
state payload, each documented in
docs/api/openapi.yamlin the same task (repository contract for OpenAPI synchronization), followed bypnpm docs:build. - Documentation synchronization:
docs/development.md(enablement matrix — the mail-transport row must stop being the console's master switch;CHECKOUT_MODEdocumentation; the developer console description gains the checkout section),docs/docker.md(Compose defaults), and.env.example/docker-compose.ymlcomments, all in the task that makes the change, plusdocs/current-state.mdanddocs/ai-handover.mdat completion. Consider a short ADR (docs/decisions/005-...) recording the parked-charge adapter decision — it is a significant, deliberate deviation from "adapter returns promptly" and belongs with the other architectural decisions if implementation confirms the design.
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:
- 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-unknownadapter would strand every local checkout inCHECKOUT_UNRESOLVEDand exercise none of the success/decline paths. - Availability defaults. "Always available locally" is implemented by deriving
MOCKING_BIRD_ENABLEDfrom 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.
- MBC-S01 — With
DEPLOYMENT_ENV=localand default configuration, an authenticated user can open/developer/mocking-birdand call every console endpoint withMAIL_MODEset todisabled,mock, orreal; with Mocking Bird disabled, all routes answer 404 by direct HTTP. Evidence:backend/src/auth-http.test.ts— "developer mocking bird console API" (404 when absent; 401/403/CSRF/rate-limit matrix;MAIL_MODEdisabled/mock/real). - MBC-S02 — Existing email interception behaves exactly as before in mock mode (intercepted
transport, scenarios, latency, history, previews, recovery preview), and no interception
occurs in real or disabled mail mode. Evidence:
backend/src/integrations/mocking-bird-email.tsand its suite (unchanged behavior; regression gate MBC-TASK-003). - MBC-S03 —
CHECKOUT_MODE=mocking-birdselectsMockingBirdCheckoutGatewaylocally; staging/production reject it at startup; automated harnesses still runfake;surfboardanddisabledbehave unchanged. Evidence:backend/src/config.test.ts"[MBC-S03] accepts Mocking Bird checkout locally...";backend/src/integrations/checkout-composition.test.ts"[MBC-S03] selects Mocking Bird only when its checkout store is available". - MBC-S04 — A commercial checkout sent through the Mocking Bird payment mode appears as a
pending console entry containing order lines, quantities, unit and gross prices, currency,
gross total,
operationId/entry id, correlation id, and timestamps, in summary, structured, and raw views. Evidence:backend/src/integrations/mocking-bird-checkout.integration.test.ts— "keeps the full request visible on the pending entry for developer inspection";backend/src/auth-http.test.ts— "reports read-only mock state, redacted captures, and synthetic previews". - MBC-S05 — Triggering success finalizes the order commercially (
Queued, sale counted, attempt stored with amocking-bird-reference) through the normal201path. Evidence:backend/src/integrations/mocking-bird-checkout.integration.test.ts— "[MBC-S05] [MVP-PAY-001-S01] succeeded outcome". - MBC-S06 / MBC-S07 — Decline and cancellation each produce
409 CHECKOUT_NOT_PAIDwith no order, no sale, and no unresolved row; the client resetsoperationId. Evidence:backend/src/integrations/mocking-bird-checkout.integration.test.ts— "[MBC-S06] [MBC-S07] [MVP-PAY-001-S02] definitive refusal". - MBC-S08 / MBC-S09 — Timeout and provider-error triggers each durably record an
unresolved attempt, answer
409 CHECKOUT_UNRESOLVED, appear inGET /api/checkout/unresolvedand the KDS panel, and suppress blind retry of the same operation. Evidence:backend/src/integrations/mocking-bird-checkout.integration.test.ts— "[MBC-S08] [MBC-S09] [MVP-PAY-001-S03] lost certainty". - MBC-S10 — Concurrent pending requests are listed and settled independently; a duplicate
finalize while pending answers
409 CHECKOUT_IN_PROGRESSwithout a second charge. Evidence:backend/src/integrations/mocking-bird-checkout.integration.test.ts— "[MBC-S10] [MVP-PAY-001-S04] two concurrent finalizes...";backend/src/auth-http.test.ts— "[MBC-S10] settles concurrent parked charges independently and rejects a repeat". - MBC-S11 — Settled entries remain visible with timestamps and outcomes until cleared;
clearing resolves parked charges as
unknownfirst; capacity never evicts a pending entry; email clear and checkout clear stay independent. Evidence:backend/src/integrations/mocking-bird-checkout.test.ts— "[MBC-S11] settled entries retain trigger, outcome, reference and timestamps until clear"; "[MBC-S11] capacity evicts settled entries only, pending entries survive"; "[MBC-S11] resolves pending charges as unknown before clearing checkout state";backend/src/auth-http.test.ts— "[MBC-S11] clears checkout state and resolves pending charges as unknown". - MBC-S12 — Hosted deployments cannot enable Mocking Bird or select its checkout mode; new
routes enforce 401/403/CSRF/rate limits; a standard build contains no developer page; no
credential or secret exists anywhere in the feature. Evidence:
backend/src/config.test.ts"[MBC-S12] rejects developer tooling enabled in hosted mode";backend/src/auth-http.test.ts(404 absent; 401/403/CSRF/429 matrix);frontend/src/app/pages.test.tsx(no developer page in standard build);tests/ci/checkout-boundary.test.mjs(network-free checkout modules; neutral port). - Automated tests cover the store, gateway, composition, config, HTTP contract, and frontend
components listed in Testing strategy;
pnpm qualitypasses; the documentation surfaces (docs/api/openapi.yaml, docs-site,docs/development.md,docs/docker.md) are synchronized in the same changes. Evidence:pnpm quality(format, lint, typecheck, i18n, docs:links, spellcheck, architecture, repository, traceability, openapi, typedoc);pnpm test:all90/90;pnpm test:coverage100%;pnpm validate:dockerexit code 0;pnpm docs:build+pnpm docs:site:check.