DEV Community

Payneteasy
Payneteasy

Posted on

Two partial refunds, one $100 capture, $120 gone

Support had a refund tool with a sane-looking guard: read the order, compute refundable = captured - refunded, reject if the requested amount is higher. It worked for months.

Then two agents opened the same $100 order within a few seconds of each other and each refunded $60. Both requests passed the guard. Both went to the gateway. The gateway accepted both, because that acquirer checked refunds against the original capture per request, not against a running total. $120 went out on a $100 payment.

The bug was where "refunded" came from. We updated that column from the refund webhook, which arrives a few seconds after the API call. So the guard was reading a number that was always slightly in the past.

The fix was boring:

  1. Lock the order row (SELECT ... FOR UPDATE).
  2. Sum refunds in status pending AND succeeded, not just succeeded.
  3. Insert a pending refund row in the same transaction.
  4. Only then call the gateway, with the refund row id as the idempotency key.
  5. Webhook flips pending to succeeded or failed, and a failed row frees the amount.

The guard now reads state we wrote ourselves, not state the gateway reports later.

If your refund guard reads a field that a webhook updates, how are you handling the window between the API call and the event?

Payneteasy handles this kind of payment flow for merchants and PSPs — see how it works.

Top comments (0)