DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

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 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)
Enter fullscreen mode Exit fullscreen mode
  • 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()
);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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)