How Switching Proration From a Single Calculation to Daily Accrual Drifted Our Ledger by $14,200
09:14 UTC. Finance's monthly close flags a reconciliation gap: the sum of every proration line item in the ledger is $14,200 higher than what actually settled in Stripe. Nobody touched the payments integration. The only recent change anywhere near billing was a refactor to how mid-cycle plan upgrades get charged, merged three weeks earlier.
the setup
When a customer upgrades plans mid-cycle, they owe a prorated amount for the days remaining on the new plan, minus a credit for the days already paid on the old one. For years this ran as a single calculation at the moment of upgrade: one multiplication, one subtraction, one row written to the ledger. Three weeks before the reconciliation gap showed up, billing moved to daily accrual instead, so usage-based add-ons purchased mid-plan could be prorated correctly against a plan that might also change again before the cycle ended.
function accrueDailyCharge(planPriceDollars: number, daysInCycle: number): number {
return planPriceDollars / daysInCycle; // sub-cent precision, rounded only when charged
}
// runs once per active subscription, every night at 00:05 UTC
async function runNightlyAccrual() {
const subs = await getActiveSubscriptions();
for (const sub of subs) {
const charge = accrueDailyCharge(sub.planPriceDollars, sub.daysInCycle);
sub.accruedTotal += charge; // float addition, accumulated in place
await saveSubscription(sub);
}
}
Nothing about accrueDailyCharge was new logic. The same division and rounding
had lived in the old single-calculation path for four years without incident, because it ran
exactly once per upgrade. The refactor didn't change the math. It changed how many times the
math ran against the same accumulator.
the scramble
First theory: a duplicate accrual run, the nightly job firing twice for the same day and double-counting some subset of subscriptions. Checking the job scheduler's run history ruled that out inside fifteen minutes: one execution per night, no overlapping runs, no retries logged.
Second theory: currency conversion drift, since the platform bills in three currencies. That one looked promising until the affected subscriptions were filtered by currency: the drift showed up in USD-only accounts just as much as multi-currency ones, in roughly the same proportion as USD's share of total subscriptions. Whatever was happening wasn't specific to currency conversion at all.
Third theory, the one that felt least likely and turned out to be exactly right: something was wrong with the accrual math itself, even though it hadn't changed. The team almost skipped this one because "the formula is the same formula it's always been" was said out loud twice in the incident channel before anyone actually re-read the new call site instead of the unchanged function body.
the hunt
The ledger stores each day's accrued charge as its own row, so the first real diagnostic step was comparing the stored per-day rows against what the formula should have produced for a handful of flagged subscriptions.
> const daily = 29.99 / 30;
> daily
0.9996666666666666
> let total = 0;
> for (let i = 0; i < 15; i++) total += daily;
> total
14.994999999999996
> Math.round(total * 100) / 100 // what the accumulator rounds to for the ledger
14.99
> Math.round(daily * 15 * 100) / 100 // what a single multiplication rounds to
15
Fifteen additions of the same float value land one cent short of what a single multiplication
gives you for the exact same fifteen days. Neither number is "wrong" in the sense of a typo
or a bad formula. 0.1, 29.99 / 30, and most other decimals a person
types are stored as the nearest representable binary fraction, not the exact value, and
summing that approximation fifteen times doesn't land in the same place as multiplying it by
fifteen once. The error on any single day is invisible. It only becomes a cent you can point
to once enough of those additions have piled up in the same accumulator.
The old single-calculation path had exactly one float operation between "compute" and "store." There was no accumulator to drift, because there was nothing to accumulate against. The new path turned that same harmless imprecision into a running sum that carried a small error forward every night, for every subscription that had been on daily accrual since the refactor shipped, and the direction of the error wasn't random. The same price and cycle length produce the same rounding artifact every time, so subscriptions on common plan tiers drifted the same way instead of canceling each other out.
-- subscriptions.accrued_total NUMERIC(10,2)? No.
-- it was DOUBLE PRECISION, inherited from a schema written when
-- this column only ever held a single write, never a running sum.
accrued_total DOUBLE PRECISION NOT NULL DEFAULT 0
Individually, each drifted subscription was off by fractions of a cent, sometimes low enough that the ledger's own cent-rounding on display hid it completely. Across roughly 40,000 subscriptions on daily accrual for up to 21 days by the time finance caught it, the drift compounded into a number large enough to show up in a monthly reconciliation that sums everything before it rounds anything.
the find
Root cause: accrued_total was a floating-point column being used as a running
sum, and the daily accrual refactor turned what used to be a single float write per
subscription per billing cycle into up to 30 float writes, each one carrying forward the
binary-representation error of everything added before it. The formula was never wrong. The
column type was wrong for the access pattern it was newly being asked to support, and nobody
re-evaluated that when the access pattern changed.
the fix
The ledger now does all money arithmetic in integer cents, and the accumulator column moved off floating point entirely:
function accrueDailyChargeCents(planPriceCents: number, daysInCycle: number): number {
// integer division with explicit remainder handling, no float ever touches money
return Math.round(planPriceCents / daysInCycle);
}
async function runNightlyAccrual() {
const subs = await getActiveSubscriptions();
for (const sub of subs) {
const chargeCents = accrueDailyChargeCents(sub.planPriceCents, sub.daysInCycle);
sub.accruedTotalCents += chargeCents; // integer addition, exact every time
await saveSubscription(sub);
}
}
ALTER TABLE subscriptions ADD COLUMN accrued_total_cents INTEGER NOT NULL DEFAULT 0;
UPDATE subscriptions
SET accrued_total_cents = ROUND(accrued_total * 100)::INTEGER;
-- accrued_total (DOUBLE PRECISION) dropped one release later,
-- after every read path had migrated to the cents column
ALTER TABLE subscriptions DROP COLUMN accrued_total;
Integer addition over integer cents has no representation error to accumulate, at any number
of iterations. The one place a rounding decision still has to happen is
Math.round(planPriceCents / daysInCycle), and that rounds a plain integer
division exactly once per day, per subscription, the same bounded, non-compounding rounding
the original single-calculation path always had.
the aftermath
A nightly reconciliation job now sums accrued_total_cents across all
subscriptions and diffs it against Stripe's settled totals, alerting on any gap over a single
cent, since a gap that size shouldn't be able to exist anymore. It's a cheap check precisely
because the fix made the invariant actually true instead of usually true.
- A float bug doesn't announce itself when it's introduced. It announces itself when something downstream changes how many times the float gets touched, which can be months or years after the line of code that carries the actual risk was written.
-
Money should be integer cents (or a decimal type) from the schema up, not just in the
function that happens to compute it that week. A column typed
DOUBLE PRECISIONis a bet that nothing will ever sum it more than once, and that bet doesn't get re-underwritten every time the access pattern around it changes. - "The formula didn't change" is not the same claim as "the risk didn't change." Review changes to money math by asking how many times the new code path will execute the same arithmetic, not just whether the arithmetic itself is still correct in isolation.
- Reconciliation that sums before it rounds will eventually surface an error that display-layer rounding was quietly absorbing. The reconciliation job didn't cause the drift. It just refused to hide it the way every per-subscription invoice already had.
The single-calculation version of this code had run the same float division for four years without drifting a visible cent, not because it was correct, but because it never got the chance to compound. Daily accrual didn't introduce a bug. It gave an existing one up to thirty more chances a month to matter.