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_payment → paid. 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:
- The tenant owns their Stripe account. No shared platform account. When they turn off Accelerando, their Stripe history stays theirs.
- We never see cardholder data. Cards go directly from the browser to Stripe Elements. Payment intents come back as tokens. Everything downstream is opaque strings.
- Webhook secrets are per-tenant. Rotation is a single
set_stripe_configcall; no coordination with a central platform.
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:
- Order state history:
git log data/<tenant>/EcomOrder/<id>.agi - Product state at checkout:
git show <commit-at-order-time>:data/<tenant>/Product/<sku>.agi - Inventory decrement:
InventoryAdjustmentrecords withreason=sale, reference=<order_id> - Payment reality:
PaymentEventtimeline showing every Stripe event, in chronological order, with the raw payload preserved
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
- Real webhook route in
worker.ts— that's production wire-up, not a demo slice. Documented above; skeleton insrc/stripe.ts. - Signature verification wired to the handler — same reason. The algorithm is documented + type-hinted; drops into the route wrapper.
- Refund workflow — Slice N (Fulfillment & Returns) picks up refund-issued-through-return semantics.
charge.refundedis recorded here; the refund's *effect* on the return workflow is next. - Multi-currency conversion — we record whatever Stripe sends. If the merchant runs multi-currency, they see the per-currency breakdown; conversion is a separate concern.
- Retry / dead-letter queue for failed processing — for a demo, errors surface as
processing_erroron the PaymentEvent. In production the worker route wraps the call in retry logic.