feat(balance): schema & migrations v14/v15 + types (#210) #219
5 changed files with 674 additions and 0 deletions
68
src-tauri/src/database/balance_holdings_schema.sql
Normal file
68
src-tauri/src/database/balance_holdings_schema.sql
Normal file
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<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!(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<String> = 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<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!(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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue