DON'T PAY AFFILIATE COMMISSIONS THE INSTANT A PAYMENT SUCCEEDS. HOLD THEM THROUGH THE REFUND WINDOW.
A payment succeeding is not the same as revenue you keep. Cards get refunded, subscriptions get disputed, and if you paid the affiliate the moment invoice.payment_succeeded fired, you are now chasing money you already ha
A payment succeeding is not the same as revenue you keep. Cards get refunded, subscriptions get disputed, and if you paid the affiliate the moment invoice.payment_succeeded fired, you are now chasing money you already handed out.
The fix is a hold window, and the cleanest way to build it is to stop treating a commission as a number and start treating it as a state machine.
Pending is not payable
Create the commission when the payment succeeds, but do not make it payable yet:
pending -> approved -> paid
\
-> reversed (refund or dispute)
-
pending: created on
invoice.payment_succeeded. Recorded, visible to the affiliate, but not eligible for payout. - approved: the hold window elapsed with no refund or dispute. Now it counts toward a payout.
- paid: included in a payout run.
- reversed: a refund or dispute landed. Terminal.
A table that supports this:
create table commissions (
id bigserial primary key,
affiliate_id bigint not null,
invoice_id text not null unique, -- idempotency: one commission per invoice
amount_cents int not null,
currency text not null,
status text not null default 'pending', -- pending | approved | paid | reversed
hold_until timestamptz not null,
created_at timestamptz not null default now()
);
Match the hold window to your actual risk
The hold is not arbitrary. Tie it to the two ways money comes back:
- Your refund policy. A 30-day money-back guarantee is a 30-day floor.
- Card disputes. These can land up to ~120 days after the charge.
Most programs hold 30 to 60 days, which covers voluntary refunds and the bulk of disputes, then accept that a rare late dispute becomes a clawback.
Create pending on payment
// invoice.payment_succeeded
const invoice = event.data.object;
const sub = await stripe.subscriptions.retrieve(invoice.subscription);
const ref = sub.metadata.affiliate_ref;
if (!ref) return;
await db.commissions.insert({
affiliate_id: affiliateIdFor(ref),
invoice_id: invoice.id, // unique -> safe against duplicate webhooks
amount_cents: Math.round(invoice.total_excluding_tax * COMMISSION_RATE),
currency: invoice.currency,
status: 'pending',
hold_until: addDays(new Date(invoice.status_transitions.paid_at * 1000), 30),
}).onConflict('invoice_id').ignore();
Approve on a schedule
A daily job promotes anything past its hold window that has not been reversed. It is idempotent, so a re-run is harmless:
update commissions
set status = 'approved'
where status = 'pending'
and hold_until < now();
Reverse at whatever stage the refund finds it
// charge.refunded or charge.dispute.created -> resolve the invoice, then:
await db.commissions
.where({ invoice_id })
.whereIn('status', ['pending', 'approved']) // not yet paid: clean reversal
.update({ status: 'reversed' });
The three cases that matter:
- Refund before approval:
pending -> reversed. Nothing was owed, nothing to chase. This is the whole reason the hold exists. - Refund after approval, before payout:
approved -> reversed. Pull it from the pending payout run. - Refund after payout: the only real clawback. You already paid, so carry a negative balance and offset the affiliate's next payout instead of deleting history.
The point of the window is to push as many refunds as possible into the first case, where a reversal costs you nothing but a status change.
I build Referralful, Stripe-native affiliate software for SaaS, and this hold-then-approve flow is how it keeps you from paying out revenue you are about to lose. The model above works the same if you roll your own.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.