DEV Community

Veristria
Veristria

Posted on Originally published at feeguard.dev

The Stripe Connect refund that only costs the platform

There is a class of bug in Stripe Connect that never throws, never logs, and never fails a webhook. The refund succeeds. The customer is made whole. The money comes out of your balance and stays with the seller.

It is worth understanding precisely, because the fix is one boolean and the detection is one join.

What a destination charge actually is

On a destination charge, one API call does two things: it charges the customer, and it creates a transfer moving funds to the connected account. Two objects, one call.

A refund, by default, only undoes the first one.

// the customer gets their money back. the transfer is untouched.
await stripe.refunds.create({ charge: 'ch_123' });

// the transfer is reversed too
await stripe.refunds.create({
  charge: 'ch_123',
  reverse_transfer: true,
});
Enter fullscreen mode Exit fullscreen mode

reverse_transfer defaults to false. That default is defensible — sometimes the platform genuinely wants to absorb a refund rather than claw it back from a seller who already shipped. What makes it a footgun is that choosing wrongly produces no signal at all.

The second flag, which is not the same flag

refund_application_fee is independent. Reversing the transfer does not refund your platform fee, and refunding the fee does not reverse the transfer.

That means a refund can be wrong in either direction on its own:

  • transfer reversed, fee kept -> you kept a fee on revenue that no longer exists
  • fee refunded, transfer not reversed -> you paid the customer and gave up the fee

Three details that bite during reconciliation

Partial refunds do not partially reverse unless you ask. A partial refund with reverse_transfer: true reverses the corresponding fraction. A partial refund without it reverses nothing at all — not a smaller amount, nothing.

A negative connected balance does not block the refund. The reversal is attempted and the account can be left negative. You find out later, as a failed payout, in a different part of the system, with no obvious link back.

Disputes behave differently again. On a lost dispute, Stripe debits the platform in full. The connected account that received the transfer is untouched unless you reverse it yourself. There is no reverse_transfer on a dispute.

The query that finds them

The reconciliation question is not "did the refund succeed". It is: for every charge.refunded, is there a matching transfer.reversal of the expected amount?

Working from the events you already receive:

SELECT r.charge_id,
       r.amount                      AS refunded,
       COALESCE(rev.amount, 0)       AS reversed,
       r.amount - COALESCE(rev.amount, 0) AS unrecovered
  FROM refunds r
  LEFT JOIN transfer_reversals rev
         ON rev.transfer_id = r.transfer_id
 WHERE r.amount > COALESCE(rev.amount, 0)
 ORDER BY unrecovered DESC;
Enter fullscreen mode Exit fullscreen mode

Every row is money that left and did not come back. Run it across your full history the first time — this accumulates silently, so the first run is usually the interesting one.

Why it survives code review

Because the code looks correct. refunds.create({ charge }) is the documented call, it does what its name says, and the omission is a parameter that is not there. Reviewers see present code, not absent parameters.

The durable fix is not to remember the flag. It is to make the two event streams reconcile automatically, so the answer to "is our ledger whole" is a number somebody looks at rather than an assumption everybody makes.

Top comments (0)