All docs

Documentation

Application fee refunds — the proportional rule

How to calculate proportional application fee refunds on partial refunds, plus the rounding and zero-decimal currency traps that break reconciliation.

The calculation

The share of the fee to return equals the share of the charge refunded:

shouldRefund = round((amount_refunded / amount) × application_fee.amount)

Stripe rounds half-up on its own proportional calculations. Matching that rounding is what keeps a reconciliation check agreeing with Stripe to the cent rather than drifting by one.

Multi-refund charges

A charge refunded across several partial refunds accumulates independent roundings. A check computed once over the total can differ from the sum of Stripe's per-refund roundings by a cent or two.

Alerting on a $0.01 discrepancy teaches users to ignore the product. A small tolerance — two minor units is plenty — removes the noise without hiding anything real.

Zero-decimal currencies

JPY, KRW, VND and others have no minor unit: an amount of 1000 means ¥1000, not ¥10.00. Dividing by 100 for display is wrong by 100× for these currencies, and a reconciliation tool that gets this wrong reports losses two orders of magnitude off.

Worked examples

Full refund, $250 charge, $25 fee. The buyer gets $250 back, so the entire $25 fee should be returned. Simple, and the case every test covers.

40% partial refund, same charge. The buyer gets $100 back. round((100 / 250) × 25) = $10.00. Leave the flag unset and you keep all $25 on a sale now worth $150 — an effective take rate of 16.7% rather than 10%.

Two sequential partial refunds of $60 and $40. Stripe rounds each independently: round((60/250) × 25) = $6, then round((40/250) × 25) = $4. Total $10, which happens to match the single calculation here. It does not always.

That last case is where reconciliation checks start disagreeing with Stripe by a cent, and why a tolerance is not optional.

Where the 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: round(0.3333 × 7) = $2.33, twice, then round(0.3334 × 7) = $2.33. Total returned: $6.99.

A check computed once over the refunded total: round((100/100) × 7) = $7.00.

One cent apart, on a charge where everything worked correctly. Multiply that across every multi-refund charge on the platform and an intolerant reconciliation reports a permanent, growing, entirely fictional discrepancy.

Two minor units of tolerance absorbs this. It is small enough that nothing a human would care about hides underneath it.

Zero-decimal and three-decimal currencies

JPY, KRW, VND, CLP and around a dozen others have no minor unit. An amount of 1000 means ¥1000, not ¥10.00. Dividing by 100 for display is wrong by a factor of 100.

BHD, JOD, KWD, OMR and TND go the other way — they are quoted in thousandths, so an amount of 1000 means 1.000 dinar.

The proportional arithmetic itself is unaffected, because it operates on ratios of integers in whatever unit Stripe uses. What breaks is display, thresholds, and any alert that compares against a hard-coded cent value. A $100 alerting threshold expressed as 10000 will fire on every ¥10,000 finding and never on a ¥1,000,000 one.

const ZERO_DECIMAL = new Set([
  'bif','clp','djf','gnf','jpy','kmf','krw','mga',
  'pyg','rwf','ugx','vnd','vuv','xaf','xof','xpf',
]);
const THREE_DECIMAL = new Set(['bhd','jod','kwd','omr','tnd']);

function exponent(currency: string): number {
  const c = currency.toLowerCase();
  if (ZERO_DECIMAL.has(c)) return 0;
  if (THREE_DECIMAL.has(c)) return 3;
  return 2;
}

Never divide before you round

The order of operations matters. round(feeAmount × (refunded / total)) and round(feeAmount × refunded / total) are the same in exact arithmetic and can differ once floating point is involved.

Compute the ratio, multiply, then round once — and never carry a fractional minor unit into a subsequent calculation. Money arithmetic should touch floating point exactly once, at the ratio, and return to integers immediately.

Guard the zero case too. A charge with amount: 0 produces a ratio of NaN, and NaN propagated into a loss amount poisons every downstream sum on the dashboard. Return zero rather than letting it through.

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);
}

Cap the ratio at 1

It should be impossible for amount_refunded to exceed amount, and in practice it is. But a reconciliation that trusts that invariant will one day compute a refund proportion above 100% from stale or malformed data and attempt to return more fee than was ever collected.

Clamping the ratio costs one comparison and turns a class of impossible-but-catastrophic bug into a no-op.

Currency is per charge, not per platform

A platform operating in one country still processes charges in whatever currency the buyer paid in. Assuming a single currency across the book is a bug waiting for your first international customer.

Every amount that comes out of this calculation carries the currency of its charge. Summing findings for a dashboard total means grouping by currency first — a combined figure that silently adds yen to dollars is worse than showing three separate numbers, because it looks authoritative.

Where a single headline number is genuinely required, convert at a stated rate and label it as converted. Never let an implicit sum stand in for one.

Reconciling in bulk

Checking one charge is straightforward. Checking ninety days of them without exhausting your Stripe rate limit takes a little more care.

Expand transfer and application_fee on the list call rather than fetching each separately — that turns three requests per charge into one. Filter to charges with a non-zero amount_refunded before doing any further work, since the rest cannot carry this discrepancy.

Paginate with starting_after and persist the cursor. A ninety-day sweep of a busy platform will not finish in one run, and a resumable scan costs one page on failure rather than the whole sweep.

let cursor: string | undefined;

do {
  const page = await stripe.charges.list({
    limit: 100,
    created: { gte: since },
    expand: ['data.transfer', 'data.application_fee'],
    ...(cursor ? { starting_after: cursor } : {}),
  });

  for (const charge of page.data) {
    if ((charge.amount_refunded ?? 0) === 0) continue;
    await check(charge);
  }

  cursor = page.data.at(-1)?.id;
  await persistCursor(cursor);
} while (page.has_more);

Set a floor on what you report

Proportional arithmetic on small charges produces small numbers. A 5% partial refund on a $12 charge with a 10% fee owes six cents back.

Technically a discrepancy. Practically, a row in a work queue that costs more attention than the money involved, and a hundred of them will bury the one finding that matters.

A floor somewhere around fifty cents keeps the queue readable. The aggregate of everything below it is worth reporting as a single line — "$14 across 340 sub-threshold items" — so nothing is hidden, but nothing individually worthless takes up a row either.