Recovery playbook
The partial-refund reversal playbook
Full refunds get the flag. Partials are where the leak lives — shipping adjustments, partial cancellations, goodwill credits — each individually too small to notice and, in aggregate, frequently the larger number. Recovery has exactly the same granularity problem: the correct amount must be computed per refund event, never per charge, and the rounding behaviour of multi-partial charges will manufacture phantom discrepancies out of thin air unless you plan for them.
Why partials are different
Stripe reverses the same proportion of the transfer as the proportion of the charge refunded — when asked via reverse_transfer: true. The parameter defaults to false regardless of refund size, so a codebase that sets the flag only on its full-refund path leaks on every partial silently. And unlike full refunds, multiple partials can land against one charge on different days through different code paths, each requiring its own computation against running totals.
The proportional computation
Expected reversal = round((amount_refunded ÷ original amount) × original transfer amount), with the ratio clamped at 1 so no data anomaly ever produces a reversal larger than the transfer. Compute once per refund event, round once, and never carry fractional minor units forward into later calculations — compounding fractional cents is how scripts drift from Stripe by growing amounts.
- Refund share of charge
- 33.33%
- Expected reversal
- $30.00
- If flag omitted
- $0.00 reversed
- Missing from seller
- $30.00
The rounding trap
Charge the same $100 in three partial refunds of $33.33, $33.33 and $33.34. Stripe rounds each proportional reversal independently: $29.99 + $29.99 + $30.00 = $89.98 against a transfer of $90. A check computed once over the refunded total expects $90.00 and reports a permanent, growing, entirely fictional $0.02 shortfall on every such charge. Two minor units of tolerance absorbs the noise without hiding anything a human would care about — a monitoring tool without tolerance trains its own operators to ignore it.
Recovering a missed partial
Create a standalone reversal for the missing amount only. Do not re-run the refund and do not reverse the full proportion again — both double-count. Scope the idempotency key to charge plus refund id so a retried job collapses onto the original response, and verify amount_reversed moved by exactly the delta afterwards.
const owed = expected - actual;
if (owed > 2) {
await stripe.transfers.createReversal(transferId,
{ amount: owed },
{ idempotencyKey: `recovery-${chargeId}-${refundId}` });
}What FeeGuard does about it
Per-refund computation with clamped ratios and the two-unit tolerance ships as default behaviour, cross-checked against the application-fee position so a charge already made whole by a fee refund never double-flags. Partial-refund-heavy verticals — rentals, ticketing, services — are where this playbook pays for itself fastest; the scanner breaks your backlog down per charge so you can see the accumulation directly.
Common questions
Does reverse_transfer on a partial refund reverse proportionally?
Yes — Stripe computes the proportion automatically. You may also pass an explicit reversal amount if your policy differs from strict proportionality, but document whichever you choose.
Why does my script disagree with Stripe by one cent?
Independent per-refund rounding. Add the two-minor-unit tolerance and phantom findings disappear without masking real ones.
Can we reverse more than the proportional amount?
Technically yes, up to the transfer balance — but over-recovering from a seller is a complaint you will lose, commercially if not legally. Proportional-plus-documented-policy is the defensible position.