# 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).
