BillTracker/docs/cents-migration-plan.md

125 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
```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.sql` must change the same columns to `INTEGER` + `-- cents` comments
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
`_runMigrationVersions` is populated; initSchema calls handleLegacyDatabase
before runMigrations. Worth fixing while in there.)
- `ROLLBACK_SQL_MAP` entry: same loop with `ROUND(${col} / 100.0, 2)`.
## Code conversion rules (stage 2)
1. **Reads:** anything selecting the 12 columns now yields cents. Services treat
them as cents; `roundMoney(x)` calls on these values become `Math.round`, and
most add/subtract/compare logic is unit-consistent and needs no change.
2. **Writes:** route input parsing converts dollars `toCents()` once, at
validation (`validateBillData`, `validatePaymentInput`, monthly-state,
starting-amounts, budgets, snowball routes).
3. **API output:** every `res.json` carrying money fields converts with
`fromCents()` add per-entity serializers (`serializeBill`, `serializePayment`,
...) in services and use them in routes instead of spreading raw rows.
4. **Unification wins:** existing `cents → dollars` bridges disappear, e.g.
`Math.round(Math.abs(tx.amount)) / 100` in routes/matches.js and
`dollarsFromTransactionAmount()` in subscriptionService payments and
transactions will finally share units.
5. **Interest math:** `mulMoney` equivalents 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_migrations`
for 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 becomes `formatCentsUSD`/`fromCents`.
## Verification before merging stage 2
1. `npm run check:server && npm test` (after fixture updates).
2. Invariant script: snapshot `SUM(col)` per money column pre-migration; assert
post-migration `SUM(col) / 100` matches to the cent.
3. Manual pass: tracker totals, snowball projection, calendar summary, CSV export
against a copy of the production DB (`backups/` has real snapshots to test with).