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.
|
-- Fiscal envelope / tax shelter (migration v12). NULL = no envelope (e.g.
|
||||||
-- a chequing account or crypto wallet). NOT an automobile type.
|
-- 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')),
|
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,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
||||||
|
|
@ -260,6 +267,36 @@ CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
||||||
UNIQUE(transaction_id, account_id)
|
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_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_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);
|
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_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_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
||||||
|
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`
|
-- 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
|
-- 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'), 'REER', 'CAD', 1, 'rrsp'),
|
||||||
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1, NULL);
|
((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)
|
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
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 SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
||||||
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
||||||
pub const BALANCE_SCHEMA: &str = include_str!("balance_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;",
|
WHERE key IN ('tfsa','rrsp') AND is_seed = 1;",
|
||||||
kind: MigrationKind::Up,
|
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()
|
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
|
// Consolidated schema (new profiles) — issue #202
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -1899,5 +2310,70 @@ mod tests {
|
||||||
"consolidated CHECK should reject a vehicle_type outside the fiscal enum"
|
"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";
|
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
|
* Asset class for priced categories. Required when `kind === 'priced'` so
|
||||||
* PriceFetchControl can route to the right provider (best-effort Yahoo for
|
* 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.
|
* automobile type. The asset class lives on the linked category.
|
||||||
*/
|
*/
|
||||||
vehicle_type?: BalanceVehicleType | null;
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
@ -662,6 +684,67 @@ export interface BalanceAccountWithCategory extends BalanceAccount {
|
||||||
category_asset_type: BalanceAssetType | null;
|
category_asset_type: BalanceAssetType | null;
|
||||||
/** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */
|
/** Mirror of `balance_categories.custom_label` — drives renderCategoryLabel. */
|
||||||
category_custom_label?: string | null;
|
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.
|
// Snapshots — added Issue #146 (Bilan #1b) for the SnapshotEditPage.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue