
Six payments edge cases that bite at 2am
Partial refunds, duplicate webhooks, currency rounding, retries, chargebacks, and clock skew: the payments edge cases that page you, with the fix for each.
Payments code fails at 2am in six predictable places: partial refunds, duplicate webhooks, currency rounding, retries, chargebacks, and clock skew. The fix for nearly all: assume every operation runs more than once, out of order, and stay correct anyway.
Payments code fails in the same six places, and it fails at 2am because that is when retries, cron jobs, and other timezones collide. The happy path is a solved problem. What pages you is partial refunds, duplicate webhooks, currency rounding, retry storms, chargebacks, and clock skew. Here is the failure mode for each, and the fix I actually reach for, drawn from shipping this across a subscription plugin, a WooCommerce marketplace, and a Laravel service that bills for usage.
1. Partial refunds derived by division
The failure. A customer refunds one of three seats. The code divides the order total by three to get the refund amount. The price does not divide cleanly, so you refund a cent too much. Multiply by volume and your gateway settlement stops matching your books.
The fix. Never derive a refund by dividing a total. It is the first tell I look for when I read a payments codebase’s refund path. Store integer cents per line item at purchase time, refund against the line item, and cap it:
$remaining = $item->quantity - $item->refunds()->sum('quantity');
if ($qty > $remaining) {
throw new RefundExceedsRemainingException($item->id, $remaining);
}
$refundCents = $item->unit_price_cents * $qty; // integer math, no division
If your money type is a float anywhere in this path, that is the bug, and it is only sleeping.
2. Duplicate webhooks
The failure. Every gateway redelivers webhooks. A payment.succeeded event arrives twice because your first 200 was slow to come back. Naive code grants the subscription twice, emails the receipt twice, and increments a usage counter twice.
The fix. Treat the event id as an idempotency key and enforce it in the database, not in application logic. A processed-events table with a unique constraint is enough:
try {
DB::table('processed_webhooks')->insert([
'event_id' => $payload['id'],
'processed_at' => now(),
]);
} catch (UniqueConstraintViolationException) {
return response()->noContent(); // already handled, ack and move on
}
$this->apply($payload); // safe: runs at most once per event id
The “check if exists, then insert” version loses under concurrency, and webhook redelivery is concurrency: the retry often lands while the first delivery is still in flight. Let the unique index be the referee. Also, acknowledge fast. Do the heavy work after you return 200, or the gateway times out waiting and redelivers, which is the exact storm you were trying to avoid.
3. Currency rounding and the fifty-cent invoice that says fifty-one
The failure. You compute tax or proration in floating point. 0.1 + 0.2 is famously not 0.3 in IEEE floats, and a monthly plan prorated across an odd number of days lands on a fraction of a cent. The customer sees an invoice total that does not equal the sum of its lines, and support cannot explain it.
The fix. Money is integer minor units, end to end. Cents in the database, cents in the domain logic, format to a decimal string only at the very edge where you render it. When you must split an amount, distribute the remainder deterministically instead of rounding each slice:
function splitCents(int $total, int $parts): array
{
$base = intdiv($total, $parts);
$remainder = $total % $parts;
// Hand the leftover cents to the first `remainder` slices, one each.
return array_map(
fn ($i) => $base + ($i < $remainder ? 1 : 0),
range(0, $parts - 1)
);
}
// splitCents(1000, 3) => [334, 333, 333], sums to exactly 1000
The sum is guaranteed to equal the input. No slice is ever rounded in isolation, so the invoice always reconciles.
4. Retries without backoff, and retries without idempotency
The failure. A charge or a provider call fails transiently, so you retry in a tight loop. Now you are hammering a struggling gateway, and if the original request actually succeeded but the response got lost, you charge the customer again.
The fix. Retries need two things that usually ship separately: exponential backoff with jitter, and an idempotency key so a retry of a succeeded call is a no-op at the provider. Every serious payment API supports an idempotency key on the request; use it.
$attempt = 0;
$key = $intent->idempotency_key; // stable across retries of THIS charge
retry:
try {
return $gateway->charge($intent->amount_cents, $key);
} catch (TransientGatewayException $e) {
if (++$attempt >= 5) throw $e;
// exponential backoff with jitter, capped
$sleepMs = min(2 ** $attempt * 100, 8000) + random_int(0, 250);
usleep($sleepMs * 1000);
goto retry;
}
The jitter matters more than people think. Without it, a fleet of workers that all failed at the same second retries at the same second, and you rebuild the exact spike that caused the failure. In my mail service, this same pattern (stable key plus jittered backoff) is what lets me fail a send over to a second provider without double-delivering.
5. Chargebacks are not refunds
The failure. A chargeback arrives as a webhook and the code routes it through the refund handler, or worse, ignores it because “the money question is between the customer and their bank.” A chargeback is a dispute with a deadline, a fee, and evidence you must submit. Treated as a refund, you lose the dispute by default and eat the fee on top of the reversed amount.
The fix. Model disputes as their own entity with a state machine and, critically, a due date you act on:
Dispute::create([
'order_id' => $order->id,
'gateway_ref' => $payload['dispute_id'],
'amount_cents' => $payload['amount'],
'reason_code' => $payload['reason'],
'status' => 'needs_response',
'evidence_due_at'=> $payload['evidence_due_by'], // this is the whole point
]);
Then a scheduled job flags disputes approaching evidence_due_at so a human submits evidence before the window closes. And revoke entitlement on a lost dispute the same way you would on a confirmed refund, or you have paid twice: once to the bank, once by leaving the product switched on.
The habit that saves you here is treating the evidence deadline as the real deliverable, not the dispute itself. Most disputes are won or lost on whether a human actually submitted something before the window closed, so the code’s only job is to make that deadline impossible to miss. Being clever about the outcome comes second.
6. Clock skew and trusting the client’s time
The failure. Anything that uses time from an untrusted source. A license check that reads the client’s system clock and decides the license is still valid. A “this token expires in 60 seconds” webhook signature verified against a server whose clock drifted. A grace period computed from a timestamp the client sent. Clocks lie, both by accident (drift) and on purpose (a user rolling their clock back to dodge an expiry).
The fix. Server time is the only time that gets to make money decisions. Verify webhook signatures with a tolerance window so honest skew passes and stale replays fail:
$skew = abs(time() - (int) $payload['timestamp']);
if ($skew > 300) { // 5 minutes each way
abort(400, 'timestamp outside tolerance');
}
For expiry, compare against a server-trusted “now,” never against a value the client controls. When I build offline license checks, the client can cache a last-known-good state, but the authoritative expiry is a server-signed timestamp, and the grace window is measured from the server’s clock, not the device’s. Five minutes of tolerance forgives real drift; it does not forgive a clock rolled back a year.
The pattern under the patterns
Five of these six come down to one habit: assume the operation will run more than once, out of order, and at the worst possible moment, and make it correct anyway. Idempotency keys, integer money, backoff with jitter, server time, and a real state machine for anything money touches. None of it is clever. All of it is the difference between sleeping through the night and explaining a double charge to a customer at 2am.
These six failures are not exotic. They are the ordinary result of assuming a payment operation runs exactly once, in order, at a convenient time, when in production it does none of those things. Build for repetition, reordering, and the worst possible moment with idempotency keys, integer money, jittered backoff, server time, and explicit state machines, and the 2am page stops coming.
FAQ
That is when retries, cron jobs, and other timezones collide, so duplicate deliveries and clock issues stack up. The happy path is a solved problem; the pages come from concurrency and time.
No. Any float anywhere in the money path is a sleeping bug, because rounding each slice in isolation makes an invoice total stop matching the sum of its lines. Keep integer minor units end to end and format to a decimal string only when you render.
Because webhook redelivery is concurrency: the retry often lands while the first delivery is still in flight, and a check-then-insert loses that race. A unique index on the event id is the referee that application logic cannot be.
No. A chargeback is a dispute with a deadline, a fee, and evidence you must submit, so route it through refund logic and you lose by default and eat the fee on top. Model it as its own entity with an evidence due date a scheduled job acts on.
The server’s, always. Client clocks drift by accident and get rolled back on purpose, so verify signatures with a tolerance window and compute expiry against a server-trusted now.
Assume every money operation will run more than once, out of order, at the worst time, and make it correct anyway. Idempotency keys, integer money, backoff with jitter, and server time cover five of the six.