Zum Inhalt springen

05 — payment-saga design

Status: Draft Date: 2026-05-23 Owner: backend (Workflows + Stripe specialist) Depends on: 01 foundation-infra, 02 identity-onboarding, 04 auction-engine Feeds: 08 advertiser-dashboard, 10 admin-console, 12 legal-compliance


Owns the durable post-auction process that turns a winner into a paid, live Spotlight slot. One Cloudflare Workflow per auction, plus a thin Stripe webhook bridge Worker that translates Stripe events into Workflow signals.

In scope: AuctionSaga class (in packages/saga); state machine, retries, sleeps, compensations; invoice-first Stripe sequence (master §10.1); cascade algorithm; webhook bridge at api.nowyourlink.com/webhooks/stripe; invoice PDF archive in R2 (10y, §147 AO); credit-note refund flow; D1 mirror saga_runs for read-side observability.

Out of scope (explicitly): auction state, bid ranking, close trigger (owned by 04; saga receives a ranked snapshot, never writes to the auction DO); ad rendering / serving (07); VIES revalidation (02); the strike column itself (02; this spec only fires the strike API); house-ad selection (10 §9; we call pickHouseAd(auction_id)).


  • Instance id = auction_id (e.g. "2026-06-12"). Discoverable: env.SAGA.get("2026-06-12").
  • Spawn: AuctionDO.alarm() at 00:00 UTC fires env.SAGA.create({ id: auction_id, params: { auction_id, rankedBidders: Bidder[] } }). rankedBidders is the top-5 snapshot at close, each { user_id, stripe_customer_id, default_payment_method_id, bid_id, amount_cents, ad_id }.
  • Complete: after ACTIVATE success or terminal compensation (HOUSE_AD, REFUND). NonRetryableError only for truly terminal failures (all 5 bidders banned, Stripe > 30h down).
  • Mirror in D1 (saga_runs, written by bridge + step.do hook):
    CREATE TABLE saga_runs (
    instance_id TEXT PRIMARY KEY, -- = auction_id
    auction_id TEXT NOT NULL,
    current_step TEXT NOT NULL, -- WIN | INVOICE_CREATE | …
    winner_user_id TEXT,
    invoice_id TEXT,
    status TEXT NOT NULL, -- running | complete | failed | compensating
    last_error TEXT,
    started_at TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    completed_at TEXT
    );
    This is the read-side surface for admin + Sentry breadcrumbs; the Workflow itself remains the source of truth.

16 vertices. Full transitions:

The winning bidder proceeds through invoice creation, finalization, payment and activation. Authentication or payment failures can cascade to another bidder or a house ad. Activation failures trigger reversal, while already disputed payments avoid duplicate refunds.The winning bidder proceeds through invoice creation, finalization, payment and activation. Authentication or payment failures can cascade to another bidder or a house ad. Activation failures trigger reversal, while already disputed payments avoid duplicate refunds.
StateEntry actionSuccess →Failure →RetriesTimeout
WINlog, write saga_runs rowINVOICE_CREATENonRetryableError030s
INVOICE_CREATEcustomers.update(address refresh) + tax-id reconcile (listTaxIds→diff→createTaxId/deleteTaxId) + invoices.create + invoiceItems.createINVOICE_FINALIZECHARGE_FAILED3 exp (30s base)5m
INVOICE_FINALIZEinvoices.finalizeInvoice; store hosted URL; archive PDF to R2INVOICE_PAYCHARGE_FAILED5 exp (1m base)30m
INVOICE_PAYinvoices.pay({ off_session:true }) then step.waitForEvent('invoice-paid:${invoice.id}')ACTIVATEAWAIT_3DS or CHARGE_FAILED3 (Stripe call only)30m wait
AWAIT_3DSsend 3DS email (hosted URL) + step.waitForEvent('3ds:${pi.id}', { timeout:'6 hours' })INVOICE_PAY (re-check via invoices.retrieve)CHARGE_FAILED06h
CHARGE_FAILEDlog; call identity strike APISTRIKEretry strike API 3x31m
STRIKEPOST /admin/users/:id/strike (idempotent on saga_instance_id)CASCADE_TO_RUNNERUP if list has next else HOUSE_ADNonRetryableError31m
CASCADE_TO_RUNNERUPshift ranked list; bump attempt namespaceINVOICE_CREATEHOUSE_AD if list empty030s
ACTIVATEwait until T+24h; RPC auctionDO.activate({ ad_id, user_id }); then re-read creative approval (E6/D27 activation-window gate, #390) — a revoke detected here routes to ACTIVATE_FAILED carrying last_error="creative-revoked:<reason>". The re-read runs AFTER the RPC so a same-day-chargeback SUPERSEDED always wins (no double-claw).LIVE (or SUPERSEDED if the slot was deactivated)ACTIVATE_FAILED (DO error / asset gone / creative revoked during the wait)5 exp (30s)10m
ACTIVATE_FAILEDlog P1 alertREFUNDNonRetryableError01m
SUPERSEDEDlog; the slot was deactivated by the DisputeSaga (same-day chargeback, #383b). NO refund — the out-of-band credit note already ranHOUSE_ADn/a030s
REFUNDcreditNotes.create({ invoice, reason:'duplicate', lines:[...] })HOUSE_ADretry 5x5 exp (1m)30m
HOUSE_ADcall pickHouseAd(auction_id) then activate[*]NonRetryableError (P0)35m
LIVEmark saga_runs.status='complete'[*]n/an/an/a
CHARGEBACK_RECEIVEDspawn DisputeSaga; this saga terminates[*]n/a0n/a

Total saga wall-clock budget: ≤ 30 hours from auction.closed to LIVE (24h pre-activate window + up to 6h SCA + retries). NonRetryableError from cloudflare:workflows is used at the ceiling for Stripe outages exceeding the budget.


4. Stripe call sequence (invoice-first, per research §4 + master §10.1)

Section titled “4. Stripe call sequence (invoice-first, per research §4 + master §10.1)”

Each bidder attempt (head of ranked list) runs these calls, one per step.do:

  1. customers.update(cust, { address }) — refresh the billing address from the current users row (users.country is NOT NULL, so the address always resolves). Tax-ID reconcile (own step.do, after the address update): customers.listTaxIds(cust) → diff the existing tax IDs against users.vat_id → customers.createTaxId(cust, { type, value }) for a new/changed VAT-ID and customers.deleteTaxId(cust, taxIdId) for a stale or now-absent one (users.vat_id is nullable — a NULL means delete any existing tax IDs). The list→diff→create/delete sequence is idempotent and replay-safe: a Workflow restart re-runs it, finds the desired tax-ID already present (or already absent), and no-ops. Note: tax_id_data is a create-only parameter — it is accepted by customers.create but silently ignored by customers.update; refreshing a VAT-ID on an existing customer must go through the Tax IDs API (customers.listTaxIds / createTaxId / deleteTaxId), never customers.update. The dedicated createTaxId endpoint takes { type, value } directly (no tax_id_data wrapper).
  2. invoices.create({ customer, collection_method:'charge_automatically', default_payment_method, automatic_tax:{ enabled:true }, auto_advance:false, pending_invoice_items_behavior:'exclude', metadata:{ auction_id, saga_instance_id, attempt }, custom_fields:[{ name:'Leistungsdatum', value:auction_id }] }) — draft invoice.
  3. invoiceItems.create({ customer, invoice, amount:bid.amount_cents, currency:'eur', description:'Spotlight — '+auction_id }).
  4. invoices.finalizeInvoice(invoice.id) — locks §14 UStG numbering; returns hosted_invoice_url + invoice_pdf; Stripe auto-creates the PI. Archive PDF to R2 (§8).
  5. invoices.pay(invoice.id, { off_session:true }) — confirms PI against saved PM.
  6. Inspect:
    • paid → ACTIVATE.
    • PI requires_action → AWAIT_3DS: email hosted_invoice_url; step.waitForEvent('3ds:${pi.id}', { timeout:'6 hours' }). On resume: invoices.retrieve(invoice.id) to confirm paid. Timeout / payment_failed → CHARGE_FAILED.
    • requires_payment_method / card_declined → CHARGE_FAILED (no same-PM retry).

Every step retry starts with invoices.retrieve(invoice.id) and short-circuits if already paid — guards Stripe-commit vs Workflow-replay races (research §3).

Stripe methods used verbatim: customers.update (address only), customers.listTaxIds, customers.createTaxId, customers.deleteTaxId, invoices.create, invoiceItems.create, invoices.finalizeInvoice, invoices.pay, invoices.retrieve, creditNotes.create.


When CHARGE_FAILED fires:

  1. POST /_internal/users/${userId}/strike { reason:'charge_failed', saga_instance_id, auction_id }. Identity (02) increments users.strikes, sets banned_at at ≥3, and is idempotent on saga_instance_id — replay-safe.
  2. Same Workflow instance, no sub-Workflow. Rationale: keeps instance_id = auction_id invariant, one Sentry trace per auction, no inter-instance signal plumbing. The “branch” is just a loop iteration with attempt-namespaced step names: invoice-create:attempt-2:user-${uid}, invoice-finalize:attempt-2:user-${uid}, invoice-pay:attempt-2:user-${uid}.
  3. Shift ranked array; if empty → HOUSE_AD.
  4. Loop bound: top 5 bidders max (rankedBidders.length ≤ 5 at spawn). Worst-case wall-clock: 1 × 6h SCA + 4 × 30min SCA + 5 × pay/finalize ceilings ≈ 10h — comfortably under the 24h pre-activate budget.
  5. Cascaded attempts mint fresh Stripe idempotency keys (Stripe keys expire 24h, research §9). Workflows replay-safety is independent — step.do is memoised by step name, not Stripe key.

3 strikes → identity sets banned_at (column owned by 02).


Hono route POST /webhooks/stripe on apps/api (ADR-0001 / D40), handler module apps/api/src/webhooks/stripe.ts, mounted into the worker’s Hono app via app.route('/', stripeWebhookRoute). Uses a Hono raw-body middleware so Stripe.webhooks.constructEventAsync receives the exact body bytes. Never writes D1 directly — it only signals the Workflow.

  • Stripe-Signature header verified via stripe.webhooks.constructEventAsync(body, sig, env.STRIPE_WEBHOOK_SECRET).
  • Reject if missing, expired, or signature mismatch → 400. Successful verify → respond 200 OK within 2s (Stripe retry SLA).
  • KV namespace STRIPE_EVENT_DEDUPE, key evt_${event.id}, TTL 7 days.
  • await env.STRIPE_EVENT_DEDUPE.get(key) — if present, log duplicate, return 200. Else put(key, '1', { expirationTtl: 604800 }) after successful dispatch.
Stripe eventAction
invoice.payment_succeededinstance.sendEvent({ type:'invoice-paid:'+invoice.id, payload:{ paid:true } })
invoice.payment_failedinstance.sendEvent({ type:'invoice-paid:'+invoice.id, payload:{ failed:true } })
payment_intent.requires_actioninstance.sendEvent({ type:'3ds:'+pi.id, payload:{ status:'requires_action', hosted_url } }) — also enqueues the 3DS email via Resend
payment_intent.succeededinstance.sendEvent({ type:'3ds:'+pi.id, payload:{ status:'succeeded' } })
payment_intent.payment_failedinstance.sendEvent({ type:'3ds:'+pi.id, payload:{ failed:true } })
charge.dispute.createdspawn env.DISPUTE_SAGA.create({ id:'dispute:'+dispute.id, params:{ auction_id, charge_id, amount } }); signal main saga CHARGEBACK_RECEIVED
invoice.voidedobservability + Sentry breadcrumb only
charge.refundedobservability only
const auctionId = event.data.object.metadata?.auction_id; // set in invoices.create
if (!auctionId) return ok(); // not ours
const inst = await env.SAGA.get(auctionId);
const status = await inst.status();
if (status.status === 'complete' || status.status === 'errored') return ok(); // log only
await inst.sendEvent({ type, payload });

Guard: env.SAGA.get(id) succeeds even for non-existent instances and sendEvent silently drops (research §9). We always check instance.status() first; if the saga is gone we log a webhook.orphan event to Sentry.


Independent idempotency systems, never confused:

The saga is spawned by an auction_closed signal from AuctionDO. Spec 04 / PR #123 sets the DO’s auction_closed_emitted flag only after emitAuctionClosed returns, which makes a successful-emit-then-failed-flag window re-emit the same close on the next alarm-retry / recovery loop. The saga therefore dedupes auction_closed at its boundary, keyed by the auction day (= instance_id), mirroring the webhook-replay pattern below:

  • KV namespace SAGA_DEDUPE, key auction_closed:${day}, TTL 7 days (claimAuctionClosedDay, packages/saga). A claim is taken before the saga is created; a second auction_closed for the same day finds the marker present, logs auction_closed.duplicate, and is a no-op — it never advances the saga past its initial state.
  • Hard backstop: the saga instance id is auction_id, unique within the Workflow, so at most one instance exists per day even if two claims race the (non-atomic) KV get-then-put window. create({ id }) is not idempotent (it throws on a duplicate id), so the transport spawns via createBatch([{ id: day, params }]), which is idempotent: a duplicate id within its retention window is silently skipped and excluded from the returned array.
  • Spawn-seam contract (consumeAuctionClosed → onFirstClose): the spawn resolves "created" (returned array has the instance) or "already-exists" (empty array — createBatch skipped the duplicate), and rejects only on a transient failure where the instance was not started. The consumer releases the KV claim only on that transient rejection (so a legitimate retry re-runs); on "already-exists" it keeps the marker and reports duplicate. Using createBatch is what lets the transport honour this without fragile duplicate-id error-string matching — a live saga’s marker is never released, so the day settles into a clean duplicate no-op instead of thrashing.

This is the hard prerequisite the recovery loop and Epic-4’s settled → archived step (PR-K) depend on; without it PR #123’s duplicate-emit trade-off is unsafe.

Handled by the runtime. Every step.do(name, fn) is memoised by (instance_id, step_name). Step names are deterministic and namespaced: ${verb}:attempt-${n}:user-${uid}. Do not rely on Stripe idempotency for replay-safety inside one instance — Workflows handles it.

Stripe idempotency (cross-instance, cross-retry-envelope)

Section titled “Stripe idempotency (cross-instance, cross-retry-envelope)”

Every mutating Stripe call sets Idempotency-Key: ${saga_instance_id}:${step_name}:${attempt_number}. Stripe keys expire after 24h so the attempt_number is a Workflow-step retry counter (0..max). For cross-day cascades, the Workflow attempt counter also increments — keys never collide across days because saga_instance_id is auction_id (date).

KV evt_${event.id} TTL 7d (covers Stripe’s 3-day retry window with margin).

The strike API (owned by 02) dedupes on (saga_instance_id, user_id) — re-firing the strike step never double-counts.

Do not use Stripe idempotency keys as the replay key inside step.do. They’re the cross-system dedupe for Stripe’s view. Workflows’ step memoisation is the intra-instance replay key.


  • Bucket: nowyourlink-invoices (single bucket, partitioned by year prefix; bound as INVOICES on apps/api where the saga runs — see ADR-0005, which reconciles this name with the repo and the single-TF-state rule).
  • Object key: invoices/${year}/${auction_id}/${invoice_number}.pdf (e.g. invoices/2026/2026-06-12/INV-2026-000123.pdf). invoice_number comes from Stripe — sequential, legally significant.
  • Upload: in INVOICE_FINALIZE step, after invoices.finalizeInvoice returns invoice_pdf URL, fetch the PDF and R2.put(key, body, { httpMetadata:{ contentType:'application/pdf' }, customMetadata:{ invoice_id, customer_id, auction_id, finalized_at } }).
  • Retention: 10 years (§147 AO, §14 UStG).
  • Immutability (per master §10.3):
    • Primary: R2 Object Lock in Compliance mode with 10-year retention. As of May 2026 it is Beta — gate on GA before relying on it alone.
    • Fallback while Object Lock is Beta:
      1. Application-level write-once guard: pre-check R2.head(key) and never overwrite. The implementation (packages/saga/r2.ts) treats a head(key) hit as an idempotent no-op skip, not a NonRetryableError — because the key embeds Stripe’s globally-unique sequential invoice_number, a collision can only be the same immutable invoice already archived (a Workflow-restart replay), never a different document. Throwing NonRetryableError there would permanently fail the saga on a successful prior archive. NonRetryableError stays reserved for the genuinely-terminal R2-write-impossible case (ADR-0005).
      2. Secondary backup: write the same PDF to an external WORM target — either an encrypted S3-Glacier-equivalent or pin hosted_invoice_url in D1 with Stripe as the canonical archive (Stripe retains finalized invoices ≥10y).
  • Bucket lifecycle: no auto-delete; deletion only via admin runbook after 10y + 1 year safety margin.

Never use refunds.create on the PI directly (loses the legally required §14 UStG correction doc). Saga always issues a credit note. Both flows credit the same invoice line (lines:[{ type:'invoice_line_item', invoice_line_item: line_id, quantity:1 }]) so Stripe auto-reverses VAT (docs.stripe.com/api/credit_notes/create) and emits a correction PDF; they differ only in how the credited money is placed (a credit note on a paid invoice must satisfy Stripe’s sum rule refund_amount + credit_amount + out_of_band_amount = credited total, so exactly one of those carries amount_paid):

  • Full reversal — undisputed (ACTIVATE_FAILED, admin refund): creditNotes.create({ invoice: invoice_id, reason:'duplicate'|'order_change', refund_amount: invoice.amount_paid, lines:[…], metadata:{ auction_id, saga_instance_id, trigger } }). refund_amount creates a real Stripe Refund on the charge — correct here because the charge is not disputed and Stripe still holds the funds.
  • Full reversal — chargeback (DisputeSaga, trigger:'chargeback'): creditNotes.create({ …, reason:'fraudulent', out_of_band_amount: invoice.amount_paid, … }) — NOT refund_amount. When charge.dispute.created fires, the card network has already pulled the funds back and Stripe has withdrawn them from the account balance, so the money is returned outside Stripe. A refund_amount credit note here is rejected by Stripe (charge_disputed — a disputed charge cannot be refunded) and would double-refund; out_of_band_amount records the out-of-Stripe return so the credit note still produces the §14 correction document without any Refund. (Implemented via the settlement: 'refund' | 'out_of_band' discriminator on the saga’s createCreditNote seam — #383.)
  • Partial (admin-initiated, #529): the admin enters a gross amount (incl. VAT — what the bidder gets back). Because invoices carry automatic_tax, the credited line amount is net, so a net-only refund_amount would breach the sum rule (refund_amount + credit_amount + out_of_band_amount == post_payment_amount) on any taxed invoice. The seam derives the net partial (round(gross × line.amount / invoice.amount_paid)), creditNotes.previews it to read the actual post_payment_amount Stripe will settle, then creditNotes.create({ ..., refund_amount: post_payment_amount, lines:[{ ..., amount: net_partial }] }) so the rule holds by construction on 19% / 7% / 0% invoices. The settled gross may differ from the entered gross by ≤1 cent (two rounding steps). Known limitation: a full refund issued after a partial still credits the original invoice.amount_paid, which exceeds the remaining creditable amount → Stripe rejects → the step wedges (not handled).
  • PDF archived to R2 at credit-notes/${year}/${auction_id}/${credit_note_number}.pdf.

Manual test-mode verification (the chargeback credit-note contract, #383). The unit suite proves the param shape against an in-memory Stripe; the live Stripe contract (a refund_amount credit note is rejected on a disputed charge) can only be confirmed against the API. Run in test mode (never the live nowyourlink.com account): (1) create + pay a saga-shaped invoice off a charge made with dispute test card 4000000000000259; (2) wait for charge.dispute.created (status:'needs_response'); (3) confirm creditNotes.create({ invoice, refund_amount: amount_paid, … }) is rejected with charge_disputed, and that the same call with out_of_band_amount instead succeeds and emits the correction PDF. This documents the empirical check the fakes cannot perform.

Manual test-mode verification (the partial credit-note contract, #529). The unit suite proves the sum rule against an in-memory Stripe that models automatic_tax (a net-only refund_amount throws); the live contract can only be confirmed against the API. Run in test mode: (1) create + pay a saga-shaped invoice with automatic_tax enabled and a non-zero VAT rate (e.g. 19% domestic, so amount_paid = net + VAT); (2) confirm creditNotes.create({ invoice, refund_amount: net_partial, lines:[{ amount: net_partial }] }) is rejected with an invalid_request_error (sum rule breached — refund_amount net-only ≠ post_payment_amount net+VAT); (3) confirm the creditNotes.preview → creditNotes.create({ refund_amount: post_payment_amount, lines:[{ amount: net_partial }] }) flow succeeds and emits the §14 correction PDF. A passing unit test is not a substitute for this empirical check.


Every step.do emits a structured log on entry + exit:

{
"auction_id": "2026-06-12",
"instance_id": "2026-06-12",
"saga_step": "invoice-finalize:attempt-1:user-abc",
"attempt": 1,
"outcome": "ok" | "retry" | "fail",
"duration_ms": 412,
"stripe_idempotency_key": "2026-06-12:invoice-finalize:1",
"error_code": "saga.invoice-finalize-timeout" | null
}

Sampled at 100% (saga events are low-volume; see master §10.2 — saga is in the 100%-logged bucket).

  • @sentry/cloudflare in apps/api + packages/saga. Each step.do wrapped in Sentry.startSpan({ name:'saga.'+stepName, op:'workflow.step' }). Breadcrumb per state transition; Sentry.captureException on terminal NonRetryableError and cascade exhaustion. Tags: auction_id, instance_id, saga_step, attempt, user_id.

HTTP error envelope (webhook bridge → 4xx)

Section titled “HTTP error envelope (webhook bridge → 4xx)”

Per master §4.5:

{ "error": { "code": "saga.webhook-signature-invalid", "message": "Stripe signature invalid", "retryable": false } }

Codes used here: saga.webhook-signature-invalid, saga.webhook-orphan, saga.charge-failed, saga.cascade-exhausted, saga.activate-failed, saga.refund-failed, saga.house-ad-fallback.


  1. Card expired between bid and close — invoices.pay returns requires_payment_method → CHARGE_FAILED → strike + cascade; bidder gets a re-Setup email (02).
  2. SCA timeout — 6h on first attempt, 30min on cascaded attempts; on timeout → CHARGE_FAILED.
  3. Runner-up also fails — loop until ranked list empty → HOUSE_AD; strike per failed attempt.
  4. All 5 bidders fail — pickHouseAd(auction_id) + activate; slot still served; P1 alert.
  5. Chargeback, same day, slot not served (#383b) — DisputeSaga reverses (out-of-band credit note) + strikes, then deactivates the won slot in AuctionDO. The DO sets a supersede marker so the original payment saga’s still-pending T+24h activate resolves to SUPERSEDED (skips LIVE, no second refund) and self-heals to HOUSE_AD — the disputed ad never reaches daily_spotlight. If the disputed ad was already LIVE (mid-serve), the DisputeSaga instead overwrites the daily_spotlight row with a house ad immediately (P1, “mid-slot replacement”). The won slot’s public surface is daily_spotlight (the DO WON_SLOT record is server-only), so “ad pulled within 1 min” (§12.9) is met by the marker (pre-serve: the row is never written) or the immediate overwrite (mid-serve). Runner-up respawn (#421) — when the pull is pre-serve AND now < T+24h - 1h AND a runner-up exists, the DisputeSaga instead spawns a fresh reclaim AuctionSaga (${auction_id}:reclaim:${dispute_id}, excludeUserId = the disputed bidder) on the next ranked bidder, the revenue-optimizing half of §11.5 (the runner-up would have paid). The marker scope is two-phase (#432): deactivate leaves the marker unconditional (it records the disputed bidder only as a pending candidate), and a separate scope-reclaim DO step — run by the DisputeSaga only after the reclaim spawn commits — promotes it to the per-bidder scope, so only the original saga’s activate is superseded (it stands down at SUPERSEDED with no house ad, respawned: true) while the reclaim saga’s runner-up activate proceeds to LIVE. The split means a permanent reclaim-spawn failure (e.g. a missing PAYMENT_SAGA binding) leaves the marker unconditional → the original saga self-heals to a house ad (a served, archivable day) instead of standing down for a reclaim that never arrived. The reclaim itself falls back to a house ad if every runner-up declines, so the slot is always served. When the window has passed, there is no runner-up, or the bidder is unmapped, the house-ad fallback (above) stands.
  6. Chargeback after slot served — credit-note + strike only; no reclaim (T&Cs in 12). The DO is archived (gone), so deactivate reports none and the DisputeSaga records no house install.
  7. Stripe outage (persistent 5xx) — step.do exponential retry to step ceiling; if total wall-clock > 30h before ACTIVATE, NonRetryableError → HOUSE_AD, P0.
  8. Workflow event latency (master §10.4) — step.waitForEvent p99 ~2s. Only AWAIT_3DS and post-invoices.pay settle wait on events; both wall-clock-bounded.
  9. event.id replay > 7d — KV TTL gone, webhook re-arrives; bridge sees instance.status() === 'complete', logs webhook.orphan, returns 200.
  10. VAT-ID lapsed — the Tax-IDs reconcile deletes the stale ID via customers.deleteTaxId (and createTaxId if users.vat_id changed); tax_id_data on customers.update would be ignored. Stripe Tax falls back to 19% DE-USt; bidder notified (12).
  11. Bidder banned mid-cascade — strike #3 sets banned_at; next iteration’s INVOICE_CREATE re-reads identity, throws NonRetryableError for that bidder only, loop continues.
  12. Creative revoked DURING the T+24h activation window (#390, post-charge/pre-serve) — moderation revokes the winning ad between charge (at close) and activation (T+24h). The charge-time E6/D27 gate (spec 04 §10) already passed, so the card is charged but the slot is not yet LIVE. ACTIVATE re-reads approval right after the activate RPC; on a revoke it routes to ACTIVATE_FAILED → REFUND → HOUSE_AD — the full-reversal credit note (settlement: "refund", the charge is undisputed so Stripe still holds the funds) and a house ad, mirroring the money-taken asset-gone path (founder ruling 2026-06-08 option b). Not a cascade — the runner-up was never charged; not activate-anyway — that would serve a revoked creative. The ACTIVATE_FAILED row carries last_error="creative-revoked:<reason>" (the audit signal distinguishing it from asset-gone). The re-read runs AFTER the activate RPC so a concurrent same-day chargeback (edge 5, SUPERSEDED) wins first — refunding a slot the DisputeSaga already reversed out-of-band would double-claw the charge.

  1. Happy path: auction.closed → LIVE in < 30min on a no-3DS card; invoice PDF in R2; saga_runs.status='complete'.
  2. SCA: 3DS email within 60s of requires_action; saga advances within 5s of bidder completing 3DS.
  3. Cascade: top decline → strike → runner-up invoiced within 2min.
  4. House-ad: 5 consecutive declines → house ad live at T+24h with P1 alert.
  5. Webhook dedupe: replaying any Stripe webhook within 7d is a no-op (KV verified in integration test).
  6. Replay safety: forced Workflow restart mid-INVOICE_FINALIZE does not double-finalize (verified via invoices.retrieve).
  7. Retention: PDF in R2 within 2min of finalize, metadata populated, no lifecycle expiry.
  8. Refund: ACTIVATE_FAILED produces a credit note with VAT correctly reversed (snapshot test).
  9. Same-day chargeback: charge.dispute.created → ad pulled within 1min via DisputeSaga.
  10. Observability: every saga event visible in Sentry with auction_id+saga_step tags; D1 mirror within 5s of state transition.
  11. Activation-window revoke (#390, edge 12): a creative revoked during the T+24h wait → ACTIVATE_FAILED → REFUND → HOUSE_AD with a full-reversal credit note (settlement: "refund") and saga_runs.last_error="creative-revoked:<reason>"; the slot serves a house ad, the winner is never LIVE. An approved creative still activates to LIVE. A concurrent same-day chargeback (SUPERSEDED) wins over the revoke gate, with no second refund. (saga unit tests: #390 — creative REVOKED during the T+24h activation window …, #390 — revoke + same-day chargeback … → SUPERSEDED wins.)

  • Workflows API surface: step.do, step.sleepUntil, step.waitForEvent, NonRetryableError from cloudflare:workflows; env.SAGA.create, env.SAGA.get, instance.status(), instance.sendEvent.
  • Do NOT use Stripe idempotency keys as the replay key inside step.do — Workflows handles that via step-name memoisation. Stripe keys are for cross-instance + Stripe-server-side dedupe only.
  • Always invoices.retrieve(id) at the top of any step.do touching an existing invoice (guards Workflow-restart vs Stripe-commit races, research §3).
  • Webhook bridge must NOT write D1. It only signals; the saga writes saga_runs.
  • Step names deterministic across replays — no Date.now()/Math.random(); use the loop attempt counter.
  • Use step.sleepUntil for T+24h to avoid wall-clock drift across retries.
  • No state in closure — flow data via step.do return values; closures are not replay-persistent.
  • NonRetryableError ceiling: reserved for truly terminal states (all bidders banned, Stripe > 30h down, R2 write impossible).
  • Local dev: stripe listen --forward-to localhost:8788/webhooks/stripe; verify dedupe with stripe events resend evt_....
  • Cross-spec coupling: strike API → 02; activate() RPC → 04; house-ad picker → 10 (§9); credit-note PDF acceptance → 12.
  • Webhook routing (D40 / ADR-0001): Hono route with a raw-body middleware applied before signature verification; deduplication via the SAGA_DEDUPE KV namespace inside the handler.