DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

AFFILIATE COMMISSIONS SHOULD COUNT BILLING CYCLES, NOT CALENDAR MONTHS

Most affiliate programs promise something like "30% recurring for 12 months." Sounds simple. Then you sit down to build it and hit the first real question: 12 months of what? Twelve calendar months from the day they signed up, or twelve invoices that actually got paid?

Those two answers diverge the moment a real customer does anything other than pay on time every month. And customers do a lot of other things.

Where calendar-month logic breaks

Say you store the affiliate's start date and pay commission on every invoice.paid where now < start_date + 12 months. Clean, until:

  • The customer's card fails in month 3. Dunning retries for 10 days, then the invoice pays. The clock kept running during the gap, so the affiliate quietly lost part of a cycle to a failed charge that had nothing to do with them.
  • The customer pauses for two months, then resumes. With calendar logic they burn 2 of their 12 months paying you nothing, and the affiliate loses two cycles they were promised.
  • The customer is on an annual plan. One invoice covers 12 months. Does the affiliate get one commission of 30% of the annual price, or nothing after, because month 13 never comes and the window closes right as the customer renews?

None of these are edge cases. Failed payments, pauses, and annual plans are just normal subscription life.

Count paid invoices instead

The promise a customer actually understands is "you earn on the first 12 payments this customer makes." So count payments, not days.

Model a commission window per subscription:

create table commission_windows (
  subscription_id  text primary key,
  affiliate_id     text not null,
  cycles_paid      int  not null default 0,
  max_cycles       int  not null default 12,
  rate             numeric not null            -- e.g. 0.30
);
Enter fullscreen mode Exit fullscreen mode

Then the handler on invoice.paid:

async function onInvoicePaid(invoice) {
  const subId = invoice.subscription;
  if (!subId) return;                       // one-off charge, not a subscription

  const win = await getWindow(subId);
  if (!win) return;                         // no affiliate on this subscription
  if (win.cycles_paid >= win.max_cycles) return;  // window already closed

  // idempotency: at most one commission per invoice, ever
  const created = await insertCommissionIfAbsent({
    invoice_id:   invoice.id,
    affiliate_id: win.affiliate_id,
    amount:       Math.round(invoice.amount_paid * win.rate),
  });
  if (!created) return;                     // webhook replay, already counted

  await incrementCyclesPaid(subId);         // one cycle closer to the cap
}
Enter fullscreen mode Exit fullscreen mode

Two things are doing the real work. insertCommissionIfAbsent keys on invoice_id with a unique constraint, so a replayed or duplicated webhook cannot double-pay. And you only incrementCyclesPaid after the commission row actually got created, so a replay that inserts nothing also advances nothing.

The annual-plan question

Now decide what a cycle means for annual plans, because it changes the math.

If you count invoices, an annual customer pays once and earns the affiliate one commission of 30% of the annual price, then a second one next year. Two cycles spread over two years. That is usually what you want: the affiliate earns on the money the customer actually paid, and 12 monthly cycles versus 2 annual cycles track roughly the same revenue.

If instead you meant "12 months of revenue" literally, an annual plan should count as 12 cycles at once and close the window in a single shot. Then set cycles_paid = max_cycles on that one annual invoice.

Pick one and write it down, because the affiliate will do the math and ask. The trap is leaving it implicit and paying annual affiliates a suspiciously different amount than monthly ones for the same plan.

Pauses handle themselves

Notice what the counter did for free. A paused subscription emits no invoice.paid, so cycles_paid never moves. The affiliate's remaining cycles just wait. When the customer resumes, the count picks up exactly where it stopped. No pause handler, no clock to freeze. That is the whole reason to count events instead of time: the thing you are counting only happens when the customer actually pays.

One race worth guarding

Counting invoices keeps the window row around as long as the subscription lives, which is fine. But pair the per-subscription cap with a real guard, because the cycles_paid >= max_cycles read and the incrementCyclesPaid write need to sit in the same transaction. A customer paying two invoices in the same second (a plan change that closes one invoice and opens another) can otherwise slip a 13th commission through the gap between the read and the write.


I build Referralful (affiliate software for SaaS, on top of Stripe), where the default is 30% for 12 months. Getting "12 months" to mean "12 payments" is one of those choices that looks trivial and quietly decides whether your affiliates trust the number on their dashboard.

More on how we handle it: https://referralful.com/?utm_source=dev.to&utm_medium=article&utm_campaign=recurring-cycle-cap

Top comments (0)