DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

Affiliate commission clawbacks: only pay on money you actually keep

The naive version of affiliate payout logic pays the affiliate the moment a customer's first invoice clears. It works right up until that customer refunds a few days later, and you've already booked (or in the worst case, sent) a commission on revenue that walked back out the door. Now you're down the sale and the payout.

If you run any affiliate or referral program on Stripe, this is the trap: invoice.paid fires, you cut the commission, and then a refund, a chargeback, or a same-week cancel undoes the money underneath it. The fix is to treat a commission as something that has to vest before it's real.

Commissions have a lifecycle, not a boolean

A commission is not "paid / not paid." It moves through states:

pending  -> approved -> paid
   |
   +-> reversed   (refund, dispute, or early cancel before it vested)
Enter fullscreen mode Exit fullscreen mode

pending means the invoice cleared but the money isn't safe yet. approved means the hold window passed and no refund landed, so it's payable. paid means you actually sent it. reversed (or voided) means the underlying charge came back before the commission vested.

Here's the row I store per commission:

// commissions table
{
  id:           "cm_123",
  affiliate_id: "aff_42",
  invoice_id:   "in_1P...",     // the Stripe invoice this came from
  charge_id:    "ch_1P...",     // the charge, for refund/dispute matching
  amount:       1500,           // commission in cents
  base_amount:  5000,           // the invoice amount we based it on
  status:       "pending",      // pending | approved | paid | reversed
  hold_until:   "2026-08-21",   // when it's eligible to approve
  created_at:   "2026-07-22"
}
Enter fullscreen mode Exit fullscreen mode

Storing both invoice_id and charge_id matters. Refunds and disputes arrive as charge events, cancellations arrive as subscription events, and you need to trace either one back to the exact commission row.

The webhook handler

Four events do the work. invoice.paid opens a pending commission. charge.refunded and charge.dispute.created reverse one. customer.subscription.deleted stops future commissions but leaves vested ones alone.

Dedupe on event.id first. Stripe retries webhooks, and without idempotency a single refund can reverse the same commission twice.

app.post("/webhooks/stripe", async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, req.headers["stripe-signature"], process.env.WH_SECRET
  );

  // idempotency: bail if we've seen this event id before
  const fresh = await db.recordEventOnce(event.id);
  if (!fresh) return res.json({ received: true, duplicate: true });

  switch (event.type) {
    case "invoice.paid":
      await onInvoicePaid(event.data.object);
      break;
    case "charge.refunded":
      await onChargeRefunded(event.data.object);
      break;
    case "charge.dispute.created":
      await onDispute(event.data.object);
      break;
    case "customer.subscription.deleted":
      await onSubscriptionCanceled(event.data.object);
      break;
  }
  res.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

recordEventOnce is a single insert into a processed_events(event_id primary key) table that returns false on conflict. Cheap and reliable.

Opening a pending commission with a hold window

On invoice.paid, resolve which affiliate referred the customer (I stash affiliate_id in the Stripe customer's metadata at signup), compute the cut, and set hold_until. I use 60 days because it sits past Stripe's common refund and most dispute windows. Pick a number that matches your own refund policy.

async function onInvoicePaid(invoice) {
  const affiliateId = await resolveAffiliate(invoice.customer);
  if (!affiliateId) return; // not a referred customer

  const rate = 0.30;
  const holdDays = 60;
  const holdUntil = new Date(Date.now() + holdDays * 864e5);

  await db.commissions.insert({
    affiliate_id: affiliateId,
    invoice_id:   invoice.id,
    charge_id:    invoice.charge,          // charge on this invoice
    base_amount:  invoice.amount_paid,
    amount:       Math.round(invoice.amount_paid * rate),
    status:       "pending",
    hold_until:   holdUntil
  });
}
Enter fullscreen mode Exit fullscreen mode

The worker that vests commissions

A daily cron promotes pending rows to approved once hold_until has passed. The condition is simple: the hold is up and the row is still pending (a refund would have already flipped it to reversed).

async function vestCommissions() {
  const due = await db.commissions.find({
    status: "pending",
    hold_until: { $lte: new Date() }
  });
  for (const c of due) {
    await db.commissions.update(c.id, { status: "approved" });
  }
}
// run daily
Enter fullscreen mode Exit fullscreen mode

Approved rows are what your payout run reads from. Nothing gets paid until it clears this gate.

Reversing correctly, including partial refunds

When charge.refunded fires, match on charge_id and prorate. A charge object carries amount and amount_refunded, so a partial refund only claws back the matching fraction of the commission.

async function onChargeRefunded(charge) {
  const c = await db.commissions.findOne({ charge_id: charge.id });
  if (!c) return;

  const refundRatio = charge.amount_refunded / charge.amount;
  const clawback = Math.round(c.amount * refundRatio);

  if (charge.refunded) {
    // fully refunded
    await db.commissions.update(c.id, { status: "reversed" });
  } else {
    // partial: shrink the commission, keep it in its current state
    await db.commissions.update(c.id, {
      amount: c.amount - clawback,
      base_amount: charge.amount - charge.amount_refunded
    });
  }

  if (c.status === "paid") await recordDebt(c.affiliate_id, clawback);
}
Enter fullscreen mode Exit fullscreen mode

If the commission was still pending or approved, reversing it costs you nothing (you never sent it). If it was already paid, recordDebt books a negative balance you net against the affiliate's next payout. charge.dispute.created runs the same reversal, usually reversing the full amount since disputes pull the whole charge.

The recurring gotcha

If you pay recurring commissions (say 30% for 12 months), every invoice.paid is its own commission row with its own hold_until. That has two consequences people miss.

A customer.subscription.deleted should stop future commissions, not reach back and reverse past ones that already vested. Month 1 through month 5 were real money you kept, so the affiliate keeps them. Cancel only kills months 6 onward.

async function onSubscriptionCanceled(subscription) {
  // future invoices simply stop firing invoice.paid, so there's
  // nothing to reverse. only void commissions still inside their
  // hold window, since that money may still refund.
  await db.commissions.updateMany(
    { subscription_id: subscription.id, status: "pending",
      hold_until: { $gt: new Date() } },
    { status: "reversed" }
  );
}
Enter fullscreen mode Exit fullscreen mode

The mental model: a churn stops the faucet, a refund drains a specific bucket. Don't conflate them.

This is the kind of logic I spend a lot of time on building Referralful. Getting the hold window and the refund matching right is what keeps you from paying out on revenue you didn't keep. Store the charge id on every commission, put a hold between "invoice cleared" and "payable," and treat each recurring invoice as its own vesting clock. Do that and a day-4 refund costs you the sale, but never the payout on top of it.

Top comments (0)