125 lines
5.9 KiB
Markdown
125 lines
5.9 KiB
Markdown
|
|
# 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).
|