If you run an affiliate program on top of Stripe, the happy path is easy. A customer signs up for $29/mo through an affiliate link, you pay 30% recurring, everyone's happy. Then the customer upgrades to a $99/mo plan on day 12 of their billing cycle, and your commission math quietly breaks.
The bug is almost always the same: someone sums invoice.amount_paid and multiplies by the commission rate. That number is wrong the moment proration enters the picture. Here's why, and how to compute the commission correctly from the invoice line items.
What Stripe actually does on an upgrade
Say the customer is on a $29/mo plan, 30-day cycle, and upgrades to $99/mo on day 12. Eighteen days are left. Stripe doesn't charge a fresh $99. It creates an immediate invoice with two proration line items:
- A credit for the unused time on the old plan: 18/30 x $29 = -$17.40 (
amount: -1740) - A charge for the remaining time on the new plan: 18/30 x $99 = +$59.40 (
amount: 5940)
Net on that invoice: $42.00. That's what the customer pays today. At the next cycle they pay the full $99.
So what should the affiliate earn on this proration invoice? Not 30% of $99. Not 30% of $29. Thirty percent of the net commissionable amount you actually collected, which is $42.00, so $12.60. If you paid on amount_paid you'd happen to get this one right, but downgrades produce a net-negative proration invoice with amount_paid of 0 and a customer credit balance, and that's where naive summing falls apart.
Sum the line items, not the invoice total
The reliable source of truth is invoice.lines.data. Each line carries an amount (in the smallest currency unit, can be negative), a proration boolean, a currency, and a period. Sum the amounts and you get the subtotal, with negative proration credits already netted out.
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// Returns the net commissionable amount in the smallest currency unit.
// Negative proration lines subtract automatically.
function commissionableAmount(invoice) {
let net = 0;
let currency = null;
for (const line of invoice.lines.data) {
// Skip pure tax-only lines if you don't pay commission on tax.
// Line amounts are pre-tax subtotal contributions.
net += line.amount;
if (currency && line.currency !== currency) {
throw new Error(`Mixed currencies on invoice ${invoice.id}`);
}
currency = line.currency;
}
return { amount: net, currency };
}
Note the currency guard. Stripe won't normally mix currencies on one invoice, but if you sum blindly across a customer's history you can end up adding cents to euros. Keep commission ledgers per currency and convert at payout time, not before.
The webhook handler
Compute commission on invoice.paid, so you only ever pay on money that actually settled. invoice.updated fires a lot and includes drafts, so it's the wrong trigger for a payout ledger.
async function handleInvoicePaid(invoice) {
// Only invoices you actually collected on.
if (invoice.amount_paid <= 0) return;
const referral = await lookupReferralByCustomer(invoice.customer);
if (!referral) return;
const { amount, currency } = commissionableAmount(invoice);
if (amount <= 0) return; // net-zero or credit-only invoice
const rate = referral.rate; // e.g. 0.30
const commission = Math.round(amount * rate);
await recordCommission({
// Idempotency: invoice.id is stable across webhook retries.
idempotencyKey: `commission:${invoice.id}`,
affiliateId: referral.affiliateId,
invoiceId: invoice.id,
currency,
amount: commission,
withinWindow: monthsSince(referral.startedAt) < 12, // 30% for 12 months
});
}
Stripe retries webhooks. If your recordCommission isn't keyed on something stable like invoice.id, a retry double-pays the affiliate. Make the insert a unique constraint on the key and swallow the duplicate.
Clawbacks: refunds and credit notes
A commission you booked on Monday can evaporate on Friday when the customer disputes the charge or you issue a credit note. If you already paid the affiliate on that revenue, you're now out of pocket.
Listen for credit_note.created. A credit note reduces what the customer owes or refunds them, and it carries its own amount and line items.
async function handleCreditNote(creditNote) {
const referral = await lookupReferralByCustomer(creditNote.customer);
if (!referral) return;
// creditNote.amount is positive; it represents money removed from the sale.
const clawbackBase = creditNote.amount; // smallest currency unit
const clawback = Math.round(clawbackBase * referral.rate);
await recordCommission({
idempotencyKey: `clawback:${creditNote.id}`,
affiliateId: referral.affiliateId,
invoiceId: creditNote.invoice,
currency: creditNote.currency,
amount: -clawback, // negative entry against the ledger
});
}
Book it as a negative ledger entry rather than deleting the original row. You want an audit trail showing $12.60 earned and $12.60 reversed, not a silent disappearance. When the affiliate asks why their balance dropped, the answer should be in the ledger.
For straight refunds without a credit note, handle charge.refunded the same way, using charge.amount_refunded as the base.
Gotchas worth writing down
-
Negative proration lines are a feature, not noise. Don't filter them out with
if (line.amount > 0). That's exactly the bug that overpays on downgrades. -
Pay on net revenue you keep. Sum line amounts (pre-tax subtotal), not
total, unless you genuinely intend to pay commission on sales tax and Stripe fees. Most programs don't. -
Idempotency on every write. Webhooks retry, and
invoice.paidcan fire more than once in edge cases. Key every ledger entry on a stable Stripe object id. - One currency per ledger entry. Store the currency next to the amount and refuse to add across currencies.
-
A net-zero proration invoice pays zero. When someone downgrades and their proration nets negative,
amount_paidis 0 and there's nothing to commission. Youramount <= 0guard handles it.
Get the line-item math right once and upgrades, downgrades, and mid-cycle seat changes all fall out of the same code path. The affiliate earns on what you actually banked, and your clawback ledger stays honest when the money moves back.
I build Referralful, affiliate software for SaaS built on Stripe, so this proration edge case is one I've had to get exactly right.
Top comments (0)