If you pay affiliate commissions on a recurring Stripe subscription, most of your logic fires on events that are weeks or months apart. A renewal in month 12 still pays the affiliate. A refund in month 2 claws a commission back. A downgrade changes the amount you owe. You cannot verify any of that by hand without waiting for real time to pass, and "it looked right in month 1" is how wrong payouts ship to production.
Stripe Test Clocks fix this. A test clock is a simulated time source you attach to a test customer. You advance it, and Stripe generates the invoices, renewals, and webhook events exactly as it would over real time, in seconds. That makes your commission ledger testable end to end.
The setup
Create a clock, then a customer bound to it, then a subscription.
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
});
const customer = await stripe.customers.create({
test_clock: clock.id,
metadata: { affiliate_id: 'aff_123' }, // your attribution lives here
});
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: 'price_monthly_2900' }],
});
The affiliate_id on customer metadata is the attribution you set at checkout. Your webhook reads it on every invoice so it knows who to credit.
Advance time and watch the events
Move the clock forward one billing cycle. Stripe issues the renewal invoice and emits the same events your production webhook handles.
await stripe.testHelpers.testClocks.advance({
frozen_time: Math.floor(Date.now() / 1000) + 31 * 24 * 60 * 60,
});
Now invoice.paid fires for the renewal. Your handler should credit aff_123 for this cycle, keyed by the invoice id so a retried webhook does not double-credit:
// inside your webhook, case 'invoice.paid'
const affiliateId = invoice.customer_metadata?.affiliate_id;
if (affiliateId) {
await ledger.credit({
affiliate_id: affiliateId,
amount: commissionOn(invoice.amount_paid),
idempotency_key: invoice.id, // one credit per invoice, ever
});
}
Test the cases that actually break
The renewal path is the easy one. The bugs live in the edges, and the clock lets you reproduce each:
-
Refund clawback. Advance to a paid invoice, then
stripe.refunds.createagainst its charge. Yourcharge.refundedhandler should reverse the matching credit, not create a negative row that a payout job later ignores. - Commission window expiry. If affiliates earn for 12 months, advance 13 cycles and assert cycle 13 credits nothing. Off-by-one errors here quietly overpay forever.
-
Proration on upgrade. Swap the price mid-cycle and advance. Stripe prorates; your commission should follow
amount_paid, not the sticker price. - Failed then recovered payment. Let an invoice go past_due, then pay it. You want one credit when it actually settles, not one on first attempt.
Assert against your ledger after each advance. Because the events are real Stripe events, a test that passes here is exercising the same code path production will.
One caveat
A test clock can hold a limited number of subscriptions and advances slowly if you jump far in one call, so step through cycle by cycle rather than leaping a year at once. Delete clocks when you are done; they count against test-mode limits.
Once this is wired up, "does our affiliate math survive a refund in month 2 and a renewal in month 12" stops being a question you answer in production. It becomes a test that runs in CI.
We do exactly this at Referralful (affiliate software built on Stripe, which I help build), so the renewal and refund handling is regression-tested rather than hoped at. Even if you roll your own, Test Clocks are the piece that makes recurring-commission logic safe to change.
Top comments (0)