mod commands; mod database; use std::sync::Mutex; use tauri::{Emitter, Manager}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_sql::{Migration, MigrationKind}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let migrations = vec![ Migration { version: 1, description: "create initial schema", sql: database::SCHEMA, kind: MigrationKind::Up, }, Migration { version: 2, description: "seed categories and keywords", sql: database::SEED_CATEGORIES, kind: MigrationKind::Up, }, Migration { version: 3, description: "add has_header to import_sources", sql: "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;", kind: MigrationKind::Up, }, Migration { version: 4, description: "add is_inputable to categories", sql: "ALTER TABLE categories ADD COLUMN is_inputable INTEGER NOT NULL DEFAULT 1;", kind: MigrationKind::Up, }, Migration { version: 5, description: "create import_config_templates table", sql: "CREATE TABLE IF NOT EXISTS import_config_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, delimiter TEXT NOT NULL DEFAULT ';', encoding TEXT NOT NULL DEFAULT 'utf-8', date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY', skip_lines INTEGER NOT NULL DEFAULT 0, has_header INTEGER NOT NULL DEFAULT 1, column_mapping TEXT NOT NULL, amount_mode TEXT NOT NULL DEFAULT 'single', sign_convention TEXT NOT NULL DEFAULT 'negative_expense', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );", kind: MigrationKind::Up, }, Migration { version: 6, description: "change imported_files unique constraint from hash to filename", sql: "CREATE TABLE imported_files_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_id INTEGER NOT NULL REFERENCES import_sources(id), filename TEXT NOT NULL, file_hash TEXT NOT NULL, import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, row_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'completed', notes TEXT, UNIQUE(source_id, filename) ); INSERT INTO imported_files_new SELECT * FROM imported_files; DROP TABLE imported_files; ALTER TABLE imported_files_new RENAME TO imported_files;", kind: MigrationKind::Up, }, Migration { version: 7, description: "add level-3 insurance subcategories", sql: "INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1); INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2); INSERT OR IGNORE INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3); UPDATE categories SET is_inputable = 0 WHERE id = 31; UPDATE keywords SET category_id = 310 WHERE keyword = 'BELAIR' AND category_id = 31; UPDATE keywords SET category_id = 311 WHERE keyword = 'PRYSM' AND category_id = 31; UPDATE keywords SET category_id = 312 WHERE keyword = 'INS/ASS' AND category_id = 31;", kind: MigrationKind::Up, }, // Migration v8 — additive: tag existing profiles with the v2 categories // taxonomy and add a nullable i18n_key column on categories so the v1 // IPC seed (applied only to brand-new profiles via consolidated_schema) // can store translation keys. Existing v2 profiles are untouched: the // column defaults to NULL (falling back to the category's `name`) and // the preference is a no-op INSERT OR IGNORE so re-runs are safe. Migration { version: 8, description: "add i18n_key to categories and categories_schema_version preference", sql: "ALTER TABLE categories ADD COLUMN i18n_key TEXT; 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, }, // Migration v10 — additive: a nullable `asset_type` column on // balance_categories so the priced-kind UI can route between providers // (best-effort Yahoo for stocks, exchange APIs for crypto). Symbol // alone is ambiguous (e.g. ETH = Ethan Allen NYSE AND Ethereum crypto). // ALTER first → adds NULL column, then UPDATE backfills the two // priced seeds (`stock`, `crypto`) created by v9. Custom priced rows // keep NULL until the user edits the category — SnapshotLineRow hides // the fetch button while asset_type is NULL. Migration { version: 10, description: "add asset_type to balance_categories", sql: "ALTER TABLE balance_categories ADD COLUMN asset_type TEXT \ CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')); \ UPDATE balance_categories SET asset_type = 'stock' \ WHERE key = 'stock' AND is_seed = 1; \ UPDATE balance_categories SET asset_type = 'crypto' \ WHERE key = 'crypto' AND is_seed = 1;", kind: MigrationKind::Up, }, // Migration v11 — cleanup orphan balance snapshots (#176). Before // useSnapshotEditor.save was made atomic via BEGIN/COMMIT, a // priced-line validation failure could leave the snapshot row // inserted but with no lines, blocking subsequent saves at that // date through the snapshot_date UNIQUE constraint. This deletes // any such orphan rows from existing profiles. New orphans are // no longer possible thanks to saveSnapshotAtomic. Migration { version: 11, description: "cleanup orphan balance snapshots", sql: "DELETE FROM balance_snapshots \ WHERE NOT EXISTS ( \ SELECT 1 FROM balance_snapshot_lines \ WHERE snapshot_id = balance_snapshots.id);", kind: MigrationKind::Up, }, // Migration v12 — Bilan axe véhicule (Étape 1), additive part. Adds // `vehicle_type` (fiscal envelope / tax shelter — NOT an automobile) to // balance_accounts and `custom_label` to balance_categories, then // backfills the envelope attribute onto the accounts that used to live // in the now-deprecated `tfsa` / `rrsp` seed categories. `cash` accounts // stay NULL — a chequing account is not a "non-registered investment". // // The two trailing UPDATEs are a defensive backfill for bug I: before // this work the category-rename path overwrote `i18n_key` with free // text. Any seed row whose i18n_key no longer matches the canonical // `balance.category.%` convention has that free text recovered into // `custom_label`, then its i18n_key is restored from `key` so the // renderCategoryLabel helper (custom_label || t(i18n_key)) keeps working. // ALTER ADD COLUMN ... CHECK (mono-column) is valid on this stack (cf v10). Migration { version: 12, description: "add vehicle_type to balance_accounts and custom_label to balance_categories", sql: "ALTER TABLE balance_accounts ADD COLUMN vehicle_type TEXT \ CHECK(vehicle_type IS NULL OR vehicle_type IN \ ('unregistered','tfsa','rrsp','rrif','fhsa','resp')); \ ALTER TABLE balance_categories ADD COLUMN custom_label TEXT; \ UPDATE balance_accounts SET vehicle_type = 'tfsa' \ WHERE balance_category_id = ( \ SELECT id FROM balance_categories WHERE key = 'tfsa'); \ UPDATE balance_accounts SET vehicle_type = 'rrsp' \ WHERE balance_category_id = ( \ SELECT id FROM balance_categories WHERE key = 'rrsp'); \ UPDATE balance_categories SET custom_label = i18n_key \ WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%'; \ UPDATE balance_categories SET i18n_key = 'balance.category.' || key \ WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%';", kind: MigrationKind::Up, }, // Migration v13 — Bilan axe véhicule (Étape 1), reclass part. Now that // the envelope lives on the account (v12), the `tfsa` / `rrsp` seed // categories become pure tax shelters with no asset-class meaning. // Re-link their accounts to the `other` asset class ("Autres") and // deactivate the two seeds so they vanish from the category dropdowns. // // Conditional + idempotent (no Down migration exists): the re-link only // fires when an `other` seed category exists (EXISTS guard), and both // statements scope to `is_seed = 1` so a user-created category that // happens to reuse the key is never touched. v12 runs first (sqlx // versioning guarantees the order), so vehicle_type is already stamped // before balance_category_id moves. Re-running v13 is a no-op: the // accounts are no longer in tfsa/rrsp and the seeds are already inactive. Migration { version: 13, description: "reclass ex-tfsa/rrsp accounts to other and deactivate the envelope seeds", sql: "UPDATE balance_accounts \ SET balance_category_id = ( \ SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1) \ WHERE balance_category_id IN ( \ SELECT id FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_seed = 1) \ AND EXISTS ( \ SELECT 1 FROM balance_categories WHERE key = 'other' AND is_seed = 1); \ UPDATE balance_categories SET is_active = 0 \ WHERE key IN ('tfsa','rrsp') AND is_seed = 1;", kind: MigrationKind::Up, }, // 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, }, // Migration v16 — Bilan détail par titre (Étape 2), data conversion part. // Converts each existing single-security "priced" account into a // `detailed` account holding exactly one position, with zero data loss. // // Step 1 mints a shared, deduped `balance_securities` row per priced // account symbol (normalized UPPER(TRIM), deduped via ON CONFLICT(symbol) // DO NOTHING — the symbol UNIQUE is COLLATE NOCASE so case-variants merge). // Critically it converts ONLY accounts whose category carries a real // `asset_type` (stock|crypto): balance_securities.asset_type is NOT NULL, // and a priced account whose category has asset_type IS NULL has no valid // routing — it must be left intact. // // Step 2 mirrors each existing priced line into one holding // (quantity/unit_price/value/price_source/price_fetched_at copied verbatim; // book_cost stays NULL — no retroactive acquisition cost for historical // lines). UNIQUE(snapshot_line_id, security_id) + ON CONFLICT DO NOTHING // makes a re-run a strict no-op. // // Step 3 collapses the now-redundant per-line qty/unit_price to NULL so a // detailed account's line keeps only its total `value` (the per-title // breakdown lives in holdings). The 🔴 security fix: this NULLing is scoped // to `id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings)` — a // line that never received a holding (priced-without-asset_type) is never // NULLed, so no silent data loss. NULLing BOTH qty+unit_price together // preserves balance_snapshot_lines' (both NULL | both NOT NULL) CHECK. // // The trailing TEMP-table guard asserts the invariant "qty was NULLed ⇒ a // holding exists" and ABORTS (CHECK(ok = 1) on an inserted 0) so any breach // rolls back the whole v16 transaction. Idempotent: ON CONFLICT no-ops on // re-run, and the guard re-passes since every NULLed line still has its // holding. Migration { version: 16, description: "convert existing priced accounts to detailed (one security + mirror holding)", sql: "INSERT INTO balance_securities (symbol, currency, asset_type) \ SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \ FROM balance_accounts a \ JOIN balance_categories c ON c.id = a.balance_category_id \ WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \ ON CONFLICT(symbol) DO NOTHING; \ INSERT INTO balance_snapshot_holdings \ (snapshot_line_id, security_id, quantity, unit_price, value, price_source, price_fetched_at) \ SELECT sl.id, s.id, sl.quantity, sl.unit_price, sl.value, sl.price_source, sl.price_fetched_at \ FROM balance_snapshot_lines sl \ JOIN balance_accounts a ON a.id = sl.account_id \ JOIN balance_securities s ON s.symbol = UPPER(TRIM(a.symbol)) \ WHERE a.symbol IS NOT NULL AND sl.quantity IS NOT NULL \ ON CONFLICT(snapshot_line_id, security_id) DO NOTHING; \ UPDATE balance_snapshot_lines \ SET quantity = NULL, unit_price = NULL \ WHERE quantity IS NOT NULL \ AND id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings); \ CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \ INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \ SELECT 1 FROM balance_snapshot_lines sl \ JOIN balance_accounts a ON a.id = sl.account_id \ WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \ AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \ ) THEN 0 ELSE 1 END; \ DROP TABLE _v16_guard;", kind: MigrationKind::Up, }, ]; tauri::Builder::default() // Single-instance plugin MUST be registered first. With the `deep-link` // feature, it forwards `simpl-resultat://` URLs to the running instance // so the OAuth2 callback reaches the process that holds the PKCE verifier. .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { if let Some(window) = app.get_webview_window("main") { let _ = window.set_focus(); } })) .manage(commands::auth_commands::OAuthState { code_verifier: Mutex::new(None), }) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_deep_link::init()) .setup(|app| { #[cfg(desktop)] app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; // Register the custom scheme at runtime on Linux (the .desktop file // handles it in prod, but register_all is a no-op there and required // for AppImage/dev builds). #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] { let _ = app.deep_link().register_all(); } // Canonical Tauri v2 pattern: on_open_url fires for both initial-launch // URLs and subsequent URLs forwarded by tauri-plugin-single-instance // (with the `deep-link` feature). let handle = app.handle().clone(); app.deep_link().on_open_url(move |event| { for url in event.urls() { let url_str = url.as_str(); let h = handle.clone(); if let Some(code) = extract_auth_code(url_str) { tauri::async_runtime::spawn(async move { match commands::handle_auth_callback(h.clone(), code).await { Ok(account) => { let _ = h.emit("auth-callback-success", &account); } Err(err) => { let _ = h.emit("auth-callback-error", &err); } } }); } else { // No `code` param — likely an OAuth error response. Surface // it to the frontend instead of leaving the UI stuck in // "loading" forever. let err_msg = extract_auth_error(url_str) .unwrap_or_else(|| "OAuth callback did not include a code".to_string()); let _ = h.emit("auth-callback-error", &err_msg); } } }); Ok(()) }) .plugin( tauri_plugin_sql::Builder::default() .add_migrations("sqlite:simpl_resultat.db", migrations) .build(), ) .invoke_handler(tauri::generate_handler![ commands::scan_import_folder, commands::read_file_content, commands::hash_file, commands::detect_encoding, commands::get_file_preview, commands::pick_folder, commands::pick_save_file, commands::pick_import_file, commands::write_export_file, commands::read_import_file, commands::is_file_encrypted, commands::load_profiles, commands::save_profiles, commands::delete_profile_db, commands::get_new_profile_init_sql, commands::hash_pin, commands::verify_pin, commands::repair_migrations, commands::validate_license_key, commands::store_license, commands::store_activation_token, commands::read_license, commands::get_edition, commands::get_machine_id, commands::check_entitlement, commands::activate_machine, commands::deactivate_machine, commands::list_activated_machines, commands::get_activation_status, commands::start_oauth, commands::refresh_auth_token, commands::get_account_info, commands::check_subscription_status, commands::logout, commands::get_token_store_mode, commands::send_feedback, commands::get_feedback_user_agent, commands::ensure_backup_dir, commands::get_file_size, commands::file_exists, commands::compute_account_return, commands::fetch_price, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } /// Extract the `code` query parameter from a deep-link callback URL. /// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123") fn extract_auth_code(url: &str) -> Option { extract_query_param(url, "code") } /// Extract an OAuth error description from a callback URL. Returns a /// formatted string combining `error` and `error_description` when present. fn extract_auth_error(url: &str) -> Option { let error = extract_query_param(url, "error")?; match extract_query_param(url, "error_description") { Some(desc) => Some(format!("{}: {}", error, desc)), None => Some(error), } } fn extract_query_param(url: &str, key: &str) -> Option { let url = url.trim(); if !url.starts_with("simpl-resultat://auth/callback") { return None; } let query = url.split('?').nth(1)?; for pair in query.split('&') { let mut kv = pair.splitn(2, '='); if kv.next()? == key { return kv.next().map(|v| { urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string()) }); } } 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"); } // ------------------------------------------------------------------------- // Issue #144 (Bilan #6) — integration tests on a seeded DB // ------------------------------------------------------------------------- // // The previous tests apply BALANCE_SCHEMA on an empty DB. These tests // simulate the realistic upgrade path: a profile DB with imported // transactions already there gets the v9 migration applied on top, and // we verify: // - existing transactions are not affected by the migration (no row // loss, no schema collision), // - link / unlink transfer round-trips on real (non-stub) transaction // ids, // - the FK RESTRICT correctly chains: try to delete a linked // transaction → blocked, unlink → delete succeeds. /// Seed a DB with the *full app schema* (transactions + categories + /// keywords + suppliers + adjustments + ...) then apply BALANCE_SCHEMA on /// top — mirroring what migration v9 does on an existing user profile. /// Returns the connection ready for assertions. fn seeded_db_with_balance_schema() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute("PRAGMA foreign_keys = ON;", []) .expect("enable FKs"); // Apply the full app schema (v1) — we only need the transactions // table for the v9 FK, but applying the whole schema verifies that // nothing in v9 collides with the existing tables. conn.execute_batch(crate::database::SCHEMA) .expect("apply v1 SCHEMA"); // Pre-seed a few transactions to mimic an existing profile (the user // already had data when we shipped v9). conn.execute_batch( "INSERT INTO transactions (date, description, amount) VALUES ('2026-01-15', 'Salary deposit', 3500.0), ('2026-02-01', 'Wealthsimple contribution', -400.0), ('2026-03-15', 'Grocery store', -125.50), ('2026-04-01', 'Wealthsimple contribution', -400.0);", ) .expect("seed transactions"); // Now apply v9 on top — same way the runtime would. conn.execute_batch(crate::database::BALANCE_SCHEMA) .expect("apply v9 BALANCE_SCHEMA on seeded DB"); conn } #[test] fn migration_v9_preserves_existing_transactions_on_seeded_db() { let conn = seeded_db_with_balance_schema(); // Existing transactions must be untouched by the migration. let count: i64 = conn .query_row("SELECT COUNT(*) FROM transactions", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 4, "existing transactions must survive the migration"); // Spot-check one row's content (no silent data mutation). let amount: f64 = conn .query_row( "SELECT amount FROM transactions WHERE description = 'Salary deposit'", [], |row| row.get(0), ) .unwrap(); assert!( (amount - 3500.0).abs() < f64::EPSILON, "salary amount must be preserved verbatim" ); // The seeded categories from BALANCE_SCHEMA must coexist with the // pre-existing categories table from v1 (different name, no clash). let bal_cat_count: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1", [], |row| row.get(0), ) .unwrap(); assert_eq!(bal_cat_count, 7); } #[test] fn integration_link_unlink_transfer_roundtrip_on_seeded_db() { let conn = seeded_db_with_balance_schema(); // Create a balance account on the seeded 'cash' category. conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')", [], ) .unwrap(); let account_id: i64 = conn .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) .unwrap(); // Pick the Feb contribution (-$400) — a typical "in" transfer for the // Wealthsimple account from the bank perspective. let tx_id: i64 = conn .query_row( "SELECT id FROM transactions WHERE date = '2026-02-01'", [], |r| r.get(0), ) .unwrap(); // 1. Link let inserted = conn .execute( "INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes) VALUES (?1, ?2, 'in', 'monthly contribution')", rusqlite::params![account_id, tx_id], ) .expect("link succeeds with real transaction id"); assert_eq!(inserted, 1); // 2. Verify the row is queryable through the joined view used by // `listAccountTransfers` in TS. let (joined_amount, direction): (f64, String) = conn .query_row( "SELECT t.amount, bat.direction FROM balance_account_transfers bat JOIN transactions t ON t.id = bat.transaction_id WHERE bat.account_id = ?1", rusqlite::params![account_id], |r| Ok((r.get(0)?, r.get(1)?)), ) .expect("joined view must read"); assert!((joined_amount - (-400.0)).abs() < f64::EPSILON); assert_eq!(direction, "in"); // 3. Try to delete the linked transaction — must be blocked (RESTRICT). let blocked = conn.execute( "DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id], ); assert!( blocked.is_err(), "linked transaction deletion must be blocked by FK RESTRICT" ); // 4. Unlink let unlinked = conn .execute( "DELETE FROM balance_account_transfers WHERE account_id = ?1 AND transaction_id = ?2", rusqlite::params![account_id, tx_id], ) .expect("unlink succeeds"); assert_eq!(unlinked, 1); // 5. After unlink, deleting the transaction must succeed. let allowed = conn .execute( "DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id], ) .expect("after unlink, transaction can be deleted"); assert_eq!(allowed, 1); // 6. Sanity: no orphan transfer rows survived. let remaining_links: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_account_transfers WHERE transaction_id = ?1", rusqlite::params![tx_id], |r| r.get(0), ) .unwrap(); assert_eq!(remaining_links, 0); } #[test] fn integration_modified_dietz_inputs_read_back_correctly_on_seeded_db() { // Reads back the snapshot endpoints + cash flows the way // `compute_account_return` does, on a DB that has both v1 transactions // and v9 balance tables. Asserts the SQL queries used by // `balance_commands.rs::read_value_at_or_before` and `read_cash_flows` // return the expected shapes. let conn = seeded_db_with_balance_schema(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')", [], ) .unwrap(); let account_id: i64 = conn .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) .unwrap(); // Two snapshot endpoints (V_start, V_end) and one mid-period contribution. conn.execute( "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-01'), ('2026-04-01')", [], ) .unwrap(); let s_start: i64 = conn .query_row( "SELECT id FROM balance_snapshots WHERE snapshot_date='2026-01-01'", [], |r| r.get(0), ) .unwrap(); let s_end: i64 = conn .query_row( "SELECT id FROM balance_snapshots WHERE snapshot_date='2026-04-01'", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1000.0)", rusqlite::params![s_start, account_id], ) .unwrap(); conn.execute( "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1500.0)", rusqlite::params![s_end, account_id], ) .unwrap(); // Link the Feb 1 contribution as an `in` transfer. let tx_id: i64 = conn .query_row( "SELECT id FROM transactions WHERE date='2026-02-01'", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) VALUES (?1, ?2, 'in')", rusqlite::params![account_id, tx_id], ) .unwrap(); // Mirror `read_value_at_or_before` for V_start — exact SQL used in // `balance_commands.rs`. let v_start: Option = conn .query_row( "SELECT l.value FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id WHERE l.account_id = ?1 AND s.snapshot_date <= ?2 ORDER BY s.snapshot_date DESC LIMIT 1", rusqlite::params![account_id, "2026-01-01"], |r| r.get(0), ) .ok(); assert_eq!(v_start, Some(1000.0)); // V_end at 2026-04-01 — picks up the second snapshot. let v_end: Option = conn .query_row( "SELECT l.value FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id WHERE l.account_id = ?1 AND s.snapshot_date <= ?2 ORDER BY s.snapshot_date DESC LIMIT 1", rusqlite::params![account_id, "2026-04-01"], |r| r.get(0), ) .ok(); assert_eq!(v_end, Some(1500.0)); // Cash flows in [2026-01-01, 2026-04-01] — exactly one (-400 abs amount → +400 in). let mut stmt = conn .prepare( "SELECT t.date, ABS(t.amount), bat.direction FROM balance_account_transfers bat JOIN transactions t ON t.id = bat.transaction_id WHERE bat.account_id = ?1 AND t.date BETWEEN ?2 AND ?3 ORDER BY t.date", ) .unwrap(); let flows: Vec<(String, f64, String)> = stmt .query_map( rusqlite::params![account_id, "2026-01-01", "2026-04-01"], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .unwrap() .map(|r| r.unwrap()) .collect(); assert_eq!(flows.len(), 1); assert_eq!(flows[0].0, "2026-02-01"); assert!((flows[0].1 - 400.0).abs() < f64::EPSILON); assert_eq!(flows[0].2, "in"); } #[test] fn integration_v9_preserves_v1_categories_and_keywords() { // Defensive: v9 introduces `balance_categories` while v1 already has // `categories`. Make sure neither is mistaken for the other and that // the v1 seeds (when present) survive the migration cleanly. let conn = seeded_db_with_balance_schema(); // Insert a v1 category + keyword (mimicking v1 seed data already present). conn.execute( "INSERT INTO categories (id, name, type, color, sort_order) VALUES (50, 'Épicerie', 'expense', '#10b981', 50)", [], ) .unwrap(); conn.execute( "INSERT INTO keywords (keyword, category_id, priority, is_active) VALUES ('IGA', 50, 100, 1)", [], ) .unwrap(); // Now insert a v9 category with the SAME numeric id (should be allowed // — different table, different namespace). conn.execute( "INSERT INTO balance_categories (id, key, i18n_key, kind, sort_order) VALUES (50, 'mortgage', 'balance.category.mortgage', 'simple', 100)", [], ) .expect( "balance_categories.id namespace must be independent from categories.id", ); // The v1 row is untouched. let v1_name: String = conn .query_row( "SELECT name FROM categories WHERE id = 50", [], |r| r.get(0), ) .unwrap(); assert_eq!(v1_name, "Épicerie"); // The v9 row is queryable on its own table. let v9_key: String = conn .query_row( "SELECT key FROM balance_categories WHERE id = 50", [], |r| r.get(0), ) .unwrap(); assert_eq!(v9_key, "mortgage"); } // ========================================================================= // Migration v10 — add asset_type to balance_categories // ------------------------------------------------------------------------- // The v10 SQL applies on top of v9 (column add + backfill of the two priced // seeds). These tests are statement-equivalent to the production migration: // they execute the same SQL string used in `lib.rs`'s Migration array. // ========================================================================= /// Production v10 SQL — kept in sync with the Migration { version: 10 } /// entry above. Tests apply this on top of fresh_db() (which already ran v9). const V10_SQL: &str = "ALTER TABLE balance_categories ADD COLUMN asset_type TEXT \ CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')); \ UPDATE balance_categories SET asset_type = 'stock' \ WHERE key = 'stock' AND is_seed = 1; \ UPDATE balance_categories SET asset_type = 'crypto' \ WHERE key = 'crypto' AND is_seed = 1;"; #[test] fn migration_v10_adds_asset_type_column() { let conn = fresh_db(); conn.execute_batch(V10_SQL).expect("apply v10"); // pragma_table_info should now list `asset_type` for balance_categories. let cols: Vec = conn .prepare("SELECT name FROM pragma_table_info('balance_categories')") .unwrap() .query_map([], |row| row.get::<_, String>(0)) .unwrap() .map(|r| r.unwrap()) .collect(); assert!( cols.contains(&"asset_type".to_string()), "asset_type column should exist after v10, got {:?}", cols ); } #[test] fn migration_v10_backfills_priced_seeds() { let conn = fresh_db(); conn.execute_batch(V10_SQL).expect("apply v10"); let stock: String = conn .query_row( "SELECT asset_type FROM balance_categories WHERE key = 'stock'", [], |r| r.get(0), ) .unwrap(); assert_eq!(stock, "stock"); let crypto: String = conn .query_row( "SELECT asset_type FROM balance_categories WHERE key = 'crypto'", [], |r| r.get(0), ) .unwrap(); assert_eq!(crypto, "crypto"); // The 5 simple seeds must remain NULL. for key in &["cash", "tfsa", "rrsp", "fund", "other"] { let v: Option = conn .query_row( "SELECT asset_type FROM balance_categories WHERE key = ?1", [key], |r| r.get(0), ) .unwrap(); assert!(v.is_none(), "simple seed {} should have NULL asset_type", key); } } #[test] fn migration_v10_leaves_custom_priced_rows_null() { let conn = fresh_db(); // Insert a custom priced category BEFORE running v10 — the column // doesn't exist yet, so it has no asset_type cell to populate. conn.execute( "INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \ VALUES ('mining_etf', 'x.mining', 'priced', 100, 0)", [], ) .expect("insert custom priced row"); conn.execute_batch(V10_SQL).expect("apply v10"); let v: Option = conn .query_row( "SELECT asset_type FROM balance_categories WHERE key = 'mining_etf'", [], |r| r.get(0), ) .unwrap(); assert!( v.is_none(), "legacy custom priced rows must stay NULL post-migration (no fetch button until user edits)" ); } #[test] fn migration_v10_check_rejects_invalid_asset_type() { let conn = fresh_db(); conn.execute_batch(V10_SQL).expect("apply v10"); let res = conn.execute( "INSERT INTO balance_categories (key, i18n_key, kind, asset_type) \ VALUES ('bogus', 'x', 'priced', 'gold')", [], ); assert!( res.is_err(), "CHECK should reject asset_type values outside ('stock','crypto')" ); } // ========================================================================= // Migration v11 — cleanup orphan balance snapshots (#176) // ------------------------------------------------------------------------- // Validates that the v11 SQL deletes snapshot rows that have no associated // lines (left behind by the pre-#176 race) while preserving rows that have // at least one line. Statement-equivalent to the production migration. // ========================================================================= /// Production v11 SQL — kept in sync with the Migration { version: 11 } /// entry above. const V11_SQL: &str = "DELETE FROM balance_snapshots \ WHERE NOT EXISTS ( \ SELECT 1 FROM balance_snapshot_lines \ WHERE snapshot_id = balance_snapshots.id);"; #[test] fn migration_v11_deletes_orphan_snapshots() { let conn = fresh_db(); conn.execute_batch(V10_SQL).expect("apply v10"); // Seed an orphan: snapshot with NO lines. conn.execute( "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-15')", [], ) .unwrap(); let orphan_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); // Seed a healthy snapshot with one line — needs an account first. // Use the seeded `cash` simple category from v9. let cash_cat_id: 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, 'Test')", [cash_cat_id], ) .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-02-15')", [], ) .unwrap(); let healthy_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, value, price_source) \ VALUES (?1, ?2, 100.0, 'manual')", [healthy_id, acc_id], ) .unwrap(); // Pre-conditions. let pre_count: i64 = conn .query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0)) .unwrap(); assert_eq!(pre_count, 2); // Apply v11. conn.execute_batch(V11_SQL).expect("apply v11"); // Orphan gone, healthy preserved. let post_count: i64 = conn .query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0)) .unwrap(); assert_eq!(post_count, 1, "v11 should delete only the orphan"); let surviving_id: i64 = conn .query_row("SELECT id FROM balance_snapshots", [], |r| r.get(0)) .unwrap(); assert_eq!(surviving_id, healthy_id); // And ensure the orphan id is gone. let still_orphan: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_snapshots WHERE id = ?1", [orphan_id], |r| r.get(0), ) .unwrap(); assert_eq!(still_orphan, 0); } #[test] fn migration_v11_is_idempotent_on_clean_db() { let conn = fresh_db(); conn.execute_batch(V10_SQL).expect("apply v10"); // Empty balance_snapshots — running v11 should be a no-op. conn.execute_batch(V11_SQL).expect("apply v11"); let count: i64 = conn .query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 0); } // ========================================================================= // Migrations v12 + v13 — Bilan axe véhicule (Étape 1) — issue #202 // ------------------------------------------------------------------------- // v12 (additive): adds `vehicle_type` (fiscal envelope — NOT an automobile) // to balance_accounts and `custom_label` to balance_categories, backfills // the envelope onto ex-tfsa/rrsp accounts (cash stays NULL), and recovers // any seed i18n_key that bug I had overwritten with free text. // v13 (reclass): re-links the ex-tfsa/rrsp accounts to the `other` asset // class and deactivates the two now-deprecated envelope seeds. Conditional // + idempotent (no Down migration exists). // // Both constants are statement-equivalent to the Migration { version: 12/13 } // entries in `lib.rs` above (kept in sync by hand, same pattern as V10/V11). // ========================================================================= /// Production v12 SQL — kept in sync with the Migration { version: 12 } entry. const V12_SQL: &str = "ALTER TABLE balance_accounts ADD COLUMN vehicle_type TEXT \ CHECK(vehicle_type IS NULL OR vehicle_type IN \ ('unregistered','tfsa','rrsp','rrif','fhsa','resp')); \ ALTER TABLE balance_categories ADD COLUMN custom_label TEXT; \ UPDATE balance_accounts SET vehicle_type = 'tfsa' \ WHERE balance_category_id = ( \ SELECT id FROM balance_categories WHERE key = 'tfsa'); \ UPDATE balance_accounts SET vehicle_type = 'rrsp' \ WHERE balance_category_id = ( \ SELECT id FROM balance_categories WHERE key = 'rrsp'); \ UPDATE balance_categories SET custom_label = i18n_key \ WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%'; \ UPDATE balance_categories SET i18n_key = 'balance.category.' || key \ WHERE is_seed = 1 AND i18n_key NOT LIKE 'balance.category.%';"; /// Production v13 SQL — kept in sync with the Migration { version: 13 } entry. const V13_SQL: &str = "UPDATE balance_accounts \ SET balance_category_id = ( \ SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1) \ WHERE balance_category_id IN ( \ SELECT id FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_seed = 1) \ AND EXISTS ( \ SELECT 1 FROM balance_categories WHERE key = 'other' AND is_seed = 1); \ UPDATE balance_categories SET is_active = 0 \ WHERE key IN ('tfsa','rrsp') AND is_seed = 1;"; /// Apply the full v9→v11 chain on a fresh DB so v12/v13 tests start from a /// realistic post-v11 state (v9 seeds + v10 asset_type + v11 cleanup). fn db_through_v11() -> Connection { let conn = fresh_db(); // already applied BALANCE_SCHEMA (v9) conn.execute_batch(V10_SQL).expect("apply v10"); conn.execute_batch(V11_SQL).expect("apply v11"); conn } #[test] fn migration_v12_adds_columns_and_backfills_vehicle() { let conn = db_through_v11(); // Seed accounts across the relevant seed categories BEFORE v12 so the // backfill has something to act on. for key in &["cash", "tfsa", "rrsp", "other"] { conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) \ VALUES ((SELECT id FROM balance_categories WHERE key = ?1), ?2)", rusqlite::params![key, format!("acct-{key}")], ) .unwrap(); } conn.execute_batch(V12_SQL).expect("apply v12"); // New columns exist. let acct_cols: Vec = conn .prepare("SELECT name FROM pragma_table_info('balance_accounts')") .unwrap() .query_map([], |r| r.get::<_, String>(0)) .unwrap() .map(|r| r.unwrap()) .collect(); assert!(acct_cols.contains(&"vehicle_type".to_string())); let cat_cols: Vec = conn .prepare("SELECT name FROM pragma_table_info('balance_categories')") .unwrap() .query_map([], |r| r.get::<_, String>(0)) .unwrap() .map(|r| r.unwrap()) .collect(); assert!(cat_cols.contains(&"custom_label".to_string())); // tfsa / rrsp accounts get the envelope; cash + other stay NULL. let read_vehicle = |name: &str| -> Option { conn.query_row( "SELECT vehicle_type FROM balance_accounts WHERE name = ?1", [name], |r| r.get(0), ) .unwrap() }; assert_eq!(read_vehicle("acct-tfsa").as_deref(), Some("tfsa")); assert_eq!(read_vehicle("acct-rrsp").as_deref(), Some("rrsp")); assert!(read_vehicle("acct-cash").is_none(), "cash must stay NULL"); assert!(read_vehicle("acct-other").is_none(), "other must stay NULL"); } #[test] fn migration_v12_defensive_backfill_recovers_overwritten_i18n_key() { // Simulate bug I: a seed category whose i18n_key was clobbered by a // user-typed free-text label ("Mon CELI perso") instead of the // canonical balance.category.tfsa key. let conn = db_through_v11(); conn.execute( "UPDATE balance_categories SET i18n_key = 'Mon CELI perso' WHERE key = 'tfsa'", [], ) .unwrap(); conn.execute_batch(V12_SQL).expect("apply v12"); // The free text is recovered into custom_label, the i18n_key restored. let (i18n, custom): (String, Option) = conn .query_row( "SELECT i18n_key, custom_label FROM balance_categories WHERE key = 'tfsa'", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(i18n, "balance.category.tfsa"); assert_eq!(custom.as_deref(), Some("Mon CELI perso")); // A seed whose i18n_key was already canonical keeps NULL custom_label. let cash_custom: Option = conn .query_row( "SELECT custom_label FROM balance_categories WHERE key = 'cash'", [], |r| r.get(0), ) .unwrap(); assert!(cash_custom.is_none()); } #[test] fn migration_v12_check_rejects_invalid_vehicle_type() { let conn = db_through_v11(); conn.execute_batch(V12_SQL).expect("apply v12"); let res = conn.execute( "INSERT INTO balance_accounts (balance_category_id, name, vehicle_type) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'car')", [], ); assert!( res.is_err(), "CHECK should reject a vehicle_type outside the fiscal enum" ); } #[test] fn migration_v13_reclasses_accounts_and_deactivates_seeds() { let conn = db_through_v11(); // Two ex-envelope accounts + an archived one, plus a cash account. conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI actif')", [], ) .unwrap(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER actif')", [], ) .unwrap(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name, is_active, archived_at) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI archivé', 0, CURRENT_TIMESTAMP)", [], ) .unwrap(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Chèque')", [], ) .unwrap(); conn.execute_batch(V12_SQL).expect("apply v12"); conn.execute_batch(V13_SQL).expect("apply v13"); let other_id: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1", [], |r| r.get(0), ) .unwrap(); // Every ex-tfsa/rrsp account (active OR archived) now points to `other`, // with the envelope preserved on vehicle_type. for (name, vehicle) in &[ ("CELI actif", "tfsa"), ("REER actif", "rrsp"), ("CELI archivé", "tfsa"), ] { let (cat_id, vt): (i64, Option) = conn .query_row( "SELECT balance_category_id, vehicle_type FROM balance_accounts WHERE name = ?1", [name], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(cat_id, other_id, "{name} should be re-linked to other"); assert_eq!(vt.as_deref(), Some(*vehicle), "{name} envelope preserved"); } // The cash account is untouched. let cash_cat: i64 = conn .query_row( "SELECT balance_category_id FROM balance_accounts WHERE name = 'Chèque'", [], |r| r.get(0), ) .unwrap(); let cash_seed: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'cash'", [], |r| r.get(0), ) .unwrap(); assert_eq!(cash_cat, cash_seed); // The tfsa/rrsp seeds are deactivated; the asset-class seeds stay active. let inactive: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_categories \ WHERE key IN ('tfsa','rrsp') AND is_seed = 1 AND is_active = 0", [], |r| r.get(0), ) .unwrap(); assert_eq!(inactive, 2, "both envelope seeds must be deactivated"); let active: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_categories WHERE is_active = 1 AND is_seed = 1", [], |r| r.get(0), ) .unwrap(); assert_eq!(active, 5, "5 asset-class seeds remain active"); } #[test] fn migration_v9_to_v13_chain_preserves_snapshot_lines_and_transfers() { // The headline regression test: run the WHOLE v9→v13 chain on a seeded // profile with snapshots + a transfer on an ex-CELI account, and assert // the financial history is byte-for-byte preserved across the reclass. let conn = seeded_db_with_balance_schema(); // v9 applied + transactions conn.execute_batch(V10_SQL).expect("apply v10"); conn.execute_batch(V11_SQL).expect("apply v11"); // A CELI account (ex-envelope) with two snapshot endpoints + a linked // contribution transfer. conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'Wealthsimple CELI')", [], ) .unwrap(); let acct_id: i64 = conn .query_row( "SELECT id FROM balance_accounts WHERE name = 'Wealthsimple CELI'", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-01'), ('2026-04-01')", [], ) .unwrap(); let s_start: i64 = conn .query_row( "SELECT id FROM balance_snapshots WHERE snapshot_date = '2026-01-01'", [], |r| r.get(0), ) .unwrap(); let s_end: i64 = conn .query_row( "SELECT id FROM balance_snapshots WHERE snapshot_date = '2026-04-01'", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1000.0)", rusqlite::params![s_start, acct_id], ) .unwrap(); conn.execute( "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (?1, ?2, 1500.0)", rusqlite::params![s_end, acct_id], ) .unwrap(); let tx_id: i64 = conn .query_row( "SELECT id FROM transactions WHERE date = '2026-02-01'", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) \ VALUES (?1, ?2, 'in')", rusqlite::params![acct_id, tx_id], ) .unwrap(); // Capture the snapshot_lines fingerprint BEFORE the reclass. let lines_before: Vec<(i64, i64, f64)> = conn .prepare("SELECT snapshot_id, account_id, value FROM balance_snapshot_lines ORDER BY id") .unwrap() .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) .unwrap() .map(|r| r.unwrap()) .collect(); // Run the reclass chain. conn.execute_batch(V12_SQL).expect("apply v12"); conn.execute_batch(V13_SQL).expect("apply v13"); // snapshot_lines are byte-for-byte identical (same ids, same values). let lines_after: Vec<(i64, i64, f64)> = conn .prepare("SELECT snapshot_id, account_id, value FROM balance_snapshot_lines ORDER BY id") .unwrap() .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) .unwrap() .map(|r| r.unwrap()) .collect(); assert_eq!( lines_before, lines_after, "snapshot_lines must be identical before/after the reclass" ); // The account moved to `other` and kept its CELI envelope. let (cat_key, vt): (String, Option) = conn .query_row( "SELECT c.key, a.vehicle_type \ FROM balance_accounts a JOIN balance_categories c ON c.id = a.balance_category_id \ WHERE a.id = ?1", [acct_id], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(cat_key, "other"); assert_eq!(vt.as_deref(), Some("tfsa")); // The transfer link is intact. let transfer_count: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_account_transfers WHERE account_id = ?1 AND transaction_id = ?2", rusqlite::params![acct_id, tx_id], |r| r.get(0), ) .unwrap(); assert_eq!(transfer_count, 1, "transfer must survive the reclass"); } #[test] fn migration_v13_is_idempotent_on_replay() { // Running v13 twice must not change anything the second time. let conn = db_through_v11(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI')", [], ) .unwrap(); conn.execute_batch(V12_SQL).expect("apply v12"); conn.execute_batch(V13_SQL).expect("apply v13 #1"); let other_id: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'other' AND is_seed = 1", [], |r| r.get(0), ) .unwrap(); // Second run: no-op. conn.execute_batch(V13_SQL).expect("apply v13 #2"); let cat_id: i64 = conn .query_row( "SELECT balance_category_id FROM balance_accounts WHERE name = 'CELI'", [], |r| r.get(0), ) .unwrap(); assert_eq!(cat_id, other_id, "account stays on other after replay"); } #[test] fn migration_v13_guard_noop_when_other_seed_missing() { // If the `other` seed is absent (e.g. hard-deleted), v13 must NOT move // accounts to a NULL category — the EXISTS guard makes it a clean no-op. let conn = db_through_v11(); // Add a CELI account, then remove the `other` seed (no FK refs to it). conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI')", [], ) .unwrap(); let tfsa_id: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'tfsa'", [], |r| r.get(0), ) .unwrap(); conn.execute("DELETE FROM balance_categories WHERE key = 'other'", []) .unwrap(); conn.execute_batch(V12_SQL).expect("apply v12"); conn.execute_batch(V13_SQL).expect("apply v13"); // The account stays on tfsa (no NULL category, no broken FK). let cat_id: Option = conn .query_row( "SELECT balance_category_id FROM balance_accounts WHERE name = 'CELI'", [], |r| r.get(0), ) .unwrap(); assert_eq!( cat_id, Some(tfsa_id), "without `other`, the account must not be moved to NULL" ); } // ========================================================================= // Migrations v14 + v15 — Bilan détail par titre (Étape 2) — issue #210 // ------------------------------------------------------------------------- // v14 (additive): creates balance_securities (instrument catalogue, symbol // normalized + COLLATE NOCASE UNIQUE) and balance_snapshot_holdings (per- // security breakdown of a snapshot line; CASCADE on line, RESTRICT on // security) + 2 indexes. // v15 (account pivot): adds `kind` (simple|detailed) + `detailed_since` to // balance_accounts and backfills kind='detailed' on accounts under a priced // category. Purely additive; no Down migration exists. // // V14_SQL is the BALANCE_HOLDINGS_SCHEMA constant (applied verbatim by the // Migration { version: 14 } entry). V15_SQL is statement-equivalent to the // Migration { version: 15 } entry, kept in sync by hand (same pattern as // V10..V13 above). // ========================================================================= /// Production v14 SQL — applied verbatim by Migration { version: 14 }. const V14_SQL: &str = crate::database::BALANCE_HOLDINGS_SCHEMA; /// Production v15 SQL — kept in sync with the Migration { version: 15 } entry. const V15_SQL: &str = "ALTER TABLE balance_accounts ADD COLUMN kind TEXT NOT NULL DEFAULT 'simple' \ CHECK (kind IN ('simple','detailed')); \ ALTER TABLE balance_accounts ADD COLUMN detailed_since DATE; \ UPDATE balance_accounts SET kind = 'detailed' \ WHERE balance_category_id IN ( \ SELECT id FROM balance_categories WHERE kind = 'priced');"; /// Apply the full v10→v13 chain on a fresh DB so v14/v15 tests start from a /// realistic post-Étape-1 state. fn db_through_v13() -> Connection { let conn = fresh_db(); // v9 BALANCE_SCHEMA conn.execute_batch(V10_SQL).expect("apply v10"); conn.execute_batch(V11_SQL).expect("apply v11"); conn.execute_batch(V12_SQL).expect("apply v12"); conn.execute_batch(V13_SQL).expect("apply v13"); conn } #[test] fn migration_v14_creates_securities_and_holdings_tables() { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); // Both tables exist. for table in &["balance_securities", "balance_snapshot_holdings"] { let n: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", [table], |r| r.get(0), ) .unwrap(); assert_eq!(n, 1, "{table} must be created by v14"); } // Both indexes exist. for idx in &[ "idx_balance_snapshot_holdings_line", "idx_balance_snapshot_holdings_security", ] { let n: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1", [idx], |r| r.get(0), ) .unwrap(); assert_eq!(n, 1, "{idx} must be created by v14"); } } #[test] fn migration_v14_symbol_is_case_insensitive_unique() { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); conn.execute( "INSERT INTO balance_securities (symbol, asset_type) VALUES ('AAPL', 'stock')", [], ) .expect("first insert"); // A case-variant of the same symbol must collide on the NOCASE UNIQUE. let res = conn.execute( "INSERT INTO balance_securities (symbol, asset_type) VALUES ('aapl', 'stock')", [], ); assert!( res.is_err(), "COLLATE NOCASE UNIQUE must reject 'aapl' after 'AAPL'" ); // currency defaults to CAD. let currency: String = conn .query_row( "SELECT currency FROM balance_securities WHERE symbol = 'AAPL'", [], |r| r.get(0), ) .unwrap(); assert_eq!(currency, "CAD"); } #[test] fn migration_v14_check_rejects_invalid_asset_type() { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); let res = conn.execute( "INSERT INTO balance_securities (symbol, asset_type) VALUES ('GLD', 'gold')", [], ); assert!( res.is_err(), "CHECK should reject asset_type outside ('stock','crypto')" ); } #[test] fn migration_v14_holdings_fk_policies_cascade_and_restrict() { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); // Build a snapshot line on a (priced) account so a holding can attach. let stock_cat: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'stock'", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Courtage')", [stock_cat], ) .unwrap(); let acc_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-01')", [], ) .unwrap(); let snap_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value) \ VALUES (?1, ?2, 10.0, 50.0, 500.0)", rusqlite::params![snap_id, acc_id], ) .unwrap(); let line_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_securities (symbol, asset_type) VALUES ('AAPL', 'stock')", [], ) .unwrap(); let sec_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_snapshot_holdings \ (snapshot_line_id, security_id, quantity, unit_price, value) \ VALUES (?1, ?2, 10.0, 50.0, 500.0)", rusqlite::params![line_id, sec_id], ) .unwrap(); // RESTRICT: deleting a referenced security must fail. let del_sec = conn.execute( "DELETE FROM balance_securities WHERE id = ?1", [sec_id], ); assert!( del_sec.is_err(), "ON DELETE RESTRICT must block deleting a referenced security" ); // CASCADE: deleting the snapshot line removes its holdings. conn.execute("DELETE FROM balance_snapshot_lines WHERE id = ?1", [line_id]) .unwrap(); let holdings_left: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1", [line_id], |r| r.get(0), ) .unwrap(); assert_eq!(holdings_left, 0, "CASCADE must remove holdings with the line"); } #[test] fn migration_v14_holdings_unique_line_security() { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); // Minimal line + security. let stock_cat: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'stock'", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'C')", [stock_cat], ) .unwrap(); let acc_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-06-02')", [], ) .unwrap(); let snap_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value) \ VALUES (?1, ?2, 1.0, 1.0, 1.0)", rusqlite::params![snap_id, acc_id], ) .unwrap(); let line_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_securities (symbol, asset_type) VALUES ('BTC', 'crypto')", [], ) .unwrap(); let sec_id: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_snapshot_holdings (snapshot_line_id, security_id, quantity, unit_price, value) \ VALUES (?1, ?2, 1.0, 1.0, 1.0)", rusqlite::params![line_id, sec_id], ) .unwrap(); // Second holding of the same security on the same line must collide. let dup = conn.execute( "INSERT INTO balance_snapshot_holdings (snapshot_line_id, security_id, quantity, unit_price, value) \ VALUES (?1, ?2, 2.0, 2.0, 4.0)", rusqlite::params![line_id, sec_id], ); assert!( dup.is_err(), "UNIQUE(snapshot_line_id, security_id) must reject a duplicate holding" ); } #[test] fn migration_v15_adds_columns_and_backfills_priced_accounts() { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); // A priced (stock) account and a simple (cash) account, both pre-v15. let stock_cat: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'stock'", [], |r| r.get(0), ) .unwrap(); let cash_cat: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'cash'", [], |r| r.get(0), ) .unwrap(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Courtage')", [stock_cat], ) .unwrap(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Chèque')", [cash_cat], ) .unwrap(); conn.execute_batch(V15_SQL).expect("apply v15"); // New columns exist. let cols: Vec = conn .prepare("SELECT name FROM pragma_table_info('balance_accounts')") .unwrap() .query_map([], |r| r.get::<_, String>(0)) .unwrap() .map(|r| r.unwrap()) .collect(); assert!(cols.contains(&"kind".to_string())); assert!(cols.contains(&"detailed_since".to_string())); // Backfill: priced account → detailed; simple account stays simple. let read_kind = |name: &str| -> String { conn.query_row( "SELECT kind FROM balance_accounts WHERE name = ?1", [name], |r| r.get(0), ) .unwrap() }; assert_eq!(read_kind("Courtage"), "detailed", "priced account → detailed"); assert_eq!(read_kind("Chèque"), "simple", "cash account stays simple"); // detailed_since stays NULL for both (pivot set later by #211). let ds: Option = conn .query_row( "SELECT detailed_since FROM balance_accounts WHERE name = 'Courtage'", [], |r| r.get(0), ) .unwrap(); assert!(ds.is_none(), "detailed_since must be NULL until conversion"); } #[test] fn migration_v15_check_rejects_invalid_kind() { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); conn.execute_batch(V15_SQL).expect("apply v15"); let res = conn.execute( "INSERT INTO balance_accounts (balance_category_id, name, kind) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'partial')", [], ); assert!( res.is_err(), "CHECK should reject a kind outside ('simple','detailed')" ); } #[test] fn migration_v15_default_kind_is_simple() { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); conn.execute_batch(V15_SQL).expect("apply v15"); // An account inserted post-v15 without specifying kind defaults to simple. conn.execute( "INSERT INTO balance_accounts (balance_category_id, name) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'New cash')", [], ) .unwrap(); let kind: String = conn .query_row( "SELECT kind FROM balance_accounts WHERE name = 'New cash'", [], |r| r.get(0), ) .unwrap(); assert_eq!(kind, "simple", "kind must default to 'simple'"); } // ========================================================================= // Migration v16 — Bilan détail par titre (Étape 2) — issue #211 // ------------------------------------------------------------------------- // Data conversion: each existing single-security priced account becomes a // detailed account with one security + one mirror holding, zero data loss. // - Step 1: one balance_securities per priced account symbol (normalized, // deduped), ONLY for accounts whose category has a real asset_type. // - Step 2: one holding per priced line (qty/price/value/source mirrored). // - Step 3: NULL the per-line qty/unit_price ONLY where a holding now // exists (the 🔴 fix — never NULL a line that got no holding). // - Trailing TEMP-table CHECK(ok = 1) aborts if any NULLed line lacks a // holding, rolling back the whole v16 transaction. // // V16_SQL is statement-equivalent to the Migration { version: 16 } entry, // kept in sync by hand (same pattern as V10..V15 above). // ========================================================================= /// Production v16 SQL — kept in sync with the Migration { version: 16 } entry. const V16_SQL: &str = "INSERT INTO balance_securities (symbol, currency, asset_type) \ SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \ FROM balance_accounts a \ JOIN balance_categories c ON c.id = a.balance_category_id \ WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \ ON CONFLICT(symbol) DO NOTHING; \ INSERT INTO balance_snapshot_holdings \ (snapshot_line_id, security_id, quantity, unit_price, value, price_source, price_fetched_at) \ SELECT sl.id, s.id, sl.quantity, sl.unit_price, sl.value, sl.price_source, sl.price_fetched_at \ FROM balance_snapshot_lines sl \ JOIN balance_accounts a ON a.id = sl.account_id \ JOIN balance_securities s ON s.symbol = UPPER(TRIM(a.symbol)) \ WHERE a.symbol IS NOT NULL AND sl.quantity IS NOT NULL \ ON CONFLICT(snapshot_line_id, security_id) DO NOTHING; \ UPDATE balance_snapshot_lines \ SET quantity = NULL, unit_price = NULL \ WHERE quantity IS NOT NULL \ AND id IN (SELECT snapshot_line_id FROM balance_snapshot_holdings); \ CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \ INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \ SELECT 1 FROM balance_snapshot_lines sl \ JOIN balance_accounts a ON a.id = sl.account_id \ WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \ AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \ ) THEN 0 ELSE 1 END; \ DROP TABLE _v16_guard;"; /// Build a realistic pre-v16 DB: full v10→v15 chain applied, then two priced /// accounts seeded — one CONVERTIBLE (seed `stock` category, asset_type set) /// and one NON-CONVERTIBLE (custom priced category with asset_type still /// NULL) — each with one snapshot line. Returns (conn, convertible_line_id, /// non_convertible_line_id). fn db_pre_v16() -> (Connection, i64, i64) { let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); conn.execute_batch(V15_SQL).expect("apply v15"); // (a) Convertible: seed `stock` category carries asset_type = 'stock' (v10). 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, symbol, kind) \ VALUES (?1, 'Courtage AAPL', ' aapl ', 'detailed')", [stock_cat], ) .unwrap(); let acc_a: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); // (b) Non-convertible: a custom priced category with NO asset_type. conn.execute( "INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \ VALUES ('custom_priced', 'custom', 'priced', 80, 0)", [], ) .unwrap(); let custom_cat: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); conn.execute( "INSERT INTO balance_accounts (balance_category_id, name, symbol, kind) \ VALUES (?1, 'Courtage XYZ', 'XYZ', 'detailed')", [custom_cat], ) .unwrap(); let acc_b: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); // One snapshot, one priced line per account. 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, price_source, price_fetched_at) \ VALUES (?1, ?2, 10.0, 50.0, 500.0, 'maximus-api', '2026-06-01T12:00:00')", rusqlite::params![snap_id, acc_a], ) .unwrap(); let line_a: 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, price_source) \ VALUES (?1, ?2, 3.0, 7.0, 21.0, 'manual')", rusqlite::params![snap_id, acc_b], ) .unwrap(); let line_b: i64 = conn .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) .unwrap(); (conn, line_a, line_b) } #[test] fn migration_v16_converts_priced_with_asset_type_and_preserves_values() { let (conn, line_a, _line_b) = db_pre_v16(); conn.execute_batch(V16_SQL).expect("apply v16"); // A security was minted from the normalized symbol (' aapl ' → 'AAPL'). let sec_id: i64 = conn .query_row( "SELECT id FROM balance_securities WHERE symbol = 'AAPL'", [], |r| r.get(0), ) .expect("security AAPL must exist (normalized UPPER(TRIM))"); let (sym, cur, at): (String, String, String) = conn .query_row( "SELECT symbol, currency, asset_type FROM balance_securities WHERE id = ?1", [sec_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .unwrap(); assert_eq!(sym, "AAPL"); assert_eq!(cur, "CAD"); assert_eq!(at, "stock"); // One mirror holding on line_a with the original qty/price/value/source. let (h_qty, h_price, h_val, h_src, h_fetched, h_book): ( f64, f64, f64, String, String, Option, ) = conn .query_row( "SELECT quantity, unit_price, value, price_source, price_fetched_at, book_cost \ FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1", [line_a], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)), ) .expect("a holding must mirror the converted line"); assert_eq!(h_qty, 10.0); assert_eq!(h_price, 50.0); assert_eq!(h_val, 500.0); assert_eq!(h_src, "maximus-api"); assert_eq!(h_fetched, "2026-06-01T12:00:00"); assert!(h_book.is_none(), "book_cost stays NULL for historical lines"); assert_eq!( h_qty * h_price, h_val, "holding value must equal quantity * unit_price" ); // The aggregated line keeps its total value but qty/unit_price are NULLed. let (l_qty, l_price, l_val): (Option, Option, f64) = conn .query_row( "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1", [line_a], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .unwrap(); assert!(l_qty.is_none(), "converted line quantity must be NULL"); assert!(l_price.is_none(), "converted line unit_price must be NULL"); assert_eq!(l_val, 500.0, "converted line value must be preserved"); } #[test] fn migration_v16_leaves_priced_without_asset_type_intact() { let (conn, _line_a, line_b) = db_pre_v16(); conn.execute_batch(V16_SQL).expect("apply v16"); // No security minted for the symbol 'XYZ' (its category had no asset_type). let xyz_secs: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'XYZ'", [], |r| r.get(0), ) .unwrap(); assert_eq!(xyz_secs, 0, "no security for a priced account without asset_type"); // No holding attached to line_b. let holdings_b: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1", [line_b], |r| r.get(0), ) .unwrap(); assert_eq!(holdings_b, 0, "non-convertible line must get no holding"); // line_b is left fully intact — qty/unit_price NOT NULLed (no silent loss). let (q, p, v): (Option, Option, f64) = conn .query_row( "SELECT quantity, unit_price, value FROM balance_snapshot_lines WHERE id = ?1", [line_b], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .unwrap(); assert_eq!(q, Some(3.0), "intact line keeps its quantity"); assert_eq!(p, Some(7.0), "intact line keeps its unit_price"); assert_eq!(v, 21.0, "intact line keeps its value"); } #[test] fn migration_v16_is_idempotent_on_rerun() { let (conn, line_a, _line_b) = db_pre_v16(); conn.execute_batch(V16_SQL).expect("first v16"); // Re-running must be a strict no-op (ON CONFLICT no-ops, guard re-passes). conn.execute_batch(V16_SQL).expect("second v16 (idempotent)"); let n_secs: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_securities WHERE symbol = 'AAPL'", [], |r| r.get(0), ) .unwrap(); assert_eq!(n_secs, 1, "re-run must not duplicate the security"); let n_holdings: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_snapshot_holdings WHERE snapshot_line_id = ?1", [line_a], |r| r.get(0), ) .unwrap(); assert_eq!(n_holdings, 1, "re-run must not duplicate the holding"); } #[test] fn migration_v16_guard_aborts_and_rolls_back_on_injected_failure() { // Simulate a buggy v16 where the holdings-insert step is skipped: step 3 // would NULL a line that got no holding, and the trailing guard must // catch the broken invariant and ABORT. We run the corrupted SQL inside // an explicit rusqlite transaction that is dropped (→ rolled back) on the // returned error — exactly how sqlx rolls back a failed migration. SQLite // ABORT resolution only undoes the offending statement, so the whole- // migration safety relies on the surrounding transaction being rolled // back (which this test asserts). let (mut conn, line_a, _line_b) = db_pre_v16(); // The corrupted batch: step 1 + step 3 + guard, with step 2 (holdings) // intentionally removed. NULLing now happens on lines with no holding. const V16_CORRUPT: &str = "\ INSERT INTO balance_securities (symbol, currency, asset_type) \ SELECT DISTINCT UPPER(TRIM(a.symbol)), a.currency, c.asset_type \ FROM balance_accounts a \ JOIN balance_categories c ON c.id = a.balance_category_id \ WHERE a.symbol IS NOT NULL AND c.asset_type IS NOT NULL \ ON CONFLICT(symbol) DO NOTHING; \ UPDATE balance_snapshot_lines SET quantity = NULL, unit_price = NULL \ WHERE quantity IS NOT NULL; \ CREATE TEMP TABLE _v16_guard (ok INTEGER CHECK (ok = 1)); \ INSERT INTO _v16_guard(ok) SELECT CASE WHEN EXISTS ( \ SELECT 1 FROM balance_snapshot_lines sl \ JOIN balance_accounts a ON a.id = sl.account_id \ WHERE a.symbol IS NOT NULL AND sl.quantity IS NULL \ AND NOT EXISTS (SELECT 1 FROM balance_snapshot_holdings h WHERE h.snapshot_line_id = sl.id) \ ) THEN 0 ELSE 1 END; \ DROP TABLE _v16_guard;"; { let tx = conn.transaction().expect("begin tx"); let res = tx.execute_batch(V16_CORRUPT); assert!( res.is_err(), "the CHECK(ok = 1) guard must abort when a NULLed line lacks a holding" ); // tx dropped here without commit → ROLLBACK (mirrors sqlx on error). } // Rollback left the DB at its pre-v16 state: zero holdings, qty intact. let total_holdings: i64 = conn .query_row("SELECT COUNT(*) FROM balance_snapshot_holdings", [], |r| r.get(0)) .unwrap(); assert_eq!(total_holdings, 0, "rollback must leave zero holdings"); let (q, p): (Option, Option) = conn .query_row( "SELECT quantity, unit_price FROM balance_snapshot_lines WHERE id = ?1", [line_a], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(q, Some(10.0), "rollback must leave quantity intact"); assert_eq!(p, Some(50.0), "rollback must leave unit_price intact"); // And no securities were committed either. let total_secs: i64 = conn .query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0)) .unwrap(); assert_eq!(total_secs, 0, "rollback must leave zero securities"); } #[test] fn migration_v16_full_chain_applies_cleanly() { // The whole v10→v16 chain on a fresh DB with no priced accounts is a // no-op for v16 and must apply without error (guard passes vacuously). let conn = db_through_v13(); conn.execute_batch(V14_SQL).expect("apply v14"); conn.execute_batch(V15_SQL).expect("apply v15"); conn.execute_batch(V16_SQL).expect("apply v16 on empty data"); let secs: i64 = conn .query_row("SELECT COUNT(*) FROM balance_securities", [], |r| r.get(0)) .unwrap(); assert_eq!(secs, 0, "no securities when there are no priced accounts"); } // ------------------------------------------------------------------------- // Consolidated schema (new profiles) — issue #202 // ------------------------------------------------------------------------- // The consolidated schema must initialize a brand-new profile cleanly with // the post-Étape-1 shape: 5 asset-class categories, 4 starter accounts (no // NULL category_id), and the CELI/REER starters carrying vehicle_type. /// Apply the full consolidated schema on a fresh in-memory DB, the same way /// `get_new_profile_init_sql` does for a brand-new profile. fn consolidated_db() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute("PRAGMA foreign_keys = ON;", []) .expect("enable FKs"); conn.execute_batch(crate::database::CONSOLIDATED_SCHEMA) .expect("apply consolidated schema"); conn } #[test] fn consolidated_schema_inits_cleanly_with_5_categories_and_4_starters() { let conn = consolidated_db(); // Exactly 5 active asset-class seeds, no tfsa/rrsp. let active_seeds: Vec = conn .prepare( "SELECT key FROM balance_categories WHERE is_seed = 1 AND is_active = 1 ORDER BY sort_order", ) .unwrap() .query_map([], |r| r.get::<_, String>(0)) .unwrap() .map(|r| r.unwrap()) .collect(); assert_eq!( active_seeds, vec!["cash", "fund", "other", "stock", "crypto"], "new profiles ship exactly 5 asset-class seeds (no envelope categories)" ); let envelope_seeds: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_categories WHERE key IN ('tfsa','rrsp')", [], |r| r.get(0), ) .unwrap(); assert_eq!(envelope_seeds, 0, "no tfsa/rrsp category in a new profile"); // 4 starter accounts, none with a NULL category (#1 review risk). let starter_count: i64 = conn .query_row("SELECT COUNT(*) FROM balance_accounts", [], |r| r.get(0)) .unwrap(); assert_eq!(starter_count, 4); let null_cat: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_accounts WHERE balance_category_id IS NULL", [], |r| r.get(0), ) .unwrap(); assert_eq!(null_cat, 0, "no starter may have a NULL balance_category_id"); // The CELI / REER starters live in `other` and carry their envelope. let other_id: i64 = conn .query_row( "SELECT id FROM balance_categories WHERE key = 'other'", [], |r| r.get(0), ) .unwrap(); for (name, vehicle) in &[("CELI", "tfsa"), ("REER", "rrsp")] { let (cat_id, vt): (i64, Option) = conn .query_row( "SELECT balance_category_id, vehicle_type FROM balance_accounts WHERE name = ?1", [name], |r| Ok((r.get(0)?, r.get(1)?)), ) .unwrap(); assert_eq!(cat_id, other_id, "{name} starter must attach to other"); assert_eq!(vt.as_deref(), Some(*vehicle), "{name} carries its envelope"); } // The plain starters have a NULL envelope. for name in &["Compte chèque", "Compte non-enregistré"] { let vt: Option = conn .query_row( "SELECT vehicle_type FROM balance_accounts WHERE name = ?1", [name], |r| r.get(0), ) .unwrap(); assert!(vt.is_none(), "{name} must have a NULL vehicle_type"); } } #[test] fn consolidated_schema_check_rejects_invalid_vehicle_type() { let conn = consolidated_db(); let res = conn.execute( "INSERT INTO balance_accounts (balance_category_id, name, vehicle_type) \ VALUES ((SELECT id FROM balance_categories WHERE key = 'cash'), 'bad', 'truck')", [], ); assert!( res.is_err(), "consolidated CHECK should reject a vehicle_type outside the fiscal enum" ); } #[test] fn consolidated_schema_has_holdings_tables_and_kind_at_parity() { // The consolidated schema (new profiles) must ship the v14 tables and // the v15 account columns from the start — parity with the migrations. let conn = consolidated_db(); for table in &["balance_securities", "balance_snapshot_holdings"] { let n: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", [table], |r| r.get(0), ) .unwrap(); assert_eq!(n, 1, "new profiles must ship {table}"); } for idx in &[ "idx_balance_snapshot_holdings_line", "idx_balance_snapshot_holdings_security", ] { let n: i64 = conn .query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?1", [idx], |r| r.get(0), ) .unwrap(); assert_eq!(n, 1, "new profiles must ship {idx}"); } // The kind/detailed_since columns exist on balance_accounts. let cols: Vec = conn .prepare("SELECT name FROM pragma_table_info('balance_accounts')") .unwrap() .query_map([], |r| r.get::<_, String>(0)) .unwrap() .map(|r| r.unwrap()) .collect(); assert!(cols.contains(&"kind".to_string())); assert!(cols.contains(&"detailed_since".to_string())); // All 4 starters sit under simple asset classes (cash/other), so the // v15 backfill is a no-op here — every starter is 'simple'. let detailed: i64 = conn .query_row( "SELECT COUNT(*) FROM balance_accounts WHERE kind = 'detailed'", [], |r| r.get(0), ) .unwrap(); assert_eq!(detailed, 0, "no starter account is under a priced category"); // The NOCASE UNIQUE on securities is enforced in the consolidated path too. conn.execute( "INSERT INTO balance_securities (symbol, asset_type) VALUES ('SHOP', 'stock')", [], ) .unwrap(); let dup = conn.execute( "INSERT INTO balance_securities (symbol, asset_type) VALUES ('shop', 'stock')", [], ); assert!(dup.is_err(), "consolidated securities must reject case-dupes"); } }