Stripe: Recurring Charges
A billing cycle that survives Jan 31, a proration that reconciles to the cent, and a dunning table whose attempts are 1-based. Three places recurring revenue quietly goes wrong.
- Pattern
- Clamped calendar math -> integer proration -> 1-based retry table
- Time
- O(1) per operation
- Space
- O(subscriptions)
Recurring billing is three small problems that look like arithmetic and are not. A calendar that does not divide evenly, a refund that has to reconcile to the cent, and a retry table that is 1-based while your array is 0-based. Each one has a wrong answer that runs fine in the demo and shows up in a support ticket a month later.
The bench injects a fake Stripe client, so nothing here touches the network. It is shaped like the real API: integer minor units, unix timestamps in seconds, list envelopes of { object: "list", data, has_more }, and a server clock of its own.
The recipe
Say before you type: "Clamp the day to the end of the target month, floor the elapsed side of every proration and take the rest as the remainder, and index the retry table from attempt minus one."
Three fields carry the state.
private client: StripeClient;
private subs = new Map<string, SubRecord>();
const RETRY_DELAYS_SEC = [3 * 86400, 5 * 86400, 7 * 86400];
Phase 1: the month does not divide
subscribe(customerId, priceId, startSec) reads the price, creates the subscription, and returns when the next invoice lands.
const price = await this.client.prices.retrieve(priceId);
const sub = await this.client.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
billing_cycle_anchor: startSec,
});
billing_cycle_anchor is the one parameter people leave off. Without it the server anchors the period to its own clock, which is close enough to right in a demo and wrong by hours in production, and every proration you compute afterwards divides by the wrong period.
Then the part they are actually asking about.
private addMonths(startSec: number, months: number): number {
const d = new Date(startSec * 1000);
const target = d.getUTCMonth() + months;
const year = d.getUTCFullYear() + Math.floor(target / 12);
const month = ((target % 12) + 12) % 12;
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); // day 0 is last month's end
const day = Math.min(d.getUTCDate(), lastDay);
return Math.floor(Date.UTC(year, month, day, d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()) / 1000);
}
Say the clamp out loud: a subscription anchored to Jan 31 bills Feb 28, or Feb 29 in a leap year. setUTCMonth(m + 1) on Jan 31 2026 gives you March 3, because JS rolls the overflow forward and Stripe does not. Adding 30 * 86400 is the same bug in a different coat, and it is worse: it drifts a day or two every single month instead of only in February.
Two more that fall out of the same helper:
- A year is twelve months, so
interval: "year"isaddMonths(start, 12)and Feb 29 2024 renews Feb 28 2025 for free. - Quarterly is not an interval. It is
interval: "month", interval_count: 3. An absentinterval_countmeans 1, andinterval_count || 1happens to be fine here only because 0 is not a legal value.
And the whole thing is in seconds. Date works in milliseconds, so there is exactly one * 1000 on the way in and one / 1000 on the way out. Miss the second and you quote a renewal date in the year 58,000.
Phase 2: the cent has to come from somewhere
They upgrade on the 16th, having paid for the whole month on the 1st. Stripe writes two proration lines: unused time on the old price, and remaining time on the new price. Both are the tail of the period. Neither is the elapsed part.
const total = sub.periodEnd - sub.periodStart;
const elapsed = Math.min(Math.max(atSec - sub.periodStart, 0), total);
private remainder(amount: number, elapsed: number, total: number): number {
const used = Math.floor((amount * elapsed) / total);
return amount - used; // used + credit === amount, by construction
}
That is the whole trick and it is worth naming: compute the elapsed side in integers, then take the other side as the remainder. Now used + credit is exactly amount, always, with no appeal to a rounding mode.
The plausible wrong answer is Math.round(amount * remaining / total) for the credit and Math.round(amount * elapsed / total) for what they consumed. Run it on a ragged offset: 4900 cents, 1234567 seconds into a 2678400 second period. The rule gives used 2258, credit 2642. Independent rounding gives used 2258, credit 2641, and a cent has vanished. Flip the fraction and a cent appears from nowhere instead. Either way it is a reconciliation ticket, and the version that invents money is the one finance notices.
Three details that get asked about:
net = charge - credit, and a downgrade makes it negative. That is a credit balance, not an error. Do not clamp it to zero.- Clamp
atSec, not the result. Before the period start is elapsed 0; at or past the period end is a fully elapsed period, so credit and charge are both 0 and net is 0. - The period does not move on a plan change.
proration_behavior: "create_prorations"is the default and saying it explicitly is the discipline. A second change in the same period prorates against whatever the first change left behind, over the same start and end. Re-anchor the period on a change and every number after it is wrong in a way nobody can reproduce.
Phase 3: the attempts are 1-based
const delay = RETRY_DELAYS_SEC[attempt - 1];
if (delay === undefined) { /* give up */ }
attempt is 1-based because that is how a human describes it: the first decline is attempt 1. The array is 0-based. Exactly one subtraction reconciles them, and it goes in exactly one place.
Get it backwards and [3, 5, 7][attempt] reads past the end on attempt 3, so a customer who still had a seven-day retry coming gets cancelled instead. Nobody notices, because the customer does not write in to say "you fired me one retry early". They just leave.
The status machine is three states and two transitions:
active --(any retry decision)--> past_due --(give up)--> canceled
Two things the interviewer is listening for on the cancel path:
- Cancel calls the API. A retry calls nothing.
await client.subscriptions.cancel(subId)fires on the give-up branch and nowhere else. - Set
status = "canceled"before the await, not after. Same lesson as spending an approval token before you run the handler: if the cancel succeeded and the response never came back, an unmarked subscription gets cancelled twice. Canceling is terminal, and a later failure on a canceled subscription still answers{ action: "cancel" }without touching the API again.
What the interviewer is listening for
Not the arithmetic. They are listening for whether you know which arithmetic breaks:
- The calendar is not uniform, so months get clamped and days get added only to things measured in days.
- Money is integers, and the invariant is that the parts add back up to the whole. Say "I compute one side and subtract" before you say anything about rounding.
- Off-by-one in a retry schedule is not a cosmetic bug. It is churn you cannot see in a dashboard.
One honest sentence to have ready: "real Stripe computes the period for me and I would read current_period_end off the subscription rather than recompute it. I am computing it here because I need to project the next bill date for a dunning window, and the moment I do that the month-end clamp is mine to get right." Worth knowing too: in API versions from 2025-03-31 onward, current_period_start and current_period_end moved off the subscription and onto each subscription item.