Zum Inhalt springen

ADR-0005 — Payment-saga R2 invoice archive + webhook-bridge location (spec 05 §6/§8 drifts)

ADR-0005 — Payment-saga R2 invoice archive + webhook-bridge location (spec 05 §6/§8 drifts)

Section titled “ADR-0005 — Payment-saga R2 invoice archive + webhook-bridge location (spec 05 §6/§8 drifts)”

Status: Accepted Date: 2026-05-31 Context: Epic #5 (payment saga). Resolves two drifts between spec 05 and the repo before PR-4 (R2 archive) and PR-5 (webhook bridge).

Spec 05 says:

  • §8 — invoice PDFs archive to an R2 bucket named nyl-invoices-prod.
  • §6 — the Stripe webhook bridge is a Hono route POST /webhooks/stripe on apps/api that signals the Workflow.

The repo as built drifts from both:

  1. apps/api already serves POST /webhooks/stripe — the spec-02 identity webhook (webhook-routes.ts: KYC / setup-intent events). A second handler at the same path is impossible.
  2. apps/webhook already exists and is the worker currently bound to PAYMENT_SAGA (→ nowyourlink-api#PaymentSagaWorkflow), an R2 bucket INVOICES = nowyourlink-invoices, and a SAGA_DLQ queue.
  3. The R2 bucket name in the repo is nowyourlink-invoices (consistent with the existing nowyourlink-creatives convention), not the spec’s nyl-invoices-prod.
  4. Single shared Terraform state (project rule): we do not stand up a second bucket just to match a spec name.

The saga (PaymentSagaWorkflow) executes inside apps/api (exported from nowyourlink-api; ADR-0001 / D40). Its INVOICE_FINALIZE step writes the PDF to R2, so the R2 binding must live on the worker the saga runs in — apps/api, not apps/webhook.

  1. R2 bucket = nowyourlink-invoices (the existing repo name). Do not create nyl-invoices-prod. Spec §8 is updated to the repo name.
  2. The R2 INVOICES binding lives on apps/api (where the saga runs). The INVOICE_FINALIZE step archives the finalized PDF there.
  3. The webhook bridge extends the existing apps/api POST /webhooks/stripe handler (per spec §6’s “on apps/api” logical contract) with a saga-event dispatch branch (invoice.*, payment_intent.*, charge.dispute.* → signal env.PAYMENT_SAGA). We do not add a second /webhooks/stripe route, and we do not build a separate bridge on apps/webhook.
  4. apps/webhook’s saga bindings are superseded. Its PAYMENT_SAGA / INVOICES / SAGA_DLQ bindings are not used by the saga (which runs in apps/api); they are cleaned up when the webhook bridge lands on apps/api (PR-5), keeping one home for the saga’s R2 + signalling.
  • Spec §6/§8 are reconciled to repo reality (bucket name; bridge co-located with the existing identity webhook on apps/api).
  • One Terraform state, one invoices bucket; the R2 resource is added to the shared TF root and bound on apps/api.
  • PR-4 adds the INVOICES binding + the INVOICE_FINALIZE → R2 archive step (write-once head() guard, §147-AO metadata).
  • Write-once = idempotent skip, not NonRetryableError. Spec §8’s fallback literally says the guard should “throw NonRetryableError on collision.” We supersede that: on a head(key) hit the archiver returns silently. The key embeds Stripe’s globally-unique sequential invoice_number, so a collision can only be the same immutable invoice already archived (a Workflow-restart replay where the R2 put committed but the step’s completion was lost), never a different document — throwing there would permanently fail the saga on a successful prior archive. NonRetryableError is reserved for the genuinely-terminal R2-write-impossible case. Spec §8 is updated to match.
  • PR-5 grows the apps/api /webhooks/stripe handler to route Stripe events to the saga and removes apps/webhook’s now-dead saga bindings.
  • The §6 “thin webhook bridge Worker” wording is satisfied logically by the apps/api route module (webhooks/stripe), not a distinct worker.