All posts

The refund that only costs the platform

On a Connect platform, refunding a charge does not automatically take back the transfer. Here is the arithmetic, and why it never shows up as an error.

FeeGuard4 min read
stripe-connectreconciliation

A customer asks for their money back. You refund the charge. Stripe returns the funds, the customer is satisfied, the support ticket closes.

The connected account keeps the transfer.

Nothing in that sequence is a bug. Stripe did exactly what it was asked to do. But the platform is now down the full transfer amount, and there is no error, no alert and no line item that says so.

The arithmetic

A single Connect charge involves three movements, not one.

  1. The customer is charged. Money arrives in the platform's balance.
  2. An application fee is taken. That is the platform's revenue.
  3. A transfer moves the rest to the connected account.

Refunding the charge reverses movement one only. The customer's money goes back. Movements two and three are separate objects with separate lifecycles, and neither is touched unless you say so.

Take a €100 charge with a €10 application fee.

  • Charge: +€100 to the platform.
  • Application fee: €10 stays with the platform.
  • Transfer: €90 to the connected account.

Refund the €100 in full and, by default:

  • The customer gets €100 back, taken from the platform balance.
  • The €10 application fee is refunded too, if you asked for it — otherwise it stays.
  • The €90 transfer is not reversed.

Net position: the platform is €90 down on a transaction that was, a moment ago, €10 up.

The flags that decide it

Two parameters control this, and their defaults are the ones that cost money.

refund_application_fee decides whether the platform gives back its own fee. Defaulting to false is usually correct for a platform-fault refund and usually wrong for a seller-fault one.

reverse_transfer decides whether the connected account gives back what it received. This is the expensive one. It defaults to false, which means every refund issued without it leaves the seller paid in full.

await stripe.refunds.create({
  charge: 'ch_...',
  refund_application_fee: true,
  reverse_transfer: true,
});

If your refund path was written by someone reading the basic refund documentation, or generated from a prompt that said "refund the charge", these flags are almost certainly absent.

Partial refunds are worse

A full refund at least has an obvious correct answer. Partial refunds require you to decide how the loss is split, and the platform bears whatever you do not explicitly allocate.

Refund €40 of that €100 charge. What should happen?

  • Reverse €36 of the transfer and refund €4 of the application fee, keeping the 10% split intact? That is proportional, and usually what both parties expect.
  • Reverse the full €40 from the transfer and keep the €10 fee? The seller absorbs the entire refund.
  • Reverse nothing? The platform absorbs €40 on a transaction that earned it €10.

Stripe will do any of these. It has no view on which is right, because that is a commercial decision, not an API one. What it will not do is warn you that the third option is what your code currently implements.

Why it never surfaces

This class of loss has four properties that make it nearly invisible.

It succeeds. Every API call returns 200. There is nothing to catch, nothing to retry, nothing to log at error level.

It is per-transaction and small. €90 does not trigger anyone's finance alert. Ten thousand of them do, but by then they are spread across a year of statements.

The objects are separate. The charge, the transfer, the application fee and the refund are four different records. Noticing the gap means joining them and doing arithmetic that no default dashboard performs.

It expires. Transfer reversals have practical limits — the connected account has to have the balance, and the further back you go the more likely it does not. Discovering a leak from March in October frequently means discovering that it is no longer recoverable.

That last point is the one that matters most. This is not a problem you can defer to a quarterly reconciliation, because a quarterly reconciliation finds losses that have already aged out of recovery.

What to check today

You can do a version of this by hand. Pull your refunds from the last 90 days, and for each one ask:

  1. Did a transfer exist for the original charge?
  2. Was a transfer reversal created against it?
  3. If the refund was partial, does the reversal amount match the split your terms of service promise?
  4. Was the application fee refunded when it should have been — or kept when it should not have been?

Any row where the answer to two is "no" is money the platform paid on the seller's behalf without deciding to. Some of it is recoverable. The oldest of it probably is not, and that is the part worth fixing the code for.

Disputes have the same structure, with a shorter fuse: a lost dispute takes the charge amount and a fee, and the transfer is untouched unless you reverse it.

The short version

reverse_transfer defaults to false. If your refund path does not set it, and does not have a deliberate reason not to, every refund you have issued has been paid for by the platform. Check the last ninety days first — those are the ones you can still get back.