1.5 KiB
Handoff: refunds & payouts idempotency
What happened
Two incidents, one root cause family:
- Refunds (INC-201, INC-214, INC-227 in docs/incidents.md): refund requests arriving without an idempotency key were double-processed whenever the storefront retried, refunding customers twice.
- Payouts: finance's batch job is about to start retrying on timeouts, and keyless payout retries would double-pay vendors the same way.
The fix
Both entry points now route through a single shared helper,
src/idempotency.js (deriveKey + once). src/refunds.js and
src/payouts.js derive a stable key from the request payload when the caller
sends none, claim it synchronously so concurrent retries share one execution,
and persist the receipt in src/store.js so retries after a restart return the
stored receipt. Gateway side effects all go through src/charge.js, so the
ledger is the source of truth for "did this actually happen".
Regression coverage
test/idempotency.test.js covers keyless refund retries, restart durability,
and a 20-way concurrent payout storm. The pre-existing test/refunds.test.js
and test/payouts.test.js still cover the keyed contract. Everything is wired
into npm test; run it before touching any of this.
Prevention
docs/runbooks/idempotency.md is the runbook: any new money-moving operation
must go through src/idempotency.js, ship with a retry regression test, and
log recurrences in docs/incidents.md. Do not bolt a second inline key-check
into a new module — extend the helper instead.