accelerando.wiki ↗ app ↗ github

Stripe Adapter

We leave payment processing to Stripe. We keep the audit trail.

The third slice of the ecommerce arc, and the smallest deliberately. The design rule up front: cards never touch our storage; tokens do. Stripe handles authorization, capture, refund, dispute resolution — the compliance-heavy specialist work. This module records what Stripe told us and translates a small set of event types into deterministic order-state transitions. Everything else Stripe sends still gets logged, so the chargeback-defense trail is complete.

The reframe: *"here is exactly what Stripe said and when they said it"* is more defensible than *"here is what our system decided the payment state was."*


The two views

Payment events — the timeline of every webhook we've received, sorted newest first. Filter by event type. Each row shows the received timestamp, event type, Stripe object id (pi_… or ch_…), linked order number, amount, and the processing result — <span style="color:#34d399;">order_marked_paid</span> / <span style="color:#94a3b8;">recorded</span> / <span style="color:#94a3b8;">no_state_change</span> / <span style="color:#f87171;">error</span>. The audit trail *is* the view.

Stripe config — per-tenant BYOK. Publishable key + webhook signing secret + optional Stripe Connect account id. The publishable key is safe to display; the webhook secret is redacted on every read (only the last 4 chars are visible, for verification purposes). Save once; the adapter is live.


What we handle vs. what we record

Five event types drive processing decisions:

| Stripe event | What we do | |---|---| | payment_intent.succeeded | Auto-transition the linked order from awaiting_paymentpaid. Stamp stripe_payment_intent_id (or stripe_charge_id) on the order. | | payment_intent.payment_failed | Record only. No auto-transition. Merchant reviews. | | charge.refunded | Record only. Refund detail lands in the ops slice. | | charge.dispute.created | Record only. The chargeback moment. Dashboard surfaces the open-dispute count. | | charge.dispute.closed | Record only. Closes the loop; dashboard's open-dispute count drops. |

Every other event Stripe sends is also recorded — the log is complete. Only these five drive side effects because auto-transitioning anything else risks silent state changes that a merchant can't reason about.


Idempotency

Stripe delivers webhooks *at least once*. Same event id can arrive multiple times if we're slow to 200-OK. The adapter is idempotent by stripe_event_id: reprocessing returns the original PaymentEvent record with idempotent_replay: true and does not re-fire side effects. The order doesn't get double-marked-paid; the audit log doesn't grow duplicate entries.

process_stripe_event(evt_abc123)  → { event: <new>, result: "order_marked_paid" }
process_stripe_event(evt_abc123)  → { event: <same>, result: "idempotent_replay", idempotent_replay: true }

That behavior alone eliminates a whole class of production bug that plagues most manually-integrated Stripe adapters.


The BYOK boundary

Every tenant supplies their own Stripe keys. This is deliberate:

For Stripe Connect merchants, the config includes an acct_… id. For direct-charge merchants, it's just the publishable key + webhook secret. Both shapes work.


Production wire-up

The tool surface ships now; the actual webhook route lives in worker.ts alongside the other route handlers:

// POST /stripe/webhook
//   1. Read raw body + Stripe-Signature header
//   2. Look up tenant from URL path segment (/stripe/webhook/<tenant_slug>)
//   3. Verify signature against tenant's webhook_secret (5-min window)
//   4. Call process_stripe_event with the parsed event fields
//   5. Return 200 immediately (Stripe retries on non-2xx)

The signature-verification skeleton lives in src/stripe.ts (timingSafeEqualHex + StripeSignatureCheckInput type) — production wire-up drops it into the worker route directly.

For the demo, the *Simulate event* button on the Payment events view fires the same handler with mocked event data. Same code path, no signature step.


What's wired

| Entity | Purpose | |---|---| | StripeConfig | Per-tenant BYOK config (publishable key, webhook secret, Connect account id, mode) | | PaymentEvent | One row per Stripe event received. Idempotent by stripe_event_id. |

Plus two new fields on EcomOrder: stripe_payment_intent_id, stripe_charge_id — the tokens that link the order back to Stripe's dashboard.

Tools (6): get_stripe_config (redacts secret), set_stripe_config (BYOK save), process_stripe_event (idempotent webhook handler), list_payment_events, payment_summary (per-order timeline), stripe_dashboard (30-day rollup + open disputes).


The chargeback-defense payoff (again)

This module is the one where the whole ecommerce arc's audit-log thesis compounds. When a customer disputes a charge:

The last piece is what this slice adds. The complete dispute evidence bundle is now: one filesystem walk + one Stripe-events timeline read. The merchant doesn't have to file a request with Shopify. They git show and they list_payment_events. Done.


What's NOT in this slice