From a1c3dafcd076e7211cb71ab97adaf431b8b3f45f Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 6 Jun 2026 12:52:55 -0400 Subject: [PATCH] feat(balance): schema & migrations v14/v15 + types (securities, holdings, account.kind) (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundations for 'détail par titre' (Étape 2), purely additive — v1-v13 are untouched and their checksums stay intact. v14 (balance_holdings_schema.sql, applied verbatim via BALANCE_HOLDINGS_SCHEMA): - balance_securities: instrument catalogue, symbol normalized + COLLATE NOCASE UNIQUE (no case-dupes), asset_type CHECK ('stock','crypto'), currency CAD. - balance_snapshot_holdings: per-security breakdown of a snapshot line, FK to balance_snapshot_lines (CASCADE) + balance_securities (RESTRICT), value denormalized, UNIQUE(snapshot_line_id, security_id) + 2 indexes. v15 (inline): balance_accounts gains kind ('simple'|'detailed', NOT NULL DEFAULT 'simple', CHECK) + detailed_since DATE; backfills kind='detailed' on accounts under a priced category. Two single-column ADDs (SQLite), idempotent. consolidated_schema.sql brought to parity: 2 tables + 2 indexes + kind/ detailed_since columns + the v15 backfill (no-op for the 4 simple starters, reproduced for future priced starters). TS types: BalanceAccountKind, BalanceSecurity, BalanceSnapshotHolding (+ WithSecurity join variant); BalanceAccount gains kind + detailed_since; BalanceAccountWithCategory exposes both kind and category_kind. Tests: +9 (v14/v15 via V14_SQL/V15_SQL consts + db_through_v13 helper, plus a consolidated parity test). cargo test 89 passed, npm build + 552 vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/database/balance_holdings_schema.sql | 68 +++ .../src/database/consolidated_schema.sql | 46 ++ src-tauri/src/database/mod.rs | 1 + src-tauri/src/lib.rs | 476 ++++++++++++++++++ src/shared/types/index.ts | 83 +++ 5 files changed, 674 insertions(+) create mode 100644 src-tauri/src/database/balance_holdings_schema.sql diff --git a/src-tauri/src/database/balance_holdings_schema.sql b/src-tauri/src/database/balance_holdings_schema.sql new file mode 100644 index 0000000..0a34fda --- /dev/null +++ b/src-tauri/src/database/balance_holdings_schema.sql @@ -0,0 +1,68 @@ +-- Balance sheet — détail par titre (Bilan Étape 2) — Migration v14 +-- Created: 2026-06-06 +-- Issue: #210 (Bilan détail #1 — schema & migrations v14/v15 + types) +-- +-- Purely additive: two new tables enabling a single account to hold many +-- securities at a given snapshot date instead of one denormalized value. +-- Conventions aligned with balance_schema.sql / consolidated_schema.sql: +-- - INTEGER PRIMARY KEY AUTOINCREMENT +-- - REAL for monetary amounts / quantities (matches transactions.amount) +-- - snake_case +-- - FK with explicit ON DELETE policies +-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps +-- +-- Design notes (spec-decisions-bilan-detail-titres.md + review caveats): +-- - balance_securities.symbol is the natural key, normalized upper/trim by +-- the service layer; COLLATE NOCASE UNIQUE prevents case-duplicates +-- ('aapl' vs 'AAPL') at the SQL level (SEC/ARCH review caveat). +-- - balance_snapshot_holdings references balance_snapshot_lines(id) rather +-- than (snapshot_id, account_id): the lines table already guarantees one +-- row per (snapshot, account) via UNIQUE(snapshot_id, account_id), so the +-- line id uniquely identifies that pair (ARCH review: keep the line FK). +-- - value is denormalized (= quantity * unit_price) so reports stay +-- reproducible without re-fetching prices — same rationale as +-- balance_snapshot_lines.value. + + +-- ========================================================================= +-- balance_securities — catalogue of investable instruments (stock | crypto) +-- ========================================================================= +-- One row per security/coin the user holds in a detailed account. `symbol` is +-- stored normalized (upper/trim) with COLLATE NOCASE so duplicates differing +-- only by case are impossible. `asset_type` mirrors balance_categories so the +-- price-fetch flow can route per security. +CREATE TABLE IF NOT EXISTS balance_securities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL COLLATE NOCASE UNIQUE, + name TEXT, + currency TEXT NOT NULL DEFAULT 'CAD', + asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + +-- ========================================================================= +-- balance_snapshot_holdings — one row per (snapshot line, security) +-- ========================================================================= +-- The per-title breakdown of a detailed account's snapshot line. CASCADE on +-- snapshot_line_id wipes holdings when the parent line is removed; RESTRICT on +-- security_id blocks deleting a security still referenced by history. `value` +-- is stored denormalized (= quantity * unit_price) for reproducible reports. +CREATE TABLE IF NOT EXISTS balance_snapshot_holdings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE, + security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT, + quantity REAL NOT NULL, + unit_price REAL NOT NULL, + value REAL NOT NULL, + book_cost REAL, + price_source TEXT, + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (snapshot_line_id, security_id) +); + +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id); diff --git a/src-tauri/src/database/consolidated_schema.sql b/src-tauri/src/database/consolidated_schema.sql index e8b4e63..17f5503 100644 --- a/src-tauri/src/database/consolidated_schema.sql +++ b/src-tauri/src/database/consolidated_schema.sql @@ -215,6 +215,13 @@ CREATE TABLE IF NOT EXISTS balance_accounts ( -- 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')), + -- Entry mode (migration v15). 'simple' = one denormalized value per + -- snapshot line; 'detailed' = a basket of per-security holdings (see + -- balance_snapshot_holdings). Accounts under a priced category are backfilled + -- to 'detailed' below. `detailed_since` is the authoritative pivot date from + -- which detailed entry is expected — NULL until the conversion flow sets it. + kind TEXT NOT NULL DEFAULT 'simple' CHECK (kind IN ('simple','detailed')), + detailed_since DATE, 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 @@ -260,6 +267,36 @@ CREATE TABLE IF NOT EXISTS balance_account_transfers ( UNIQUE(transaction_id, account_id) ); +-- Détail par titre (Bilan Étape 2, migration v14). Kept in sync with +-- `balance_holdings_schema.sql` (the source of truth applied by Migration v14 +-- in lib.rs). balance_securities is the instrument catalogue (symbol normalized +-- upper/trim, COLLATE NOCASE UNIQUE); balance_snapshot_holdings is the per-title +-- breakdown of a detailed account's snapshot line. +CREATE TABLE IF NOT EXISTS balance_securities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL COLLATE NOCASE UNIQUE, + name TEXT, + currency TEXT NOT NULL DEFAULT 'CAD', + asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS balance_snapshot_holdings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE, + security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT, + quantity REAL NOT NULL, + unit_price REAL NOT NULL, + value REAL NOT NULL, + book_cost REAL, + price_source TEXT, + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (snapshot_line_id, security_id) +); + CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id); CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1; CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id); @@ -267,6 +304,8 @@ CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapsho CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_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_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id); -- 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 @@ -296,6 +335,13 @@ INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, ve ((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); +-- Détail par titre (migration v15 mirror): accounts under a priced category are +-- detailed-entry by default. The 4 starters above all sit under simple asset +-- classes (cash / other), so this is currently a no-op — kept at parity with the +-- v15 backfill so any future priced starter is stamped correctly. +UPDATE balance_accounts SET kind = 'detailed' + WHERE balance_category_id IN (SELECT id FROM balance_categories WHERE kind = 'priced'); + -- 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 ('theme', 'light'); diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 5711298..74cf233 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -2,3 +2,4 @@ pub const SCHEMA: &str = include_str!("schema.sql"); pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql"); pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql"); pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql"); +pub const BALANCE_HOLDINGS_SCHEMA: &str = include_str!("balance_holdings_schema.sql"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1857e48..0b11c7d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -203,6 +203,53 @@ pub fn run() { WHERE key IN ('tfsa','rrsp') AND is_seed = 1;", kind: MigrationKind::Up, }, + // Migration v14 — Bilan détail par titre (Étape 2), foundation part. + // Purely additive: two new tables that let a single account hold many + // securities at a snapshot date, instead of one denormalized value. + // - balance_securities: the catalogue of investable instruments + // (a stock or a crypto). `symbol` is the natural key, stored + // normalized (upper/trim by the service layer) with COLLATE NOCASE + // so 'aapl' and 'AAPL' can never coexist as duplicates (SEC/ARCH + // review caveat). `asset_type` mirrors balance_categories.asset_type + // so the price-fetch flow can route stock vs crypto per security. + // - balance_snapshot_holdings: one row per (snapshot line, security). + // The FK targets balance_snapshot_lines(id) — there is already + // exactly one line per (snapshot, account) via that table's + // UNIQUE(snapshot_id, account_id), so the line id uniquely pins the + // (snapshot, account) pair (ARCH review caveat — keep line FK, not + // snapshot+account). ON DELETE CASCADE wipes holdings when the line + // goes; ON DELETE RESTRICT on security_id blocks deleting a security + // still referenced by history. `value` is denormalized (= quantity * + // unit_price) so reports stay reproducible without re-fetching, same + // rationale as balance_snapshot_lines.value. No Down migration. + Migration { + version: 14, + description: "create balance_securities and balance_snapshot_holdings", + sql: database::BALANCE_HOLDINGS_SCHEMA, + kind: MigrationKind::Up, + }, + // Migration v15 — Bilan détail par titre (Étape 2), account pivot part. + // Adds the `kind` discriminator that tells whether an account stores a + // single denormalized value ('simple') or a basket of per-security + // holdings ('detailed'), plus `detailed_since` — the authoritative pivot + // date from which detailed entry is expected (revision decision). The + // backfill stamps kind='detailed' on every account currently linked to a + // priced category, since those are exactly the ones that gain the + // per-title breakdown; `detailed_since` stays NULL until the conversion + // flow (#211) sets it. SQLite allows only one column per ADD COLUMN, so + // the two ALTERs are separate statements (same multi-ALTER idempotence + // shape as v12). Re-running is safe: the UPDATE is naturally idempotent. + Migration { + version: 15, + description: "add kind and detailed_since to balance_accounts", + sql: "ALTER TABLE balance_accounts ADD COLUMN kind TEXT NOT NULL DEFAULT 'simple' \ + CHECK (kind IN ('simple','detailed')); \ + ALTER TABLE balance_accounts ADD COLUMN detailed_since DATE; \ + UPDATE balance_accounts SET kind = 'detailed' \ + WHERE balance_category_id IN ( \ + SELECT id FROM balance_categories WHERE kind = 'priced');", + kind: MigrationKind::Up, + }, ]; tauri::Builder::default() @@ -1793,6 +1840,370 @@ mod tests { ); } + // ========================================================================= + // Migrations v14 + v15 — Bilan détail par titre (Étape 2) — issue #210 + // ------------------------------------------------------------------------- + // v14 (additive): creates balance_securities (instrument catalogue, symbol + // normalized + COLLATE NOCASE UNIQUE) and balance_snapshot_holdings (per- + // security breakdown of a snapshot line; CASCADE on line, RESTRICT on + // security) + 2 indexes. + // v15 (account pivot): adds `kind` (simple|detailed) + `detailed_since` to + // balance_accounts and backfills kind='detailed' on accounts under a priced + // category. Purely additive; no Down migration exists. + // + // V14_SQL is the BALANCE_HOLDINGS_SCHEMA constant (applied verbatim by the + // Migration { version: 14 } entry). V15_SQL is statement-equivalent to the + // Migration { version: 15 } entry, kept in sync by hand (same pattern as + // V10..V13 above). + // ========================================================================= + + /// Production v14 SQL — applied verbatim by Migration { version: 14 }. + const V14_SQL: &str = crate::database::BALANCE_HOLDINGS_SCHEMA; + + /// Production v15 SQL — kept in sync with the Migration { version: 15 } entry. + const V15_SQL: &str = "ALTER TABLE balance_accounts ADD COLUMN kind TEXT NOT NULL DEFAULT 'simple' \ + CHECK (kind IN ('simple','detailed')); \ + ALTER TABLE balance_accounts ADD COLUMN detailed_since DATE; \ + UPDATE balance_accounts SET kind = 'detailed' \ + WHERE balance_category_id IN ( \ + SELECT id FROM balance_categories WHERE kind = 'priced');"; + + /// Apply the full v10→v13 chain on a fresh DB so v14/v15 tests start from a + /// realistic post-Étape-1 state. + fn db_through_v13() -> Connection { + let conn = fresh_db(); // v9 BALANCE_SCHEMA + conn.execute_batch(V10_SQL).expect("apply v10"); + conn.execute_batch(V11_SQL).expect("apply v11"); + conn.execute_batch(V12_SQL).expect("apply v12"); + conn.execute_batch(V13_SQL).expect("apply v13"); + conn + } + + #[test] + fn migration_v14_creates_securities_and_holdings_tables() { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + + // Both tables exist. + for table in &["balance_securities", "balance_snapshot_holdings"] { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(n, 1, "{table} must be created by v14"); + } + + // Both indexes exist. + for idx in &[ + "idx_balance_snapshot_holdings_line", + "idx_balance_snapshot_holdings_security", + ] { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1", + [idx], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(n, 1, "{idx} must be created by v14"); + } + } + + #[test] + fn migration_v14_symbol_is_case_insensitive_unique() { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + + conn.execute( + "INSERT INTO balance_securities (symbol, asset_type) VALUES ('AAPL', 'stock')", + [], + ) + .expect("first insert"); + + // A case-variant of the same symbol must collide on the NOCASE UNIQUE. + let res = conn.execute( + "INSERT INTO balance_securities (symbol, asset_type) VALUES ('aapl', 'stock')", + [], + ); + assert!( + res.is_err(), + "COLLATE NOCASE UNIQUE must reject 'aapl' after 'AAPL'" + ); + + // currency defaults to CAD. + let currency: String = conn + .query_row( + "SELECT currency FROM balance_securities WHERE symbol = 'AAPL'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(currency, "CAD"); + } + + #[test] + fn migration_v14_check_rejects_invalid_asset_type() { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + let res = conn.execute( + "INSERT INTO balance_securities (symbol, asset_type) VALUES ('GLD', 'gold')", + [], + ); + assert!( + res.is_err(), + "CHECK should reject asset_type outside ('stock','crypto')" + ); + } + + #[test] + fn migration_v14_holdings_fk_policies_cascade_and_restrict() { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + + // Build a snapshot line on a (priced) account so a holding can attach. + let stock_cat: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'stock'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Courtage')", + [stock_cat], + ) + .unwrap(); + let acc_id: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-01')", + [], + ) + .unwrap(); + let snap_id: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value) \ + VALUES (?1, ?2, 10.0, 50.0, 500.0)", + rusqlite::params![snap_id, acc_id], + ) + .unwrap(); + let line_id: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_securities (symbol, asset_type) VALUES ('AAPL', 'stock')", + [], + ) + .unwrap(); + let sec_id: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_holdings \ + (snapshot_line_id, security_id, quantity, unit_price, value) \ + VALUES (?1, ?2, 10.0, 50.0, 500.0)", + rusqlite::params![line_id, sec_id], + ) + .unwrap(); + + // RESTRICT: deleting a referenced security must fail. + let del_sec = conn.execute( + "DELETE FROM balance_securities WHERE id = ?1", + [sec_id], + ); + assert!( + del_sec.is_err(), + "ON DELETE RESTRICT must block deleting a referenced security" + ); + + // CASCADE: deleting the snapshot line removes its holdings. + conn.execute("DELETE FROM balance_snapshot_lines WHERE id = ?1", [line_id]) + .unwrap(); + let holdings_left: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1", + [line_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(holdings_left, 0, "CASCADE must remove holdings with the line"); + } + + #[test] + fn migration_v14_holdings_unique_line_security() { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + + // Minimal line + security. + let stock_cat: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'stock'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'C')", + [stock_cat], + ) + .unwrap(); + let acc_id: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-02')", + [], + ) + .unwrap(); + let snap_id: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value) \ + VALUES (?1, ?2, 1.0, 1.0, 1.0)", + rusqlite::params![snap_id, acc_id], + ) + .unwrap(); + let line_id: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_securities (symbol, asset_type) VALUES ('BTC', 'crypto')", + [], + ) + .unwrap(); + let sec_id: i64 = conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_holdings (snapshot_line_id, security_id, quantity, unit_price, value) \ + VALUES (?1, ?2, 1.0, 1.0, 1.0)", + rusqlite::params![line_id, sec_id], + ) + .unwrap(); + // Second holding of the same security on the same line must collide. + let dup = conn.execute( + "INSERT INTO balance_snapshot_holdings (snapshot_line_id, security_id, quantity, unit_price, value) \ + VALUES (?1, ?2, 2.0, 2.0, 4.0)", + rusqlite::params![line_id, sec_id], + ); + assert!( + dup.is_err(), + "UNIQUE(snapshot_line_id, security_id) must reject a duplicate holding" + ); + } + + #[test] + fn migration_v15_adds_columns_and_backfills_priced_accounts() { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + + // A priced (stock) account and a simple (cash) account, both pre-v15. + let stock_cat: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'stock'", + [], + |r| r.get(0), + ) + .unwrap(); + let cash_cat: i64 = conn + .query_row( + "SELECT id FROM balance_categories WHERE key = 'cash'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Courtage')", + [stock_cat], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Chèque')", + [cash_cat], + ) + .unwrap(); + + conn.execute_batch(V15_SQL).expect("apply v15"); + + // New columns exist. + let cols: Vec = conn + .prepare("SELECT name FROM pragma_table_info('balance_accounts')") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert!(cols.contains(&"kind".to_string())); + assert!(cols.contains(&"detailed_since".to_string())); + + // Backfill: priced account → detailed; simple account stays simple. + let read_kind = |name: &str| -> String { + conn.query_row( + "SELECT kind FROM balance_accounts WHERE name = ?1", + [name], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!(read_kind("Courtage"), "detailed", "priced account → detailed"); + assert_eq!(read_kind("Chèque"), "simple", "cash account stays simple"); + + // detailed_since stays NULL for both (pivot set later by #211). + let ds: Option = conn + .query_row( + "SELECT detailed_since FROM balance_accounts WHERE name = 'Courtage'", + [], + |r| r.get(0), + ) + .unwrap(); + assert!(ds.is_none(), "detailed_since must be NULL until conversion"); + } + + #[test] + fn migration_v15_check_rejects_invalid_kind() { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + conn.execute_batch(V15_SQL).expect("apply v15"); + let res = conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, kind) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'partial')", + [], + ); + assert!( + res.is_err(), + "CHECK should reject a kind outside ('simple','detailed')" + ); + } + + #[test] + fn migration_v15_default_kind_is_simple() { + let conn = db_through_v13(); + conn.execute_batch(V14_SQL).expect("apply v14"); + conn.execute_batch(V15_SQL).expect("apply v15"); + // An account inserted post-v15 without specifying kind defaults to simple. + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'New cash')", + [], + ) + .unwrap(); + let kind: String = conn + .query_row( + "SELECT kind FROM balance_accounts WHERE name = 'New cash'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(kind, "simple", "kind must default to 'simple'"); + } + // ------------------------------------------------------------------------- // Consolidated schema (new profiles) — issue #202 // ------------------------------------------------------------------------- @@ -1899,5 +2310,70 @@ mod tests { "consolidated CHECK should reject a vehicle_type outside the fiscal enum" ); } + + #[test] + fn consolidated_schema_has_holdings_tables_and_kind_at_parity() { + // The consolidated schema (new profiles) must ship the v14 tables and + // the v15 account columns from the start — parity with the migrations. + let conn = consolidated_db(); + + for table in &["balance_securities", "balance_snapshot_holdings"] { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(n, 1, "new profiles must ship {table}"); + } + for idx in &[ + "idx_balance_snapshot_holdings_line", + "idx_balance_snapshot_holdings_security", + ] { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1", + [idx], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(n, 1, "new profiles must ship {idx}"); + } + + // The kind/detailed_since columns exist on balance_accounts. + let cols: Vec = conn + .prepare("SELECT name FROM pragma_table_info('balance_accounts')") + .unwrap() + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert!(cols.contains(&"kind".to_string())); + assert!(cols.contains(&"detailed_since".to_string())); + + // All 4 starters sit under simple asset classes (cash/other), so the + // v15 backfill is a no-op here — every starter is 'simple'. + let detailed: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_accounts WHERE kind = 'detailed'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(detailed, 0, "no starter account is under a priced category"); + + // The NOCASE UNIQUE on securities is enforced in the consolidated path too. + conn.execute( + "INSERT INTO balance_securities (symbol, asset_type) VALUES ('SHOP', 'stock')", + [], + ) + .unwrap(); + let dup = conn.execute( + "INSERT INTO balance_securities (symbol, asset_type) VALUES ('shop', 'stock')", + [], + ); + assert!(dup.is_err(), "consolidated securities must reject case-dupes"); + } } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 0caf51b..2b24a50 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -563,6 +563,16 @@ export interface TransactionPageResult { export type BalanceCategoryKind = "simple" | "priced"; +/** + * Account entry mode (Bilan détail par titre, migration v15). 'simple' = the + * snapshot line stores one denormalized value; 'detailed' = the line is broken + * down into per-security holdings (see BalanceSnapshotHolding). Accounts under a + * priced category are stamped 'detailed' by the v15 backfill. Distinct from + * BalanceCategoryKind: a category is simple/priced, an account is + * simple/detailed — the pivot is per-account, not per-category. + */ +export type BalanceAccountKind = "simple" | "detailed"; + /** * Asset class for priced categories. Required when `kind === 'priced'` so * PriceFetchControl can route to the right provider (best-effort Yahoo for @@ -649,6 +659,18 @@ export interface BalanceAccount { * automobile type. The asset class lives on the linked category. */ vehicle_type?: BalanceVehicleType | null; + /** + * Entry mode (migration v15). 'simple' = one denormalized value per snapshot + * line; 'detailed' = a basket of per-security holdings. Defaults to 'simple'; + * priced-category accounts are backfilled to 'detailed'. + */ + kind: BalanceAccountKind; + /** + * Authoritative pivot date (ISO YYYY-MM-DD) from which detailed entry is + * expected for this account (migration v15). NULL until the conversion flow + * sets it; snapshots before this date stay simple. + */ + detailed_since?: string | null; created_at: string; updated_at: string; } @@ -662,6 +684,67 @@ export interface BalanceAccountWithCategory extends BalanceAccount { category_asset_type: BalanceAssetType | null; /** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */ category_custom_label?: string | null; + // Note: the account's own `kind` (simple|detailed, from BalanceAccount) is + // distinct from `category_kind` (simple|priced) — both are exposed here. +} + +// Détail par titre (Bilan Étape 2) — migrations v14/v15. A detailed account's +// snapshot line is broken down into per-security holdings. Securities are a +// shared catalogue keyed by normalized symbol; holdings reference the snapshot +// line (one line per snapshot+account) plus a security. + +/** + * An investable instrument (stock or crypto) held in a detailed account. + * Backed by `balance_securities` (migration v14). `symbol` is stored normalized + * (upper/trim) and is UNIQUE COLLATE NOCASE so case-variants can't duplicate. + */ +export interface BalanceSecurity { + id: number; + /** Normalized (upper/trim) symbol, e.g. 'AAPL', 'BTC'. UNIQUE NOCASE. */ + symbol: string; + /** Human-readable name (e.g. 'Apple Inc.'); optional. */ + name: string | null; + /** ISO 4217. Defaults to 'CAD'. */ + currency: string; + /** Routes the price-fetch flow (stock vs crypto provider). */ + asset_type: BalanceAssetType; + created_at: string; + updated_at: string; +} + +/** + * One per-security line within a detailed account's snapshot line. Backed by + * `balance_snapshot_holdings` (migration v14). `value` is denormalized + * (= quantity * unit_price) for reproducible reports. `book_cost` (optional) + * feeds the unrealized-gain column. + */ +export interface BalanceSnapshotHolding { + id: number; + /** FK to balance_snapshot_lines(id) — the (snapshot, account) pair. */ + snapshot_line_id: number; + security_id: number; + quantity: number; + unit_price: number; + value: number; + /** Acquisition cost basis for the unrealized-gain column; optional. */ + book_cost: number | null; + /** 'manual' | 'maximus-api' | NULL. */ + price_source: string | null; + price_fetched_at: string | null; + created_at: string; + updated_at: string; +} + +/** + * Joined view exposing the security's symbol/asset_type alongside the holding — + * used by the per-title drill-down table so it can render and route prices + * without a second lookup. + */ +export interface BalanceSnapshotHoldingWithSecurity + extends BalanceSnapshotHolding { + security_symbol: string; + security_name: string | null; + security_asset_type: BalanceAssetType; } // Snapshots — added Issue #146 (Bilan #1b) for the SnapshotEditPage. -- 2.45.2