Repository navigation
fix(refunds): replay committed refund when queued idempotent retry wakes after commit #1255
Description
Summary
In POST /api/refunds, when a matching idempotent retry arrives while the original refund request is awaiting asynchronous PIN verification (verifyPinAsync) and queues behind withRefundPinLock, the retry does not re-check refund_idempotency after the original request commits. If the retry carries a mistyped/stale override_pin or encounters the PIN rate limit, it returns 403 approver_pin_invalid (or 429 pin_rate_limited) with outcome: 'not_applied' even though the refund has already committed.
Verified Reproduction
On 08187174bfa47f08154e2b9b64cdb17b0ef2c459:
- Submit Request 1 to
POST /api/refundswith a valid manager PIN andIdempotency-Key: K. Request 1 enterswithRefundPinLockand yields duringverifyPinAsync. - Before Request 1 commits, submit Request 2 from the same user with the same
Idempotency-Key: Kand identical refund payload (bill_id,amount,method,approver_id), but a stale/invalidoverride_pin. - Request 2 misses the pre-lock
refund_idempotencycheck atmain/routes/refunds.ts:246-265and queues onwithRefundPinLock(main/routes/refunds.ts:292). - Request 1 finishes
verifyPinAsync, commits the refund andrefund_idempotencyrecord insidewithTxn, and returns201. - Request 2 acquires
withRefundPinLock, evaluatesverifyPinAsyncwithout re-checkingrefund_idempotency, increments the IP PIN rate-limit counter, and returns403 Invalid manager PIN(outcome: 'not_applied').
Root Cause
main/routes/refunds.ts checks refund_idempotency at Step 1 (lines 246-265) prior to withRefundPinLock, and createRefund checks refund_idempotency inside withTxn (main/services/refund.ts:294-318), but withRefundPinLock (main/routes/refunds.ts:292-328) never re-checks refund_idempotency upon acquiring the lock or before recording a PIN verification failure.
Expected Behavior & Chosen Design
Re-check refund_idempotency inside withRefundPinLock (before checking isPinRateLimited or running verifyPinAsync, and before recording a PIN failure if a concurrent cross-IP request committed during verifyPinAsync):
- If a committed
refund_idempotencyrow exists for(userId, idempotencyKey):- Enforce
String(prior.bill_id) === String(billId) && prior.request_hash === requestHash(throwing409 idempotency_conflicton mismatch). - Return the stored
201response without incrementingcheckPinRateLimitor re-runningwithTxn.
- Enforce
Acceptance Criteria
- A queued retry with the same
(userId, idempotencyKey)and matchingbill_id/request_hashthat wakes after the original request commits returns201with the committed refund response, even if the retry'soverride_pinis stale/invalid or the IP is at the rate-limit threshold. - A queued retry with the same
(userId, idempotencyKey)but a conflicting payload (bill_idorrequest_hashmismatch) returns409 idempotency_conflict. - If the first request fails and does not commit to
refund_idempotency, subsequent requests with the sameIdempotency-Keystill enforce full PIN rate limiting and PIN verification.
Required Regression Tests
- Concurrent original refund (valid PIN) + queued matching retry (invalid/stale PIN) returns
201for both with identical refund ID, 1 DB refund row, and no failed-PIN rate-limit penalty. - Concurrent original refund + queued retry with mismatched payload returns
409 idempotency_conflict. - Failed first attempt (invalid PIN) + second attempt (invalid PIN) with same
Idempotency-Keyboth return403and enforce rate limiting.
Related Historical Context & Considerations
- Related PR / Issue: fix(refunds): verify refund PIN asynchronously outside write transaction (#1227) #1228, fix(auth): execute refund manager PIN verification asynchronously outside database write transaction #1227
- Security & Financial Considerations: Because
refund_idempotencyis written strictly insidewithTxnaftercreateRefundverifies the manager/owner PIN, replaying an already-committed record for the same(userId, idempotencyKey, billId, requestHash)never permits an unauthorized refund to be created.
Activity
fix(refunds): replay committed refund idempotency in PIN lock
fix(main): replay committed refund idempotency in PIN lock (FreeOpenS…
khaira777 commentedon Oct 21, 2026
Post-Merge Verification & Closure (N3)
20280c97b4cf64f82e3acdb99676004e1652d731mainCommit SHA:5d532c8c278dd1d696df6eb40fd2f938e0675442POST /api/refunds(main/routes/refunds.ts), when a concurrent or queued idempotent retry with the same(userId, idempotencyKey)waited onwithRefundPinLockwhile the original request completedverifyPinAsyncand committed torefund_idempotency, the waking retry did not re-queryrefund_idempotencybefore evaluatingisPinRateLimitedorverifyPinAsync.refund_idempotencylookup immediately upon enteringwithRefundPinLockbeforeisPinRateLimitedandverifyPinAsync. If a committed record exists for(userId, idempotencyKey)with matchingbill_idandrequest_hash, the stored201response is replayed immediately without re-verifying or recording a failed PIN attempt; mismatchedbill_idorrequest_hashthrows409 idempotency_conflict.tests/async-refund-pin-event-loop.test.ts(40/40assertions passed, covering concurrent wrong-PIN retry replay, rate-limited retry replay, and mismatched payload409 idempotency_conflict).