Unreversed transfer

Partial refund transfer math on Stripe Connect

Partial refunds are where Connect reconciliation gets genuinely hard. The formula is simple; the rounding, the ordering, and the accumulation across multiple refunds are what break implementations.

The formula

Expected Reversal = (amount_refunded / original_charge_amount) × original_transfer_amount

Missing = Expected Reversal − sum(existing transfer reversals)

Compute the ratio, multiply, round once. Never carry a fractional minor unit into a later calculation, and clamp the ratio at 1 — an amount_refunded above amount should be impossible, but a reconciliation that trusts that invariant will one day try to reverse more than the transfer.

function proportional(part: number, whole: number, total: number): number {
  if (whole <= 0 || part <= 0 || total <= 0) return 0;
  const ratio = Math.min(part / whole, 1);
  return Math.round(total * ratio);
}

Where rounding actually diverges

Take a $100 charge with a $7 fee refunded in three increments of $33.33, $33.33 and $33.34.

Stripe rounds each independently: $2.33 + $2.33 + $2.33 = $6.99. A check computed once over the refunded total gives round(1.0 × 7) = $7.00.

One cent apart on a charge where everything worked. Across every multi-refund charge, an intolerant reconciliation reports a permanent, growing, entirely fictional discrepancy. Two minor units of tolerance absorbs it without hiding anything real.

The ordering race

charge.refunded and transfer.reversed for the same charge arrive within seconds of each other, in either order, with no ordering guarantee from Stripe.

A check that runs on the refund event before the reversal lands reads amount_reversed: 0 and computes a shortfall for money already on its way back. Every one of those is a false positive, and false positives are how monitoring gets switched off.

Two mitigations, both needed: delay refund-event processing a few seconds so an in-flight reversal settles, and serialise per charge behind a lock so two events cannot both read pre-reversal state.

Never reconcile from the webhook payload

The payload is a snapshot from when Stripe queued the event, not when your worker runs. Between those moments a reversal can be created or a second refund can land.

Use the payload for routing — which charge, which event — and re-fetch live state for anything you intend to act on. One extra API call removes an entire class of false positive.