5.9 KiB
Cents Migration Plan (v1.03) — dollars (REAL) → integer cents
Status: Stage 1 shipped (2026-06-10). Stage 2 (schema flip) NOT yet applied.
Stage 1 (done): utils/money.js cents-core module; every float summation/rounding
site in services/routes now uses roundMoney / sumMoney / mulMoney (cent-exact).
Stage 2 (this document): flip storage and compute to integer cents.
Why staged
The schema flip and the code conversion must land atomically — migrations run automatically at boot, so flipping storage with any of the ~288 query sites still assuming dollars corrupts data 100× on write or displays garbage on read. Stage 2 should be done on a branch, file by file, with the checklist below.
Target architecture
- DB: integer cents in all money columns (matches SimpleFIN
transactions/financial_accounts, which are already cents). - Services: compute in cents end-to-end.
- API: keeps returning dollars (convert at route serialization, parse at input). This keeps the web client (142 money sites) and the mobile app untouched.
Column inventory (12 columns, 8 tables)
| Table | Columns (currently dollars REAL) |
|---|---|
| bills | expected_amount, current_balance, minimum_payment |
| payments | amount, balance_delta |
| monthly_bill_state | actual_amount |
| monthly_starting_amounts | first_amount, fifteenth_amount, other_amount |
| monthly_income | amount |
| spending_budgets | amount |
| snowball_plans | extra_payment |
| users | snowball_extra_payment |
NOT migrated: bills.interest_rate (a percentage), transactions.amount,
financial_accounts.balance/available_balance (already cents).
v1.03 migration (add to the migrations array in db/database.js)
{
version: 'v1.03',
description: 'money columns: dollars (REAL) → integer cents',
run() {
const conv = [
['bills', ['expected_amount', 'current_balance', 'minimum_payment']],
['payments', ['amount', 'balance_delta']],
['monthly_bill_state', ['actual_amount']],
['monthly_starting_amounts', ['first_amount', 'fifteenth_amount', 'other_amount']],
['monthly_income', ['amount']],
['spending_budgets', ['amount']],
['snowball_plans', ['extra_payment']],
['users', ['snowball_extra_payment']],
];
for (const [table, cols] of conv) {
for (const col of cols) {
db.exec(`UPDATE ${table} SET ${col} = CAST(ROUND(${col} * 100) AS INTEGER) WHERE ${col} IS NOT NULL`);
}
}
console.log('[v1.03] money columns converted to integer cents');
}
}
Notes:
- SQLite affinity: existing columns stay declared REAL but hold integer values — exact in both SQLite and JS (integers < 2^53). No table rebuild needed.
db/schema.sqlmust change the same columns toINTEGER+-- centscomments so fresh installs are born in cents (v1.03 then no-ops on zero rows).- Registration: only the
runMigrations()array matters. (The reconcileLegacyMigrations sync assertion never fires — it runs before_runMigrationVersionsis populated; initSchema calls handleLegacyDatabase before runMigrations. Worth fixing while in there.) ROLLBACK_SQL_MAPentry: same loop withROUND(${col} / 100.0, 2).
Code conversion rules (stage 2)
- Reads: anything selecting the 12 columns now yields cents. Services treat
them as cents;
roundMoney(x)calls on these values becomeMath.round, and most add/subtract/compare logic is unit-consistent and needs no change. - Writes: route input parsing converts dollars →
toCents()once, at validation (validateBillData,validatePaymentInput, monthly-state, starting-amounts, budgets, snowball routes). - API output: every
res.jsoncarrying money fields converts withfromCents()— add per-entity serializers (serializeBill,serializePayment, ...) in services and use them in routes instead of spreading raw rows. - Unification wins: existing
cents → dollarsbridges disappear, e.g.Math.round(Math.abs(tx.amount)) / 100in routes/matches.js anddollarsFromTransactionAmount()in subscriptionService — payments and transactions will finally share units. - Interest math:
mulMoneyequivalents work directly in cents:Math.round(balanceCents * rate / 100 / 12).
Query-site inventory (grep (FROM|INTO|UPDATE) <table>, server-side)
bills: 127 · payments: 108 · monthly_bill_state: 18 · snowball_plans: 20 · monthly_starting_amounts: 7 · spending_budgets: 6 · monthly_income: 2
Suggested file order (leaf → hub): paymentValidation → billsService → paymentAccountingService → statusService → trackerService → routes/payments → routes/bills → snowball/apr → analytics/spending/summary → subscription → import/export → notification/calendarFeed.
Hazards (each needs explicit handling)
- services/userDbImportService.js copies raw numeric values from uploaded DBs
(lines ~152-219). After v1.03 it MUST check the source DB's
schema_migrationsfor v1.03: present → copy as-is; absent →toCents()each money field. - Spreadsheet/CSV imports (
spreadsheetImportService,csvTransactionImportService) parse user dollars — convert at their INSERT statements. - Backups/restore: safe automatically — restore swaps the file,
closeDb()→getDb()re-runs migrations, so pre-v1.03 backups get converted at next init. - Tests: ~67 money assertions/fixtures insert dollars via raw SQL — multiply fixtures and expectations by 100 where they touch the 12 columns.
- export.js CSV:
toFixed(2)on dollars becomesformatCentsUSD/fromCents.
Verification before merging stage 2
npm run check:server && npm test(after fixture updates).- Invariant script: snapshot
SUM(col)per money column pre-migration; assert post-migrationSUM(col) / 100matches to the cent. - Manual pass: tracker totals, snowball projection, calendar summary, CSV export
against a copy of the production DB (
backups/has real snapshots to test with).