Affiliate software has exactly one job it absolutely cannot get wrong: the numbers. Clicks can be approximate. A dashboard can be a little slow. But if you pay an affiliate for a sale that got refunded, or pay the same commission twice, you've quietly handed away money, and the kind of trust that's hard to win back once an affiliate notices their balance doesn't reconcile.
This week we shipped two fixes in that category. Neither is flashy. Both close a hole that only opens under conditions you can't reproduce on demand, which is exactly why they're worth writing down.
The assumption that isn't true
Here's the mental model almost everyone starts with when they wire up payment webhooks:
A customer buys → we get an
order.paidwebhook. Later they get a refund → we get anorder.refundedwebhook. The sale always comes first, because the sale happened first.
The first half is fine. The second half is wrong, and it's wrong on purpose. Stripe says it plainly in their docs: they do not guarantee delivery in the order events are generated. Polar behaves the same way. And both providers retry failed deliveries with backoff, so even if events leave in order, a single hiccup on the order webhook (a cold start, a deploy, a 500 from a downstream dependency) can let a later event sail past it.
So the real world includes this sequence, rare but inevitable at volume:
order.refundedfor orderpi_8xk2…arrives.- ...the
order.paidforpi_8xk2…arrives a few seconds later, having been retried.
Read our old refund handler against that order of events:
// A refund came in. Find the conversion it refers to and mark it refunded.
const conv = await prisma.conversion.findFirst({
where: { tenantId: tenant.id, orderId: { in: candidates } },
});
if (!conv) return { status: "ignored" }; // ← nothing to refund... yet
When the refund lands first, there is no conversion to find. So we shrugged and returned ignored. Then the sale webhook showed up, found no refund on record, and dutifully created a payable conversion. The affiliate is now credited for a sale whose money has already gone back to the customer. Nothing errored. Nothing logged a problem. The books are just quietly wrong.
Fixing it: remember the refund
The fix is the same trick accountants have used forever, if you can't apply a transaction yet, you don't throw it away, you leave a note. When a refund can't find its sale, we now write a small tombstone: a row that says "order pi_8xk2… has been refunded," with nothing else attached.
// No conversion yet, the refund beat the sale. Leave a tombstone for every
// id the order might arrive under, so the order branch can act on it later.
for (const orderId of candidates) {
await prisma.refund.upsert({
where: { tenantId_orderId: { tenantId: tenant.id, orderId } },
create: { tenantId: tenant.id, orderId },
update: {}, // already noted, idempotent
});
}
Then, when the sale finally arrives, we check for that note before deciding the conversion is payable:
const tombstone = await prisma.refund.findUnique({
where: { tenantId_orderId: { tenantId: tenant.id, orderId } },
});
const initialStatus = tombstone ? "refunded" : "pending";
If the note exists, the conversion is born already refunded, it never counts toward earnings, and we skip the "you earned your first commission!" email, because there's nothing to celebrate. Once consumed, the tombstone is deleted so the table stays small.
A detail worth calling out: Stripe refunds don't reference the checkout session you might have stored the sale under. A charge.refunded carries a payment intent, an invoice, and a charge id, never the cs_… session. So we tombstone every candidate id the refund mentions, and key new conversions off the payment intent (or invoice, for subscriptions). Whichever id the sale eventually arrives under, the note is waiting.
The same change made refunds idempotent, which matters just as much. Providers redeliver. A refund webhook that fires twice used to run the update twice; now the second delivery sees the conversion is already refunded and does nothing. Re-delivery is a no-op, by design.
The second bug: paying twice in the same instant
The other fix lived in the owner-facing "Mark paid" button. The code already looked careful, it ran inside a database transaction:
await prisma.$transaction(async (tx) => {
const pending = await tx.conversion.findMany({ where: { ...status: "pending" } });
const sum = pending.reduce((acc, c) => acc + c.commissionCents, 0);
await tx.payout.create({ data: { amountCents: sum, ... } });
await tx.conversion.updateMany({ where: { id: { in: ids } }, data: { status: "paid" } });
});
A transaction feels like it should make this safe. It doesn't, not against the failure mode that actually happens here, which is two Mark paid calls firing at almost the same moment. A double-click. Two people on the team in the dashboard at once. A flaky network that resubmits the form.
Under the default isolation level (READ COMMITTED), both calls can run their findMany and see the same set of pending conversions, because neither has committed yet. Both compute the same sum. Both create a payout row. Both mark the same conversions paid. The transaction guaranteed each call was atomic, it never guaranteed the two calls couldn't overlap. Result: two payout rows for one set of commissions.
The fix is to stop treating the transaction as the guard and make the status transition itself the guard:
const claimed = await tx.conversion.updateMany({
where: { id: { in: ids }, status: "pending" }, // only rows still pending
data: { status: "paid" },
});
if (claimed.count !== ids.length) {
throw new ConcurrentPayoutError(); // someone else got here first, roll back
}
Now the two calls race to flip the same rows out of pending, and a row can only be flipped once. The loser's updateMany matches fewer rows than it expected, throws, and rolls back its payout entirely. Exactly one payout is written. The owner who lost the race just clicks again and pays whatever is genuinely still pending.
Why we bother writing these up
There's a temptation to file fixes like these under "internal plumbing" and move on. We publish them for the same reason we built a webhook delivery log into the dashboard: the failure modes of money software should be legible, not magic. If you run an affiliate program on Sled, you should be able to know why a number is what it is, including the boring guarantees underneath it:
- A refund always wins, even if it arrives before the sale.
- A redelivered webhook never double-counts.
- A payout never pays the same commission twice.
None of these will ever show up as a feature you click. They show up as a balance that always reconciles. That's the whole point.
Running affiliates on Polar or Stripe and want this kind of money-correctness without building it yourself? Start a program on Sled, flat $19/mo, no cut of your commissions.