Merge pull request 'feat(balance): data layer — vehicle_type + custom_label migrations, starters, service' (#206) from issue-202-data-layer-vehicle-type into main

This commit is contained in:
maximus 2026-06-02 00:43:34 +00:00
commit cb58bbb31a
7 changed files with 1191 additions and 43 deletions

View file

@ -197,7 +197,10 @@ CREATE TABLE IF NOT EXISTS balance_categories (
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0, 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 ( CREATE TABLE IF NOT EXISTS balance_accounts (
@ -209,6 +212,9 @@ CREATE TABLE IF NOT EXISTS balance_accounts (
notes TEXT, notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
archived_at DATETIME, 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, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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 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_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); 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 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), ('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), ('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
('other', 'balance.category.other', 'simple', 50, 1, NULL), ('other', 'balance.category.other', 'simple', 50, 1, NULL),
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'), ('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 -- balance_accounts) — once created they are indistinguishable from
-- user-created accounts and can be renamed/archived freely. Existing profiles -- user-created accounts and can be renamed/archived freely. Existing profiles
-- get the same 4 proposed via StarterAccountsModal on first /balance visit. -- 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), -- Bilan axe véhicule (Étape 1): the CELI / REER starters are no longer linked
((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD', 1), -- to a `tfsa` / `rrsp` category (those seeds are gone). They now attach to the
((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER', 'CAD', 1), -- `other` asset class and carry the envelope in `vehicle_type`. Linking them to
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1); -- 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) -- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');

View file

@ -143,6 +143,66 @@ pub fn run() {
WHERE snapshot_id = balance_snapshots.id);", WHERE snapshot_id = balance_snapshots.id);",
kind: MigrationKind::Up, 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() tauri::Builder::default()
@ -1296,5 +1356,548 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(count, 0); 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<String> = 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<String> = 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<String> {
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<String>) = 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<String> = 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<String>) = 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<String>) = 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<i64> = 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<String> = 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<String>) = 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<String> = 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"
);
}
} }

View file

@ -411,8 +411,8 @@ describe("integration — currency lock (CAD only)", () => {
c.sql.includes("INSERT INTO balance_accounts") c.sql.includes("INSERT INTO balance_accounts")
); );
expect(insertCall).toBeDefined(); expect(insertCall).toBeDefined();
// [category_id, name, symbol, currency, notes] // [category_id, name, symbol, currency, notes, vehicle_type] (#202)
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]); expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null, null]);
}); });
it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => { it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {

View file

@ -30,7 +30,7 @@ beforeEach(() => {
}); });
describe("STARTER_ACCOUNTS", () => { 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).toHaveLength(4);
expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([ expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([
"cash", "cash",
@ -39,10 +39,24 @@ describe("STARTER_ACCOUNTS", () => {
"other", "other",
]); ]);
for (const s of STARTER_ACCOUNTS) { for (const s of STARTER_ACCOUNTS) {
expect(s.categoryKey).toBe(s.key);
expect(s.i18nKey).toMatch(/^balance\.starters\.items\./); 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", () => { describe("getStarterCollisions", () => {
@ -74,6 +88,27 @@ describe("getStarterCollisions", () => {
expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque" 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 () => { it("excludes archived accounts via SQL filter", async () => {
mockSelect.mockResolvedValueOnce([]); mockSelect.mockResolvedValueOnce([]);
await getStarterCollisions(); await getStarterCollisions();
@ -134,6 +169,34 @@ describe("proposeStarterAccounts", () => {
expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow 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 () => { it("rolls back on insert failure", async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
mockSelect mockSelect

View file

@ -21,6 +21,7 @@ import {
updateBalanceCategory, updateBalanceCategory,
deleteBalanceCategory, deleteBalanceCategory,
listBalanceAccounts, listBalanceAccounts,
getBalanceAccount,
createBalanceAccount, createBalanceAccount,
updateBalanceAccount, updateBalanceAccount,
archiveBalanceAccount, archiveBalanceAccount,
@ -108,15 +109,16 @@ describe("createBalanceCategory", () => {
const params = mockExecute.mock.calls[0][1] as unknown[]; const params = mockExecute.mock.calls[0][1] as unknown[];
expect(sql).toContain("INSERT INTO balance_categories"); expect(sql).toContain("INSERT INTO balance_categories");
expect(sql).toContain("is_seed"); expect(sql).toContain("is_seed");
// is_seed hardcoded to 0; asset_type passed as the 5th param. // is_seed hardcoded to 0; asset_type = $5, custom_label = $6 (#202).
expect(sql).toContain("0, $5)"); expect(sql).toContain("0, $5, $6)");
// simple kind → asset_type coerced to NULL regardless of input. // simple kind → asset_type coerced to NULL; custom_label NULL when omitted.
expect(params).toEqual([ expect(params).toEqual([
"ferr", "ferr",
"balance.category.ferr", "balance.category.ferr",
"simple", "simple",
35, 35,
null, 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 // Accounts
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -352,7 +466,8 @@ describe("createBalanceAccount", () => {
}); });
expect(id).toBe(7); expect(id).toBe(7);
const params = mockExecute.mock.calls[0][1] as unknown[]; 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 () => { 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", () => { describe("updateBalanceAccount", () => {
it("rejects when account does not exist", async () => { it("rejects when account does not exist", async () => {
mockSelect.mockResolvedValueOnce([]); mockSelect.mockResolvedValueOnce([]);

View file

@ -23,6 +23,7 @@ import type {
BalanceSnapshot, BalanceSnapshot,
BalanceSnapshotLine, BalanceSnapshotLine,
BalanceTransferDirection, BalanceTransferDirection,
BalanceVehicleType,
} from "../shared/types"; } from "../shared/types";
import { BALANCE_CURRENCY_CAD } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types";
@ -40,6 +41,7 @@ export type BalanceErrorCode =
| "kind_invalid" | "kind_invalid"
| "asset_type_required" | "asset_type_required"
| "asset_type_invalid" | "asset_type_invalid"
| "vehicle_type_invalid"
| "snapshot_date_required" | "snapshot_date_required"
| "snapshot_date_taken" | "snapshot_date_taken"
| "snapshot_date_exists" | "snapshot_date_exists"
@ -70,11 +72,21 @@ export class BalanceServiceError extends Error {
// Categories // Categories
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export async function listBalanceCategories(): Promise<BalanceCategory[]> { export async function listBalanceCategories(options?: {
includeInactive?: boolean;
}): Promise<BalanceCategory[]> {
// 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 db = await getDb();
const where = includeInactive ? "" : "WHERE is_active = 1";
return db.select<BalanceCategory[]>( return db.select<BalanceCategory[]>(
`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 FROM balance_categories
${where}
ORDER BY sort_order, key` ORDER BY sort_order, key`
); );
} }
@ -84,7 +96,8 @@ export async function getBalanceCategory(
): Promise<BalanceCategory | null> { ): Promise<BalanceCategory | null> {
const db = await getDb(); const db = await getDb();
const rows = await db.select<BalanceCategory[]>( const rows = await db.select<BalanceCategory[]>(
`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 FROM balance_categories
WHERE id = $1`, WHERE id = $1`,
[id] [id]
@ -104,6 +117,11 @@ export interface CreateBalanceCategoryInput {
* the input value. * the input value.
*/ */
asset_type?: BalanceAssetType | null; 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"); throw new BalanceServiceError("kind_invalid", "Invalid category kind");
} }
const assetType = normalizeAssetTypeForKind(input.kind, input.asset_type); 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 db = await getDb();
const result = await db.execute( const result = await db.execute(
`INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed, asset_type) `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)`, VALUES ($1, $2, $3, $4, 1, 0, $5, $6)`,
[ [
input.key.trim(), input.key.trim(),
input.i18n_key.trim(), input.i18n_key.trim(),
input.kind, input.kind,
input.sort_order ?? 0, input.sort_order ?? 0,
assetType, assetType,
customLabel,
] ]
); );
return result.lastInsertId as number; 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 { export interface UpdateBalanceCategoryInput {
i18n_key?: string; i18n_key?: string;
sort_order?: number; sort_order?: number;
@ -146,6 +182,13 @@ export interface UpdateBalanceCategoryInput {
* existing kind is priced (would unset a required field). * existing kind is priced (would unset a required field).
*/ */
asset_type?: BalanceAssetType | null; 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 input.asset_type !== undefined
? normalizeAssetTypeForKind(existing.kind, input.asset_type) ? normalizeAssetTypeForKind(existing.kind, input.asset_type)
: existing.asset_type; : existing.asset_type;
const normalizedLabel = normalizeCustomLabel(input.custom_label);
const customLabel =
normalizedLabel !== undefined ? normalizedLabel : existing.custom_label ?? null;
await db.execute( await db.execute(
`UPDATE balance_categories `UPDATE balance_categories
SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4 SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4,
WHERE id = $5`, custom_label = $5
[i18n, sortOrder, isActive, assetType, id] 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"; : "WHERE a.is_active = 1 AND a.archived_at IS NULL";
return db.select<BalanceAccountWithCategory[]>( return db.select<BalanceAccountWithCategory[]>(
`SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency, `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.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 FROM balance_accounts a
INNER JOIN balance_categories c ON c.id = a.balance_category_id INNER JOIN balance_categories c ON c.id = a.balance_category_id
${where} ${where}
@ -273,7 +322,7 @@ export async function getBalanceAccount(
const db = await getDb(); const db = await getDb();
const rows = await db.select<BalanceAccount[]>( const rows = await db.select<BalanceAccount[]>(
`SELECT id, balance_category_id, name, symbol, currency, notes, `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 FROM balance_accounts
WHERE id = $1`, WHERE id = $1`,
[id] [id]
@ -288,6 +337,43 @@ export interface CreateBalanceAccountInput {
/** Defaults to 'CAD'. MVP rejects any other value. */ /** Defaults to 'CAD'. MVP rejects any other value. */
currency?: string; currency?: string;
notes?: string | null; 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" "Linked balance category not found"
); );
} }
const vehicleType = normalizeVehicleType(input.vehicle_type);
const db = await getDb(); const db = await getDb();
const result = await db.execute( const result = await db.execute(
`INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active) `INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active, vehicle_type)
VALUES ($1, $2, $3, $4, $5, 1)`, VALUES ($1, $2, $3, $4, $5, 1, $6)`,
[ [
input.balance_category_id, input.balance_category_id,
input.name.trim(), input.name.trim(),
input.symbol ? input.symbol.trim() : null, input.symbol ? input.symbol.trim() : null,
currency, currency,
input.notes ? input.notes.trim() : null, input.notes ? input.notes.trim() : null,
vehicleType,
] ]
); );
return result.lastInsertId as number; return result.lastInsertId as number;
@ -336,6 +424,12 @@ export interface UpdateBalanceAccountInput {
symbol?: string | null; symbol?: string | null;
notes?: string | null; notes?: string | null;
is_active?: boolean; 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( export async function updateBalanceAccount(
@ -377,13 +471,19 @@ export async function updateBalanceAccount(
: existing.is_active : existing.is_active
? 1 ? 1
: 0; : 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(); const db = await getDb();
await db.execute( await db.execute(
`UPDATE balance_accounts `UPDATE balance_accounts
SET balance_category_id = $1, name = $2, symbol = $3, notes = $4, SET balance_category_id = $1, name = $2, symbol = $3, notes = $4,
is_active = $5, updated_at = CURRENT_TIMESTAMP is_active = $5, vehicle_type = $6, updated_at = CURRENT_TIMESTAMP
WHERE id = $6`, WHERE id = $7`,
[categoryId, name, symbol, notes, isActive, id] [categoryId, name, symbol, notes, isActive, vehicleType, id]
); );
} }
@ -437,14 +537,26 @@ export async function unarchiveBalanceAccount(id: number): Promise<void> {
// names/categories MUST stay in sync between the two sources. // names/categories MUST stay in sync between the two sources.
export interface StarterDef { 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"; key: "cash" | "tfsa" | "rrsp" | "other";
/** Default account name (FR — matches consolidated_schema seed). */ /** Default account name (FR — matches consolidated_schema seed). */
name: string; name: string;
/** i18n key for the user-facing label in the modal. */ /** i18n key for the user-facing label in the modal. */
i18nKey: string; 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[] = [ export const STARTER_ACCOUNTS: StarterDef[] = [
@ -453,24 +565,28 @@ export const STARTER_ACCOUNTS: StarterDef[] = [
name: "Compte chèque", name: "Compte chèque",
i18nKey: "balance.starters.items.cash", i18nKey: "balance.starters.items.cash",
categoryKey: "cash", categoryKey: "cash",
vehicleType: null,
}, },
{ {
key: "tfsa", key: "tfsa",
name: "CELI", name: "CELI",
i18nKey: "balance.starters.items.tfsa", i18nKey: "balance.starters.items.tfsa",
categoryKey: "tfsa", categoryKey: "other",
vehicleType: "tfsa",
}, },
{ {
key: "rrsp", key: "rrsp",
name: "REER", name: "REER",
i18nKey: "balance.starters.items.rrsp", i18nKey: "balance.starters.items.rrsp",
categoryKey: "rrsp", categoryKey: "other",
vehicleType: "rrsp",
}, },
{ {
key: "other", key: "other",
name: "Compte non-enregistré", name: "Compte non-enregistré",
i18nKey: "balance.starters.items.other", i18nKey: "balance.starters.items.other",
categoryKey: "other", categoryKey: "other",
vehicleType: null,
}, },
]; ];
@ -482,13 +598,17 @@ export const STARTER_ACCOUNTS: StarterDef[] = [
*/ */
export async function getStarterCollisions(): Promise<Set<string>> { export async function getStarterCollisions(): Promise<Set<string>> {
const db = await getDb(); 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< const rows = await db.select<
{ key: string; account_name: string }[] { key: string; account_name: string }[]
>( >(
`SELECT c.key AS key, a.name AS account_name `SELECT c.key AS key, a.name AS account_name
FROM balance_accounts a FROM balance_accounts a
INNER JOIN balance_categories c ON c.id = a.balance_category_id 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` AND a.archived_at IS NULL`
); );
const collisions = new Set<string>(); const collisions = new Set<string>();
@ -530,9 +650,12 @@ export async function proposeStarterAccounts(
for (const starter of wanted) { for (const starter of wanted) {
// Resolve category id by key. Seeded keys are guaranteed to exist on // Resolve category id by key. Seeded keys are guaranteed to exist on
// a freshly migrated profile (Migration v9), so we surface a clean // 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 }[]>( 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] [starter.categoryKey]
); );
if (catRows.length === 0) { if (catRows.length === 0) {
@ -554,9 +677,9 @@ export async function proposeStarterAccounts(
continue; continue;
} }
const result = await db.execute( const result = await db.execute(
`INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) `INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type)
VALUES ($1, $2, 'CAD', 1)`, VALUES ($1, $2, 'CAD', 1, $3)`,
[catRows[0].id, starter.name] [catRows[0].id, starter.name, starter.vehicleType]
); );
inserted.push(result.lastInsertId as number); inserted.push(result.lastInsertId as number);
} }
@ -1215,6 +1338,8 @@ export interface AccountLatestSnapshot {
category_key: string; category_key: string;
category_i18n_key: string; category_i18n_key: string;
category_kind: BalanceCategoryKind; 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. */ /** Date of the snapshot whose value is reported, or null if no snapshot exists. */
latest_snapshot_date: string | null; latest_snapshot_date: string | null;
/** Value at that snapshot, or null if the account has no snapshot lines. */ /** 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.key AS category_key,
c.i18n_key AS category_i18n_key, c.i18n_key AS category_i18n_key,
c.kind AS category_kind, c.kind AS category_kind,
c.custom_label AS category_custom_label,
(SELECT s.snapshot_date (SELECT s.snapshot_date
FROM balance_snapshot_lines l FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id JOIN balance_snapshots s ON s.id = l.snapshot_id

View file

@ -572,6 +572,22 @@ export type BalanceCategoryKind = "simple" | "priced";
*/ */
export type BalanceAssetType = "stock" | "crypto"; 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 const BALANCE_CURRENCY_CAD = "CAD";
export interface BalanceCategory { export interface BalanceCategory {
@ -592,6 +608,13 @@ export interface BalanceCategory {
* form requires it on creation when kind=priced. * form requires it on creation when kind=priced.
*/ */
asset_type: BalanceAssetType | null; 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 { export interface BalanceAccount {
@ -606,6 +629,11 @@ export interface BalanceAccount {
is_active: boolean; is_active: boolean;
/** Soft-delete timestamp; archived accounts hide from new snapshots. */ /** Soft-delete timestamp; archived accounts hide from new snapshots. */
archived_at: string | null; 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; created_at: string;
updated_at: string; updated_at: string;
} }
@ -617,6 +645,8 @@ export interface BalanceAccountWithCategory extends BalanceAccount {
category_kind: BalanceCategoryKind; category_kind: BalanceCategoryKind;
/** Mirror of `balance_categories.asset_type` — drives PriceFetchControl. */ /** Mirror of `balance_categories.asset_type` — drives PriceFetchControl. */
category_asset_type: BalanceAssetType | null; 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. // Snapshots — added Issue #146 (Bilan #1b) for the SnapshotEditPage.