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:
commit
cb58bbb31a
7 changed files with 1191 additions and 43 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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<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 where = includeInactive ? "" : "WHERE is_active = 1";
|
||||
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
|
||||
${where}
|
||||
ORDER BY sort_order, key`
|
||||
);
|
||||
}
|
||||
|
|
@ -84,7 +96,8 @@ export async function getBalanceCategory(
|
|||
): Promise<BalanceCategory | null> {
|
||||
const db = await getDb();
|
||||
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
|
||||
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<BalanceAccountWithCategory[]>(
|
||||
`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<BalanceAccount[]>(
|
||||
`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<void> {
|
|||
// 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<Set<string>> {
|
||||
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<string>();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue