diff --git a/src-tauri/src/database/balance_schema.sql b/src-tauri/src/database/balance_schema.sql new file mode 100644 index 0000000..e1e420d --- /dev/null +++ b/src-tauri/src/database/balance_schema.sql @@ -0,0 +1,153 @@ +-- Balance sheet schema (Bilan) — Migration v9 +-- Created: 2026-04-25 +-- Issue: #138 (Bilan #1a — Schema migration + balance.service skeleton + AccountsPage) +-- +-- Adds 5 tables, 7 indexes, and seeds 7 standard categories (5 simple + 2 priced). +-- Conventions aligned with consolidated_schema.sql: +-- - INTEGER PRIMARY KEY AUTOINCREMENT +-- - REAL for monetary amounts (matches transactions.amount) +-- - snake_case +-- - FK with explicit ON DELETE policies +-- - is_* INTEGER NOT NULL DEFAULT for booleans +-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps +-- +-- MVP constraints (decisions-log + spec-decisions-bilan.md): +-- - balance_accounts.currency hardcoded to 'CAD' via CHECK — v2 will lift this +-- - balance_account_transfers.transaction_id ON DELETE RESTRICT (preserves +-- reproducibility of Modified Dietz returns calculated on past periods) +-- - balance_snapshot_lines kind invariants: (quantity, unit_price) both NULL +-- (simple kind) OR both NOT NULL (priced kind) + + +-- ========================================================================= +-- balance_categories — taxonomy of asset types +-- ========================================================================= +-- Seeded with 7 standard categories (is_seed = 1). Users can add custom +-- categories with their own kind ('simple' or 'priced'). Seeded categories +-- can be renamed but never deleted. +CREATE TABLE IF NOT EXISTS balance_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, -- 'cash', 'tfsa', 'rrsp', 'fund', 'stock', 'crypto', 'other' + i18n_key TEXT NOT NULL, -- 'balance.category.cash', etc. + kind TEXT NOT NULL CHECK(kind IN ('simple','priced')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_seed INTEGER NOT NULL DEFAULT 0 +); + + +-- ========================================================================= +-- balance_accounts — user's specific holdings +-- ========================================================================= +-- A "TFSA at Wealthsimple", a "BTC in Ledger", etc. +-- For priced categories, `symbol` identifies the security/coin. +-- For simple categories, `symbol` is NULL. +-- MVP: currency hardcoded to 'CAD' — v2 lifts the CHECK and adds a rate table. +CREATE TABLE IF NOT EXISTS balance_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + balance_category_id INTEGER NOT NULL, + name TEXT NOT NULL, + symbol TEXT, + currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'), + notes TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + archived_at DATETIME, + 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 +); + + +-- ========================================================================= +-- balance_snapshots — point-in-time captures +-- ========================================================================= +-- One snapshot per `snapshot_date` (UNIQUE). Editing a snapshot = updating +-- its lines, not creating a duplicate. +CREATE TABLE IF NOT EXISTS balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date DATE NOT NULL UNIQUE, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + +-- ========================================================================= +-- balance_snapshot_lines — one row per (snapshot, account) +-- ========================================================================= +-- Storage shape: +-- - simple kind: value is set, quantity/unit_price are NULL +-- - priced kind: quantity + unit_price are set, value = quantity * unit_price +-- Stored denormalized (value always set, even for priced rows) so reports +-- are reproducible without re-fetching prices and the user can override a +-- fetched price. +-- The CHECK enforces kind invariants at SQL level for direct-write safety. +CREATE TABLE IF NOT EXISTS balance_snapshot_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + quantity REAL, + unit_price REAL, + value REAL NOT NULL, + price_source TEXT, -- 'manual' | 'maximus-api' | NULL for simple + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT, + UNIQUE(snapshot_id, account_id), + CHECK ( + (quantity IS NULL AND unit_price IS NULL) + OR (quantity IS NOT NULL AND unit_price IS NOT NULL) + ) +); + + +-- ========================================================================= +-- balance_account_transfers — links transactions to accounts (capital flows) +-- ========================================================================= +-- Used by the Modified Dietz return calculator (Issue #142 / Bilan #4) to +-- separate contributions from gains. Direction follows the account's +-- perspective: 'in' = capital added (deposit/buy), 'out' = capital removed +-- (withdrawal/sell). The amount is taken from the linked transaction (no +-- duplication). +-- +-- transaction_id ON DELETE RESTRICT: preserves reproducibility of past +-- Modified Dietz returns. The UI must force unlink before allowing the +-- transaction to be deleted. +CREATE TABLE IF NOT EXISTS balance_account_transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT, + UNIQUE(transaction_id, account_id) +); + + +-- ========================================================================= +-- Indexes (7 total) +-- ========================================================================= +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); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(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_snapshots_date ON balance_snapshots(snapshot_date); + + +-- ========================================================================= +-- Seed (7 standard categories — idempotent via INSERT OR IGNORE on `key`) +-- ========================================================================= +INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES + ('cash', 'balance.category.cash', 'simple', 10, 1), + ('tfsa', 'balance.category.tfsa', 'simple', 20, 1), + ('rrsp', 'balance.category.rrsp', 'simple', 30, 1), + ('fund', 'balance.category.fund', 'simple', 40, 1), + ('other', 'balance.category.other', 'simple', 50, 1), + ('stock', 'balance.category.stock', 'priced', 60, 1), + ('crypto', 'balance.category.crypto', 'priced', 70, 1); diff --git a/src-tauri/src/database/consolidated_schema.sql b/src-tauri/src/database/consolidated_schema.sql index cb593ec..4009287 100644 --- a/src-tauri/src/database/consolidated_schema.sql +++ b/src-tauri/src/database/consolidated_schema.sql @@ -181,6 +181,95 @@ CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, mon CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); +-- ============================================================================ +-- Balance sheet (Bilan) — Migration v9 mirror for new profiles +-- ============================================================================ +-- 5 tables + 7 indexes + seeded categories. Kept in sync with +-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs). +-- New profiles created from this consolidated schema get the balance feature +-- preinstalled without needing to replay v9 separately. + +CREATE TABLE IF NOT EXISTS balance_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + i18n_key TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('simple','priced')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_seed INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS balance_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + balance_category_id INTEGER NOT NULL, + name TEXT NOT NULL, + symbol TEXT, + currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'), + notes TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + archived_at DATETIME, + 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 +); + +CREATE TABLE IF NOT EXISTS balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date DATE NOT NULL UNIQUE, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS balance_snapshot_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + quantity REAL, + unit_price REAL, + value REAL NOT NULL, + price_source TEXT, + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT, + UNIQUE(snapshot_id, account_id), + CHECK ( + (quantity IS NULL AND unit_price IS NULL) + OR (quantity IS NOT NULL AND unit_price IS NOT NULL) + ) +); + +CREATE TABLE IF NOT EXISTS balance_account_transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT, + UNIQUE(transaction_id, account_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); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(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_snapshots_date ON balance_snapshots(snapshot_date); + +INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES + ('cash', 'balance.category.cash', 'simple', 10, 1), + ('tfsa', 'balance.category.tfsa', 'simple', 20, 1), + ('rrsp', 'balance.category.rrsp', 'simple', 30, 1), + ('fund', 'balance.category.fund', 'simple', 40, 1), + ('other', 'balance.category.other', 'simple', 50, 1), + ('stock', 'balance.category.stock', 'priced', 60, 1), + ('crypto', 'balance.category.crypto', 'priced', 70, 1); + -- 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 2560b01..5711298 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -1,3 +1,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"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 56e88eb..4dc3a55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -95,6 +95,19 @@ pub fn run() { INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');", kind: MigrationKind::Up, }, + // Migration v9 — Bilan (balance sheet) schema: + // 5 tables (balance_categories, balance_accounts, balance_snapshots, + // balance_snapshot_lines, balance_account_transfers) + 7 indexes + + // 7 seeded categories (5 simple + 2 priced). + // CHECK(currency = 'CAD') is hardcoded for the MVP (will be lifted in v2 + // with a multi-currency rate table). FK transaction_id ON DELETE + // RESTRICT preserves reproducibility of Modified Dietz returns. + Migration { + version: 9, + description: "create balance schema", + sql: database::BALANCE_SCHEMA, + kind: MigrationKind::Up, + }, ]; tauri::Builder::default() @@ -240,3 +253,443 @@ fn extract_query_param(url: &str, key: &str) -> Option { } None } + +// ============================================================================= +// Tests for migration v9 — balance schema +// ----------------------------------------------------------------------------- +// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9 +// migration) on a fresh in-memory SQLite database and assert that: +// - the schema applies cleanly (all 5 tables + 7 indexes created) +// - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1 +// - CHECK constraints reject invalid kind / direction / currency / kind invariants +// - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) / +// (transaction_id,account_id) / category key +// - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT +// on transaction_id and on category with linked accounts) +// +// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep +// required. The migration v9 SQL is the source of truth; v1-v8 are not +// required here because v9 is additive and only references the existing +// `transactions` table for the FK on balance_account_transfers — we mirror +// that with a minimal `transactions` table for the integration scenarios. +#[cfg(test)] +mod tests { + use rusqlite::Connection; + + /// Apply the v9 schema on a fresh in-memory DB. Includes a minimal + /// `transactions` table because balance_account_transfers references it. + fn fresh_db() -> Connection { + let conn = Connection::open_in_memory().expect("open in-memory db"); + // FKs must be enabled per-connection in SQLite. + conn.execute("PRAGMA foreign_keys = ON;", []) + .expect("enable FKs"); + // Minimal transactions table mirroring the relevant columns the FK + // references (id is the only column we need at the SQL level). + conn.execute_batch( + "CREATE TABLE transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + description TEXT NOT NULL, + amount REAL NOT NULL + );", + ) + .expect("create stub transactions table"); + conn.execute_batch(crate::database::BALANCE_SCHEMA) + .expect("apply BALANCE_SCHEMA"); + conn + } + + #[test] + fn migration_v9_applies_cleanly() { + let conn = fresh_db(); + // 5 expected tables + let tables: Vec = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'balance_%' ORDER BY name", + ) + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!( + tables, + vec![ + "balance_account_transfers", + "balance_accounts", + "balance_categories", + "balance_snapshot_lines", + "balance_snapshots", + ] + ); + // 7 expected indexes + let index_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_balance_%'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(index_count, 7); + } + + #[test] + fn migration_v9_seeds_7_categories() { + let conn = fresh_db(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 7); + + let simple_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE kind = 'simple' AND is_seed = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(simple_count, 5); + + let priced_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE kind = 'priced' AND is_seed = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(priced_count, 2); + + // Seeded keys are stable + let stock_kind: String = conn + .query_row( + "SELECT kind FROM balance_categories WHERE key = 'stock'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stock_kind, "priced"); + } + + #[test] + fn migration_v9_rejects_non_cad_currency() { + let conn = fresh_db(); + // 'cash' category exists from seed; try to insert a non-CAD account. + let result = conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, currency) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'USD account', 'USD')", + [], + ); + assert!( + result.is_err(), + "CHECK(currency='CAD') should reject 'USD'" + ); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.to_lowercase().contains("check")); + } + + #[test] + fn migration_v9_accepts_default_cad_currency() { + let conn = fresh_db(); + let inserted = conn + .execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .expect("CAD default insert should succeed"); + assert_eq!(inserted, 1); + } + + #[test] + fn migration_v9_rejects_invalid_kind() { + let conn = fresh_db(); + let result = conn.execute( + "INSERT INTO balance_categories (key, i18n_key, kind) VALUES ('bogus', 'x.bogus', 'unknown')", + [], + ); + assert!(result.is_err(), "kind CHECK should reject 'unknown'"); + } + + #[test] + fn migration_v9_unique_snapshot_date() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ) + .unwrap(); + let result = conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ); + assert!(result.is_err(), "UNIQUE(snapshot_date) should reject dup"); + } + + #[test] + fn migration_v9_kind_invariants_check() { + let conn = fresh_db(); + // Setup: a snapshot + an account + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + let snap_id: i64 = conn + .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + + // OK: simple kind (quantity + unit_price both NULL) + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) + VALUES (?1, ?2, 1234.56)", + rusqlite::params![snap_id, acct_id], + ) + .expect("simple kind row (qty/price both NULL) should be accepted"); + + // OK: priced kind (both set) — needs second account on a priced category + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, symbol) + VALUES ((SELECT id FROM balance_categories WHERE key='stock'), 'AAPL', 'AAPL')", + [], + ) + .unwrap(); + let acct2_id: i64 = conn + .query_row( + "SELECT id FROM balance_accounts WHERE name='AAPL'", + [], + |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, 200.0, 2000.0)", + rusqlite::params![snap_id, acct2_id], + ) + .expect("priced kind row (both set) should be accepted"); + + // KO: only quantity set, unit_price NULL → CHECK violation + let bad = conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, value) + VALUES (?1, ?2, 10.0, 0.0)", + rusqlite::params![snap_id, acct_id], + ); + assert!( + bad.is_err(), + "kind invariants CHECK should reject (qty set, price NULL)" + ); + + // KO: only unit_price set, quantity NULL → CHECK violation + let bad2 = conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, unit_price, value) + VALUES (?1, ?2, 200.0, 0.0)", + rusqlite::params![snap_id, acct_id], + ); + assert!( + bad2.is_err(), + "kind invariants CHECK should reject (price set, qty NULL)" + ); + } + + #[test] + fn migration_v9_unique_snapshot_account_pair() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + let snap_id: i64 = conn + .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) + VALUES (?1, ?2, 100.0)", + rusqlite::params![snap_id, acct_id], + ) + .unwrap(); + let dup = conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) + VALUES (?1, ?2, 200.0)", + rusqlite::params![snap_id, acct_id], + ); + assert!(dup.is_err(), "UNIQUE(snapshot_id, account_id) should reject dup"); + } + + #[test] + fn migration_v9_fk_cascade_on_snapshot_delete() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + let snap_id: i64 = conn + .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) + VALUES (?1, ?2, 100.0)", + rusqlite::params![snap_id, acct_id], + ) + .unwrap(); + conn.execute( + "DELETE FROM balance_snapshots WHERE id = ?1", + rusqlite::params![snap_id], + ) + .expect("delete snapshot should cascade"); + let remaining: i64 = conn + .query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0)) + .unwrap(); + assert_eq!(remaining, 0, "snapshot delete should cascade lines"); + } + + #[test] + fn migration_v9_fk_restrict_on_transaction_delete() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)", + [], + ) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let tx_id: i64 = conn + .query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) + VALUES (?1, ?2, 'in')", + rusqlite::params![acct_id, tx_id], + ) + .unwrap(); + + // Attempting to delete the linked transaction must be rejected (RESTRICT) + let result = conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]); + assert!( + result.is_err(), + "FK RESTRICT should block deleting linked transaction" + ); + + // Once the transfer is removed, deletion is allowed again + conn.execute( + "DELETE FROM balance_account_transfers WHERE transaction_id = ?1", + rusqlite::params![tx_id], + ) + .unwrap(); + conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]) + .expect("after unlink, transaction can be deleted"); + } + + #[test] + fn migration_v9_fk_restrict_on_category_with_accounts() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + // Try to delete the seeded 'cash' category while an account references it + let result = conn.execute( + "DELETE FROM balance_categories WHERE key = 'cash'", + [], + ); + assert!( + result.is_err(), + "FK RESTRICT should block deleting category with linked accounts" + ); + } + + #[test] + fn migration_v9_unique_transaction_account_transfer() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)", + [], + ) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let tx_id: i64 = conn + .query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) + VALUES (?1, ?2, 'in')", + rusqlite::params![acct_id, tx_id], + ) + .unwrap(); + let dup = conn.execute( + "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) + VALUES (?1, ?2, 'out')", + rusqlite::params![acct_id, tx_id], + ); + assert!( + dup.is_err(), + "UNIQUE(transaction_id, account_id) should reject dup" + ); + } + + #[test] + fn migration_v9_seed_idempotent_on_replay() { + // The migration uses `INSERT OR IGNORE` keyed by `key`, so applying + // the schema twice on the same DB must not duplicate seeded rows. + let conn = fresh_db(); + conn.execute_batch(crate::database::BALANCE_SCHEMA) + .expect("apply BALANCE_SCHEMA twice"); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 7, "seed must remain idempotent on replay"); + } +} +