Splitting $1,000.01 into three equal payments exposes a small bug in a lot of CSV exporters: rounding every payment separately changes the total.
const totalCents = 100001;
const eachPayment = Math.round(totalCents / 3);
console.log(eachPayment); // 33334
console.log(eachPayment * 3); // 100002: one cent too much
Integer cents make reconciliation possible, but they do not decide where the remainder goes. That needs an allocation rule.
I maintain jackpot-annuity, an MIT-licensed cash-flow component built for Jackpot Calculator. It exports growing payment schedules and their present value. Here is the rounding rule it uses, followed by a runnable CSV export.
Round cumulative allocations
For this three-payment example, the exact cumulative shares are one-third, two-thirds and the whole total. Round those cumulative amounts, then subtract adjacent amounts:
| Payment | Rounded cumulative cents | This payment's cents |
|---|---|---|
| 1 | 33334 | 33334 |
| 2 | 66667 | 33333 |
| 3 | 100001 | 33334 |
The payments add to 100001 cents. Each differs from an exact one-third share by less than a cent.
The same principle works for a growing schedule. Normalize the payment weights, compute cumulative allocations, then round and take differences. The last cumulative allocation is the supplied total. This also avoids assigning a negative final payment when a very small total is spread across many periods.
Export a reconciled schedule
Use Node.js 20 or newer. In an empty directory:
npm init -y
npm install jackpot-annuity@0.1.0
Save this as export.mjs:
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { calculateAnnuity, toCSV } from 'jackpot-annuity';
const result = calculateAnnuity({
total: '1000.01',
payments: 3,
growthRate: 0,
discountRate: 0,
timing: 'due',
});
const exportedTotal = result.rows.reduce(
(sum, row) => sum + row.amountCents, 0,
);
assert.equal(exportedTotal, result.totalCents);
assert.equal(exportedTotal, 100001);
await writeFile('schedule.csv', toCSV(result), 'utf8');
console.log(result.rows.map(row => row.amountCents));
Run node export.mjs. It prints [33334, 33333, 33334] and writes:
payment,year,amount_usd,present_value_usd
1,0,333.34,333.34
2,1,333.33,333.33
3,2,333.34,333.34
For a shell-only export, the equivalent command is:
npx jackpot-annuity@0.1.0 1000.01 --payments 3 --growth 0 --discount 0 > schedule.csv
Keep units and timing explicit
The total option accepts dollars. Monetary outputs are cents. API rates are decimal fractions: growthRate: 0.05 means 5%. The CLI takes percentages instead: --growth 5.
Change the options to model a hypothetical $100 million advertised annuity:
const result = calculateAnnuity({
total: '100M',
payments: 30,
growthRate: 0.05,
discountRate: 0.04,
timing: 'due',
});
console.log(result.totalCents); // 10000000000
console.log(result.presentValueCents); // 5205342203
Here due puts the first payment at year 0 and the last at year 29. ordinary moves those payments to years 1 through 30. Changing that timing changes present value even when the nominal payment amounts are identical.
The package does not infer a cash offer from the advertised amount. Supply an actual quote separately. It also leaves taxes out of the payment schedule; the interactive lottery annuity calculator includes those additional estimates.
Reconcile the right column
The nominal payment amounts always sum to the input total. Present value has a different rounding boundary: the aggregate is rounded once after discounting all payments, while each displayed row is rounded individually. Adding the displayed row present values can therefore differ from the aggregate by a few cents.
When importing a CSV, convert its fixed-decimal money strings back to cents before adding them. Keep the exported aggregate and timing assumptions with the file so another person can reproduce the calculation. For large inputs, pass the dollar amount as a string to avoid losing precision before it reaches the parser.
This article was drafted with AI assistance. Its executable examples were checked against jackpot-annuity@0.1.0, including the CSV totals and present-value output.
Top comments (1)
The separate nominal-total and present-value rounding boundaries are worth making explicit. Since the CSV shows only rows, how would a downstream importer recover the aggregate and timing assumptions without the surrounding article? A companion manifest with package version, input total, due/ordinary timing, rates and aggregate PV would make the export reproducible. I'd also round-trip a one-cent total across more payments than cents and assert both exact nominal reconciliation and nonnegative rows after parsing the CSV back.