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, }, ]; 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, ]) .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"); } }