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.
Top comments (0)