From 5861346eb3a05a94893703d60af3d8210d783aa6 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 1 Jun 2026 20:37:56 -0400 Subject: [PATCH] =?UTF-8?q?feat(balance):=20data=20layer=20=E2=80=94=20veh?= =?UTF-8?q?icle=5Ftype=20+=20custom=5Flabel=20migrations,=20starters,=20se?= =?UTF-8?q?rvice=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bilan axe véhicule (Étape 1) data foundation: separate the fiscal envelope (now balance_accounts.vehicle_type) from the asset class (the category). - Migration v12 (additive): add vehicle_type (fiscal enum, nullable) to balance_accounts + custom_label to balance_categories; backfill the envelope onto ex-tfsa/rrsp accounts (cash stays NULL); defensive recovery of any seed i18n_key overwritten by free text (bug I). - Migration v13 (reclass, conditional/idempotent): re-link ex-tfsa/rrsp accounts to the `other` asset class and deactivate the two envelope seeds. - consolidated_schema.sql: 2 new columns, 5 asset-class seeds (no tfsa/rrsp), CELI/REER starters re-pointed to `other` + vehicle_type (avoids NULL FK). - Types: BalanceVehicleType, custom_label / vehicle_type / category_custom_label. - Service: normalizeVehicleType + vehicle_type_invalid; CRUD writes the new columns; SELECT/JOINs read them back; STARTER_ACCOUNTS + proposeStarterAccounts (is_active=1 + vehicle_type) + getStarterCollisions adjusted. - Tests: Rust chain v9→v13 (snapshot_lines identical, transfers intact, archived ex-tfsa covered, seeds deactivated, v13 EXISTS guard, idempotence, CHECK reject) + consolidated complete (5 categories, 4 starters, 0 NULL FK); TS service + StarterAccountsModal specs. No diff to migrations <= v11. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/database/consolidated_schema.sql | 30 +- src-tauri/src/lib.rs | 603 ++++++++++++++++++ src/__integration__/balance-flow.test.ts | 4 +- .../balance/StarterAccountsModal.test.tsx | 67 +- src/services/balance.service.test.ts | 320 +++++++++- src/services/balance.service.ts | 180 +++++- src/shared/types/index.ts | 30 + 7 files changed, 1191 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/database/consolidated_schema.sql b/src-tauri/src/database/consolidated_schema.sql index 83a08a9..e8b4e63 100644 --- a/src-tauri/src/database/consolidated_schema.sql +++ b/src-tauri/src/database/consolidated_schema.sql @@ -197,7 +197,10 @@ CREATE TABLE IF NOT EXISTS balance_categories ( sort_order INTEGER NOT NULL DEFAULT 0, is_active INTEGER NOT NULL DEFAULT 1, is_seed INTEGER NOT NULL DEFAULT 0, - asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')) + asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')), + -- User-facing override for the category label (migration v12). When set, + -- the UI shows this verbatim; otherwise it falls back to t(i18n_key). + custom_label TEXT ); CREATE TABLE IF NOT EXISTS balance_accounts ( @@ -209,6 +212,9 @@ CREATE TABLE IF NOT EXISTS balance_accounts ( notes TEXT, is_active INTEGER NOT NULL DEFAULT 1, archived_at DATETIME, + -- Fiscal envelope / tax shelter (migration v12). NULL = no envelope (e.g. + -- a chequing account or crypto wallet). NOT an automobile type. + vehicle_type TEXT CHECK(vehicle_type IS NULL OR vehicle_type IN ('unregistered','tfsa','rrsp','rrif','fhsa','resp')), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT @@ -262,10 +268,12 @@ CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_acco CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id); CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); +-- Asset classes only (Bilan axe véhicule, Étape 1). The former `tfsa` / `rrsp` +-- seeds were fiscal envelopes, not asset classes — they now live on the account +-- as `vehicle_type` (see balance_accounts.vehicle_type). New profiles ship with +-- exactly 5 asset classes: Liquidités, Fonds/FNB, Actions, Crypto, Autres. INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES ('cash', 'balance.category.cash', 'simple', 10, 1, NULL), - ('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL), - ('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL), ('fund', 'balance.category.fund', 'simple', 40, 1, NULL), ('other', 'balance.category.other', 'simple', 50, 1, NULL), ('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'), @@ -276,11 +284,17 @@ INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_se -- balance_accounts) — once created they are indistinguishable from -- user-created accounts and can be renamed/archived freely. Existing profiles -- get the same 4 proposed via StarterAccountsModal on first /balance visit. -INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) VALUES - ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1), - ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD', 1), - ((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER', 'CAD', 1), - ((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1); +-- +-- Bilan axe véhicule (Étape 1): the CELI / REER starters are no longer linked +-- to a `tfsa` / `rrsp` category (those seeds are gone). They now attach to the +-- `other` asset class and carry the envelope in `vehicle_type`. Linking them to +-- a deleted key would make the subselect return NULL → NOT NULL violation → +-- broken new-profile init, so the asset class MUST resolve to `other`. +INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type) VALUES + ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1, NULL), + ((SELECT id FROM balance_categories WHERE key = 'other'), 'CELI', 'CAD', 1, 'tfsa'), + ((SELECT id FROM balance_categories WHERE key = 'other'), 'REER', 'CAD', 1, 'rrsp'), + ((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1, NULL); -- Default preferences (new profiles ship with the v1 IPC taxonomy) INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42c809e..1857e48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -143,6 +143,66 @@ pub fn run() { WHERE snapshot_id = balance_snapshots.id);", kind: MigrationKind::Up, }, + // Migration v12 — Bilan axe véhicule (Étape 1), additive part. Adds + // `vehicle_type` (fiscal envelope / tax shelter — NOT an automobile) to + // balance_accounts and `custom_label` to balance_categories, then + // backfills the envelope attribute onto the accounts that used to live + // in the now-deprecated `tfsa` / `rrsp` seed categories. `cash` accounts + // stay NULL — a chequing account is not a "non-registered investment". + // + // The two trailing UPDATEs are a defensive backfill for bug I: before + // this work the category-rename path overwrote `i18n_key` with free + // text. Any seed row whose i18n_key no longer matches the canonical + // `balance.category.%` convention has that free text recovered into + // `custom_label`, then its i18n_key is restored from `key` so the + // renderCategoryLabel helper (custom_label || t(i18n_key)) keeps working. + // ALTER ADD COLUMN ... CHECK (mono-column) is valid on this stack (cf v10). + Migration { + version: 12, + description: "add vehicle_type to balance_accounts and custom_label to balance_categories", + sql: "ALTER TABLE balance_accounts ADD COLUMN vehicle_type TEXT \ + CHECK(vehicle_type IS NULL OR vehicle_type IN \ + ('unregistered','tfsa','rrsp','rrif','fhsa','resp')); \ + ALTER TABLE balance_categories ADD COLUMN custom_label TEXT; \ + UPDATE balance_accounts SET vehicle_type = 'tfsa' \ + WHERE balance_category_id = ( \ + SELECT id FROM balance_categories WHERE key = 'tfsa'); \ + UPDATE balance_accounts SET vehicle_type = 'rrsp' \ + WHERE balance_category_id = ( \ + SELECT id FROM balance_categories WHERE key = 'rrsp'); \ + UPDATE balance_categories SET custom_label = i18n_key \ + WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%'; \ + UPDATE balance_categories SET i18n_key = 'balance.category.' || key \ + WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%';", + kind: MigrationKind::Up, + }, + // Migration v13 — Bilan axe véhicule (Étape 1), reclass part. Now that + // the envelope lives on the account (v12), the `tfsa` / `rrsp` seed + // categories become pure tax shelters with no asset-class meaning. + // Re-link their accounts to the `other` asset class ("Autres") and + // deactivate the two seeds so they vanish from the category dropdowns. + // + // Conditional + idempotent (no Down migration exists): the re-link only + // fires when an `other` seed category exists (EXISTS guard), and both + // statements scope to `is_seed = 1` so a user-created category that + // happens to reuse the key is never touched. v12 runs first (sqlx + // versioning guarantees the order), so vehicle_type is already stamped + // before balance_category_id moves. Re-running v13 is a no-op: the + // accounts are no longer in tfsa/rrsp and the seeds are already inactive. + Migration { + version: 13, + description: "reclass ex-tfsa/rrsp accounts to other and deactivate the envelope seeds", + sql: "UPDATE balance_accounts \ + SET balance_category_id = ( \ + SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1) \ + WHERE balance_category_id IN ( \ + SELECT id FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_seed = 1) \ + AND EXISTS ( \ + SELECT 1 FROM balance_categories WHERE key = 'other' AND is_seed = 1); \ + UPDATE balance_categories SET is_active = 0 \ + WHERE key IN ('tfsa','rrsp') AND is_seed = 1;", + kind: MigrationKind::Up, + }, ]; tauri::Builder::default() @@ -1296,5 +1356,548 @@ mod tests { .unwrap(); assert_eq!(count, 0); } + + // ========================================================================= + // Migrations v12 + v13 — Bilan axe véhicule (Étape 1) — issue #202 + // ------------------------------------------------------------------------- + // v12 (additive): adds `vehicle_type` (fiscal envelope — NOT an automobile) + // to balance_accounts and `custom_label` to balance_categories, backfills + // the envelope onto ex-tfsa/rrsp accounts (cash stays NULL), and recovers + // any seed i18n_key that bug I had overwritten with free text. + // v13 (reclass): re-links the ex-tfsa/rrsp accounts to the `other` asset + // class and deactivates the two now-deprecated envelope seeds. Conditional + // + idempotent (no Down migration exists). + // + // Both constants are statement-equivalent to the Migration { version: 12/13 } + // entries in `lib.rs` above (kept in sync by hand, same pattern as V10/V11). + // ========================================================================= + + /// Production v12 SQL — kept in sync with the Migration { version: 12 } entry. + const V12_SQL: &str = "ALTER TABLE balance_accounts ADD COLUMN vehicle_type TEXT \ + CHECK(vehicle_type IS NULL OR vehicle_type IN \ + ('unregistered','tfsa','rrsp','rrif','fhsa','resp')); \ + ALTER TABLE balance_categories ADD COLUMN custom_label TEXT; \ + UPDATE balance_accounts SET vehicle_type = 'tfsa' \ + WHERE balance_category_id = ( \ + SELECT id FROM balance_categories WHERE key = 'tfsa'); \ + UPDATE balance_accounts SET vehicle_type = 'rrsp' \ + WHERE balance_category_id = ( \ + SELECT id FROM balance_categories WHERE key = 'rrsp'); \ + UPDATE balance_categories SET custom_label = i18n_key \ + WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%'; \ + UPDATE balance_categories SET i18n_key = 'balance.category.' || key \ + WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%';"; + + /// Production v13 SQL — kept in sync with the Migration { version: 13 } entry. + const V13_SQL: &str = "UPDATE balance_accounts \ + SET balance_category_id = ( \ + SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1) \ + WHERE balance_category_id IN ( \ + SELECT id FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_seed = 1) \ + AND EXISTS ( \ + SELECT 1 FROM balance_categories WHERE key = 'other' AND is_seed = 1); \ + UPDATE balance_categories SET is_active = 0 \ + WHERE key IN ('tfsa','rrsp') AND is_seed = 1;"; + + /// Apply the full v9→v11 chain on a fresh DB so v12/v13 tests start from a + /// realistic post-v11 state (v9 seeds + v10 asset_type + v11 cleanup). + fn db_through_v11() -> Connection { + let conn = fresh_db(); // already applied BALANCE_SCHEMA (v9) + conn.execute_batch(V10_SQL).expect("apply v10"); + conn.execute_batch(V11_SQL).expect("apply v11"); + conn + } + + #[test] + fn migration_v12_adds_columns_and_backfills_vehicle() { + let conn = db_through_v11(); + + // Seed accounts across the relevant seed categories BEFORE v12 so the + // backfill has something to act on. + for key in &["cash", "tfsa", "rrsp", "other"] { + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) \ + VALUES ((SELECT id FROM balance_categories WHERE key = ?1), ?2)", + rusqlite::params![key, format!("acct-{key}")], + ) + .unwrap(); + } + + conn.execute_batch(V12_SQL).expect("apply v12"); + + // New columns exist. + let acct_cols: Vec = conn + .prepare("SELECT name FROM pragma_table_info('balance_accounts')") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert!(acct_cols.contains(&"vehicle_type".to_string())); + let cat_cols: Vec = conn + .prepare("SELECT name FROM pragma_table_info('balance_categories')") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert!(cat_cols.contains(&"custom_label".to_string())); + + // tfsa / rrsp accounts get the envelope; cash + other stay NULL. + let read_vehicle = |name: &str| -> Option { + conn.query_row( + "SELECT vehicle_type FROM balance_accounts WHERE name = ?1", + [name], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!(read_vehicle("acct-tfsa").as_deref(), Some("tfsa")); + assert_eq!(read_vehicle("acct-rrsp").as_deref(), Some("rrsp")); + assert!(read_vehicle("acct-cash").is_none(), "cash must stay NULL"); + assert!(read_vehicle("acct-other").is_none(), "other must stay NULL"); + } + + #[test] + fn migration_v12_defensive_backfill_recovers_overwritten_i18n_key() { + // Simulate bug I: a seed category whose i18n_key was clobbered by a + // user-typed free-text label ("Mon CELI perso") instead of the + // canonical balance.category.tfsa key. + let conn = db_through_v11(); + conn.execute( + "UPDATE balance_categories SET i18n_key = 'Mon CELI perso' WHERE key = 'tfsa'", + [], + ) + .unwrap(); + + conn.execute_batch(V12_SQL).expect("apply v12"); + + // The free text is recovered into custom_label, the i18n_key restored. + let (i18n, custom): (String, Option) = conn + .query_row( + "SELECT i18n_key, custom_label FROM balance_categories WHERE key = 'tfsa'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(i18n, "balance.category.tfsa"); + assert_eq!(custom.as_deref(), Some("Mon CELI perso")); + + // A seed whose i18n_key was already canonical keeps NULL custom_label. + let cash_custom: Option = conn + .query_row( + "SELECT custom_label FROM balance_categories WHERE key = 'cash'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(cash_custom.is_none()); + } + + #[test] + fn migration_v12_check_rejects_invalid_vehicle_type() { + let conn = db_through_v11(); + conn.execute_batch(V12_SQL).expect("apply v12"); + let res = conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, vehicle_type) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'car')", + [], + ); + assert!( + res.is_err(), + "CHECK should reject a vehicle_type outside the fiscal enum" + ); + } + + #[test] + fn migration_v13_reclasses_accounts_and_deactivates_seeds() { + let conn = db_through_v11(); + + // Two ex-envelope accounts + an archived one, plus a cash account. + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI actif')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER actif')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, is_active, archived_at) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI archivé', 0, CURRENT_TIMESTAMP)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Chèque')", + [], + ) + .unwrap(); + + conn.execute_batch(V12_SQL).expect("apply v12"); + conn.execute_batch(V13_SQL).expect("apply v13"); + + let other_id: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1", + [], + |r| r.get(0), + ) + .unwrap(); + + // Every ex-tfsa/rrsp account (active OR archived) now points to `other`, + // with the envelope preserved on vehicle_type. + for (name, vehicle) in &[ + ("CELI actif", "tfsa"), + ("REER actif", "rrsp"), + ("CELI archivé", "tfsa"), + ] { + let (cat_id, vt): (i64, Option) = conn + .query_row( + "SELECT balance_category_id, vehicle_type FROM balance_accounts WHERE name = ?1", + [name], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(cat_id, other_id, "{name} should be re-linked to other"); + assert_eq!(vt.as_deref(), Some(*vehicle), "{name} envelope preserved"); + } + + // The cash account is untouched. + let cash_cat: i64 = conn + .query_row( + "SELECT balance_category_id FROM balance_accounts WHERE name = 'Chèque'", + [], + |r| r.get(0), + ) + .unwrap(); + let cash_seed: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'cash'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(cash_cat, cash_seed); + + // The tfsa/rrsp seeds are deactivated; the asset-class seeds stay active. + let inactive: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories \ + WHERE key IN ('tfsa','rrsp') AND is_seed = 1 AND is_active = 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(inactive, 2, "both envelope seeds must be deactivated"); + let active: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE is_active = 1 AND is_seed = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(active, 5, "5 asset-class seeds remain active"); + } + + #[test] + fn migration_v9_to_v13_chain_preserves_snapshot_lines_and_transfers() { + // The headline regression test: run the WHOLE v9→v13 chain on a seeded + // profile with snapshots + a transfer on an ex-CELI account, and assert + // the financial history is byte-for-byte preserved across the reclass. + let conn = seeded_db_with_balance_schema(); // v9 applied + transactions + conn.execute_batch(V10_SQL).expect("apply v10"); + conn.execute_batch(V11_SQL).expect("apply v11"); + + // A CELI account (ex-envelope) with two snapshot endpoints + a linked + // contribution transfer. + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'Wealthsimple CELI')", + [], + ) + .unwrap(); + let acct_id: i64 = conn + .query_row( + "SELECT id FROM balance_accounts WHERE name = 'Wealthsimple CELI'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-01'), ('2026-04-01')", + [], + ) + .unwrap(); + let s_start: i64 = conn + .query_row( + "SELECT id FROM balance_snapshots WHERE snapshot_date = '2026-01-01'", + [], + |r| r.get(0), + ) + .unwrap(); + let s_end: i64 = conn + .query_row( + "SELECT id FROM balance_snapshots WHERE snapshot_date = '2026-04-01'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1000.0)", + rusqlite::params![s_start, acct_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1500.0)", + rusqlite::params![s_end, acct_id], + ) + .unwrap(); + let tx_id: i64 = conn + .query_row( + "SELECT id FROM transactions WHERE date = '2026-02-01'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) \ + VALUES (?1, ?2, 'in')", + rusqlite::params![acct_id, tx_id], + ) + .unwrap(); + + // Capture the snapshot_lines fingerprint BEFORE the reclass. + let lines_before: Vec<(i64, i64, f64)> = conn + .prepare("SELECT snapshot_id, account_id, value FROM balance_snapshot_lines ORDER BY id") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + + // Run the reclass chain. + conn.execute_batch(V12_SQL).expect("apply v12"); + conn.execute_batch(V13_SQL).expect("apply v13"); + + // snapshot_lines are byte-for-byte identical (same ids, same values). + let lines_after: Vec<(i64, i64, f64)> = conn + .prepare("SELECT snapshot_id, account_id, value FROM balance_snapshot_lines ORDER BY id") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!( + lines_before, lines_after, + "snapshot_lines must be identical before/after the reclass" + ); + + // The account moved to `other` and kept its CELI envelope. + let (cat_key, vt): (String, Option) = conn + .query_row( + "SELECT c.key, a.vehicle_type \ + FROM balance_accounts a JOIN balance_categories c ON c.id = a.balance_category_id \ + WHERE a.id = ?1", + [acct_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(cat_key, "other"); + assert_eq!(vt.as_deref(), Some("tfsa")); + + // The transfer link is intact. + let transfer_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_account_transfers WHERE account_id = ?1 AND transaction_id = ?2", + rusqlite::params![acct_id, tx_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(transfer_count, 1, "transfer must survive the reclass"); + } + + #[test] + fn migration_v13_is_idempotent_on_replay() { + // Running v13 twice must not change anything the second time. + let conn = db_through_v11(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI')", + [], + ) + .unwrap(); + conn.execute_batch(V12_SQL).expect("apply v12"); + conn.execute_batch(V13_SQL).expect("apply v13 #1"); + let other_id: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1", + [], + |r| r.get(0), + ) + .unwrap(); + // Second run: no-op. + conn.execute_batch(V13_SQL).expect("apply v13 #2"); + let cat_id: i64 = conn + .query_row( + "SELECT balance_category_id FROM balance_accounts WHERE name = 'CELI'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(cat_id, other_id, "account stays on other after replay"); + } + + #[test] + fn migration_v13_guard_noop_when_other_seed_missing() { + // If the `other` seed is absent (e.g. hard-deleted), v13 must NOT move + // accounts to a NULL category — the EXISTS guard makes it a clean no-op. + let conn = db_through_v11(); + // Add a CELI account, then remove the `other` seed (no FK refs to it). + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI')", + [], + ) + .unwrap(); + let tfsa_id: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'tfsa'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute("DELETE FROM balance_categories WHERE key = 'other'", []) + .unwrap(); + + conn.execute_batch(V12_SQL).expect("apply v12"); + conn.execute_batch(V13_SQL).expect("apply v13"); + + // The account stays on tfsa (no NULL category, no broken FK). + let cat_id: Option = conn + .query_row( + "SELECT balance_category_id FROM balance_accounts WHERE name = 'CELI'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + cat_id, + Some(tfsa_id), + "without `other`, the account must not be moved to NULL" + ); + } + + // ------------------------------------------------------------------------- + // Consolidated schema (new profiles) — issue #202 + // ------------------------------------------------------------------------- + // The consolidated schema must initialize a brand-new profile cleanly with + // the post-Étape-1 shape: 5 asset-class categories, 4 starter accounts (no + // NULL category_id), and the CELI/REER starters carrying vehicle_type. + + /// Apply the full consolidated schema on a fresh in-memory DB, the same way + /// `get_new_profile_init_sql` does for a brand-new profile. + fn consolidated_db() -> Connection { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute("PRAGMA foreign_keys = ON;", []) + .expect("enable FKs"); + conn.execute_batch(crate::database::CONSOLIDATED_SCHEMA) + .expect("apply consolidated schema"); + conn + } + + #[test] + fn consolidated_schema_inits_cleanly_with_5_categories_and_4_starters() { + let conn = consolidated_db(); + + // Exactly 5 active asset-class seeds, no tfsa/rrsp. + let active_seeds: Vec = conn + .prepare( + "SELECT key FROM balance_categories WHERE is_seed = 1 AND is_active = 1 ORDER BY sort_order", + ) + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!( + active_seeds, + vec!["cash", "fund", "other", "stock", "crypto"], + "new profiles ship exactly 5 asset-class seeds (no envelope categories)" + ); + let envelope_seeds: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE key IN ('tfsa','rrsp')", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(envelope_seeds, 0, "no tfsa/rrsp category in a new profile"); + + // 4 starter accounts, none with a NULL category (#1 review risk). + let starter_count: i64 = conn + .query_row("SELECT COUNT(*) FROM balance_accounts", [], |r| r.get(0)) + .unwrap(); + assert_eq!(starter_count, 4); + let null_cat: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_accounts WHERE balance_category_id IS NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(null_cat, 0, "no starter may have a NULL balance_category_id"); + + // The CELI / REER starters live in `other` and carry their envelope. + let other_id: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'other'", + [], + |r| r.get(0), + ) + .unwrap(); + for (name, vehicle) in &[("CELI", "tfsa"), ("REER", "rrsp")] { + let (cat_id, vt): (i64, Option) = conn + .query_row( + "SELECT balance_category_id, vehicle_type FROM balance_accounts WHERE name = ?1", + [name], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(cat_id, other_id, "{name} starter must attach to other"); + assert_eq!(vt.as_deref(), Some(*vehicle), "{name} carries its envelope"); + } + + // The plain starters have a NULL envelope. + for name in &["Compte chèque", "Compte non-enregistré"] { + let vt: Option = conn + .query_row( + "SELECT vehicle_type FROM balance_accounts WHERE name = ?1", + [name], + |r| r.get(0), + ) + .unwrap(); + assert!(vt.is_none(), "{name} must have a NULL vehicle_type"); + } + } + + #[test] + fn consolidated_schema_check_rejects_invalid_vehicle_type() { + let conn = consolidated_db(); + let res = conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, vehicle_type) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'truck')", + [], + ); + assert!( + res.is_err(), + "consolidated CHECK should reject a vehicle_type outside the fiscal enum" + ); + } } diff --git a/src/__integration__/balance-flow.test.ts b/src/__integration__/balance-flow.test.ts index d738178..3df4bd9 100644 --- a/src/__integration__/balance-flow.test.ts +++ b/src/__integration__/balance-flow.test.ts @@ -411,8 +411,8 @@ describe("integration — currency lock (CAD only)", () => { c.sql.includes("INSERT INTO balance_accounts") ); expect(insertCall).toBeDefined(); - // [category_id, name, symbol, currency, notes] - expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]); + // [category_id, name, symbol, currency, notes, vehicle_type] (#202) + expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null, null]); }); it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => { diff --git a/src/components/balance/StarterAccountsModal.test.tsx b/src/components/balance/StarterAccountsModal.test.tsx index d56239c..f491a91 100644 --- a/src/components/balance/StarterAccountsModal.test.tsx +++ b/src/components/balance/StarterAccountsModal.test.tsx @@ -30,7 +30,7 @@ beforeEach(() => { }); describe("STARTER_ACCOUNTS", () => { - it("ships exactly 4 starters mapping cash/tfsa/rrsp/other", () => { + it("ships exactly 4 starters keyed cash/tfsa/rrsp/other", () => { expect(STARTER_ACCOUNTS).toHaveLength(4); expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([ "cash", @@ -39,10 +39,24 @@ describe("STARTER_ACCOUNTS", () => { "other", ]); for (const s of STARTER_ACCOUNTS) { - expect(s.categoryKey).toBe(s.key); expect(s.i18nKey).toMatch(/^balance\.starters\.items\./); } }); + + it("maps CELI/REER to the `other` asset class with a vehicle_type (#202)", () => { + // After the envelope/asset-class split, only `cash` keeps its own class; + // CELI/REER/non-registered all live under `other`, and the envelope is + // carried by vehicle_type (NOT an automobile type). + const byKey = Object.fromEntries(STARTER_ACCOUNTS.map((s) => [s.key, s])); + expect(byKey.cash.categoryKey).toBe("cash"); + expect(byKey.cash.vehicleType).toBeNull(); + expect(byKey.tfsa.categoryKey).toBe("other"); + expect(byKey.tfsa.vehicleType).toBe("tfsa"); + expect(byKey.rrsp.categoryKey).toBe("other"); + expect(byKey.rrsp.vehicleType).toBe("rrsp"); + expect(byKey.other.categoryKey).toBe("other"); + expect(byKey.other.vehicleType).toBeNull(); + }); }); describe("getStarterCollisions", () => { @@ -74,6 +88,27 @@ describe("getStarterCollisions", () => { expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque" }); + it("flags the CELI starter when a CELI account lives in the `other` class (#202)", async () => { + // Post-split, the CELI starter's categoryKey is `other`. An exact-name CELI + // account in `other` is a collision; a non-registered account in `other` is not. + mockSelect.mockResolvedValueOnce([ + { key: "other", account_name: "CELI" }, + { key: "other", account_name: "Compte non-enregistré" }, + ]); + const result = await getStarterCollisions(); + expect(result.has("tfsa")).toBe(true); + expect(result.has("other")).toBe(true); + expect(result.has("rrsp")).toBe(false); + expect(result.has("cash")).toBe(false); + }); + + it("queries only the cash + other asset classes (#202)", async () => { + mockSelect.mockResolvedValueOnce([]); + await getStarterCollisions(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toMatch(/c\.key IN \('cash','other'\)/); + }); + it("excludes archived accounts via SQL filter", async () => { mockSelect.mockResolvedValueOnce([]); await getStarterCollisions(); @@ -134,6 +169,34 @@ describe("proposeStarterAccounts", () => { expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow }); + it("resolves categories with is_active=1 and writes vehicle_type (#202)", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN + mockSelect + .mockResolvedValueOnce([{ id: 50 }]) // other category lookup (for tfsa starter) + .mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean + mockExecute + .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 200 }) // INSERT CELI + .mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT + + const result = await proposeStarterAccounts(["tfsa"]); + expect(result).toEqual([200]); + + // Category lookup must require is_active = 1 so a deactivated ex-envelope + // seed is never picked up. + const lookupSql = mockSelect.mock.calls[0][0] as string; + expect(lookupSql).toMatch(/is_active = 1/); + // The CELI starter is told to resolve `other`, not `tfsa`. + expect(mockSelect.mock.calls[0][1]).toEqual(["other"]); + + // The INSERT carries vehicle_type='tfsa' as its 3rd param. + const insertCall = mockExecute.mock.calls.find((c) => + /INSERT INTO balance_accounts/.test(c[0] as string) + )!; + const insertSql = insertCall[0] as string; + expect(insertSql).toContain("vehicle_type"); + expect((insertCall[1] as unknown[])[2]).toBe("tfsa"); + }); + it("rolls back on insert failure", async () => { mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN mockSelect diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index 76e10ab..e4a0e9b 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -21,6 +21,7 @@ import { updateBalanceCategory, deleteBalanceCategory, listBalanceAccounts, + getBalanceAccount, createBalanceAccount, updateBalanceAccount, archiveBalanceAccount, @@ -108,15 +109,16 @@ describe("createBalanceCategory", () => { const params = mockExecute.mock.calls[0][1] as unknown[]; expect(sql).toContain("INSERT INTO balance_categories"); expect(sql).toContain("is_seed"); - // is_seed hardcoded to 0; asset_type passed as the 5th param. - expect(sql).toContain("0, $5)"); - // simple kind → asset_type coerced to NULL regardless of input. + // is_seed hardcoded to 0; asset_type = $5, custom_label = $6 (#202). + expect(sql).toContain("0, $5, $6)"); + // simple kind → asset_type coerced to NULL; custom_label NULL when omitted. expect(params).toEqual([ "ferr", "balance.category.ferr", "simple", 35, null, + null, ]); }); @@ -280,6 +282,118 @@ describe("updateBalanceCategory", () => { }); }); +// ----------------------------------------------------------------------------- +// custom_label (Bilan axe véhicule, Étape 1 — issue #202) +// ----------------------------------------------------------------------------- + +describe("balance categories — custom_label (#202)", () => { + it("listBalanceCategories selects custom_label and filters is_active when asked", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceCategories({ includeInactive: false }); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("custom_label"); + expect(sql).toContain("WHERE is_active = 1"); + }); + + it("listBalanceCategories includes inactive categories by default (#202 behavior-neutral)", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceCategories(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("custom_label"); + expect(sql).not.toContain("WHERE is_active = 1"); + }); + + it("createBalanceCategory trims custom_label and stores it as the 6th param", async () => { + mockExecute.mockResolvedValueOnce({ lastInsertId: 5, rowsAffected: 1 }); + await createBalanceCategory({ + key: "savings", + i18n_key: "balance.category.savings", + kind: "simple", + custom_label: " Mon épargne ", + }); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("custom_label"); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[5]).toBe("Mon épargne"); + }); + + it("createBalanceCategory normalizes a blank custom_label to null", async () => { + mockExecute.mockResolvedValueOnce({ lastInsertId: 6, rowsAffected: 1 }); + await createBalanceCategory({ + key: "x", + i18n_key: "balance.category.x", + kind: "simple", + custom_label: " ", + }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[5]).toBeNull(); + }); + + it("updateBalanceCategory sets custom_label without touching i18n_key (fixes bug I)", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + asset_type: null, + custom_label: null, + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceCategory(1, { custom_label: "Comptes courants" }); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("custom_label = $5"); + const params = mockExecute.mock.calls[0][1] as unknown[]; + // i18n_key (param 0) preserved verbatim; custom_label (param 4) is the rename. + expect(params[0]).toBe("balance.category.cash"); + expect(params[4]).toBe("Comptes courants"); + }); + + it("updateBalanceCategory clears custom_label on explicit null", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + asset_type: null, + custom_label: "Old label", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceCategory(1, { custom_label: null }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[4]).toBeNull(); + }); + + it("updateBalanceCategory preserves existing custom_label when omitted", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + asset_type: null, + custom_label: "Kept label", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceCategory(1, { sort_order: 99 }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[4]).toBe("Kept label"); + }); +}); + // ----------------------------------------------------------------------------- // Accounts // ----------------------------------------------------------------------------- @@ -352,7 +466,8 @@ describe("createBalanceAccount", () => { }); expect(id).toBe(7); const params = mockExecute.mock.calls[0][1] as unknown[]; - expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null]); + // 6th param is vehicle_type (NULL when not provided) — #202. + expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null, null]); }); it("allows a priced-category account WITHOUT a symbol (Issue #199)", async () => { @@ -383,6 +498,203 @@ describe("createBalanceAccount", () => { }); }); +// ----------------------------------------------------------------------------- +// vehicle_type (Bilan axe véhicule, Étape 1 — issue #202) +// ----------------------------------------------------------------------------- + +describe("balance accounts — vehicle_type (#202)", () => { + const cashCategoryRow = { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + asset_type: null, + custom_label: null, + }; + + it("createBalanceAccount stores a valid vehicle_type as the 6th param", async () => { + mockSelect.mockResolvedValueOnce([cashCategoryRow]); + mockExecute.mockResolvedValueOnce({ lastInsertId: 8, rowsAffected: 1 }); + await createBalanceAccount({ + balance_category_id: 1, + name: "Mon CELI", + vehicle_type: "tfsa", + }); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("vehicle_type"); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[5]).toBe("tfsa"); + }); + + it("createBalanceAccount accepts every enum value", async () => { + for (const v of ["unregistered", "tfsa", "rrsp", "rrif", "fhsa", "resp"] as const) { + mockSelect.mockReset(); + mockExecute.mockReset(); + mockSelect.mockResolvedValueOnce([cashCategoryRow]); + mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 }); + await createBalanceAccount({ + balance_category_id: 1, + name: `acct-${v}`, + vehicle_type: v, + }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[5]).toBe(v); + } + }); + + it("createBalanceAccount rejects an out-of-enum vehicle_type", async () => { + mockSelect.mockResolvedValueOnce([cashCategoryRow]); + await expect( + createBalanceAccount({ + balance_category_id: 1, + name: "Bad", + // @ts-expect-error testing runtime guard — not an automobile type + vehicle_type: "car", + }) + ).rejects.toMatchObject({ code: "vehicle_type_invalid" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("createBalanceAccount stores NULL when vehicle_type is omitted", async () => { + mockSelect.mockResolvedValueOnce([cashCategoryRow]); + mockExecute.mockResolvedValueOnce({ lastInsertId: 8, rowsAffected: 1 }); + await createBalanceAccount({ balance_category_id: 1, name: "Encaisse" }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[5]).toBeNull(); + }); + + it("getBalanceAccount selects vehicle_type", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Encaisse", + symbol: null, + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + vehicle_type: "tfsa", + created_at: "", + updated_at: "", + }, + ]); + const acct = await getBalanceAccount(7); + expect(acct?.vehicle_type).toBe("tfsa"); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("vehicle_type"); + }); + + it("listBalanceAccounts threads vehicle_type and category_custom_label from the join", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceAccounts(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("a.vehicle_type"); + expect(sql).toContain("c.custom_label AS category_custom_label"); + }); + + it("updateBalanceAccount preserves the existing vehicle_type when omitted", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Mon CELI", + symbol: null, + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + vehicle_type: "tfsa", + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceAccount(7, { name: "CELI renommé" }); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("vehicle_type = $6"); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[5]).toBe("tfsa"); // preserved + }); + + it("updateBalanceAccount sets a new vehicle_type when provided", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Compte", + symbol: null, + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + vehicle_type: null, + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceAccount(7, { vehicle_type: "rrsp" }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[5]).toBe("rrsp"); + }); + + it("updateBalanceAccount clears vehicle_type on explicit null", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Compte", + symbol: null, + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + vehicle_type: "tfsa", + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceAccount(7, { vehicle_type: null }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[5]).toBeNull(); + }); + + it("updateBalanceAccount rejects an out-of-enum vehicle_type", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Compte", + symbol: null, + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + vehicle_type: null, + created_at: "", + updated_at: "", + }, + ]); + await expect( + // @ts-expect-error testing runtime guard + updateBalanceAccount(7, { vehicle_type: "truck" }) + ).rejects.toMatchObject({ code: "vehicle_type_invalid" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("getAccountsLatestSnapshot threads category_custom_label", async () => { + mockSelect.mockResolvedValueOnce([]); + await getAccountsLatestSnapshot(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("c.custom_label AS category_custom_label"); + }); +}); + describe("updateBalanceAccount", () => { it("rejects when account does not exist", async () => { mockSelect.mockResolvedValueOnce([]); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index 1ad803e..ca78fc2 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -23,6 +23,7 @@ import type { BalanceSnapshot, BalanceSnapshotLine, BalanceTransferDirection, + BalanceVehicleType, } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types"; @@ -40,6 +41,7 @@ export type BalanceErrorCode = | "kind_invalid" | "asset_type_required" | "asset_type_invalid" + | "vehicle_type_invalid" | "snapshot_date_required" | "snapshot_date_taken" | "snapshot_date_exists" @@ -70,11 +72,21 @@ export class BalanceServiceError extends Error { // Categories // ----------------------------------------------------------------------------- -export async function listBalanceCategories(): Promise { +export async function listBalanceCategories(options?: { + includeInactive?: boolean; +}): Promise { + // Default `true` keeps the data-layer change (#202) behavior-neutral: the + // existing callers see every category. The dropdown-side filtering of + // deactivated ex-envelope seeds (tfsa/rrsp after v13) is threaded by the UI + // issue (#203) via `includeInactive: false`. + const includeInactive = options?.includeInactive ?? true; const db = await getDb(); + const where = includeInactive ? "" : "WHERE is_active = 1"; return db.select( - `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type + `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type, + custom_label FROM balance_categories + ${where} ORDER BY sort_order, key` ); } @@ -84,7 +96,8 @@ export async function getBalanceCategory( ): Promise { const db = await getDb(); const rows = await db.select( - `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type + `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type, + custom_label FROM balance_categories WHERE id = $1`, [id] @@ -104,6 +117,11 @@ export interface CreateBalanceCategoryInput { * the input value. */ asset_type?: BalanceAssetType | null; + /** + * Optional user-facing label override (migration v12). Empty/blank is + * normalized to NULL so the renderer falls back to `t(i18n_key)`. + */ + custom_label?: string | null; } /** @@ -121,21 +139,39 @@ export async function createBalanceCategory( throw new BalanceServiceError("kind_invalid", "Invalid category kind"); } const assetType = normalizeAssetTypeForKind(input.kind, input.asset_type); + // Coalesce undefined → null so we never bind `undefined` to SQL on insert. + const customLabel = normalizeCustomLabel(input.custom_label) ?? null; const db = await getDb(); const result = await db.execute( - `INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed, asset_type) - VALUES ($1, $2, $3, $4, 1, 0, $5)`, + `INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed, asset_type, custom_label) + VALUES ($1, $2, $3, $4, 1, 0, $5, $6)`, [ input.key.trim(), input.i18n_key.trim(), input.kind, input.sort_order ?? 0, assetType, + customLabel, ] ); return result.lastInsertId as number; } +/** + * Normalize a free-text label to `string | null`: trims whitespace and maps + * an empty result to NULL so the renderer falls back to `t(i18n_key)`. + * `undefined` input is preserved as `undefined` so callers can detect + * "field not provided" vs "explicitly cleared" in update flows. + */ +function normalizeCustomLabel( + raw: string | null | undefined +): string | null | undefined { + if (raw === undefined) return undefined; + if (raw === null) return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + export interface UpdateBalanceCategoryInput { i18n_key?: string; sort_order?: number; @@ -146,6 +182,13 @@ export interface UpdateBalanceCategoryInput { * existing kind is priced (would unset a required field). */ asset_type?: BalanceAssetType | null; + /** + * User-facing label override (migration v12). Pass a string to set/rename, + * `null` (or blank) to clear and fall back to `t(i18n_key)`. Omit to leave + * unchanged. This is the supported way to rename a category — it never + * touches `i18n_key` (fixes bug I). + */ + custom_label?: string | null; } /** @@ -174,11 +217,15 @@ export async function updateBalanceCategory( input.asset_type !== undefined ? normalizeAssetTypeForKind(existing.kind, input.asset_type) : existing.asset_type; + const normalizedLabel = normalizeCustomLabel(input.custom_label); + const customLabel = + normalizedLabel !== undefined ? normalizedLabel : existing.custom_label ?? null; await db.execute( `UPDATE balance_categories - SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4 - WHERE id = $5`, - [i18n, sortOrder, isActive, assetType, id] + SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4, + custom_label = $5 + WHERE id = $6`, + [i18n, sortOrder, isActive, assetType, customLabel, id] ); } @@ -257,9 +304,11 @@ export async function listBalanceAccounts(options?: { : "WHERE a.is_active = 1 AND a.archived_at IS NULL"; return db.select( `SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency, - a.notes, a.is_active, a.archived_at, a.created_at, a.updated_at, + a.notes, a.is_active, a.archived_at, a.vehicle_type, + a.created_at, a.updated_at, c.key AS category_key, c.i18n_key AS category_i18n_key, - c.kind AS category_kind, c.asset_type AS category_asset_type + c.kind AS category_kind, c.asset_type AS category_asset_type, + c.custom_label AS category_custom_label FROM balance_accounts a INNER JOIN balance_categories c ON c.id = a.balance_category_id ${where} @@ -273,7 +322,7 @@ export async function getBalanceAccount( const db = await getDb(); const rows = await db.select( `SELECT id, balance_category_id, name, symbol, currency, notes, - is_active, archived_at, created_at, updated_at + is_active, archived_at, vehicle_type, created_at, updated_at FROM balance_accounts WHERE id = $1`, [id] @@ -288,6 +337,43 @@ export interface CreateBalanceAccountInput { /** Defaults to 'CAD'. MVP rejects any other value. */ currency?: string; notes?: string | null; + /** + * Fiscal envelope / tax shelter (migration v12). Optional/nullable — NOT an + * automobile type. Validated against the enum; an out-of-enum value throws + * `vehicle_type_invalid`. + */ + vehicle_type?: BalanceVehicleType | null; +} + +/** The six recognized fiscal envelopes. Kept in sync with the SQL CHECK. */ +const VEHICLE_TYPES: readonly BalanceVehicleType[] = [ + "unregistered", + "tfsa", + "rrsp", + "rrif", + "fhsa", + "resp", +]; + +/** + * Validate an optional `vehicle_type` against the fiscal-envelope enum. + * Mirrors `normalizeAssetTypeForKind`. NULL/undefined are allowed (the + * envelope is optional); any non-enum string throws `vehicle_type_invalid`. + * Returns `null` for absent input, the validated value otherwise. + */ +function normalizeVehicleType( + raw: BalanceVehicleType | null | undefined +): BalanceVehicleType | null { + if (raw === null || raw === undefined) { + return null; + } + if (!VEHICLE_TYPES.includes(raw)) { + throw new BalanceServiceError( + "vehicle_type_invalid", + `vehicle_type must be one of ${VEHICLE_TYPES.join(", ")}` + ); + } + return raw; } /** @@ -315,16 +401,18 @@ export async function createBalanceAccount( "Linked balance category not found" ); } + const vehicleType = normalizeVehicleType(input.vehicle_type); const db = await getDb(); const result = await db.execute( - `INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active) - VALUES ($1, $2, $3, $4, $5, 1)`, + `INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active, vehicle_type) + VALUES ($1, $2, $3, $4, $5, 1, $6)`, [ input.balance_category_id, input.name.trim(), input.symbol ? input.symbol.trim() : null, currency, input.notes ? input.notes.trim() : null, + vehicleType, ] ); return result.lastInsertId as number; @@ -336,6 +424,12 @@ export interface UpdateBalanceAccountInput { symbol?: string | null; notes?: string | null; is_active?: boolean; + /** + * Fiscal envelope / tax shelter (migration v12). Pass a value to set, `null` + * to clear, omit to leave unchanged. Validated against the enum. NOT an + * automobile type. + */ + vehicle_type?: BalanceVehicleType | null; } export async function updateBalanceAccount( @@ -377,13 +471,19 @@ export async function updateBalanceAccount( : existing.is_active ? 1 : 0; + // Read-and-rewrite: when the caller omits vehicle_type, preserve the + // existing value so a full UPDATE never silently wipes the envelope. + const vehicleType = + input.vehicle_type !== undefined + ? normalizeVehicleType(input.vehicle_type) + : existing.vehicle_type ?? null; const db = await getDb(); await db.execute( `UPDATE balance_accounts SET balance_category_id = $1, name = $2, symbol = $3, notes = $4, - is_active = $5, updated_at = CURRENT_TIMESTAMP - WHERE id = $6`, - [categoryId, name, symbol, notes, isActive, id] + is_active = $5, vehicle_type = $6, updated_at = CURRENT_TIMESTAMP + WHERE id = $7`, + [categoryId, name, symbol, notes, isActive, vehicleType, id] ); } @@ -437,14 +537,26 @@ export async function unarchiveBalanceAccount(id: number): Promise { // names/categories MUST stay in sync between the two sources. export interface StarterDef { - /** Stable identifier used by the modal checkbox state. */ + /** + * Stable identifier used by the modal checkbox state. Kept as cash/tfsa/ + * rrsp/other for UI continuity even though the CELI/REER starters now + * attach to the `other` asset class (Bilan axe véhicule, Étape 1). + */ key: "cash" | "tfsa" | "rrsp" | "other"; /** Default account name (FR — matches consolidated_schema seed). */ name: string; /** i18n key for the user-facing label in the modal. */ i18nKey: string; - /** balance_categories.key that this starter attaches to. */ - categoryKey: "cash" | "tfsa" | "rrsp" | "other"; + /** + * balance_categories.key (asset class) this starter attaches to. After the + * envelope/asset-class split, CELI and REER both map to `other`. + */ + categoryKey: "cash" | "other"; + /** + * Fiscal envelope stamped on the created account (migration v12). NULL for + * the plain chequing / non-registered starters. + */ + vehicleType: BalanceVehicleType | null; } export const STARTER_ACCOUNTS: StarterDef[] = [ @@ -453,24 +565,28 @@ export const STARTER_ACCOUNTS: StarterDef[] = [ name: "Compte chèque", i18nKey: "balance.starters.items.cash", categoryKey: "cash", + vehicleType: null, }, { key: "tfsa", name: "CELI", i18nKey: "balance.starters.items.tfsa", - categoryKey: "tfsa", + categoryKey: "other", + vehicleType: "tfsa", }, { key: "rrsp", name: "REER", i18nKey: "balance.starters.items.rrsp", - categoryKey: "rrsp", + categoryKey: "other", + vehicleType: "rrsp", }, { key: "other", name: "Compte non-enregistré", i18nKey: "balance.starters.items.other", categoryKey: "other", + vehicleType: null, }, ]; @@ -482,13 +598,17 @@ export const STARTER_ACCOUNTS: StarterDef[] = [ */ export async function getStarterCollisions(): Promise> { const db = await getDb(); + // After the envelope/asset-class split (Étape 1) the CELI/REER/non-registered + // starters all live in the `other` class and are told apart only by name, so + // the collision check matches on (categoryKey, name). The category filter is + // the union of the starters' asset classes: cash + other. const rows = await db.select< { key: string; account_name: string }[] >( `SELECT c.key AS key, a.name AS account_name FROM balance_accounts a INNER JOIN balance_categories c ON c.id = a.balance_category_id - WHERE c.key IN ('cash','tfsa','rrsp','other') + WHERE c.key IN ('cash','other') AND a.archived_at IS NULL` ); const collisions = new Set(); @@ -530,9 +650,12 @@ export async function proposeStarterAccounts( for (const starter of wanted) { // Resolve category id by key. Seeded keys are guaranteed to exist on // a freshly migrated profile (Migration v9), so we surface a clean - // error if somehow missing rather than letting the FK fire. + // error if somehow missing rather than letting the FK fire. We require + // `is_active = 1` so a deactivated ex-envelope seed (tfsa/rrsp after v13) + // is never picked up — the starters now resolve to the active `other` + // class and carry the envelope in vehicle_type instead. const catRows = await db.select<{ id: number }[]>( - `SELECT id FROM balance_categories WHERE key = $1`, + `SELECT id FROM balance_categories WHERE key = $1 AND is_active = 1`, [starter.categoryKey] ); if (catRows.length === 0) { @@ -554,9 +677,9 @@ export async function proposeStarterAccounts( continue; } const result = await db.execute( - `INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) - VALUES ($1, $2, 'CAD', 1)`, - [catRows[0].id, starter.name] + `INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type) + VALUES ($1, $2, 'CAD', 1, $3)`, + [catRows[0].id, starter.name, starter.vehicleType] ); inserted.push(result.lastInsertId as number); } @@ -1215,6 +1338,8 @@ export interface AccountLatestSnapshot { category_key: string; category_i18n_key: string; category_kind: BalanceCategoryKind; + /** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */ + category_custom_label?: string | null; /** Date of the snapshot whose value is reported, or null if no snapshot exists. */ latest_snapshot_date: string | null; /** Value at that snapshot, or null if the account has no snapshot lines. */ @@ -1243,6 +1368,7 @@ export async function getAccountsLatestSnapshot(): Promise< c.key AS category_key, c.i18n_key AS category_i18n_key, c.kind AS category_kind, + c.custom_label AS category_custom_label, (SELECT s.snapshot_date FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index b216059..c2c512f 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -572,6 +572,22 @@ export type BalanceCategoryKind = "simple" | "priced"; */ export type BalanceAssetType = "stock" | "crypto"; +/** + * Fiscal envelope / tax shelter attached to an account (Bilan axe véhicule, + * migration v12). This is the registration status of the account, NOT an + * automobile type. NULL/absent means no envelope (e.g. a chequing account or + * a crypto wallet). The asset class lives separately on the category. + * unregistered = Non-enregistré · tfsa = CELI · rrsp = REER · rrif = FERR + * fhsa = CELIAPP · resp = REEE + */ +export type BalanceVehicleType = + | "unregistered" + | "tfsa" + | "rrsp" + | "rrif" + | "fhsa" + | "resp"; + export const BALANCE_CURRENCY_CAD = "CAD"; export interface BalanceCategory { @@ -592,6 +608,13 @@ export interface BalanceCategory { * form requires it on creation when kind=priced. */ asset_type: BalanceAssetType | null; + /** + * User-facing label override (migration v12). When set (non-empty), the UI + * renders this verbatim; otherwise it falls back to `t(i18n_key)`. This is + * how category renaming works now — it never touches `i18n_key` anymore + * (fixes bug I where renaming clobbered the translation key). + */ + custom_label?: string | null; } export interface BalanceAccount { @@ -606,6 +629,11 @@ export interface BalanceAccount { is_active: boolean; /** Soft-delete timestamp; archived accounts hide from new snapshots. */ archived_at: string | null; + /** + * Fiscal envelope / tax shelter (migration v12). NULL = no envelope. NOT an + * automobile type. The asset class lives on the linked category. + */ + vehicle_type?: BalanceVehicleType | null; created_at: string; updated_at: string; } @@ -617,6 +645,8 @@ export interface BalanceAccountWithCategory extends BalanceAccount { category_kind: BalanceCategoryKind; /** Mirror of `balance_categories.asset_type` — drives PriceFetchControl. */ category_asset_type: BalanceAssetType | null; + /** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */ + category_custom_label?: string | null; } // Snapshots — added Issue #146 (Bilan #1b) for the SnapshotEditPage. -- 2.45.2