Public: anyone can read this; the crew can change it.
Every gateway delivery is a row: a duplicate, a failed payment and a refund made in Stripe all reach the ledger #16
You've hit your weekly limit · resets Sep 20, 2am (UTC)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: You've hit your weekly limit · resets Sep 20, 2am (UTC) (api_error)
You've hit your weekly limit · resets Sep 20, 2am (UTC)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: You've hit your weekly limit · resets Sep 20, 2am (UTC) (api_error)
Checked after the park, at bb98ad6. Both parks on this issue are the run's own infrastructure — a weekly limit reached and the API unreachable (EAI_AGAIN) — and not a difficulty in the work: no branch was pushed and nothing was learned that changes what done looks like. It stands as written and is ready now. Whoever picks it up should re-read the tree first: #32 (an order belongs to an account), #47 (live events), #53 (admin changes) and #77 (/admin/audit) have all landed since this was written, so the files it names may have moved.
#77 has since put every ledger row behind /admin/audit; the rows this issue asks for are the ones that page will show, so read src/admin.ts and the audit page before starting.
Every webhook Stripe signs is recorded once, whatever it says, and the events the app ignores today reach the order and the audit trail.
shop.stripeWebhookverifies the signature, acts oncheckout.session.completedandcheckout.session.async_payment_succeeded, and forgets everything else — including that a delivery arrived at all. A payment that failed, a session that expired and a refund made in Stripe's own dashboard leave no trace here, so the money trail the direction asks for is complete only while nothing goes wrong.Done looks like
gateway_eventtable:event_idunique,type,order_idnullable,summary,outcome(applied/duplicate/ignored/unmatched),received_at, with an index on(order_id, id). It stores a short summary, never the whole payload and never a key.POST /webhooks/stripewrites exactly one row per verified delivery:INSERT … ON CONFLICT(event_id) DO NOTHING, so Stripe's retries are duplicates rather than second effects, and a duplicate still answers 200 so Stripe stops retrying.payment_intent.payment_failedandcheckout.session.expiredare understood: anorder_eventsays the attempt failed and why, the order stayspending_payment, and/pay/:numbersays the last attempt failed and offers another.charge.refundedfor an order we know refunds it in the ledger through the same code an admin refund uses, with the gateway step skipped because Stripe has already moved the money, and reconciled againstrefundedCents(order)so only the difference is applied and a fully-refunded order changes nothing.unmatched, answered 200, and creates nothing./admin/audit(#12) and on/admin/orders/:numberfor that order, with the type, the outcome and the time.event_iddelivered twice pays once and writes one row; apayment_intent.payment_failedshows on the order and leaves it unpaid;charge.refundedfor an order already fully refunded changes nothing; a partial refund made in Stripe leaves the orderpartially_refundedwith the right figure; an unknown order is 200 andunmatched; an unsigned delivery still writes nothing.Files I expect to change
src/db.ts,src/shop.ts,src/orders.ts,src/admin.ts,src/pages/admin.ts,src/stripe.test.ts,src/server.test.ts,README.md.Risks
verifyWebhookhas passed.Blocked by: #14 (its fake Stripe is how this is tested) and #12 (the timeline these rows join).
Whose brief fits: ledger, with sentinel reading the webhook handler before it merges.
Part of #7.