From f758282c2ea952b9df8ade70515bc6ee7a3bef68 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 6 Oct 2026 20:35:00 -0400 Subject: [PATCH 1/5] fix(transactions): bind the manual-categorization flag as an integer (#390) updateTransactionCategory bound the JS boolean `true`. tauri-plugin-sql passes a bool to sqlx as a JSON value, so the column received the TEXT 'true', which the getting-started checklist's `is_manually_categorized = 1` never matches: step 5 never ticked. toSqlFlag turns a boolean, a number or a backup's string into 0/1. It is used when a category is set by hand and when a JSON or CSV backup is restored, since a backup exported while the bug stood carries "true" (the CSV path read it as 0). Co-Authored-By: Claude Opus 5.5 --- src/services/dataExportService.test.ts | 34 +++++++++++++++++ src/services/dataExportService.ts | 7 +++- src/services/transactionService.test.ts | 21 ++++++++++- src/services/transactionService.ts | 5 ++- src/utils/sqlFlag.test.ts | 49 +++++++++++++++++++++++++ src/utils/sqlFlag.ts | 22 +++++++++++ 6 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 src/utils/sqlFlag.test.ts create mode 100644 src/utils/sqlFlag.ts diff --git a/src/services/dataExportService.test.ts b/src/services/dataExportService.test.ts index fdef24e..3e101bf 100644 --- a/src/services/dataExportService.test.ts +++ b/src/services/dataExportService.test.ts @@ -38,6 +38,7 @@ import { importCategoriesOnly, importTransactionsOnly, importTransactionsWithCategories, + parseImportedCsv, parseImportedJson, serializeToJson, validateImportedFormatRows, @@ -728,3 +729,36 @@ describe("the refusal messages are translated (#331)", () => { } }); }); + +// --------------------------------------------------------------------------- +// 5. The manual-categorization flag (#390) +// --------------------------------------------------------------------------- + +describe("a restore writes the manual flag as an integer (#390)", () => { + // A backup exported while the Transactions page bound the JS boolean carries + // the text "true"; written back as is, the checklist's `= 1` misses it. + const fromTheBug = { ...transactionFixture(1), is_manually_categorized: "true" as unknown as number }; + + it("restores the 'true' of an older JSON backup as 1", async () => { + await importTransactionsWithCategories( + envelopeData({ transactions: [fromTheBug, transactionFixture(2)] }), + "backup.json" + ); + expect(db.tables.transactions.map((t) => t.is_manually_categorized)).toEqual([1, 0]); + }); + + it("does the same in transactions_only mode", async () => { + await importTransactionsOnly(envelopeData({ transactions: [fromTheBug] }), "backup.json"); + expect(db.tables.transactions[0].is_manually_categorized).toBe(1); + }); + + it("reads the flag of a CSV export, 'true' included", () => { + const csv = + "date,description,amount,is_manually_categorized,is_split\n" + + "2026-01-01,a,-1,true,0\n" + + "2026-01-02,b,-2,1,0\n" + + "2026-01-03,c,-3,0,0\n"; + const { transactions } = parseImportedCsv(csv); + expect(transactions.map((t) => t.is_manually_categorized)).toEqual([1, 1, 0]); + }); +}); diff --git a/src/services/dataExportService.ts b/src/services/dataExportService.ts index 7050564..bd3ef9a 100644 --- a/src/services/dataExportService.ts +++ b/src/services/dataExportService.ts @@ -1,6 +1,7 @@ import type Database from "@tauri-apps/plugin-sql"; import { getDb, withTransaction } from "./db"; import Papa from "papaparse"; +import { toSqlFlag } from "../utils/sqlFlag"; import type { Category, ImportConfigTemplate, @@ -360,7 +361,8 @@ export function parseImportedCsv(content: string): { category_name: row.category_name || null, original_description: row.original_description || null, notes: row.notes || null, - is_manually_categorized: parseInt(row.is_manually_categorized) || 0, + // An export from the time of #390 carries "true" in this column. + is_manually_categorized: toSqlFlag(row.is_manually_categorized), is_split: parseInt(row.is_split) || 0, parent_transaction_id: row.parent_transaction_id ? parseInt(row.parent_transaction_id) @@ -638,7 +640,8 @@ async function attachTransactions( tx.category_id, tx.original_description, tx.notes, - tx.is_manually_categorized, + // A JSON backup from the time of #390 carries the text "true". + toSqlFlag(tx.is_manually_categorized), tx.is_split, tx.parent_transaction_id, sourceId, diff --git a/src/services/transactionService.test.ts b/src/services/transactionService.test.ts index ed615e5..317fb95 100644 --- a/src/services/transactionService.test.ts +++ b/src/services/transactionService.test.ts @@ -9,7 +9,7 @@ vi.mock("./db", () => { }); import { getDb } from "./db"; -import { getTransactionCount } from "./transactionService"; +import { getTransactionCount, updateTransactionCategory } from "./transactionService"; const mockSelect = vi.fn(); const mockExecute = vi.fn(); @@ -41,3 +41,22 @@ describe("getTransactionCount (#351)", () => { await expect(getTransactionCount()).resolves.toBe(0); }); }); + +describe("updateTransactionCategory (#390)", () => { + // tauri-plugin-sql stores a bound JS boolean as the TEXT 'true', which the + // getting-started checklist's `is_manually_categorized = 1` never matches. + it("binds the manual flag as the integer 1, never as a boolean", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateTransactionCategory(5, 12, true); + expect(mockExecute).toHaveBeenCalledTimes(1); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params).toEqual([12, 1, 5]); + expect(typeof params[1]).toBe("number"); + }); + + it("binds 0 when the category is not set by hand", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateTransactionCategory(5, null, false); + expect(mockExecute.mock.calls[0][1]).toEqual([null, 0, 5]); + }); +}); diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index c10ee08..0de93e0 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -4,6 +4,7 @@ import { isLinkedTransactionFkError, type LinkedTransferTooltipRow, } from "./balance.service"; +import { toSqlFlag } from "../utils/sqlFlag"; import type { Transaction, TransactionRow, @@ -299,9 +300,11 @@ export async function updateTransactionCategory( isManual: boolean ): Promise { const db = await getDb(); + // Never bind the boolean itself: tauri-plugin-sql would store the TEXT + // 'true', which `is_manually_categorized = 1` never matches (#390). await db.execute( `UPDATE transactions SET category_id = $1, is_manually_categorized = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3`, - [categoryId, isManual, txId] + [categoryId, toSqlFlag(isManual), txId] ); } diff --git a/src/utils/sqlFlag.test.ts b/src/utils/sqlFlag.test.ts new file mode 100644 index 0000000..1676153 --- /dev/null +++ b/src/utils/sqlFlag.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { toSqlFlag } from "./sqlFlag"; + +describe("toSqlFlag (#390)", () => { + it("maps the UI boolean to an integer, never to the boolean itself", () => { + expect(toSqlFlag(true)).toBe(1); + expect(toSqlFlag(false)).toBe(0); + }); + + it("keeps database integers", () => { + expect(toSqlFlag(1)).toBe(1); + expect(toSqlFlag(0)).toBe(0); + }); + + it("treats any finite non-zero number as set, like SQLite", () => { + expect(toSqlFlag(2)).toBe(1); + expect(toSqlFlag(-1)).toBe(1); + }); + + it("maps NaN and infinities to 0", () => { + expect(toSqlFlag(Number.NaN)).toBe(0); + expect(toSqlFlag(Number.POSITIVE_INFINITY)).toBe(0); + }); + + it("reads the 'true' a backup carries from the time of the bug", () => { + expect(toSqlFlag("true")).toBe(1); + expect(toSqlFlag("TRUE")).toBe(1); + expect(toSqlFlag(" true ")).toBe(1); + }); + + it("reads a CSV cell", () => { + expect(toSqlFlag("1")).toBe(1); + expect(toSqlFlag(" 1 ")).toBe(1); + expect(toSqlFlag("0")).toBe(0); + expect(toSqlFlag("")).toBe(0); + }); + + it("maps every other string to 0", () => { + expect(toSqlFlag("false")).toBe(0); + expect(toSqlFlag("yes")).toBe(0); + expect(toSqlFlag("01")).toBe(0); + }); + + it("maps absent values to 0", () => { + expect(toSqlFlag(null)).toBe(0); + expect(toSqlFlag(undefined)).toBe(0); + expect(toSqlFlag({})).toBe(0); + }); +}); diff --git a/src/utils/sqlFlag.ts b/src/utils/sqlFlag.ts new file mode 100644 index 0000000..2b297e5 --- /dev/null +++ b/src/utils/sqlFlag.ts @@ -0,0 +1,22 @@ +/** + * Turn any truthy-looking value into the integer SQLite expects for a 0/1 + * flag column (#390). + * + * Never bind a JS boolean through tauri-plugin-sql: the plugin only special- + * cases null, strings and numbers, so `true` reaches sqlx as a JSON value and + * is stored as the TEXT `'true'`. An INTEGER column keeps that text (it is not + * a number), and `WHERE flag = 1` never matches it. + * + * Accepts what a flag can look like on the way in: a boolean from the UI, a + * number from the database, or a string from a backup (`"1"`, or `"true"` in a + * backup exported while the bug stood). + */ +export function toSqlFlag(value: unknown): 0 | 1 { + if (value === true) return 1; + if (typeof value === "number") return Number.isFinite(value) && value !== 0 ? 1 : 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" ? 1 : 0; + } + return 0; +} -- 2.45.2 From b0206ae56b6b1777a6f83ed92c63263f61728987 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 6 Oct 2026 20:35:00 -0400 Subject: [PATCH 2/5] fix(profiles): catch secondary profiles up on missed migrations (#390) tauri-plugin-sql migrates only the database registered with add_migrations, the default profile's. A secondary profile kept the consolidated schema of the version that created it and never received a migration afterwards. prepare_profile_db runs before every Database.load: - secondary profiles catch up with the same migrations() list (pinned SHA-384), history kept in _sqlx_migrations; a database without history gets a floor inferred from its schema (the seven released creation states are test fixtures), so nothing at or below it is ever replayed; - a VACUUM INTO copy is taken before the first write, synced, then renamed; - each version runs in its own transaction with foreign keys on; one that fails on a foreign key is retried once without them and its orphans are set to NULL where the key declares ON DELETE SET NULL; - every profile gets its TEXT manual-categorization flags repaired; - failures are logged with file names only and the profile still opens. New profiles are born with their full history (get_new_profile_init_sql). The plugin keeps databases under app_config_dir(), not app_data_dir(): the same folder on Windows and macOS, not on Linux. repair_migrations, delete_profile_db and compute_account_return looked in the wrong folder there. profile_db_path resolves the plugin's folder and validates the file name; deleting a profile now also removes its SQLite side files and catch-up copies. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.toml | 3 + src-tauri/src/commands/balance_commands.rs | 17 +- src-tauri/src/commands/profile_commands.rs | 201 ++- .../src/database/consolidated_schema.sql | 17 +- .../fixtures/consolidated_v0.10.0.sql | 824 ++++++++++ .../database/fixtures/consolidated_v0.3.0.sql | 184 +++ .../database/fixtures/consolidated_v0.8.4.sql | 657 ++++++++ .../database/fixtures/consolidated_v0.9.0.sql | 747 +++++++++ .../database/fixtures/consolidated_v0.9.1.sql | 764 +++++++++ .../src/database/fixtures/seed_v0.3.0.sql | 232 +++ .../src/database/fixtures/seed_v0.4.0.sql | 241 +++ src-tauri/src/database/mod.rs | 6 + src-tauri/src/database/profile_db.rs | 1392 +++++++++++++++++ src-tauri/src/lib.rs | 29 +- src/services/db.test.ts | 121 +- src/services/db.ts | 69 + 16 files changed, 5447 insertions(+), 57 deletions(-) create mode 100644 src-tauri/src/database/fixtures/consolidated_v0.10.0.sql create mode 100644 src-tauri/src/database/fixtures/consolidated_v0.3.0.sql create mode 100644 src-tauri/src/database/fixtures/consolidated_v0.8.4.sql create mode 100644 src-tauri/src/database/fixtures/consolidated_v0.9.0.sql create mode 100644 src-tauri/src/database/fixtures/consolidated_v0.9.1.sql create mode 100644 src-tauri/src/database/fixtures/seed_v0.3.0.sql create mode 100644 src-tauri/src/database/fixtures/seed_v0.4.0.sql create mode 100644 src-tauri/src/database/profile_db.rs diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 797077b..3383a26 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -66,6 +66,9 @@ hmac = "0.12" ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } # HTTP mock server for balance_commands fetch_price tests (Issue #155). mockito = "1.6" +# MockRuntime for the profile database folder test (#390). The `test` +# feature adds no crate (`test = []` in tauri's manifest). +tauri = { version = "2", features = ["test"] } [features] # Dev-only escape hatch: when enabled, the SR_DEV_EDITION env var forces the diff --git a/src-tauri/src/commands/balance_commands.rs b/src-tauri/src/commands/balance_commands.rs index 5a75140..26ae3ea 100644 --- a/src-tauri/src/commands/balance_commands.rs +++ b/src-tauri/src/commands/balance_commands.rs @@ -9,8 +9,9 @@ //! `Authorization`, `Accept`, and `User-Agent` headers. //! //! Database access pattern: -//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`, -//! matching the existing `repair_migrations` helper in `profile_commands.rs`. +//! - All reads open the file `profile_db_path` resolves (`profile_commands.rs`): +//! the folder where tauri-plugin-sql keeps the databases, which is NOT +//! `app_data_dir` on Linux (#390). //! - The frontend passes `db_filename` (the active profile DB), exactly //! like it does for `repair_migrations` and `delete_profile_db`. Keeps //! the active-profile resolution where it already lives (in TS) and @@ -258,16 +259,10 @@ pub fn compute_account_return( let start_date = parse_iso_date(&period_start, "period_start")?; let end_date = parse_iso_date(&period_end, "period_end")?; - let app_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("Cannot get app data dir: {}", e))?; - let db_path = app_dir.join(&db_filename); + let db_path = super::profile_commands::profile_db_path(&app, &db_filename)?; if !db_path.exists() { - return Err(format!( - "Profile database not found: {}", - db_path.display() - )); + // The file name only: the folder holds the OS user name. + return Err(format!("Profile database not found: {}", db_filename)); } let conn = Connection::open(&db_path) diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index f955ae6..49f99a4 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -3,10 +3,12 @@ use rand::RngCore; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256, Sha384}; use std::fs; +use std::path::{Path, PathBuf}; use subtle::ConstantTimeEq; use tauri::Manager; use crate::database; +use crate::database::profile_db; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { @@ -32,6 +34,49 @@ fn get_profiles_path(app: &tauri::AppHandle) -> Result` under `app_config_dir()` (tauri-plugin-sql +/// 2.3.2, `wrapper.rs`: `DbPool::connect` then `path_mapper`), not under +/// `app_data_dir()`, where `profiles.json` and the licence files live. Both +/// are the same folder on Windows and macOS, not on Linux (`~/.config/` +/// and `~/.local/share/`): until #390, every command that looked for a +/// profile database under `app_data_dir()` found nothing on Linux. +pub(crate) fn profile_db_dir(app: &tauri::AppHandle) -> Result { + app.path() + .app_config_dir() + .map_err(|e| format!("Cannot get app config dir: {}", e)) +} + +/// Path of a profile database, from the bare file name the webview passes. +pub(crate) fn profile_db_path( + app: &tauri::AppHandle, + db_filename: &str, +) -> Result { + profile_db_path_in(&profile_db_dir(app)?, db_filename) +} + +/// Accept only the two shapes the app ever gives a profile database: +/// `simpl_resultat.db` and `profile_.db` (ProfileContext, since v0.3.0). +/// `Path::join` with an absolute value would replace the folder, and these +/// commands write and delete. +pub(crate) fn profile_db_path_in(dir: &Path, db_filename: &str) -> Result { + if !is_profile_db_filename(db_filename) { + return Err("invalid_profile_db_filename".to_string()); + } + Ok(dir.join(db_filename)) +} + +fn is_profile_db_filename(name: &str) -> bool { + if name == database::DEFAULT_PROFILE_DB { + return true; + } + match name.strip_prefix("profile_").and_then(|rest| rest.strip_suffix(".db")) { + Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit()), + None => false, + } +} + fn make_default_config() -> ProfilesConfig { let now = chrono_now(); let default_id = "default".to_string(); @@ -92,35 +137,63 @@ pub fn save_profiles(app: tauri::AppHandle, config: ProfilesConfig) -> Result<() fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e)) } +/// Delete a secondary profile's database, with its SQLite side files and the +/// copies a catch-up took (#390): the guide promises that deleting a profile +/// deletes all its data. #[tauri::command] pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(), String> { - if db_filename == "simpl_resultat.db" { + if db_filename == database::DEFAULT_PROFILE_DB { return Err("Cannot delete the default profile database".to_string()); } + let db_path = profile_db_path(&app, &db_filename)?; + delete_profile_files(&db_path, &db_filename) +} - let app_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("Cannot get app data dir: {}", e))?; - let db_path = app_dir.join(&db_filename); - - if db_path.exists() { - fs::remove_file(&db_path) - .map_err(|e| format!("Cannot delete database file: {}", e))?; +fn delete_profile_files(db_path: &Path, db_filename: &str) -> Result<(), String> { + for suffix in ["", "-wal", "-shm", "-journal"] { + let path = db_path.with_file_name(format!("{}{}", db_filename, suffix)); + if path.exists() { + fs::remove_file(&path).map_err(|e| format!("Cannot delete database file: {}", e))?; + } + } + if let Some(dir) = db_path.parent() { + profile_db::remove_backups( + &dir.join(profile_db::MIGRATION_BACKUP_DIR), + db_filename.trim_end_matches(".db"), + )?; } - Ok(()) } +/// SQL that creates a secondary profile, run by the webview through the +/// plugin (`initializeNewProfileDb`). #[tauri::command] pub fn get_new_profile_init_sql() -> Result, String> { - // Brand-new profiles ship with the v1 IPC-aligned category taxonomy: the - // consolidated schema bakes the v1 seed (categories + keywords + the - // categories_schema_version='v1' preference) directly. The legacy v2 seed - // migration still runs first because tauri-plugin-sql applies every - // declared migration on `Database.load`; the consolidated script then - // deletes the v2 rows and re-inserts the v1 rows in the 1000+ id range. - Ok(vec![database::CONSOLIDATED_SCHEMA.to_string()]) + // The consolidated schema is the whole database: tauri-plugin-sql runs no + // migration for a `profile_.db`, only the default profile's URL is + // registered (lib.rs). It carries the v1 IPC-aligned taxonomy (categories, + // keywords, categories_schema_version='v1'). The second statement writes + // the complete migration history, so later versions reach the profile + // through `prepare_profile_db` without inferring anything (#390). + Ok(vec![ + database::CONSOLIDATED_SCHEMA.to_string(), + profile_db::history_sql(&crate::migrations()), + ]) +} + +/// Bring a profile database up to date and repair legacy values before the +/// webview opens it (#390, ADR 0021). Secondary profiles catch up on the +/// migrations they missed; every profile gets its text manual-categorization +/// flags repaired. Sync on purpose: Tauri runs sync commands on the main +/// thread, which serializes two opens of the same profile (React StrictMode +/// mounts twice in development). +#[tauri::command] +pub fn prepare_profile_db( + app: tauri::AppHandle, + db_filename: String, +) -> Result { + let db_path = profile_db_path(&app, &db_filename)?; + profile_db::prepare_at(&db_path, &db_filename, &crate::migrations()) } // Argon2id parameters for PIN hashing (same as export_import_commands.rs) @@ -225,11 +298,7 @@ fn hex_decode(hex: &str) -> Result, String> { /// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords). #[tauri::command] pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result { - let app_dir = app - .path() - .app_data_dir() - .map_err(|e| format!("Cannot get app data dir: {}", e))?; - let db_path = app_dir.join(&db_filename); + let db_path = profile_db_path(&app, &db_filename)?; if !db_path.exists() { return Ok(false); @@ -375,4 +444,90 @@ mod tests { let decoded = hex_decode(&encoded).unwrap(); assert_eq!(original, decoded); } + + // -- profile database location (#390) ------------------------------------ + + #[test] + fn profile_databases_live_where_the_sql_plugin_opens_them() { + // tauri-plugin-sql resolves `sqlite:` under app_config_dir(). + // On Linux (the CI platform) that differs from app_data_dir(), so + // going back to the data folder turns this red. + let app = tauri::test::mock_app(); + let path = profile_db_path(app.handle(), "profile_0a1b2c3d.db").unwrap(); + assert_eq!(path.parent(), Some(app.path().app_config_dir().unwrap().as_path())); + } + + #[test] + fn profile_db_path_accepts_the_two_shapes_the_app_uses() { + let dir = Path::new("/profiles"); + assert_eq!( + profile_db_path_in(dir, "simpl_resultat.db"), + Ok(dir.join("simpl_resultat.db")) + ); + assert_eq!( + profile_db_path_in(dir, "profile_0a1b2c3d.db"), + Ok(dir.join("profile_0a1b2c3d.db")) + ); + } + + #[test] + fn profile_db_path_refuses_anything_else() { + let dir = Path::new("/profiles"); + for name in [ + "", + "/etc/passwd", + "../simpl_resultat.db", + "profile_.db", + "profile_0a1b/../x.db", + "profile_0a1b2c3d.sqlite", + "profile_0a1b2c3d.db.bak", + "PROFILE_0a1b2c3d.db", + "profile_xyz.db", + "C:\\Users\\x\\profile_0a1b2c3d.db", + ] { + assert_eq!( + profile_db_path_in(dir, name), + Err("invalid_profile_db_filename".to_string()), + "{:?}", + name + ); + } + } + + #[test] + fn deleting_a_profile_removes_its_files_and_only_its_files() { + let dir = std::env::temp_dir().join(format!("simpl-resultat-delete-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let backups = dir.join(profile_db::MIGRATION_BACKUP_DIR); + fs::create_dir_all(&backups).unwrap(); + for name in [ + "profile_0a1b2c3d.db", + "profile_0a1b2c3d.db-wal", + "profile_0a1b2c3d.db-shm", + "profile_99999999.db", + ] { + fs::write(dir.join(name), b"x").unwrap(); + } + for name in ["profile_0a1b2c3d-before-v7.db", "profile_99999999-before-v17.db"] { + fs::write(backups.join(name), b"x").unwrap(); + } + + delete_profile_files(&dir.join("profile_0a1b2c3d.db"), "profile_0a1b2c3d.db").unwrap(); + + let mut left: Vec = fs::read_dir(&dir) + .unwrap() + .chain(fs::read_dir(&backups).unwrap()) + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + left.sort(); + assert_eq!( + left, + vec![ + "migration-backups".to_string(), + "profile_99999999-before-v17.db".to_string(), + "profile_99999999.db".to_string(), + ] + ); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/src-tauri/src/database/consolidated_schema.sql b/src-tauri/src/database/consolidated_schema.sql index 44725bf..5266f87 100644 --- a/src-tauri/src/database/consolidated_schema.sql +++ b/src-tauri/src/database/consolidated_schema.sql @@ -15,12 +15,11 @@ CREATE TABLE IF NOT EXISTS import_sources ( column_mapping TEXT NOT NULL, skip_lines INTEGER NOT NULL DEFAULT 0, has_header INTEGER NOT NULL DEFAULT 1, - -- Full import format (migration v17). These four columns are INERT on the - -- production path: this script runs AFTER tauri-plugin-sql has applied every - -- migration and only uses CREATE TABLE IF NOT EXISTS, so a brand-new profile - -- actually receives them from v17. They are mirrored here to keep this file - -- the tested reference definition -- a parity test compares it against the - -- v1->v17 chain so the DEFAULT and CHECK of the two cannot drift apart. + -- Full import format (migration v17). A secondary profile receives these + -- four columns from this script, not from v17: tauri-plugin-sql runs no + -- migration on a `profile_.db` (#390, ADR 0021). A parity test compares + -- this definition with the v1->v17 chain so the DEFAULT and CHECK of the + -- two cannot drift apart. -- `amount_mode` admits 'absolute_indicator' from the start so the third mode -- can ship without another migration. `template_id` is a provenance tag -- only, never re-read as format: the eight format columns above are @@ -374,9 +373,9 @@ INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_pro -- ============================================================================ -- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec -- ---------------------------------------------------------------------------- --- Reset any pre-existing category data (possible when tauri-plugin-sql runs --- the historical v2 seed migration on this fresh DB before this script --- executes). Keywords/categories are wiped and re-inserted with the v1 IDs +-- Reset any pre-existing category data. Defensive only: tauri-plugin-sql runs +-- no migration on a secondary profile's database, so no v2 seed precedes this +-- script (#390). Keywords/categories are wiped and re-inserted with the v1 IDs -- (1000+ range) so existing references in migrations v3-v7 stay inert. -- ============================================================================ DELETE FROM keywords; diff --git a/src-tauri/src/database/fixtures/consolidated_v0.10.0.sql b/src-tauri/src/database/fixtures/consolidated_v0.10.0.sql new file mode 100644 index 0000000..17f5503 --- /dev/null +++ b/src-tauri/src/database/fixtures/consolidated_v0.10.0.sql @@ -0,0 +1,824 @@ +-- Consolidated schema for new profile databases +-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds +-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately +-- use the new standard. Existing profiles keep their v2 seed and are only +-- tagged via the migration (categories_schema_version = 'v2'). +-- Used ONLY for initializing new profile databases (not for the default profile). + +CREATE TABLE IF NOT EXISTS import_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + date_format TEXT NOT NULL DEFAULT '%d/%m/%Y', + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + column_mapping TEXT NOT NULL, + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS imported_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + 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, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE, + UNIQUE(source_id, filename) +); + +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER, + color TEXT, + icon TEXT, + type TEXT NOT NULL DEFAULT 'expense', + is_active INTEGER NOT NULL DEFAULT 1, + is_inputable INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + i18n_key TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + normalized_name TEXT NOT NULL, + category_id INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT NOT NULL, + category_id INTEGER NOT NULL, + supplier_id INTEGER, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + UNIQUE(keyword, category_id) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + description TEXT NOT NULL, + amount REAL NOT NULL, + category_id INTEGER, + supplier_id INTEGER, + source_id INTEGER, + file_id INTEGER, + original_description TEXT, + notes TEXT, + is_manually_categorized INTEGER NOT NULL DEFAULT 0, + is_split INTEGER NOT NULL DEFAULT 0, + parent_transaction_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL, + FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL, + FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + date DATE NOT NULL, + is_recurring INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS adjustment_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + description TEXT, + FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS budget_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + amount REAL NOT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(category_id, year, month) +); + +CREATE TABLE IF NOT EXISTS budget_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS budget_template_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(template_id, category_id) +); + +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 +); + +CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date); +CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id); +CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id); +CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id); +CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id); +CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id); +CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id); +CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type); +CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id); +CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name); +CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id); +CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword); +CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month); +CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); +CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); + +-- ============================================================================ +-- Balance sheet (Bilan) — Migration v9 mirror for new profiles +-- ============================================================================ +-- 5 tables + 7 indexes + seeded categories. Kept in sync with +-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs). +-- New profiles created from this consolidated schema get the balance feature +-- preinstalled without needing to replay v9 separately. + +CREATE TABLE IF NOT EXISTS balance_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + i18n_key TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('simple','priced')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_seed INTEGER NOT NULL DEFAULT 0, + asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')), + -- User-facing override for the category label (migration v12). When set, + -- the UI shows this verbatim; otherwise it falls back to t(i18n_key). + custom_label TEXT +); + +CREATE TABLE IF NOT EXISTS balance_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + balance_category_id INTEGER NOT NULL, + name TEXT NOT NULL, + symbol TEXT, + currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'), + notes TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + archived_at DATETIME, + -- Fiscal envelope / tax shelter (migration v12). NULL = no envelope (e.g. + -- a chequing account or crypto wallet). NOT an automobile type. + vehicle_type TEXT CHECK(vehicle_type IS NULL OR vehicle_type IN ('unregistered','tfsa','rrsp','rrif','fhsa','resp')), + -- Entry mode (migration v15). 'simple' = one denormalized value per + -- snapshot line; 'detailed' = a basket of per-security holdings (see + -- balance_snapshot_holdings). Accounts under a priced category are backfilled + -- to 'detailed' below. `detailed_since` is the authoritative pivot date from + -- which detailed entry is expected — NULL until the conversion flow sets it. + kind TEXT NOT NULL DEFAULT 'simple' CHECK (kind IN ('simple','detailed')), + detailed_since DATE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date DATE NOT NULL UNIQUE, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS balance_snapshot_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + quantity REAL, + unit_price REAL, + value REAL NOT NULL, + price_source TEXT, + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT, + UNIQUE(snapshot_id, account_id), + CHECK ( + (quantity IS NULL AND unit_price IS NULL) + OR (quantity IS NOT NULL AND unit_price IS NOT NULL) + ) +); + +CREATE TABLE IF NOT EXISTS balance_account_transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT, + UNIQUE(transaction_id, account_id) +); + +-- Détail par titre (Bilan Étape 2, migration v14). Kept in sync with +-- `balance_holdings_schema.sql` (the source of truth applied by Migration v14 +-- in lib.rs). balance_securities is the instrument catalogue (symbol normalized +-- upper/trim, COLLATE NOCASE UNIQUE); balance_snapshot_holdings is the per-title +-- breakdown of a detailed account's snapshot line. +CREATE TABLE IF NOT EXISTS balance_securities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL COLLATE NOCASE UNIQUE, + name TEXT, + currency TEXT NOT NULL DEFAULT 'CAD', + asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS balance_snapshot_holdings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE, + security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT, + quantity REAL NOT NULL, + unit_price REAL NOT NULL, + value REAL NOT NULL, + book_cost REAL, + price_source TEXT, + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (snapshot_line_id, security_id) +); + +CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id); +CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1; +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id); + +-- Asset classes only (Bilan axe véhicule, Étape 1). The former `tfsa` / `rrsp` +-- seeds were fiscal envelopes, not asset classes — they now live on the account +-- as `vehicle_type` (see balance_accounts.vehicle_type). New profiles ship with +-- exactly 5 asset classes: Liquidités, Fonds/FNB, Actions, Crypto, Autres. +INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES + ('cash', 'balance.category.cash', 'simple', 10, 1, NULL), + ('fund', 'balance.category.fund', 'simple', 40, 1, NULL), + ('other', 'balance.category.other', 'simple', 50, 1, NULL), + ('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'), + ('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto'); + +-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so +-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on +-- balance_accounts) — once created they are indistinguishable from +-- user-created accounts and can be renamed/archived freely. Existing profiles +-- get the same 4 proposed via StarterAccountsModal on first /balance visit. +-- +-- Bilan axe véhicule (Étape 1): the CELI / REER starters are no longer linked +-- to a `tfsa` / `rrsp` category (those seeds are gone). They now attach to the +-- `other` asset class and carry the envelope in `vehicle_type`. Linking them to +-- a deleted key would make the subselect return NULL → NOT NULL violation → +-- broken new-profile init, so the asset class MUST resolve to `other`. +INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type) VALUES + ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1, NULL), + ((SELECT id FROM balance_categories WHERE key = 'other'), 'CELI', 'CAD', 1, 'tfsa'), + ((SELECT id FROM balance_categories WHERE key = 'other'), 'REER', 'CAD', 1, 'rrsp'), + ((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1, NULL); + +-- Détail par titre (migration v15 mirror): accounts under a priced category are +-- detailed-entry by default. The 4 starters above all sit under simple asset +-- classes (cash / other), so this is currently a no-op — kept at parity with the +-- v15 backfill so any future priced starter is stamped correctly. +UPDATE balance_accounts SET kind = 'detailed' + WHERE balance_category_id IN (SELECT id FROM balance_categories WHERE kind = 'priced'); + +-- Default preferences (new profiles ship with the v1 IPC taxonomy) +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY'); +INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1'); +-- Suppress StarterAccountsModal on first /balance visit for new profiles +-- (Issue #179). The 4 starter accounts are already seeded above, so the +-- modal would only show 4 collision rows with no actionable choice. Pre- +-- writing the pref skips that briefly-empty UX entirely. Suggestion S1 +-- from PR #185 review (#187). +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_proposed', '{"shown_at":"seed","accepted":[]}'); + +-- ============================================================================ +-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec +-- ---------------------------------------------------------------------------- +-- Reset any pre-existing category data (possible when tauri-plugin-sql runs +-- the historical v2 seed migration on this fresh DB before this script +-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs +-- (1000+ range) so existing references in migrations v3-v7 stay inert. +-- ============================================================================ +DELETE FROM keywords; +UPDATE transactions SET category_id = NULL; +DELETE FROM categories; + +-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts) +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root'); + +-- 1000 — Revenus +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres'); + +-- 1100 — Alimentation +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine'); + +-- 1200 — Logement +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance'); + +-- 1300 — Ménage & ameublement +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie'); + +-- 1400 — Vêtements & chaussures +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires'); + +-- 1500 — Transport +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location'); + +-- 1600 — Santé & soins personnels +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie'); + +-- 1700 — Loisirs, formation & lecture +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire'); + +-- 1800 — Boissons, tabac & cannabis +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac'); + +-- 1900 — Finances & obligations +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers'); + +-- 1950 — Transferts & placements +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes'); + +-- ============================================================================ +-- Keywords — Canadian suppliers (150+ entries) +-- ============================================================================ + +-- Alimentation > Épicerie régulière (1111) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50); + +-- Épicerie > Boucherie (1112) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0); + +-- Épicerie > Boulangerie (1113) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0); + +-- Épicerie > Dépanneur (1114) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0); + +-- Restauration > Restaurant (1121) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0); + +-- Restauration > Café (1122) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0); + +-- Restauration > Fast food (1123) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0); + +-- Restauration > Livraison (1124) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0); + +-- Logement > Hypothèque (1212) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0); + +-- Logement > Taxes municipales (1213) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0); + +-- Électricité (1221) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0); + +-- Gaz (1222) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0); + +-- Internet (1231) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0); + +-- Mobile (1232) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0); + +-- Entretien maison (1241) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0); + +-- Matériaux & outils (1243) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0); + +-- Assurance habitation (1250) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0); + +-- Meubles (1311) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0); + +-- Électroménagers (1312) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0); + +-- Décoration (1313) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0); + +-- Vêtements (1410) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0); + +-- Transport — Essence (1512) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10); + +-- Permis / SAAQ (1514) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0); + +-- Transport public — autobus/métro (1521) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0); + +-- Train de banlieue (1522) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0); + +-- Taxi / Uber (1523) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0); + +-- Avion (1531) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0); + +-- Hébergement (1533) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0); + +-- Pharmacie (1611) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0); + +-- Thérapies (1615) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0); + +-- Cinéma & spectacles (1711) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0); + +-- Jeux vidéo (1712) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0); + +-- Streaming vidéo (1713) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0); + +-- Streaming musique (1714) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0); + +-- Jouets & hobbies (1715) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0); + +-- Équipement sportif (1722) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0); + +-- Parcs & activités (1723) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0); + +-- Lecture (1741) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0); + +-- Animaux — nourriture (1751) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0); + +-- Alcool (1810) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0); + +-- Cannabis (1820) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0); + +-- Frais bancaires (1911) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0); + +-- Impôts (1921, 1922) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0); + +-- Dons de charité (1931) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0); + +-- Cotisations professionnelles (1932) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0); + +-- Cadeaux (1940) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0); + +-- Divers (1946) — catch-all marketplace +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0); + +-- Placements (1964) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0); + +-- Paie (1011) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0); diff --git a/src-tauri/src/database/fixtures/consolidated_v0.3.0.sql b/src-tauri/src/database/fixtures/consolidated_v0.3.0.sql new file mode 100644 index 0000000..227d3b7 --- /dev/null +++ b/src-tauri/src/database/fixtures/consolidated_v0.3.0.sql @@ -0,0 +1,184 @@ +-- Consolidated schema for new profile databases +-- This file bakes in the base schema + all migrations (v3-v6) +-- Used ONLY for initializing new profile databases (not for the default profile) + +CREATE TABLE IF NOT EXISTS import_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + date_format TEXT NOT NULL DEFAULT '%d/%m/%Y', + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + column_mapping TEXT NOT NULL, + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS imported_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + 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, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE, + UNIQUE(source_id, filename) +); + +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER, + color TEXT, + icon TEXT, + type TEXT NOT NULL DEFAULT 'expense', + is_active INTEGER NOT NULL DEFAULT 1, + is_inputable INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + normalized_name TEXT NOT NULL, + category_id INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT NOT NULL, + category_id INTEGER NOT NULL, + supplier_id INTEGER, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + UNIQUE(keyword, category_id) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + description TEXT NOT NULL, + amount REAL NOT NULL, + category_id INTEGER, + supplier_id INTEGER, + source_id INTEGER, + file_id INTEGER, + original_description TEXT, + notes TEXT, + is_manually_categorized INTEGER NOT NULL DEFAULT 0, + is_split INTEGER NOT NULL DEFAULT 0, + parent_transaction_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL, + FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL, + FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + date DATE NOT NULL, + is_recurring INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS adjustment_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + description TEXT, + FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS budget_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + amount REAL NOT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(category_id, year, month) +); + +CREATE TABLE IF NOT EXISTS budget_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS budget_template_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(template_id, category_id) +); + +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 +); + +CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date); +CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id); +CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id); +CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id); +CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id); +CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id); +CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id); +CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type); +CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id); +CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name); +CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id); +CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword); +CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month); +CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); +CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); + +-- Default preferences +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY'); diff --git a/src-tauri/src/database/fixtures/consolidated_v0.8.4.sql b/src-tauri/src/database/fixtures/consolidated_v0.8.4.sql new file mode 100644 index 0000000..cb593ec --- /dev/null +++ b/src-tauri/src/database/fixtures/consolidated_v0.8.4.sql @@ -0,0 +1,657 @@ +-- Consolidated schema for new profile databases +-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds +-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately +-- use the new standard. Existing profiles keep their v2 seed and are only +-- tagged via the migration (categories_schema_version = 'v2'). +-- Used ONLY for initializing new profile databases (not for the default profile). + +CREATE TABLE IF NOT EXISTS import_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + date_format TEXT NOT NULL DEFAULT '%d/%m/%Y', + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + column_mapping TEXT NOT NULL, + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS imported_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + 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, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE, + UNIQUE(source_id, filename) +); + +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER, + color TEXT, + icon TEXT, + type TEXT NOT NULL DEFAULT 'expense', + is_active INTEGER NOT NULL DEFAULT 1, + is_inputable INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + i18n_key TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + normalized_name TEXT NOT NULL, + category_id INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT NOT NULL, + category_id INTEGER NOT NULL, + supplier_id INTEGER, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + UNIQUE(keyword, category_id) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + description TEXT NOT NULL, + amount REAL NOT NULL, + category_id INTEGER, + supplier_id INTEGER, + source_id INTEGER, + file_id INTEGER, + original_description TEXT, + notes TEXT, + is_manually_categorized INTEGER NOT NULL DEFAULT 0, + is_split INTEGER NOT NULL DEFAULT 0, + parent_transaction_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL, + FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL, + FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + date DATE NOT NULL, + is_recurring INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS adjustment_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + description TEXT, + FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS budget_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + amount REAL NOT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(category_id, year, month) +); + +CREATE TABLE IF NOT EXISTS budget_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS budget_template_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(template_id, category_id) +); + +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 +); + +CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date); +CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id); +CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id); +CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id); +CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id); +CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id); +CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id); +CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type); +CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id); +CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name); +CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id); +CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword); +CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month); +CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); +CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); + +-- Default preferences (new profiles ship with the v1 IPC taxonomy) +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY'); +INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1'); + +-- ============================================================================ +-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec +-- ---------------------------------------------------------------------------- +-- Reset any pre-existing category data (possible when tauri-plugin-sql runs +-- the historical v2 seed migration on this fresh DB before this script +-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs +-- (1000+ range) so existing references in migrations v3-v7 stay inert. +-- ============================================================================ +DELETE FROM keywords; +UPDATE transactions SET category_id = NULL; +DELETE FROM categories; + +-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts) +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root'); + +-- 1000 — Revenus +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres'); + +-- 1100 — Alimentation +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine'); + +-- 1200 — Logement +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance'); + +-- 1300 — Ménage & ameublement +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie'); + +-- 1400 — Vêtements & chaussures +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires'); + +-- 1500 — Transport +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location'); + +-- 1600 — Santé & soins personnels +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie'); + +-- 1700 — Loisirs, formation & lecture +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire'); + +-- 1800 — Boissons, tabac & cannabis +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac'); + +-- 1900 — Finances & obligations +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers'); + +-- 1950 — Transferts & placements +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes'); + +-- ============================================================================ +-- Keywords — Canadian suppliers (150+ entries) +-- ============================================================================ + +-- Alimentation > Épicerie régulière (1111) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50); + +-- Épicerie > Boucherie (1112) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0); + +-- Épicerie > Boulangerie (1113) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0); + +-- Épicerie > Dépanneur (1114) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0); + +-- Restauration > Restaurant (1121) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0); + +-- Restauration > Café (1122) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0); + +-- Restauration > Fast food (1123) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0); + +-- Restauration > Livraison (1124) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0); + +-- Logement > Hypothèque (1212) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0); + +-- Logement > Taxes municipales (1213) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0); + +-- Électricité (1221) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0); + +-- Gaz (1222) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0); + +-- Internet (1231) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0); + +-- Mobile (1232) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0); + +-- Entretien maison (1241) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0); + +-- Matériaux & outils (1243) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0); + +-- Assurance habitation (1250) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0); + +-- Meubles (1311) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0); + +-- Électroménagers (1312) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0); + +-- Décoration (1313) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0); + +-- Vêtements (1410) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0); + +-- Transport — Essence (1512) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10); + +-- Permis / SAAQ (1514) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0); + +-- Transport public — autobus/métro (1521) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0); + +-- Train de banlieue (1522) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0); + +-- Taxi / Uber (1523) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0); + +-- Avion (1531) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0); + +-- Hébergement (1533) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0); + +-- Pharmacie (1611) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0); + +-- Thérapies (1615) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0); + +-- Cinéma & spectacles (1711) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0); + +-- Jeux vidéo (1712) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0); + +-- Streaming vidéo (1713) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0); + +-- Streaming musique (1714) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0); + +-- Jouets & hobbies (1715) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0); + +-- Équipement sportif (1722) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0); + +-- Parcs & activités (1723) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0); + +-- Lecture (1741) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0); + +-- Animaux — nourriture (1751) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0); + +-- Alcool (1810) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0); + +-- Cannabis (1820) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0); + +-- Frais bancaires (1911) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0); + +-- Impôts (1921, 1922) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0); + +-- Dons de charité (1931) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0); + +-- Cotisations professionnelles (1932) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0); + +-- Cadeaux (1940) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0); + +-- Divers (1946) — catch-all marketplace +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0); + +-- Placements (1964) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0); + +-- Paie (1011) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0); diff --git a/src-tauri/src/database/fixtures/consolidated_v0.9.0.sql b/src-tauri/src/database/fixtures/consolidated_v0.9.0.sql new file mode 100644 index 0000000..6341d9d --- /dev/null +++ b/src-tauri/src/database/fixtures/consolidated_v0.9.0.sql @@ -0,0 +1,747 @@ +-- Consolidated schema for new profile databases +-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds +-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately +-- use the new standard. Existing profiles keep their v2 seed and are only +-- tagged via the migration (categories_schema_version = 'v2'). +-- Used ONLY for initializing new profile databases (not for the default profile). + +CREATE TABLE IF NOT EXISTS import_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + date_format TEXT NOT NULL DEFAULT '%d/%m/%Y', + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + column_mapping TEXT NOT NULL, + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS imported_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + 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, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE, + UNIQUE(source_id, filename) +); + +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER, + color TEXT, + icon TEXT, + type TEXT NOT NULL DEFAULT 'expense', + is_active INTEGER NOT NULL DEFAULT 1, + is_inputable INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + i18n_key TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + normalized_name TEXT NOT NULL, + category_id INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT NOT NULL, + category_id INTEGER NOT NULL, + supplier_id INTEGER, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + UNIQUE(keyword, category_id) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + description TEXT NOT NULL, + amount REAL NOT NULL, + category_id INTEGER, + supplier_id INTEGER, + source_id INTEGER, + file_id INTEGER, + original_description TEXT, + notes TEXT, + is_manually_categorized INTEGER NOT NULL DEFAULT 0, + is_split INTEGER NOT NULL DEFAULT 0, + parent_transaction_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL, + FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL, + FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + date DATE NOT NULL, + is_recurring INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS adjustment_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + description TEXT, + FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS budget_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + amount REAL NOT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(category_id, year, month) +); + +CREATE TABLE IF NOT EXISTS budget_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS budget_template_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(template_id, category_id) +); + +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 +); + +CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date); +CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id); +CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id); +CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id); +CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id); +CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id); +CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id); +CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type); +CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id); +CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name); +CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id); +CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword); +CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month); +CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); +CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); + +-- ============================================================================ +-- Balance sheet (Bilan) — Migration v9 mirror for new profiles +-- ============================================================================ +-- 5 tables + 7 indexes + seeded categories. Kept in sync with +-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs). +-- New profiles created from this consolidated schema get the balance feature +-- preinstalled without needing to replay v9 separately. + +CREATE TABLE IF NOT EXISTS balance_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + i18n_key TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('simple','priced')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_seed INTEGER NOT NULL DEFAULT 0, + asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')) +); + +CREATE TABLE IF NOT EXISTS balance_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + balance_category_id INTEGER NOT NULL, + name TEXT NOT NULL, + symbol TEXT, + currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'), + notes TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + archived_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date DATE NOT NULL UNIQUE, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS balance_snapshot_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + quantity REAL, + unit_price REAL, + value REAL NOT NULL, + price_source TEXT, + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT, + UNIQUE(snapshot_id, account_id), + CHECK ( + (quantity IS NULL AND unit_price IS NULL) + OR (quantity IS NOT NULL AND unit_price IS NOT NULL) + ) +); + +CREATE TABLE IF NOT EXISTS balance_account_transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT, + UNIQUE(transaction_id, account_id) +); + +CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id); +CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1; +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); + +INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES + ('cash', 'balance.category.cash', 'simple', 10, 1, NULL), + ('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL), + ('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL), + ('fund', 'balance.category.fund', 'simple', 40, 1, NULL), + ('other', 'balance.category.other', 'simple', 50, 1, NULL), + ('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'), + ('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto'); + +-- Default preferences (new profiles ship with the v1 IPC taxonomy) +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY'); +INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1'); + +-- ============================================================================ +-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec +-- ---------------------------------------------------------------------------- +-- Reset any pre-existing category data (possible when tauri-plugin-sql runs +-- the historical v2 seed migration on this fresh DB before this script +-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs +-- (1000+ range) so existing references in migrations v3-v7 stay inert. +-- ============================================================================ +DELETE FROM keywords; +UPDATE transactions SET category_id = NULL; +DELETE FROM categories; + +-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts) +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root'); + +-- 1000 — Revenus +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres'); + +-- 1100 — Alimentation +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine'); + +-- 1200 — Logement +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance'); + +-- 1300 — Ménage & ameublement +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie'); + +-- 1400 — Vêtements & chaussures +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires'); + +-- 1500 — Transport +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location'); + +-- 1600 — Santé & soins personnels +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie'); + +-- 1700 — Loisirs, formation & lecture +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire'); + +-- 1800 — Boissons, tabac & cannabis +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac'); + +-- 1900 — Finances & obligations +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers'); + +-- 1950 — Transferts & placements +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes'); + +-- ============================================================================ +-- Keywords — Canadian suppliers (150+ entries) +-- ============================================================================ + +-- Alimentation > Épicerie régulière (1111) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50); + +-- Épicerie > Boucherie (1112) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0); + +-- Épicerie > Boulangerie (1113) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0); + +-- Épicerie > Dépanneur (1114) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0); + +-- Restauration > Restaurant (1121) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0); + +-- Restauration > Café (1122) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0); + +-- Restauration > Fast food (1123) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0); + +-- Restauration > Livraison (1124) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0); + +-- Logement > Hypothèque (1212) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0); + +-- Logement > Taxes municipales (1213) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0); + +-- Électricité (1221) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0); + +-- Gaz (1222) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0); + +-- Internet (1231) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0); + +-- Mobile (1232) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0); + +-- Entretien maison (1241) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0); + +-- Matériaux & outils (1243) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0); + +-- Assurance habitation (1250) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0); + +-- Meubles (1311) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0); + +-- Électroménagers (1312) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0); + +-- Décoration (1313) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0); + +-- Vêtements (1410) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0); + +-- Transport — Essence (1512) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10); + +-- Permis / SAAQ (1514) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0); + +-- Transport public — autobus/métro (1521) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0); + +-- Train de banlieue (1522) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0); + +-- Taxi / Uber (1523) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0); + +-- Avion (1531) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0); + +-- Hébergement (1533) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0); + +-- Pharmacie (1611) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0); + +-- Thérapies (1615) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0); + +-- Cinéma & spectacles (1711) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0); + +-- Jeux vidéo (1712) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0); + +-- Streaming vidéo (1713) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0); + +-- Streaming musique (1714) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0); + +-- Jouets & hobbies (1715) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0); + +-- Équipement sportif (1722) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0); + +-- Parcs & activités (1723) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0); + +-- Lecture (1741) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0); + +-- Animaux — nourriture (1751) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0); + +-- Alcool (1810) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0); + +-- Cannabis (1820) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0); + +-- Frais bancaires (1911) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0); + +-- Impôts (1921, 1922) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0); + +-- Dons de charité (1931) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0); + +-- Cotisations professionnelles (1932) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0); + +-- Cadeaux (1940) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0); + +-- Divers (1946) — catch-all marketplace +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0); + +-- Placements (1964) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0); + +-- Paie (1011) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0); diff --git a/src-tauri/src/database/fixtures/consolidated_v0.9.1.sql b/src-tauri/src/database/fixtures/consolidated_v0.9.1.sql new file mode 100644 index 0000000..83a08a9 --- /dev/null +++ b/src-tauri/src/database/fixtures/consolidated_v0.9.1.sql @@ -0,0 +1,764 @@ +-- Consolidated schema for new profile databases +-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds +-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately +-- use the new standard. Existing profiles keep their v2 seed and are only +-- tagged via the migration (categories_schema_version = 'v2'). +-- Used ONLY for initializing new profile databases (not for the default profile). + +CREATE TABLE IF NOT EXISTS import_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + date_format TEXT NOT NULL DEFAULT '%d/%m/%Y', + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + column_mapping TEXT NOT NULL, + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS imported_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + 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, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE, + UNIQUE(source_id, filename) +); + +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + parent_id INTEGER, + color TEXT, + icon TEXT, + type TEXT NOT NULL DEFAULT 'expense', + is_active INTEGER NOT NULL DEFAULT 1, + is_inputable INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0, + i18n_key TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + normalized_name TEXT NOT NULL, + category_id INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT NOT NULL, + category_id INTEGER NOT NULL, + supplier_id INTEGER, + priority INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + UNIQUE(keyword, category_id) +); + +CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + description TEXT NOT NULL, + amount REAL NOT NULL, + category_id INTEGER, + supplier_id INTEGER, + source_id INTEGER, + file_id INTEGER, + original_description TEXT, + notes TEXT, + is_manually_categorized INTEGER NOT NULL DEFAULT 0, + is_split INTEGER NOT NULL DEFAULT 0, + parent_transaction_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL, + FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL, + FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL, + FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL, + FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS adjustments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + date DATE NOT NULL, + is_recurring INTEGER NOT NULL DEFAULT 0, + recurrence_rule TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS adjustment_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + adjustment_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + description TEXT, + FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS budget_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + amount REAL NOT NULL, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(category_id, year, month) +); + +CREATE TABLE IF NOT EXISTS budget_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS budget_template_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + amount REAL NOT NULL, + FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE, + UNIQUE(template_id, category_id) +); + +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 +); + +CREATE TABLE IF NOT EXISTS user_preferences ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date); +CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id); +CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id); +CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id); +CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id); +CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id); +CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id); +CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type); +CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id); +CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name); +CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id); +CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword); +CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month); +CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); +CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); + +-- ============================================================================ +-- Balance sheet (Bilan) — Migration v9 mirror for new profiles +-- ============================================================================ +-- 5 tables + 7 indexes + seeded categories. Kept in sync with +-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs). +-- New profiles created from this consolidated schema get the balance feature +-- preinstalled without needing to replay v9 separately. + +CREATE TABLE IF NOT EXISTS balance_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + i18n_key TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('simple','priced')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_seed INTEGER NOT NULL DEFAULT 0, + asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')) +); + +CREATE TABLE IF NOT EXISTS balance_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + balance_category_id INTEGER NOT NULL, + name TEXT NOT NULL, + symbol TEXT, + currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'), + notes TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + archived_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date DATE NOT NULL UNIQUE, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS balance_snapshot_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + quantity REAL, + unit_price REAL, + value REAL NOT NULL, + price_source TEXT, + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT, + UNIQUE(snapshot_id, account_id), + CHECK ( + (quantity IS NULL AND unit_price IS NULL) + OR (quantity IS NOT NULL AND unit_price IS NOT NULL) + ) +); + +CREATE TABLE IF NOT EXISTS balance_account_transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT, + UNIQUE(transaction_id, account_id) +); + +CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id); +CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1; +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); + +INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES + ('cash', 'balance.category.cash', 'simple', 10, 1, NULL), + ('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL), + ('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL), + ('fund', 'balance.category.fund', 'simple', 40, 1, NULL), + ('other', 'balance.category.other', 'simple', 50, 1, NULL), + ('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'), + ('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto'); + +-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so +-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on +-- balance_accounts) — once created they are indistinguishable from +-- user-created accounts and can be renamed/archived freely. Existing profiles +-- get the same 4 proposed via StarterAccountsModal on first /balance visit. +INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) VALUES + ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1), + ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD', 1), + ((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER', 'CAD', 1), + ((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1); + +-- Default preferences (new profiles ship with the v1 IPC taxonomy) +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR'); +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY'); +INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1'); +-- Suppress StarterAccountsModal on first /balance visit for new profiles +-- (Issue #179). The 4 starter accounts are already seeded above, so the +-- modal would only show 4 collision rows with no actionable choice. Pre- +-- writing the pref skips that briefly-empty UX entirely. Suggestion S1 +-- from PR #185 review (#187). +INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_proposed', '{"shown_at":"seed","accepted":[]}'); + +-- ============================================================================ +-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec +-- ---------------------------------------------------------------------------- +-- Reset any pre-existing category data (possible when tauri-plugin-sql runs +-- the historical v2 seed migration on this fresh DB before this script +-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs +-- (1000+ range) so existing references in migrations v3-v7 stay inert. +-- ============================================================================ +DELETE FROM keywords; +UPDATE transactions SET category_id = NULL; +DELETE FROM categories; + +-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts) +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root'); + +-- 1000 — Revenus +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres'); + +-- 1100 — Alimentation +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine'); + +-- 1200 — Logement +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance'); + +-- 1300 — Ménage & ameublement +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie'); + +-- 1400 — Vêtements & chaussures +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires'); + +-- 1500 — Transport +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location'); + +-- 1600 — Santé & soins personnels +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie'); + +-- 1700 — Loisirs, formation & lecture +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire'); + +-- 1800 — Boissons, tabac & cannabis +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac'); + +-- 1900 — Finances & obligations +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers'); + +-- 1950 — Transferts & placements +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel'); +INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes'); + +-- ============================================================================ +-- Keywords — Canadian suppliers (150+ entries) +-- ============================================================================ + +-- Alimentation > Épicerie régulière (1111) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50); + +-- Épicerie > Boucherie (1112) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0); + +-- Épicerie > Boulangerie (1113) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0); + +-- Épicerie > Dépanneur (1114) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0); + +-- Restauration > Restaurant (1121) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0); + +-- Restauration > Café (1122) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0); + +-- Restauration > Fast food (1123) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0); + +-- Restauration > Livraison (1124) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0); + +-- Logement > Hypothèque (1212) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0); + +-- Logement > Taxes municipales (1213) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0); + +-- Électricité (1221) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0); + +-- Gaz (1222) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0); + +-- Internet (1231) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0); + +-- Mobile (1232) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0); + +-- Entretien maison (1241) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0); + +-- Matériaux & outils (1243) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0); + +-- Assurance habitation (1250) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0); + +-- Meubles (1311) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0); + +-- Électroménagers (1312) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0); + +-- Décoration (1313) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0); + +-- Vêtements (1410) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0); + +-- Transport — Essence (1512) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10); + +-- Permis / SAAQ (1514) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0); + +-- Transport public — autobus/métro (1521) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0); + +-- Train de banlieue (1522) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0); + +-- Taxi / Uber (1523) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0); + +-- Avion (1531) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0); + +-- Hébergement (1533) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0); + +-- Pharmacie (1611) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0); + +-- Thérapies (1615) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0); + +-- Cinéma & spectacles (1711) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0); + +-- Jeux vidéo (1712) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0); + +-- Streaming vidéo (1713) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0); + +-- Streaming musique (1714) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0); + +-- Jouets & hobbies (1715) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0); + +-- Équipement sportif (1722) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0); + +-- Parcs & activités (1723) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0); + +-- Lecture (1741) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0); + +-- Animaux — nourriture (1751) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0); + +-- Alcool (1810) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0); + +-- Cannabis (1820) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0); + +-- Frais bancaires (1911) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0); + +-- Impôts (1921, 1922) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0); + +-- Dons de charité (1931) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0); + +-- Cotisations professionnelles (1932) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0); + +-- Cadeaux (1940) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0); + +-- Divers (1946) — catch-all marketplace +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0); + +-- Placements (1964) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0); + +-- Paie (1011) +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0); +INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0); diff --git a/src-tauri/src/database/fixtures/seed_v0.3.0.sql b/src-tauri/src/database/fixtures/seed_v0.3.0.sql new file mode 100644 index 0000000..aeba40c --- /dev/null +++ b/src-tauri/src/database/fixtures/seed_v0.3.0.sql @@ -0,0 +1,232 @@ +-- Migration v2: Seed categories and keywords from SommaireDepense.csv + +-- Clear existing data (FK order: keywords -> categories) +DELETE FROM keywords; +UPDATE transactions SET category_id = NULL; +DELETE FROM categories; + +-- ========================================== +-- Parent categories (Regroupement level) +-- ========================================== +INSERT INTO categories (id, name, type, sort_order) VALUES (1, 'Revenus', 'income', 1); +INSERT INTO categories (id, name, type, sort_order) VALUES (2, 'Dépenses récurrentes', 'expense', 2); +INSERT INTO categories (id, name, type, sort_order) VALUES (3, 'Dépenses ponctuelles', 'expense', 3); +INSERT INTO categories (id, name, type, sort_order) VALUES (4, 'Maison', 'expense', 4); +INSERT INTO categories (id, name, type, sort_order) VALUES (5, 'Placements', 'transfer', 5); +INSERT INTO categories (id, name, type, sort_order) VALUES (6, 'Autres', 'expense', 6); + +-- ========================================== +-- Child categories +-- ========================================== + +-- Revenus children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (10, 'Paie', 1, 'income', '#22c55e', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (11, 'Autres revenus', 1, 'income', '#4ade80', 2); + +-- Dépenses récurrentes children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (20, 'Loyer', 2, 'expense', '#ef4444', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (21, 'Électricité', 2, 'expense', '#f59e0b', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (22, 'Épicerie', 2, 'expense', '#10b981', 3); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (23, 'Dons', 2, 'expense', '#ec4899', 4); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (24, 'Restaurant', 2, 'expense', '#f97316', 5); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (25, 'Frais bancaires', 2, 'expense', '#6b7280', 6); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (26, 'Jeux, Films & Livres', 2, 'expense', '#8b5cf6', 7); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (27, 'Abonnements Musique', 2, 'expense', '#06b6d4', 8); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (28, 'Transport en commun', 2, 'expense', '#3b82f6', 9); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (29, 'Internet & Télécom', 2, 'expense', '#6366f1', 10); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (30, 'Animaux', 2, 'expense', '#a855f7', 11); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (31, 'Assurances', 2, 'expense', '#14b8a6', 12); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (32, 'Pharmacie', 2, 'expense', '#f43f5e', 13); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (33, 'Taxes municipales', 2, 'expense', '#78716c', 14); + +-- Dépenses ponctuelles children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (40, 'Voiture', 3, 'expense', '#64748b', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (41, 'Amazon', 3, 'expense', '#f59e0b', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (42, 'Électroniques', 3, 'expense', '#3b82f6', 3); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (43, 'Alcool', 3, 'expense', '#7c3aed', 4); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (44, 'Cadeaux', 3, 'expense', '#ec4899', 5); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (45, 'Vêtements', 3, 'expense', '#d946ef', 6); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (46, 'CPA', 3, 'expense', '#0ea5e9', 7); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (47, 'Voyage', 3, 'expense', '#f97316', 8); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (48, 'Sports & Plein air', 3, 'expense', '#22c55e', 9); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (49, 'Spectacles & sorties', 3, 'expense', '#e11d48', 10); + +-- Maison children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (50, 'Hypothèque', 4, 'expense', '#dc2626', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (51, 'Achats maison', 4, 'expense', '#ea580c', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (52, 'Entretien maison', 4, 'expense', '#ca8a04', 3); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (53, 'Électroménagers & Meubles', 4, 'expense', '#0d9488', 4); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (54, 'Outils', 4, 'expense', '#b45309', 5); + +-- Placements children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (60, 'Placements', 5, 'transfer', '#2563eb', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (61, 'Transferts', 5, 'transfer', '#7c3aed', 2); + +-- Autres children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (70, 'Impôts', 6, 'expense', '#dc2626', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (71, 'Paiement CC', 6, 'transfer', '#6b7280', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (72, 'Retrait cash', 6, 'expense', '#57534e', 3); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (73, 'Projets', 6, 'expense', '#0ea5e9', 4); + +-- ========================================== +-- Keywords +-- ========================================== + +-- Paie (10) +INSERT INTO keywords (keyword, category_id) VALUES ('PAY/PAY', 10); + +-- Électricité (21) +INSERT INTO keywords (keyword, category_id) VALUES ('HYDRO-QUEBEC', 21); + +-- Épicerie (22) +INSERT INTO keywords (keyword, category_id) VALUES ('METRO', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('IGA', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('MAXI', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('SUPER C', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('BOUCHERIE LAFLECHE', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('BOULANGERIE JARRY', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('DOLLARAMA', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('WALMART', 22); + +-- Dons (23) +INSERT INTO keywords (keyword, category_id) VALUES ('OXFAM', 23); +INSERT INTO keywords (keyword, category_id) VALUES ('CENTRAIDE', 23); +INSERT INTO keywords (keyword, category_id) VALUES ('FPA', 23); + +-- Restaurant (24) +INSERT INTO keywords (keyword, category_id) VALUES ('SUBWAY', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('MCDONALD', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('A&W', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('DD/DOORDASH', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('DOORDASH', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('SUSHI', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('DOMINOS', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('BELLE PROVINCE', 24); + +-- Frais bancaires (25) +INSERT INTO keywords (keyword, category_id) VALUES ('PROGRAMME PERFORMANCE', 25); + +-- Jeux, Films & Livres (26) +INSERT INTO keywords (keyword, category_id) VALUES ('STEAMGAMES', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('PLAYSTATION', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('PRIMEVIDEO', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('NINTENDO', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('RENAUD-BRAY', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('CINEMA DU PARC', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('LEGO', 26); + +-- Abonnements Musique (27) +INSERT INTO keywords (keyword, category_id) VALUES ('SPOTIFY', 27); + +-- Transport en commun (28) +INSERT INTO keywords (keyword, category_id) VALUES ('STM', 28); +INSERT INTO keywords (keyword, category_id) VALUES ('GARE MONT-SAINT', 28); +INSERT INTO keywords (keyword, category_id) VALUES ('GARE SAINT-HUBERT', 28); +INSERT INTO keywords (keyword, category_id) VALUES ('GARE CENTRALE', 28); +INSERT INTO keywords (keyword, category_id) VALUES ('REM', 28); + +-- Internet & Télécom (29) +INSERT INTO keywords (keyword, category_id) VALUES ('VIDEOTRON', 29); +INSERT INTO keywords (keyword, category_id) VALUES ('ORICOM', 29); + +-- Animaux (30) +INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30); + +-- Assurances (31) +INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 31); +INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 31); +INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 31); + +-- Pharmacie (32) +INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32); +INSERT INTO keywords (keyword, category_id) VALUES ('FAMILIPRIX', 32); +INSERT INTO keywords (keyword, category_id) VALUES ('PHARMAPRIX', 32); + +-- Taxes municipales (33) +INSERT INTO keywords (keyword, category_id) VALUES ('M-ST-HILAIRE TX', 33); +INSERT INTO keywords (keyword, category_id) VALUES ('CSS PATRIOT', 33); + +-- Voiture (40) +INSERT INTO keywords (keyword, category_id) VALUES ('SHELL', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('ESSO', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('ULTRAMAR', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('PETRO-CANADA', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('SAAQ', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('CREVIER', 40); + +-- Amazon (41) +INSERT INTO keywords (keyword, category_id) VALUES ('AMAZON', 41); +INSERT INTO keywords (keyword, category_id) VALUES ('AMZN', 41); + +-- Électroniques (42) +INSERT INTO keywords (keyword, category_id) VALUES ('MICROSOFT', 42); +INSERT INTO keywords (keyword, category_id) VALUES ('ADDISON ELECTRONIQUE', 42); + +-- Alcool (43) +INSERT INTO keywords (keyword, category_id) VALUES ('SAQ', 43); +INSERT INTO keywords (keyword, category_id) VALUES ('SQDC', 43); + +-- Cadeaux (44) +INSERT INTO keywords (keyword, category_id) VALUES ('DANS UN JARDIN', 44); + +-- Vêtements (45) +INSERT INTO keywords (keyword, category_id) VALUES ('UNIQLO', 45); +INSERT INTO keywords (keyword, category_id) VALUES ('WINNERS', 45); +INSERT INTO keywords (keyword, category_id) VALUES ('SIMONS', 45); + +-- CPA (46) +INSERT INTO keywords (keyword, category_id) VALUES ('ORDRE DES COMPTABL', 46); + +-- Voyage (47) +INSERT INTO keywords (keyword, category_id) VALUES ('NORWEGIAN CRUISE', 47); +INSERT INTO keywords (keyword, category_id) VALUES ('AEROPORTS DE MONTREAL', 47); +INSERT INTO keywords (keyword, category_id) VALUES ('HILTON', 47); + +-- Sports & Plein air (48) +INSERT INTO keywords (keyword, category_id) VALUES ('BLOC SHOP', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('SEPAQ', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('LA CORDEE', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('MOUNTAIN EQUIPMENT', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('PHYSIOACTIF', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('DECATHLON', 48); + +-- Spectacles & sorties (49) +INSERT INTO keywords (keyword, category_id) VALUES ('TICKETMASTER', 49); +INSERT INTO keywords (keyword, category_id) VALUES ('CLUB SODA', 49); +INSERT INTO keywords (keyword, category_id) VALUES ('LEPOINTDEVENTE', 49); + +-- Hypothèque (50) +INSERT INTO keywords (keyword, category_id) VALUES ('MTG/HYP', 50); + +-- Achats maison (51) +INSERT INTO keywords (keyword, category_id) VALUES ('CANADIAN TIRE', 51); +INSERT INTO keywords (keyword, category_id) VALUES ('CANAC', 51); +INSERT INTO keywords (keyword, category_id) VALUES ('RONA', 51); + +-- Entretien maison (52) +INSERT INTO keywords (keyword, category_id) VALUES ('IKEA', 52); + +-- Électroménagers & Meubles (53) +INSERT INTO keywords (keyword, category_id) VALUES ('TANGUAY', 53); +INSERT INTO keywords (keyword, category_id) VALUES ('BOUCLAIR', 53); + +-- Outils (54) +INSERT INTO keywords (keyword, category_id) VALUES ('BMR', 54); +INSERT INTO keywords (keyword, category_id) VALUES ('HOME DEPOT', 54); +INSERT INTO keywords (keyword, category_id) VALUES ('PRINCESS AUTO', 54); + +-- Placements (60) +INSERT INTO keywords (keyword, category_id) VALUES ('DYNAMIC FUND', 60); +INSERT INTO keywords (keyword, category_id) VALUES ('FIDELITY', 60); +INSERT INTO keywords (keyword, category_id) VALUES ('AGF', 60); + +-- Transferts (61) +INSERT INTO keywords (keyword, category_id) VALUES ('WS INVESTMENTS', 61); +INSERT INTO keywords (keyword, category_id) VALUES ('PEAK INVESTMENT', 61); + +-- Impôts (70) +INSERT INTO keywords (keyword, category_id) VALUES ('GOUV. QUEBEC', 70); + +-- Projets (73) +INSERT INTO keywords (keyword, category_id) VALUES ('CLAUDE.AI', 73); +INSERT INTO keywords (keyword, category_id) VALUES ('NAME-CHEAP', 73); diff --git a/src-tauri/src/database/fixtures/seed_v0.4.0.sql b/src-tauri/src/database/fixtures/seed_v0.4.0.sql new file mode 100644 index 0000000..0582449 --- /dev/null +++ b/src-tauri/src/database/fixtures/seed_v0.4.0.sql @@ -0,0 +1,241 @@ +-- Migration v2: Seed categories and keywords from SommaireDepense.csv + +-- Clear existing data (FK order: keywords -> categories) +DELETE FROM keywords; +UPDATE transactions SET category_id = NULL; +DELETE FROM categories; + +-- ========================================== +-- Parent categories (Regroupement level) +-- ========================================== +INSERT INTO categories (id, name, type, sort_order) VALUES (1, 'Revenus', 'income', 1); +INSERT INTO categories (id, name, type, sort_order) VALUES (2, 'Dépenses récurrentes', 'expense', 2); +INSERT INTO categories (id, name, type, sort_order) VALUES (3, 'Dépenses ponctuelles', 'expense', 3); +INSERT INTO categories (id, name, type, sort_order) VALUES (4, 'Maison', 'expense', 4); +INSERT INTO categories (id, name, type, sort_order) VALUES (5, 'Placements', 'transfer', 5); +INSERT INTO categories (id, name, type, sort_order) VALUES (6, 'Autres', 'expense', 6); + +-- ========================================== +-- Child categories +-- ========================================== + +-- Revenus children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (10, 'Paie', 1, 'income', '#22c55e', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (11, 'Autres revenus', 1, 'income', '#4ade80', 2); + +-- Dépenses récurrentes children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (20, 'Loyer', 2, 'expense', '#ef4444', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (21, 'Électricité', 2, 'expense', '#f59e0b', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (22, 'Épicerie', 2, 'expense', '#10b981', 3); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (23, 'Dons', 2, 'expense', '#ec4899', 4); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (24, 'Restaurant', 2, 'expense', '#f97316', 5); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (25, 'Frais bancaires', 2, 'expense', '#6b7280', 6); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (26, 'Jeux, Films & Livres', 2, 'expense', '#8b5cf6', 7); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (27, 'Abonnements Musique', 2, 'expense', '#06b6d4', 8); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (28, 'Transport en commun', 2, 'expense', '#3b82f6', 9); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (29, 'Internet & Télécom', 2, 'expense', '#6366f1', 10); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (30, 'Animaux', 2, 'expense', '#a855f7', 11); +INSERT INTO categories (id, name, parent_id, type, color, sort_order, is_inputable) VALUES (31, 'Assurances', 2, 'expense', '#14b8a6', 12, 0); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (32, 'Pharmacie', 2, 'expense', '#f43f5e', 13); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (33, 'Taxes municipales', 2, 'expense', '#78716c', 14); + +-- Dépenses ponctuelles children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (40, 'Voiture', 3, 'expense', '#64748b', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (41, 'Amazon', 3, 'expense', '#f59e0b', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (42, 'Électroniques', 3, 'expense', '#3b82f6', 3); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (43, 'Alcool', 3, 'expense', '#7c3aed', 4); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (44, 'Cadeaux', 3, 'expense', '#ec4899', 5); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (45, 'Vêtements', 3, 'expense', '#d946ef', 6); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (46, 'CPA', 3, 'expense', '#0ea5e9', 7); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (47, 'Voyage', 3, 'expense', '#f97316', 8); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (48, 'Sports & Plein air', 3, 'expense', '#22c55e', 9); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (49, 'Spectacles & sorties', 3, 'expense', '#e11d48', 10); + +-- Maison children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (50, 'Hypothèque', 4, 'expense', '#dc2626', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (51, 'Achats maison', 4, 'expense', '#ea580c', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (52, 'Entretien maison', 4, 'expense', '#ca8a04', 3); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (53, 'Électroménagers & Meubles', 4, 'expense', '#0d9488', 4); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (54, 'Outils', 4, 'expense', '#b45309', 5); + +-- Placements children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (60, 'Placements', 5, 'transfer', '#2563eb', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (61, 'Transferts', 5, 'transfer', '#7c3aed', 2); + +-- Autres children +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (70, 'Impôts', 6, 'expense', '#dc2626', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (71, 'Paiement CC', 6, 'transfer', '#6b7280', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (72, 'Retrait cash', 6, 'expense', '#57534e', 3); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (73, 'Projets', 6, 'expense', '#0ea5e9', 4); + +-- ========================================== +-- Grandchild categories (Level 3 — under Assurances) +-- ========================================== +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2); +INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3); + +-- ========================================== +-- Keywords +-- ========================================== + +-- Paie (10) +INSERT INTO keywords (keyword, category_id) VALUES ('PAY/PAY', 10); + +-- Électricité (21) +INSERT INTO keywords (keyword, category_id) VALUES ('HYDRO-QUEBEC', 21); + +-- Épicerie (22) +INSERT INTO keywords (keyword, category_id) VALUES ('METRO', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('IGA', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('MAXI', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('SUPER C', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('BOUCHERIE LAFLECHE', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('BOULANGERIE JARRY', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('DOLLARAMA', 22); +INSERT INTO keywords (keyword, category_id) VALUES ('WALMART', 22); + +-- Dons (23) +INSERT INTO keywords (keyword, category_id) VALUES ('OXFAM', 23); +INSERT INTO keywords (keyword, category_id) VALUES ('CENTRAIDE', 23); +INSERT INTO keywords (keyword, category_id) VALUES ('FPA', 23); + +-- Restaurant (24) +INSERT INTO keywords (keyword, category_id) VALUES ('SUBWAY', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('MCDONALD', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('A&W', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('DD/DOORDASH', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('DOORDASH', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('SUSHI', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('DOMINOS', 24); +INSERT INTO keywords (keyword, category_id) VALUES ('BELLE PROVINCE', 24); + +-- Frais bancaires (25) +INSERT INTO keywords (keyword, category_id) VALUES ('PROGRAMME PERFORMANCE', 25); + +-- Jeux, Films & Livres (26) +INSERT INTO keywords (keyword, category_id) VALUES ('STEAMGAMES', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('PLAYSTATION', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('PRIMEVIDEO', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('NINTENDO', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('RENAUD-BRAY', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('CINEMA DU PARC', 26); +INSERT INTO keywords (keyword, category_id) VALUES ('LEGO', 26); + +-- Abonnements Musique (27) +INSERT INTO keywords (keyword, category_id) VALUES ('SPOTIFY', 27); + +-- Transport en commun (28) +INSERT INTO keywords (keyword, category_id) VALUES ('STM', 28); +INSERT INTO keywords (keyword, category_id) VALUES ('GARE MONT-SAINT', 28); +INSERT INTO keywords (keyword, category_id) VALUES ('GARE SAINT-HUBERT', 28); +INSERT INTO keywords (keyword, category_id) VALUES ('GARE CENTRALE', 28); +INSERT INTO keywords (keyword, category_id) VALUES ('REM', 28); + +-- Internet & Télécom (29) +INSERT INTO keywords (keyword, category_id) VALUES ('VIDEOTRON', 29); +INSERT INTO keywords (keyword, category_id) VALUES ('ORICOM', 29); + +-- Animaux (30) +INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30); + +-- Assurance-auto (310) +INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 310); +-- Assurance-habitation (311) +INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 311); +-- Assurance-vie (312) +INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 312); + +-- Pharmacie (32) +INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32); +INSERT INTO keywords (keyword, category_id) VALUES ('FAMILIPRIX', 32); +INSERT INTO keywords (keyword, category_id) VALUES ('PHARMAPRIX', 32); + +-- Taxes municipales (33) +INSERT INTO keywords (keyword, category_id) VALUES ('M-ST-HILAIRE TX', 33); +INSERT INTO keywords (keyword, category_id) VALUES ('CSS PATRIOT', 33); + +-- Voiture (40) +INSERT INTO keywords (keyword, category_id) VALUES ('SHELL', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('ESSO', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('ULTRAMAR', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('PETRO-CANADA', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('SAAQ', 40); +INSERT INTO keywords (keyword, category_id) VALUES ('CREVIER', 40); + +-- Amazon (41) +INSERT INTO keywords (keyword, category_id) VALUES ('AMAZON', 41); +INSERT INTO keywords (keyword, category_id) VALUES ('AMZN', 41); + +-- Électroniques (42) +INSERT INTO keywords (keyword, category_id) VALUES ('MICROSOFT', 42); +INSERT INTO keywords (keyword, category_id) VALUES ('ADDISON ELECTRONIQUE', 42); + +-- Alcool (43) +INSERT INTO keywords (keyword, category_id) VALUES ('SAQ', 43); +INSERT INTO keywords (keyword, category_id) VALUES ('SQDC', 43); + +-- Cadeaux (44) +INSERT INTO keywords (keyword, category_id) VALUES ('DANS UN JARDIN', 44); + +-- Vêtements (45) +INSERT INTO keywords (keyword, category_id) VALUES ('UNIQLO', 45); +INSERT INTO keywords (keyword, category_id) VALUES ('WINNERS', 45); +INSERT INTO keywords (keyword, category_id) VALUES ('SIMONS', 45); + +-- CPA (46) +INSERT INTO keywords (keyword, category_id) VALUES ('ORDRE DES COMPTABL', 46); + +-- Voyage (47) +INSERT INTO keywords (keyword, category_id) VALUES ('NORWEGIAN CRUISE', 47); +INSERT INTO keywords (keyword, category_id) VALUES ('AEROPORTS DE MONTREAL', 47); +INSERT INTO keywords (keyword, category_id) VALUES ('HILTON', 47); + +-- Sports & Plein air (48) +INSERT INTO keywords (keyword, category_id) VALUES ('BLOC SHOP', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('SEPAQ', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('LA CORDEE', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('MOUNTAIN EQUIPMENT', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('PHYSIOACTIF', 48); +INSERT INTO keywords (keyword, category_id) VALUES ('DECATHLON', 48); + +-- Spectacles & sorties (49) +INSERT INTO keywords (keyword, category_id) VALUES ('TICKETMASTER', 49); +INSERT INTO keywords (keyword, category_id) VALUES ('CLUB SODA', 49); +INSERT INTO keywords (keyword, category_id) VALUES ('LEPOINTDEVENTE', 49); + +-- Hypothèque (50) +INSERT INTO keywords (keyword, category_id) VALUES ('MTG/HYP', 50); + +-- Achats maison (51) +INSERT INTO keywords (keyword, category_id) VALUES ('CANADIAN TIRE', 51); +INSERT INTO keywords (keyword, category_id) VALUES ('CANAC', 51); +INSERT INTO keywords (keyword, category_id) VALUES ('RONA', 51); + +-- Entretien maison (52) +INSERT INTO keywords (keyword, category_id) VALUES ('IKEA', 52); + +-- Électroménagers & Meubles (53) +INSERT INTO keywords (keyword, category_id) VALUES ('TANGUAY', 53); +INSERT INTO keywords (keyword, category_id) VALUES ('BOUCLAIR', 53); + +-- Outils (54) +INSERT INTO keywords (keyword, category_id) VALUES ('BMR', 54); +INSERT INTO keywords (keyword, category_id) VALUES ('HOME DEPOT', 54); +INSERT INTO keywords (keyword, category_id) VALUES ('PRINCESS AUTO', 54); + +-- Placements (60) +INSERT INTO keywords (keyword, category_id) VALUES ('DYNAMIC FUND', 60); +INSERT INTO keywords (keyword, category_id) VALUES ('FIDELITY', 60); +INSERT INTO keywords (keyword, category_id) VALUES ('AGF', 60); + +-- Transferts (61) +INSERT INTO keywords (keyword, category_id) VALUES ('WS INVESTMENTS', 61); +INSERT INTO keywords (keyword, category_id) VALUES ('PEAK INVESTMENT', 61); + +-- Impôts (70) +INSERT INTO keywords (keyword, category_id) VALUES ('GOUV. QUEBEC', 70); + +-- Projets (73) +INSERT INTO keywords (keyword, category_id) VALUES ('CLAUDE.AI', 73); +INSERT INTO keywords (keyword, category_id) VALUES ('NAME-CHEAP', 73); diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 74cf233..3312ae9 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -1,5 +1,11 @@ +pub mod profile_db; + pub const SCHEMA: &str = include_str!("schema.sql"); pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql"); pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql"); pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql"); pub const BALANCE_HOLDINGS_SCHEMA: &str = include_str!("balance_holdings_schema.sql"); + +/// File name of the default profile's database: the only one tauri-plugin-sql +/// migrates itself (`add_migrations` in lib.rs). +pub const DEFAULT_PROFILE_DB: &str = "simpl_resultat.db"; diff --git a/src-tauri/src/database/profile_db.rs b/src-tauri/src/database/profile_db.rs new file mode 100644 index 0000000..9614d39 --- /dev/null +++ b/src-tauri/src/database/profile_db.rs @@ -0,0 +1,1392 @@ +//! Preparing a profile database before the webview opens it (#390, ADR 0021). +//! +//! tauri-plugin-sql migrates only the database registered with +//! `add_migrations` — the default profile, `simpl_resultat.db` (lib.rs) — and +//! drops that list after the first load. A secondary profile +//! (`profile_.db`) is created from the consolidated schema of the version +//! that creates it, and until #390 never received a migration afterwards. +//! +//! This module catches such a database up with the very same `Migration` list, +//! keeping the history in the `_sqlx_migrations` table sqlx uses for the +//! default profile, and repairs a value an old bug left in every profile. +//! It works on a plain `rusqlite::Connection`, so every rule is tested against +//! real SQLite and the databases each released version created. + +use rusqlite::{params, Connection, OpenFlags, TransactionBehavior}; +use serde::Serialize; +use sha2::{Digest, Sha384}; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::time::{Duration, Instant}; +use tauri_plugin_sql::{Migration, MigrationKind}; + +use super::DEFAULT_PROFILE_DB; + +/// Subfolder, next to the profile databases, that holds the copy taken before +/// a catch-up. Removed with the profile (`delete_profile_db`). +pub const MIGRATION_BACKUP_DIR: &str = "migration-backups"; + +/// The DDL sqlx 0.8 creates (sqlx-sqlite `migrate.rs`), so the default profile +/// and the secondary ones keep their history in one format. +const SQLX_MIGRATIONS_DDL: &str = "CREATE TABLE IF NOT EXISTS _sqlx_migrations ( + version BIGINT PRIMARY KEY, + description TEXT NOT NULL, + installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN NOT NULL, + checksum BLOB NOT NULL, + execution_time BIGINT NOT NULL +);"; + +/// Until #390 the Transactions page bound the JS boolean `true`, which +/// tauri-plugin-sql stores as the TEXT 'true' (an INTEGER column keeps a text +/// that is not a number). Only `'true'` was ever written; any other text is +/// read as "not set", like `toSqlFlag` on the TypeScript side. +const REPAIR_MANUAL_FLAGS_SQL: &str = "UPDATE transactions \ + SET is_manually_categorized = CASE WHEN is_manually_categorized = 'true' THEN 1 ELSE 0 END \ + WHERE typeof(is_manually_categorized) = 'text'"; + +/// What a catch-up did, for the Journal. +#[derive(Debug, Default, Clone, PartialEq, Serialize)] +pub struct CatchUp { + /// Highest version whose schema change the database already carried. + pub floor: Option, + /// Versions written to the history without being run: the database already + /// had their effect (first history of a secondary profile, or a gap left by + /// an interrupted run). + pub recorded: Vec, + /// Versions this run applied, in order. + pub applied: Vec, + /// Rows a version could only apply with foreign keys off, set back to NULL + /// as their key declares (ON DELETE SET NULL), as (table, rows). + pub fk_repaired: Vec<(String, i64)>, + /// Foreign key violations the database already had, found by the final + /// check, as (table, rows). Reported, never touched. + pub fk_violations: Vec<(String, i64)>, + /// File name of the copy taken before the first applied version. + pub backup_file: Option, +} + +/// What `prepare_at` did to one profile database. Paths never leave this +/// module: the report names files only, since error-level Journal lines can be +/// attached to a feedback report. +#[derive(Debug, Default, Clone, PartialEq, Serialize)] +pub struct ProfilePreparation { + pub catch_up: Option, + /// Why the catch-up stopped, when it did. The profile still opens. + pub catch_up_error: Option, + /// Rows whose manual-categorization flag was repaired. + pub repaired_flags: usize, + pub elapsed_ms: u64, +} + +fn read_err(e: rusqlite::Error) -> String { + format!("schema_read_failed: {}", e) +} + +fn table_exists(conn: &Connection, table: &str) -> Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [table], + |row| row.get(0), + ) + .map_err(read_err) +} + +fn column_exists(conn: &Connection, table: &str, column: &str) -> Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2)", + [table, column], + |row| row.get(0), + ) + .map_err(read_err) +} + +/// Highest migration version whose schema change this database carries, +/// checked newest first. Each marker is a column or table that version +/// introduced and that no earlier state had. +/// +/// Only databases created before #390 need it: since then a new profile gets +/// its history with its schema (`history_sql`). Those older databases form a +/// closed set, one state per consolidated schema a release shipped (fixtures +/// in `database/fixtures/`): v0.3.0-v0.8.3 → 6, v0.8.4 → 8, v0.9.0 and v0.9.1 +/// → 10, v0.10.0-v0.14.0 → 16, v0.15.0-v0.17.x → 17. The 14, 12 and 9 markers +/// cover databases a development build created between two releases. Never +/// add a marker for a later migration: no database without a history can +/// carry it. +pub fn infer_floor(conn: &Connection) -> Result { + // v17 — import format on the sources. `amount_mode` alone is no marker: + // import_config_templates has carried it since v5. + if column_exists(conn, "import_sources", "header_signature")? { + return Ok(17); + } + // v15 added `kind`. The only released state carrying it (v0.10.0-v0.14.0) + // was created with v16's data conversion already folded in. + if column_exists(conn, "balance_accounts", "kind")? { + return Ok(16); + } + if table_exists(conn, "balance_securities")? { + return Ok(14); + } + // v12; v13 is an idempotent reclass and is simply re-run. + if column_exists(conn, "balance_accounts", "vehicle_type")? { + return Ok(12); + } + // v10; v0.9.1 (v11, an idempotent orphan cleanup) has the same schema. + if column_exists(conn, "balance_categories", "asset_type")? { + return Ok(10); + } + if table_exists(conn, "balance_categories")? { + return Ok(9); + } + if column_exists(conn, "categories", "i18n_key")? { + return Ok(8); + } + // v3 to v6, all in the consolidated schema multi-profile shipped with. + if table_exists(conn, "import_config_templates")? + && column_exists(conn, "categories", "is_inputable")? + && column_exists(conn, "import_sources", "has_header")? + { + return Ok(6); + } + Err("unknown_layout".to_string()) +} + +fn checksum(sql: &str) -> Vec { + Sha384::digest(sql.as_bytes()).to_vec() +} + +fn recorded_versions(conn: &Connection) -> Result, String> { + if !table_exists(conn, "_sqlx_migrations")? { + return Ok(BTreeSet::new()); + } + let dirty: Option = conn + .query_row( + "SELECT MIN(version) FROM _sqlx_migrations WHERE success = 0", + [], + |row| row.get(0), + ) + .map_err(read_err)?; + if let Some(version) = dirty { + // Same refusal as sqlx: a half-applied version needs a human. + return Err(format!("dirty_history:v{}", version)); + } + let mut stmt = conn + .prepare("SELECT version FROM _sqlx_migrations") + .map_err(read_err)?; + let versions = stmt + .query_map([], |row| row.get::<_, i64>(0)) + .map_err(read_err)? + .collect::, _>>() + .map_err(read_err)?; + Ok(versions) +} + +fn record(conn: &Connection, migration: &Migration, execution_time: i64) -> rusqlite::Result { + conn.execute( + "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \ + VALUES (?1, ?2, 1, ?3, ?4)", + params![ + migration.version, + migration.description, + checksum(migration.sql), + execution_time + ], + ) +} + +/// One row of `PRAGMA foreign_key_check`: (table, rowid, foreign key id). +type Violation = (String, Option, i64); + +fn violations(conn: &Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare("PRAGMA foreign_key_check")?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(3)?)))?; + rows.collect() +} + +fn count_by_table<'a>(tables: impl Iterator) -> Vec<(String, i64)> { + let mut counts: Vec<(String, i64)> = Vec::new(); + for table in tables { + match counts.iter_mut().find(|(name, _)| name == table) { + Some((_, n)) => *n += 1, + None => counts.push((table.to_string(), 1)), + } + } + counts.sort(); + counts +} + +fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +fn is_foreign_key_failure(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(failure, _) + if failure.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY + ) +} + +/// Undo the violations a version introduced, as each key declares: only +/// `ON DELETE SET NULL` can be repaired (the column goes back to NULL). Any +/// other new violation is an error, so the caller rolls the version back. +fn repair_new_violations(conn: &Connection, before: &BTreeSet) -> Result, String> { + let new: Vec = violations(conn) + .map_err(|e| e.to_string())? + .into_iter() + .filter(|v| !before.contains(v)) + .collect(); + for (table, rowid, fkid) in &new { + let unrepairable = || format!("unrepairable foreign key in {}", table); + let rowid = rowid.ok_or_else(unrepairable)?; + let columns: Vec<(String, String)> = conn + .prepare("SELECT \"from\", on_delete FROM pragma_foreign_key_list(?1) WHERE id = ?2") + .and_then(|mut stmt| { + stmt.query_map(params![table, fkid], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect() + }) + .map_err(|e| e.to_string())?; + if columns.is_empty() || columns.iter().any(|(_, action)| action != "SET NULL") { + return Err(unrepairable()); + } + let assignments = columns + .iter() + .map(|(column, _)| format!("{} = NULL", quote_ident(column))) + .collect::>() + .join(", "); + conn.execute( + &format!("UPDATE {} SET {} WHERE rowid = ?1", quote_ident(table), assignments), + [rowid], + ) + .map_err(|e| e.to_string())?; + } + let left = violations(conn) + .map_err(|e| e.to_string())? + .into_iter() + .any(|v| !before.contains(&v)); + if left { + return Err("foreign key violations left after the repair".to_string()); + } + Ok(count_by_table(new.iter().map(|(table, _, _)| table.as_str()))) +} + +fn run_in_transaction(conn: &mut Connection, migration: &Migration, started: Instant) -> rusqlite::Result<()> { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute_batch(migration.sql)?; + record(&tx, migration, started.elapsed().as_nanos() as i64)?; + tx.commit() +} + +/// Second attempt of a version that failed on a foreign key: once, with +/// foreign keys off, then repair what it left. Foreign keys are back on +/// whatever happens. +fn retry_without_foreign_keys( + conn: &mut Connection, + migration: &Migration, + started: Instant, +) -> Result, String> { + conn.execute_batch("PRAGMA foreign_keys = OFF").map_err(|e| e.to_string())?; + let attempt = (|| -> Result, String> { + let before: BTreeSet = violations(conn).map_err(|e| e.to_string())?.into_iter().collect(); + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + tx.execute_batch(migration.sql).map_err(|e| e.to_string())?; + let repaired = repair_new_violations(&tx, &before)?; + record(&tx, migration, started.elapsed().as_nanos() as i64).map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(repaired) + })(); + let restored = conn.execute_batch("PRAGMA foreign_keys = ON").map_err(|e| e.to_string()); + let repaired = attempt?; + restored?; + Ok(repaired) +} + +fn apply_pending(conn: &mut Connection, pending: &[&Migration], report: &mut CatchUp) -> Result<(), String> { + for migration in pending { + let started = Instant::now(); + // Dropping the transaction on an error rolls this version back; the + // earlier ones stay committed and recorded. + match run_in_transaction(conn, migration, started) { + Ok(()) => {} + Err(e) if is_foreign_key_failure(&e) => { + let repaired = retry_without_foreign_keys(conn, migration, started) + .map_err(|e| format!("migration_failed:v{}: {}", migration.version, e))?; + for (table, rows) in repaired { + match report.fk_repaired.iter_mut().find(|(name, _)| *name == table) { + Some((_, n)) => *n += rows, + None => report.fk_repaired.push((table, rows)), + } + } + } + Err(e) => return Err(format!("migration_failed:v{}: {}", migration.version, e)), + } + report.applied.push(migration.version); + } + Ok(()) +} + +/// Bring a secondary profile database to the last migration. +/// +/// - `floor` comes from the schema itself, not from the history, so an +/// interrupted first run can never make a version below it look pending. +/// That is what keeps the destructive v2 seed (DELETE FROM categories) from +/// ever being replayed onto a populated database. +/// - `backup(conn, first_pending)` runs before the first write, and only when +/// something is pending. A failed backup stops everything. +/// - The history (table and every version up to the floor) is written in one +/// transaction; then each pending version runs in its own transaction and +/// the first failure stops the run, earlier versions kept. +/// - Foreign keys stay on, as sqlx keeps them for the default profile. A +/// version that fails on a foreign key is retried once without them, then +/// the rows it left pointing nowhere are set back to NULL when their key +/// declares ON DELETE SET NULL; anything else rolls it back. The case it +/// exists for: v7 adds subcategories under category 31, which a profile +/// whose categories were restored from a newer export no longer has. +pub fn catch_up(conn: &mut Connection, migrations: &[Migration], backup: F) -> Result +where + F: FnOnce(&Connection, i64) -> Result, +{ + let floor = infer_floor(conn)?; + let recorded = recorded_versions(conn)?; + let has_history = table_exists(conn, "_sqlx_migrations")?; + + let mut ups: Vec<&Migration> = migrations + .iter() + .filter(|m| matches!(m.kind, MigrationKind::Up)) + .collect(); + ups.sort_by_key(|m| m.version); + let to_record: Vec<&Migration> = ups + .iter() + .copied() + .filter(|m| m.version <= floor && !recorded.contains(&m.version)) + .collect(); + let pending: Vec<&Migration> = ups + .iter() + .copied() + .filter(|m| m.version > floor && !recorded.contains(&m.version)) + .collect(); + + // Second line of defence, unreachable while `infer_floor` never returns + // below 6: the initial schema and the v2 seed must never run on a + // database that already holds transactions. + if pending.iter().any(|m| m.version <= 2) && table_exists(conn, "transactions")? { + return Err("refused_initial_migrations".to_string()); + } + + let mut report = CatchUp { + floor: Some(floor), + ..CatchUp::default() + }; + + if let Some(first) = pending.first() { + report.backup_file = Some(backup(conn, first.version)?); + } + + if !has_history || !to_record.is_empty() { + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|e| format!("history_write_failed: {}", e))?; + tx.execute_batch(SQLX_MIGRATIONS_DDL) + .map_err(|e| format!("history_write_failed: {}", e))?; + for migration in &to_record { + record(&tx, migration, 0).map_err(|e| format!("history_write_failed: {}", e))?; + } + tx.commit().map_err(|e| format!("history_write_failed: {}", e))?; + report.recorded = to_record.iter().map(|m| m.version).collect(); + } + + if pending.is_empty() { + return Ok(report); + } + + // Foreign keys on, as sqlx runs the default profile's migrations. + conn.execute_batch("PRAGMA foreign_keys = ON") + .map_err(|e| format!("pragma_failed: {}", e))?; + if let Err(e) = apply_pending(conn, &pending, &mut report) { + return Err(if report.applied.is_empty() { + e + } else { + format!("{} (applied before stopping: {:?})", e, report.applied) + }); + } + let left = violations(conn).map_err(read_err)?; + report.fk_violations = count_by_table(left.iter().map(|(table, _, _)| table.as_str())); + Ok(report) +} + +/// Normalize the manual-categorization flag of every transaction stored as +/// text. Returns the number of rows changed; 0 on a database without the table. +pub fn repair_manual_flags(conn: &Connection) -> Result { + if !table_exists(conn, "transactions")? { + return Ok(0); + } + conn.execute(REPAIR_MANUAL_FLAGS_SQL, []) + .map_err(|e| format!("flag_repair_failed: {}", e)) +} + +/// Copy the database to `/-before-v.db` and return +/// the file name. The copy is written as `.tmp` and renamed only once +/// complete, so an existing final file is trusted as is and kept; a `.tmp` +/// left by an interrupted run is replaced. The path is bound, never formatted +/// into the SQL, so a folder such as `C:\Users\L'Heureux` works. +pub fn backup_to(conn: &Connection, dir: &Path, stem: &str, first_pending: i64) -> Result { + let name = format!("{}-before-v{}.db", stem, first_pending); + let final_path = dir.join(&name); + if final_path.exists() { + return Ok(name); + } + fs::create_dir_all(dir).map_err(|e| format!("backup_failed: {}", e))?; + let tmp_path = dir.join(format!("{}.tmp", name)); + if tmp_path.exists() { + fs::remove_file(&tmp_path).map_err(|e| format!("backup_failed: {}", e))?; + } + let tmp = tmp_path + .to_str() + .ok_or_else(|| "backup_failed: path is not valid UTF-8".to_string())?; + conn.execute("VACUUM INTO ?1", [tmp]) + .map_err(|e| format!("backup_failed: {}", e))?; + // VACUUM INTO does not sync its output; the rename must not publish a + // copy a power cut could still truncate. Write access: Windows refuses + // to flush a file opened read-only. + fs::OpenOptions::new() + .write(true) + .open(&tmp_path) + .and_then(|file| file.sync_all()) + .map_err(|e| format!("backup_failed: {}", e))?; + fs::rename(&tmp_path, &final_path).map_err(|e| format!("backup_failed: {}", e))?; + #[cfg(unix)] + if let Ok(folder) = fs::File::open(dir) { + // Best effort: makes the rename itself durable. + let _ = folder.sync_all(); + } + Ok(name) +} + +/// SQL that writes the complete history of a database created from the +/// current consolidated schema. `get_new_profile_init_sql` returns it right +/// after the schema, so a new profile is born with its history and never goes +/// through `infer_floor`. `INSERT OR IGNORE`: a version already recorded keeps +/// its row. +pub fn history_sql(migrations: &[Migration]) -> String { + let mut sql = String::from(SQLX_MIGRATIONS_DDL); + for migration in migrations.iter().filter(|m| matches!(m.kind, MigrationKind::Up)) { + let hex: String = checksum(migration.sql).iter().map(|b| format!("{:02X}", b)).collect(); + sql.push_str(&format!( + "\nINSERT OR IGNORE INTO _sqlx_migrations (version, description, success, checksum, execution_time) \ + VALUES ({}, '{}', 1, X'{}', 0);", + migration.version, + migration.description.replace('\'', "''"), + hex + )); + } + sql +} + +/// Replace the profile folder in a message with a placeholder. The folder +/// holds the OS user name, and error-level Journal lines can be attached to a +/// feedback report (Loi 25). +pub fn scrub(message: String, dir: &Path) -> String { + let full = dir.to_string_lossy(); + if full.is_empty() { + return message; + } + message.replace(full.as_ref(), "") +} + +/// Remove the catch-up copies of one profile (and any `.tmp` left behind). +/// Returns how many files were removed. +pub fn remove_backups(dir: &Path, stem: &str) -> Result { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(format!("backup_cleanup_failed: {}", e)), + }; + let prefix = format!("{}-before-v", stem); + let mut removed = 0; + for entry in entries { + let entry = entry.map_err(|e| format!("backup_cleanup_failed: {}", e))?; + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.starts_with(&prefix) && (name.ends_with(".db") || name.ends_with(".db.tmp")) { + fs::remove_file(entry.path()).map_err(|e| format!("backup_cleanup_failed: {}", e))?; + removed += 1; + } + } + Ok(removed) +} + +/// Prepare one profile database before tauri-plugin-sql opens it. +/// +/// A missing file is left alone: never create a database here, the plugin +/// does that on first load. The default profile is never caught up here (the +/// plugin migrates it); every profile gets the flag repair. A catch-up that +/// stops is reported in `catch_up_error` and the profile still opens, like it +/// did before #390; only a database that cannot be opened is an error. +pub fn prepare_at(db_path: &Path, db_filename: &str, migrations: &[Migration]) -> Result { + let started = Instant::now(); + let mut report = ProfilePreparation::default(); + if !db_path.exists() { + return Ok(report); + } + let dir = db_path + .parent() + .ok_or_else(|| "open_failed: database has no parent folder".to_string())?; + let mut conn = Connection::open_with_flags( + db_path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|e| scrub(format!("open_failed: {}", e), dir))?; + conn.busy_timeout(Duration::from_secs(5)) + .map_err(|e| scrub(format!("open_failed: {}", e), dir))?; + + if db_filename != DEFAULT_PROFILE_DB { + let backup_dir = dir.join(MIGRATION_BACKUP_DIR); + let stem = db_filename.trim_end_matches(".db"); + match catch_up(&mut conn, migrations, |conn, first| backup_to(conn, &backup_dir, stem, first)) { + Ok(done) => report.catch_up = Some(done), + Err(e) => report.catch_up_error = Some(scrub(e, dir)), + } + } + + report.repaired_flags = repair_manual_flags(&conn).map_err(|e| scrub(e, dir))?; + report.elapsed_ms = started.elapsed().as_millis() as u64; + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + + // The SQL each release ran to create a secondary profile + // (`get_new_profile_init_sql` of that tag), copied verbatim from git. + const CONSOLIDATED_V0_3_0: &str = include_str!("fixtures/consolidated_v0.3.0.sql"); + const SEED_V0_3_0: &str = include_str!("fixtures/seed_v0.3.0.sql"); + const SEED_V0_4_0: &str = include_str!("fixtures/seed_v0.4.0.sql"); + const CONSOLIDATED_V0_8_4: &str = include_str!("fixtures/consolidated_v0.8.4.sql"); + const CONSOLIDATED_V0_9_0: &str = include_str!("fixtures/consolidated_v0.9.0.sql"); + const CONSOLIDATED_V0_9_1: &str = include_str!("fixtures/consolidated_v0.9.1.sql"); + const CONSOLIDATED_V0_10_0: &str = include_str!("fixtures/consolidated_v0.10.0.sql"); + + const SECONDARY: &str = "profile_0a1b2c3d.db"; + + struct State { + name: &'static str, + init: Vec<&'static str>, + floor: i64, + } + + fn released_states() -> Vec { + vec![ + State { name: "A1 v0.3.0-v0.8.3", init: vec![CONSOLIDATED_V0_3_0, SEED_V0_3_0], floor: 6 }, + State { name: "A2 v0.4.0", init: vec![CONSOLIDATED_V0_3_0, SEED_V0_4_0], floor: 6 }, + State { name: "B v0.8.4", init: vec![CONSOLIDATED_V0_8_4], floor: 8 }, + State { name: "C v0.9.0", init: vec![CONSOLIDATED_V0_9_0], floor: 10 }, + State { name: "D v0.9.1", init: vec![CONSOLIDATED_V0_9_1], floor: 10 }, + State { name: "E v0.10.0-v0.14.0", init: vec![CONSOLIDATED_V0_10_0], floor: 16 }, + ] + } + + fn migrations() -> Vec { + crate::migrations() + } + + fn max_version() -> i64 { + migrations().iter().map(|m| m.version).max().unwrap() + } + + fn versions(from: i64, to: i64) -> Vec { + (from..=to).collect() + } + + /// Cargo runs tests in parallel: every test gets its own folder. + fn unique_dir(tag: &str) -> PathBuf { + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "simpl-resultat-profile-db-{}-{}-{}", + std::process::id(), + tag, + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn create_db(dir: &Path, name: &str, init: &[&str]) -> PathBuf { + let path = dir.join(name); + let conn = Connection::open(&path).unwrap(); + for sql in init { + conn.execute_batch(sql).unwrap(); + } + path + } + + fn open(path: &Path) -> Connection { + Connection::open(path).unwrap() + } + + fn count(conn: &Connection, sql: &str) -> i64 { + conn.query_row(sql, [], |row| row.get(0)).unwrap() + } + + fn has_table(conn: &Connection, table: &str) -> bool { + table_exists(conn, table).unwrap() + } + + fn no_backup(_: &Connection, _: i64) -> Result { + Ok("not-written.db".to_string()) + } + + /// One source whose mapping reads debit/credit columns, one file, and one + /// transaction classified by hand while the bug stood (TEXT 'true'). + /// Returns the transaction's category. + fn seed_user_rows(conn: &Connection) -> i64 { + conn.execute( + "INSERT INTO import_sources (name, date_format, delimiter, encoding, column_mapping, skip_lines, has_header) \ + VALUES ('Visa', 'YYYY-MM-DD', ',', 'utf-8', \ + '{\"date\":0,\"description\":1,\"debitAmount\":2,\"creditAmount\":3}', 0, 1)", + [], + ) + .unwrap(); + let source = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO imported_files (source_id, filename, file_hash, row_count, status) \ + VALUES (?1, 'visa.csv', 'hash', 1, 'completed')", + [source], + ) + .unwrap(); + let file = conn.last_insert_rowid(); + let category = count(conn, "SELECT MIN(id) FROM categories"); + conn.execute( + "INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, is_manually_categorized) \ + VALUES ('2026-03-02', 'IGA', -42.5, ?1, ?2, ?3, 'true')", + params![category, source, file], + ) + .unwrap(); + category + } + + /// An envelope account in the old `tfsa` category, a stock account with a + /// priced line, and an orphan snapshot: what v11, v12, v13, v15 and v16 + /// exist to transform. + fn seed_balance_rows(conn: &Connection) { + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, currency) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, symbol, currency) \ + VALUES ((SELECT id FROM balance_categories WHERE key = 'stock'), 'Apple', ' aapl ', 'CAD')", + [], + ) + .unwrap(); + let apple = conn.last_insert_rowid(); + conn.execute("INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-30')", []) + .unwrap(); + let snapshot = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value) \ + VALUES (?1, ?2, 10, 150, 1500)", + params![snapshot, apple], + ) + .unwrap(); + conn.execute("INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-03-31')", []) + .unwrap(); + } + + // -- schema comparison -------------------------------------------------- + + type ColumnShape = (String, String, i64, Option, i64); + type ForeignKeyShape = (String, String, Option, String, String); + type IndexShape = (String, i64, Vec); + + #[derive(Debug, PartialEq)] + struct Shape { + columns: BTreeMap>, + foreign_keys: BTreeMap>, + indexes: BTreeMap>, + } + + /// Tables, columns (as a set: ALTER appends where the consolidated schema + /// declares mid-table), foreign keys and indexes. Automatic index names + /// depend on table history, so they compare by origin and columns. + fn shape(conn: &Connection) -> Shape { + let tables: Vec = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' \ + AND name NOT LIKE 'sqlite_%' AND name <> '_sqlx_migrations' ORDER BY name", + ) + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + let mut result = Shape { + columns: BTreeMap::new(), + foreign_keys: BTreeMap::new(), + indexes: BTreeMap::new(), + }; + for table in tables { + let columns: BTreeSet = conn + .prepare("SELECT name, type, \"notnull\", dflt_value, pk FROM pragma_table_info(?1)") + .unwrap() + .query_map([&table], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))) + .unwrap() + .collect::>() + .unwrap(); + let foreign_keys: BTreeSet = conn + .prepare( + "SELECT \"table\", \"from\", \"to\", on_update, on_delete FROM pragma_foreign_key_list(?1)", + ) + .unwrap() + .query_map([&table], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))) + .unwrap() + .collect::>() + .unwrap(); + let listed: Vec<(String, i64, String)> = conn + .prepare("SELECT name, \"unique\", origin FROM pragma_index_list(?1)") + .unwrap() + .query_map([&table], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .collect::>() + .unwrap(); + let mut indexes = BTreeSet::new(); + for (name, unique, origin) in listed { + let columns: Vec = conn + .prepare("SELECT name FROM pragma_index_info(?1) ORDER BY seqno") + .unwrap() + .query_map([&name], |r| r.get::<_, Option>(0)) + .unwrap() + .map(|c| c.unwrap().unwrap_or_else(|| "".to_string())) + .collect(); + let key = if origin == "c" { name } else { origin }; + indexes.insert((key, unique, columns)); + } + result.columns.insert(table.clone(), columns); + result.foreign_keys.insert(table.clone(), foreign_keys); + result.indexes.insert(table, indexes); + } + result + } + + fn fresh_profile_shape() -> Shape { + let dir = unique_dir("reference"); + let path = create_db(&dir, SECONDARY, &[crate::database::CONSOLIDATED_SCHEMA]); + shape(&open(&path)) + } + + /// Every version recorded once, applied, with the checksum sqlx computes. + fn assert_history_complete(conn: &Connection, context: &str) { + let rows: Vec<(i64, i64, Vec)> = conn + .prepare("SELECT version, success, checksum FROM _sqlx_migrations ORDER BY version") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .collect::>() + .unwrap(); + let expected: Vec<(i64, i64, Vec)> = migrations() + .iter() + .map(|m| (m.version, 1, checksum(m.sql))) + .collect(); + assert_eq!(rows, expected, "{}", context); + } + + // -- the released states ------------------------------------------------ + + #[test] + fn every_released_state_infers_its_floor() { + for state in released_states() { + let dir = unique_dir("floor"); + let path = create_db(&dir, SECONDARY, &state.init); + assert_eq!(infer_floor(&open(&path)), Ok(state.floor), "{}", state.name); + } + } + + #[test] + fn every_released_state_catches_up_to_the_consolidated_schema() { + let reference = fresh_profile_shape(); + let max = max_version(); + for state in released_states() { + let dir = unique_dir("released"); + let path = create_db(&dir, SECONDARY, &state.init); + let category = seed_user_rows(&open(&path)); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up_error, None, "{}", state.name); + let done = report.catch_up.clone().unwrap(); + assert_eq!(done.floor, Some(state.floor), "{}", state.name); + assert_eq!(done.recorded, versions(1, state.floor), "{}", state.name); + assert_eq!(done.applied, versions(state.floor + 1, max), "{}", state.name); + assert_eq!(done.fk_repaired, Vec::<(String, i64)>::new(), "{}", state.name); + assert_eq!(done.fk_violations, Vec::<(String, i64)>::new(), "{}", state.name); + let expected_backup = format!("profile_0a1b2c3d-before-v{}.db", state.floor + 1); + assert_eq!(done.backup_file.as_deref(), Some(expected_backup.as_str()), "{}", state.name); + assert_eq!(report.repaired_flags, 1, "{}", state.name); + + let conn = open(&path); + assert_eq!(shape(&conn), reference, "{}", state.name); + assert_history_complete(&conn, state.name); + let flag: (i64, String, i64) = conn + .query_row( + "SELECT is_manually_categorized, typeof(is_manually_categorized), category_id FROM transactions", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + assert_eq!(flag, (1, "integer".to_string(), category), "{}", state.name); + let mode: String = conn + .query_row("SELECT amount_mode FROM import_sources WHERE name = 'Visa'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(mode, "debit_credit", "{}: v17 backfill", state.name); + assert!(dir.join(MIGRATION_BACKUP_DIR).join(&expected_backup).exists(), "{}", state.name); + } + } + + #[test] + fn the_bilan_data_of_a_v0_9_profile_is_converted() { + for state in released_states().into_iter().filter(|s| s.floor == 10) { + let dir = unique_dir("bilan"); + let path = create_db(&dir, SECONDARY, &state.init); + seed_balance_rows(&open(&path)); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + assert_eq!(report.catch_up_error, None, "{}", state.name); + + let conn = open(&path); + // v11: the snapshot without lines is gone. + assert_eq!(count(&conn, "SELECT COUNT(*) FROM balance_snapshots"), 1, "{}", state.name); + // v12 then v13: the envelope moves onto the account, which leaves the + // deprecated `tfsa` category for `other`. + let celi: (String, String) = conn + .query_row( + "SELECT a.vehicle_type, c.key FROM balance_accounts a \ + JOIN balance_categories c ON c.id = a.balance_category_id WHERE a.name = 'CELI'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(celi, ("tfsa".to_string(), "other".to_string()), "{}", state.name); + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM balance_categories WHERE key IN ('tfsa','rrsp') AND is_active = 1"), + 0, + "{}", + state.name + ); + // v15 then v16: the priced account becomes detailed, holding one position. + let kind: String = conn + .query_row("SELECT kind FROM balance_accounts WHERE name = 'Apple'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(kind, "detailed", "{}", state.name); + let holding: (String, String, f64, f64, f64) = conn + .query_row( + "SELECT s.symbol, s.asset_type, h.quantity, h.unit_price, h.value \ + FROM balance_snapshot_holdings h JOIN balance_securities s ON s.id = h.security_id", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .unwrap(); + assert_eq!(holding, ("AAPL".to_string(), "stock".to_string(), 10.0, 150.0, 1500.0), "{}", state.name); + let line: (Option, Option, f64) = conn + .query_row("SELECT quantity, unit_price, value FROM balance_snapshot_lines", [], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + }) + .unwrap(); + assert_eq!(line, (None, None, 1500.0), "{}", state.name); + } + } + + #[test] + fn development_states_catch_up_too() { + let all = migrations(); + let run = |conn: &Connection, wanted: &[i64]| { + for version in wanted { + let migration = all.iter().find(|m| m.version == *version).unwrap(); + conn.execute_batch(migration.sql).unwrap(); + } + }; + let reference = fresh_profile_shape(); + let max = max_version(); + let cases: Vec<(&str, &str, Vec, i64)> = vec![ + ("v0.8.4 + v9", CONSOLIDATED_V0_8_4, vec![9], 9), + ("v0.9.0 + v12", CONSOLIDATED_V0_9_0, vec![12], 12), + ("v0.9.0 + v12..v14", CONSOLIDATED_V0_9_0, vec![12, 13, 14], 14), + ]; + for (name, init, extra, floor) in cases { + let dir = unique_dir("dev"); + let path = create_db(&dir, SECONDARY, &[init]); + run(&open(&path), &extra); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up_error, None, "{}", name); + let done = report.catch_up.unwrap(); + assert_eq!(done.floor, Some(floor), "{}", name); + assert_eq!(done.applied, versions(floor + 1, max), "{}", name); + let conn = open(&path); + assert_eq!(shape(&conn), reference, "{}", name); + assert_history_complete(&conn, name); + } + } + + #[test] + fn a_new_profile_is_born_with_its_history() { + // What createProfile runs, statement by statement, through the plugin. + let dir = unique_dir("new"); + let init = crate::commands::get_new_profile_init_sql().unwrap(); + let statements: Vec<&str> = init.iter().map(String::as_str).collect(); + let path = create_db(&dir, SECONDARY, &statements); + let mut conn = open(&path); + assert_history_complete(&conn, "new profile"); + + let report = catch_up(&mut conn, &migrations(), |_: &Connection, _: i64| -> Result { + panic!("a new profile needs no backup") + }) + .unwrap(); + + assert!(report.recorded.is_empty()); + assert!(report.applied.is_empty()); + assert_eq!(report.backup_file, None); + assert!(!dir.join(MIGRATION_BACKUP_DIR).exists()); + } + + #[test] + fn the_legacy_floor_stops_at_v17() { + // `infer_floor` serves only databases created before #390. The current + // consolidated schema must still carry the newest legacy marker; a + // later migration needs no marker of its own. + let dir = unique_dir("legacy-top"); + let path = create_db(&dir, SECONDARY, &[crate::database::CONSOLIDATED_SCHEMA]); + assert_eq!(infer_floor(&open(&path)), Ok(17)); + } + + #[test] + fn history_sql_escapes_descriptions() { + let list = vec![Migration { + version: 1, + description: "the user's file", + sql: "SELECT 1;", + kind: MigrationKind::Up, + }]; + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(&history_sql(&list)).unwrap(); + let description: String = conn + .query_row("SELECT description FROM _sqlx_migrations", [], |r| r.get(0)) + .unwrap(); + assert_eq!(description, "the user's file"); + } + + #[test] + fn scrub_removes_the_profile_folder() { + let dir = Path::new("/home/someone/.config/com.simpl.resultat"); + let message = "backup_failed: unable to open database: /home/someone/.config/com.simpl.resultat/migration-backups/x.tmp".to_string(); + assert_eq!( + scrub(message, dir), + "backup_failed: unable to open database: /migration-backups/x.tmp" + ); + } + + #[test] + fn a_second_open_changes_nothing() { + for state in released_states() { + let dir = unique_dir("twice"); + let path = create_db(&dir, SECONDARY, &state.init); + seed_user_rows(&open(&path)); + prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + let again = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(again.catch_up_error, None, "{}", state.name); + let done = again.catch_up.unwrap(); + assert!(done.recorded.is_empty(), "{}", state.name); + assert!(done.applied.is_empty(), "{}", state.name); + assert_eq!(done.backup_file, None, "{}", state.name); + assert_eq!(again.repaired_flags, 0, "{}", state.name); + assert_eq!(fs::read_dir(dir.join(MIGRATION_BACKUP_DIR)).unwrap().count(), 1, "{}", state.name); + } + } + + // -- an interrupted first run ------------------------------------------- + + #[test] + fn an_interrupted_first_run_never_replays_the_seed() { + // A history table without its rows is what a crash between the CREATE + // and the INSERTs of a non-atomic first write would leave. Replaying v2 + // would empty the categories and every transaction's category. + for partial in [vec![], vec![1_i64, 2, 3]] { + let dir = unique_dir("partial"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_3_0, SEED_V0_3_0]); + let category; + { + let conn = open(&path); + category = seed_user_rows(&conn); + conn.execute( + "INSERT INTO categories (id, name, type, sort_order) VALUES (9001, 'Mine', 'expense', 99)", + [], + ) + .unwrap(); + conn.execute_batch(SQLX_MIGRATIONS_DDL).unwrap(); + for migration in migrations().iter().filter(|m| partial.contains(&m.version)) { + record(&conn, migration, 0).unwrap(); + } + } + let categories_before = count(&open(&path), "SELECT COUNT(*) FROM categories"); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up_error, None, "{:?}", partial); + let done = report.catch_up.unwrap(); + let expected_recorded: Vec = versions(1, 6).into_iter().filter(|v| !partial.contains(v)).collect(); + assert_eq!(done.recorded, expected_recorded, "{:?}", partial); + assert_eq!(done.applied, versions(7, max_version()), "{:?}", partial); + let conn = open(&path); + // v7 adds the three insurance subcategories, nothing is deleted. + assert_eq!(count(&conn, "SELECT COUNT(*) FROM categories"), categories_before + 3, "{:?}", partial); + assert_eq!(count(&conn, "SELECT COUNT(*) FROM categories WHERE id = 9001"), 1, "{:?}", partial); + assert_eq!(count(&conn, "SELECT category_id FROM transactions"), category, "{:?}", partial); + assert_history_complete(&conn, "partial"); + } + } + + #[test] + fn a_profile_without_category_31_still_catches_up() { + // A restore of a newer export replaces the v2 taxonomy, so v7's + // subcategories have no parent to point at. v7 is retried without + // foreign keys, and its three rows become root categories, as the key's + // ON DELETE SET NULL declares. + let dir = unique_dir("no31"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_3_0, SEED_V0_3_0]); + open(&path) + .execute_batch( + "DELETE FROM keywords; DELETE FROM categories; \ + INSERT INTO categories (id, name, type, sort_order) VALUES (1000, 'Logement', 'expense', 1);", + ) + .unwrap(); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up_error, None); + let done = report.catch_up.unwrap(); + assert_eq!(done.applied, versions(7, max_version())); + assert_eq!(done.fk_repaired, vec![("categories".to_string(), 3)]); + assert_eq!(done.fk_violations, Vec::<(String, i64)>::new()); + let conn = open(&path); + assert_history_complete(&conn, "no31"); + assert!(violations(&conn).unwrap().is_empty()); + assert_eq!( + count(&conn, "SELECT COUNT(*) FROM categories WHERE id IN (310, 311, 312) AND parent_id IS NULL"), + 3 + ); + // What a restore of this profile's own export does: every category + // inserted in id order, foreign keys on. + let create_sql: String = conn + .query_row("SELECT sql FROM sqlite_master WHERE name = 'categories'", [], |r| r.get(0)) + .unwrap(); + let copy = Connection::open_in_memory().unwrap(); + copy.execute_batch(&format!("PRAGMA foreign_keys = ON; {};", create_sql)).unwrap(); + let rows: Vec<(i64, String, Option, String)> = conn + .prepare("SELECT id, name, parent_id, type FROM categories ORDER BY id") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))) + .unwrap() + .collect::>() + .unwrap(); + for (id, name, parent, kind) in rows { + copy.execute( + "INSERT INTO categories (id, name, parent_id, type) VALUES (?1, ?2, ?3, ?4)", + params![id, name, parent, kind], + ) + .unwrap(); + } + } + + #[test] + fn a_foreign_key_that_cannot_be_set_to_null_rolls_the_version_back() { + let dir = unique_dir("unrepairable"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]); + let mut list: Vec = migrations().into_iter().filter(|m| m.version <= 16).collect(); + // Both keys of a snapshot line are CASCADE / RESTRICT, never SET NULL. + list.push(Migration { + version: 17, + description: "points nowhere", + sql: "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) VALUES (999, 999, 1);", + kind: MigrationKind::Up, + }); + let mut conn = open(&path); + + let error = catch_up(&mut conn, &list, no_backup).unwrap_err(); + + assert!(error.starts_with("migration_failed:v17: unrepairable foreign key"), "{}", error); + assert_eq!(count(&conn, "SELECT COUNT(*) FROM balance_snapshot_lines"), 0); + assert_eq!(count(&conn, "SELECT MAX(version) FROM _sqlx_migrations"), 16); + let foreign_keys: i64 = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0)).unwrap(); + assert_eq!(foreign_keys, 1); + } + + #[test] + fn an_unknown_layout_is_left_untouched() { + let dir = unique_dir("unknown"); + let path = create_db(&dir, SECONDARY, &["CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT);"]); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up_error.as_deref(), Some("unknown_layout")); + assert_eq!(report.repaired_flags, 0); + let conn = open(&path); + assert!(!has_table(&conn, "_sqlx_migrations")); + assert!(!dir.join(MIGRATION_BACKUP_DIR).exists()); + } + + #[test] + fn a_dirty_history_is_refused() { + let dir = unique_dir("dirty"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]); + open(&path) + .execute_batch(&format!( + "{} INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) \ + VALUES (17, 'half', 0, x'00', -1);", + SQLX_MIGRATIONS_DDL + )) + .unwrap(); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up_error.as_deref(), Some("dirty_history:v17")); + assert!(!column_exists(&open(&path), "import_sources", "header_signature").unwrap()); + } + + #[test] + fn a_failing_version_stops_the_run_and_keeps_the_earlier_ones() { + let dir = unique_dir("failing"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]); + let mut list: Vec = migrations().into_iter().filter(|m| m.version <= 16).collect(); + list.push(Migration { + version: 17, + description: "works", + sql: "CREATE TABLE ok_one (x INTEGER);", + kind: MigrationKind::Up, + }); + list.push(Migration { + version: 18, + description: "breaks halfway", + sql: "CREATE TABLE half_done (x INTEGER); INSERT INTO no_such_table VALUES (1);", + kind: MigrationKind::Up, + }); + let mut conn = open(&path); + + let error = catch_up(&mut conn, &list, no_backup).unwrap_err(); + + assert!(error.starts_with("migration_failed:v18"), "{}", error); + assert!(error.ends_with("(applied before stopping: [17])"), "{}", error); + assert!(has_table(&conn, "ok_one")); + assert!(!has_table(&conn, "half_done"), "the failing version is rolled back"); + assert_eq!(count(&conn, "SELECT MAX(version) FROM _sqlx_migrations"), 17); + let foreign_keys: i64 = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0)).unwrap(); + assert_eq!(foreign_keys, 1, "foreign keys are back on after a failure"); + } + + // -- the backup --------------------------------------------------------- + + #[test] + fn the_backup_holds_the_database_as_it_was() { + let dir = unique_dir("backup"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]); + seed_user_rows(&open(&path)); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + let name = report.catch_up.unwrap().backup_file.unwrap(); + assert_eq!(name, "profile_0a1b2c3d-before-v17.db"); + let copy = open(&dir.join(MIGRATION_BACKUP_DIR).join(&name)); + assert!(!column_exists(©, "import_sources", "header_signature").unwrap()); + assert!(!has_table(©, "_sqlx_migrations")); + let flag: String = copy + .query_row("SELECT typeof(is_manually_categorized) FROM transactions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(flag, "text", "taken before the repair too"); + assert!(!dir.join(MIGRATION_BACKUP_DIR).join(format!("{}.tmp", name)).exists()); + } + + #[test] + fn an_interrupted_backup_is_replaced() { + let dir = unique_dir("stale-tmp"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]); + let backups = dir.join(MIGRATION_BACKUP_DIR); + fs::create_dir_all(&backups).unwrap(); + let tmp = backups.join("profile_0a1b2c3d-before-v17.db.tmp"); + fs::write(&tmp, b"half a copy").unwrap(); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up_error, None); + assert!(!tmp.exists()); + let copy = open(&backups.join("profile_0a1b2c3d-before-v17.db")); + assert!(has_table(©, "transactions")); + } + + #[test] + fn a_complete_backup_is_kept() { + let dir = unique_dir("kept"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]); + let backups = dir.join(MIGRATION_BACKUP_DIR); + fs::create_dir_all(&backups).unwrap(); + let existing = backups.join("profile_0a1b2c3d-before-v17.db"); + fs::write(&existing, b"earlier copy").unwrap(); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up.unwrap().applied, vec![17]); + assert_eq!(fs::read(&existing).unwrap(), b"earlier copy"); + } + + #[test] + fn a_failed_backup_writes_nothing() { + let dir = unique_dir("no-backup"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]); + // A file where the folder should be: create_dir_all fails. + fs::write(dir.join(MIGRATION_BACKUP_DIR), b"not a folder").unwrap(); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + let error = report.catch_up_error.unwrap(); + assert!(error.starts_with("backup_failed"), "{}", error); + assert!(!error.contains(dir.to_str().unwrap()), "{}", error); + let conn = open(&path); + assert!(!has_table(&conn, "_sqlx_migrations")); + assert!(!column_exists(&conn, "import_sources", "header_signature").unwrap()); + } + + #[test] + fn remove_backups_takes_only_this_profile() { + let dir = unique_dir("cleanup"); + for name in [ + "profile_0a1b2c3d-before-v7.db", + "profile_0a1b2c3d-before-v17.db.tmp", + "profile_99999999-before-v17.db", + "profile_0a1b2c3d-notes.txt", + ] { + fs::write(dir.join(name), b"x").unwrap(); + } + + assert_eq!(remove_backups(&dir, "profile_0a1b2c3d"), Ok(2)); + let mut left: Vec = fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + left.sort(); + assert_eq!(left, vec!["profile_0a1b2c3d-notes.txt", "profile_99999999-before-v17.db"]); + assert_eq!(remove_backups(&dir.join("absent"), "profile_0a1b2c3d"), Ok(0)); + } + + // -- the default profile, missing files and the repair ------------------ + + #[test] + fn the_default_profile_is_only_repaired() { + let dir = unique_dir("default"); + let path = create_db(&dir, DEFAULT_PROFILE_DB, &[CONSOLIDATED_V0_3_0, SEED_V0_3_0]); + seed_user_rows(&open(&path)); + + let report = prepare_at(&path, DEFAULT_PROFILE_DB, &migrations()).unwrap(); + + assert_eq!(report.catch_up, None); + assert_eq!(report.catch_up_error, None); + assert_eq!(report.repaired_flags, 1); + let conn = open(&path); + assert!(!has_table(&conn, "_sqlx_migrations"), "tauri-plugin-sql migrates the default profile"); + assert!(!dir.join(MIGRATION_BACKUP_DIR).exists()); + } + + #[test] + fn a_missing_database_is_not_created() { + let dir = unique_dir("missing"); + let path = dir.join(SECONDARY); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report, ProfilePreparation { elapsed_ms: report.elapsed_ms, ..ProfilePreparation::default() }); + assert!(!path.exists()); + } + + #[test] + fn repair_turns_text_flags_into_integers_and_leaves_integers_alone() { + let dir = unique_dir("repair"); + let path = create_db(&dir, SECONDARY, &[crate::database::CONSOLIDATED_SCHEMA]); + let conn = open(&path); + for flag in ["'true'", "1", "0", "'false'", "'yes'"] { + conn.execute( + &format!( + "INSERT INTO transactions (date, description, amount, is_manually_categorized) \ + VALUES ('2026-01-01', 'x', -1, {})", + flag + ), + [], + ) + .unwrap(); + } + + assert_eq!(repair_manual_flags(&conn), Ok(3)); + + let flags: Vec<(i64, String)> = conn + .prepare("SELECT is_manually_categorized, typeof(is_manually_categorized) FROM transactions ORDER BY id") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + let integer = "integer".to_string(); + assert_eq!( + flags, + vec![(1, integer.clone()), (1, integer.clone()), (0, integer.clone()), (0, integer.clone()), (0, integer)] + ); + assert_eq!(repair_manual_flags(&conn), Ok(0)); + } + + #[test] + fn repair_skips_a_database_without_transactions() { + let conn = Connection::open_in_memory().unwrap(); + assert_eq!(repair_manual_flags(&conn), Ok(0)); + } + + // -- the migration list itself ------------------------------------------ + + #[test] + fn migration_checksums_match_the_released_ones() { + // SHA-384 of every migration's SQL, computed on main before + // `migrations()` was extracted from `run()` (#390). sqlx refuses to + // open the default profile when a recorded checksum changes, and a + // secondary profile's history must hold the same values. + let pinned: [(i64, &str); 17] = [ + (1, "a2f964547e0d0ebf141a978b1f2b5bfd030be6618e13dcdc6fa16504a269b6a036d3dbf7e82700db07c6aa85948bfedf"), + (2, "59d9c344713108fa6a1fb5c63635d5baab1d78bd8d5aa438a28dafdcb0e78c4cb66a11a6a2fec4358b61c45a9e055c96"), + (3, "fcb7bc6a921a68cb682f6441655c2152e3e131af6cacac8fec69b5ff73bd9c5a95bdf44a74fcfdfc5e322976f1c7b4d3"), + (4, "610fc4d778e3507bad1e3bfad5d682a534e753c326db6cf7d7af95a6772e37773518af0a7363bc0e47bbf6e9c49a9531"), + (5, "131c4b1e0fd6265e5afda9d4f9e505290b8b7108d71b33a957646068f8b116b0cddfca9f285742a93dc7101f4ac8794c"), + (6, "329e36c0cc63309292c10c92a9137a8f7150c2e5f84e4660ccf41fd4a85a3531e72b74f4ecd321c0236eb37bad934d8f"), + (7, "4c57798526796c44abe39116e4d498d6a6b330b58e134b67d23c7c1aad7da9e7856f8ac4296b9177591aaf127a2c54bc"), + (8, "a8763846054027b38f03f9d29219b08891ce931be07ceb221fe6e346d81a037cb9f77953ba230850810029bf25fd4cec"), + (9, "0f826db3a4bb2910e92c76be1de83a2d3858df783782b0278cbafcb0ae11b40ff1af540478e59a4cf1f7b4ba7933307a"), + (10, "646ae1048a55a04fbdd40eee045c75950f28f11492c31d94d2f0a32999e8f488650b3548c1d33e02b4b403ff91bf4077"), + (11, "037129d61869502d377ef0723dee830ad6bf023ef49564dfad9e4a1af1a342bee4161805b6b460bfac3f9748fb01cfd1"), + (12, "e4ad66f1b7fe8baa174d3fa3f898a98efb44293d35bae665fe4844b090fdfc57383f114f5f756066f09264cc30723f31"), + (13, "a85e83cea40c9bd70b435ead61db91eecd7bf4de2f80149e2cbf525f65fc1bbcf17eb5b29e6741f41bc8cb699d18b5e9"), + (14, "e1ef785df3777651f13bae14ebe8da3696f1b793a7676dffe1aaeed14e66f8214eece72466c8df002e746352c333c8b5"), + (15, "ee608cf4174c8127975aab5039348c1278c365c25c640186a6719b1cf056007772d0296a2efd9a05f5ecf351ef3a128f"), + (16, "e1be444fca469235871b89dfc264e6020a8ef1dbafff7bc487597eee29971baadd0bd694a352a4eea7e7b9d4ae752d99"), + (17, "0c60796250708aa2e4339640c99836f5cbdc52e1901231e3fbdc5668772b4d1e09c4fc697c94698523f56151492749b5"), + ]; + let actual: Vec<(i64, String)> = migrations() + .iter() + .map(|m| (m.version, checksum(m.sql).iter().map(|b| format!("{:02x}", b)).collect())) + .collect(); + let expected: Vec<(i64, String)> = pinned.iter().map(|(v, h)| (*v, h.to_string())).collect(); + assert_eq!(actual, expected); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5a0ea21..f22a134 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,9 +6,12 @@ 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![ +/// Every migration, in order. tauri-plugin-sql applies them to the default +/// profile on load; `database::profile_db` catches the secondary profiles up +/// with the same list (#390). The SQL bytes are checksummed by sqlx and pinned +/// by a test: never edit an existing entry, append a new version. +pub(crate) fn migrations() -> Vec { + vec![ Migration { version: 1, description: "create initial schema", @@ -365,7 +368,12 @@ pub fn run() { WHERE column_mapping LIKE '%debitAmount%';", kind: MigrationKind::Up, }, - ]; + ] +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let migrations = migrations(); tauri::Builder::default() // Single-instance plugin MUST be registered first. With the `deep-link` @@ -429,7 +437,7 @@ pub fn run() { }) .plugin( tauri_plugin_sql::Builder::default() - .add_migrations("sqlite:simpl_resultat.db", migrations) + .add_migrations(&format!("sqlite:{}", database::DEFAULT_PROFILE_DB), migrations) .build(), ) .invoke_handler(tauri::generate_handler![ @@ -452,6 +460,7 @@ pub fn run() { commands::hash_pin, commands::verify_pin, commands::repair_migrations, + commands::prepare_profile_db, commands::validate_license_key, commands::store_license, commands::remove_license, @@ -3867,12 +3876,10 @@ mod tests { #[test] fn consolidated_schema_matches_v17_chain_on_import_sources_at_parity() { - // The consolidated schema is the tested reference definition for new - // profiles. Without this test the DEFAULT or the CHECK of the four v17 - // columns could drift between the two definitions unnoticed — the - // columns are inert on the production path (this script runs after every - // migration and only uses CREATE TABLE IF NOT EXISTS), so nothing else - // would surface the divergence. + // The consolidated schema is the definition a secondary profile is + // created with: tauri-plugin-sql runs no migration on its database + // (#390). Without this test the DEFAULT or the CHECK of the four v17 + // columns could drift between the two definitions unnoticed. let chain = db_pre_v17(); chain.execute_batch(V17_SQL).expect("apply v17"); let consolidated = consolidated_db(); diff --git a/src/services/db.test.ts b/src/services/db.test.ts index ef09826..9672428 100644 --- a/src/services/db.test.ts +++ b/src/services/db.test.ts @@ -12,7 +12,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Ordered log of every fake-db operation's start/end, shared with the fake db. const events: string[] = []; // Hoisted handle so the (hoisted) vi.mock factory can reach the per-test db. -const h = vi.hoisted(() => ({ fakeDb: null as unknown as FakeDb })); +const h = vi.hoisted(() => ({ + fakeDb: null as unknown as FakeDb, + // What `prepare_profile_db` answers (#390); a test may make it throw. + prepare: async (): Promise => null, +})); interface FakeDb { execute: (q: string, p?: unknown[]) => Promise<{ rowsAffected: number }>; @@ -23,9 +27,21 @@ interface FakeDb { vi.mock("@tauri-apps/plugin-sql", () => ({ default: { load: vi.fn(async () => h.fakeDb) }, })); -vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => false) })); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(async (command: string) => (command === "prepare_profile_db" ? h.prepare() : false)), +})); +vi.mock("./logService", () => ({ logInfo: vi.fn(), logError: vi.fn() })); -import { connectToProfile, getDb, withTransaction } from "./db"; +import Database from "@tauri-apps/plugin-sql"; +import { invoke } from "@tauri-apps/api/core"; +import { logError, logInfo } from "./logService"; +import { + connectToProfile, + describePreparation, + getDb, + withTransaction, + type ProfilePreparation, +} from "./db"; // Each op records start, yields a real macrotask (a window for interleaving if // the lock were broken), then records end. The lock must keep them ordered. @@ -50,6 +66,7 @@ function makeFakeDb(): FakeDb { beforeEach(async () => { events.length = 0; h.fakeDb = makeFakeDb(); + h.prepare = async () => null; await connectToProfile("test.db"); }); @@ -121,3 +138,101 @@ describe("db serialization (database-locked fix)", () => { ); }); }); + +// --------------------------------------------------------------------------- +// Profile preparation (#390) +// --------------------------------------------------------------------------- + +function report(overrides: Partial = {}): ProfilePreparation { + return { catch_up: null, catch_up_error: null, repaired_flags: 0, elapsed_ms: 12, ...overrides }; +} + +function catchUp(overrides: Partial> = {}) { + return { + floor: 6, + recorded: [], + applied: [], + fk_repaired: [], + fk_violations: [], + backup_file: null, + ...overrides, + }; +} + +describe("a profile is prepared before the plugin opens it (#390)", () => { + beforeEach(() => { + vi.mocked(invoke).mockClear(); + vi.mocked(Database.load).mockClear(); + vi.mocked(logInfo).mockClear(); + vi.mocked(logError).mockClear(); + }); + + it("prepares after the checksum repair and before Database.load", async () => { + await connectToProfile("profile_0a1b2c3d.db"); + + const invoked = vi.mocked(invoke).mock; + expect(invoked.calls.map((call) => call[0])).toEqual(["repair_migrations", "prepare_profile_db"]); + expect(invoked.calls[1][1]).toEqual({ dbFilename: "profile_0a1b2c3d.db" }); + expect(invoked.invocationCallOrder[1]).toBeLessThan( + vi.mocked(Database.load).mock.invocationCallOrder[0] + ); + }); + + it("still opens the profile when the preparation fails, and says so in the Journal", async () => { + h.prepare = async () => { + throw "invalid_profile_db_filename"; + }; + + await connectToProfile("profile_0a1b2c3d.db"); + + expect(Database.load).toHaveBeenCalledWith("sqlite:profile_0a1b2c3d.db"); + expect(logError).toHaveBeenCalledWith( + "Profile profile_0a1b2c3d.db: database preparation failed: invalid_profile_db_filename" + ); + }); + + it("writes what changed to the Journal", async () => { + h.prepare = async () => + report({ + catch_up: catchUp({ applied: [7, 8, 17], backup_file: "profile_0a1b2c3d-before-v7.db" }), + repaired_flags: 2, + }); + + await connectToProfile("profile_0a1b2c3d.db"); + + expect(vi.mocked(logInfo).mock.calls.map((call) => call[0])).toEqual([ + "Profile profile_0a1b2c3d.db: database updated with versions 7 to 17 in 12 ms, copy of the previous state kept as profile_0a1b2c3d-before-v7.db", + "Profile profile_0a1b2c3d.db: 2 manual categorization flag(s) repaired", + ]); + expect(logError).not.toHaveBeenCalled(); + }); +}); + +describe("describePreparation (#390)", () => { + it("says nothing when nothing changed", () => { + expect(describePreparation("x.db", report({ catch_up: catchUp() }))).toEqual({ info: [], errors: [] }); + }); + + it("tolerates a missing report", () => { + expect(describePreparation("x.db", null)).toEqual({ info: [], errors: [] }); + expect(describePreparation("x.db", false as never)).toEqual({ info: [], errors: [] }); + }); + + it("notes a history written without any update", () => { + const lines = describePreparation("x.db", report({ catch_up: catchUp({ recorded: [1, 2, 17] }) })); + expect(lines.info).toEqual(["Profile x.db: migration history recorded up to version 17"]); + }); + + it("names the rows a version detached from a missing parent", () => { + const lines = describePreparation( + "x.db", + report({ catch_up: catchUp({ applied: [7], fk_repaired: [["categories", 3]] }) }) + ); + expect(lines.info).toContain("Profile x.db: 3 row(s) of categories detached from a missing parent"); + }); + + it("reports a stopped catch-up as an error", () => { + const lines = describePreparation("x.db", report({ catch_up_error: "migration_failed:v12: boom" })); + expect(lines.errors).toEqual(["Profile x.db: database update stopped: migration_failed:v12: boom"]); + }); +}); diff --git a/src/services/db.ts b/src/services/db.ts index f0cae67..3682e38 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -1,5 +1,6 @@ import Database from "@tauri-apps/plugin-sql"; import { invoke } from "@tauri-apps/api/core"; +import { logError, logInfo } from "./logService"; let rawInstance: Database | null = null; let serializedInstance: Database | null = null; @@ -103,6 +104,61 @@ export async function withTransaction( }); } +/** What `prepare_profile_db` did to one profile database (#390). */ +export interface ProfilePreparation { + catch_up: { + floor: number | null; + recorded: number[]; + applied: number[]; + fk_repaired: Array<[string, number]>; + fk_violations: Array<[string, number]>; + backup_file: string | null; + } | null; + catch_up_error: string | null; + repaired_flags: number; + elapsed_ms: number; +} + +/** + * Journal lines for one preparation: only what changed. Names files, never + * folders: an error line can be attached to a feedback report, and the + * folder holds the OS user name. + */ +export function describePreparation( + dbFilename: string, + report: ProfilePreparation | null | undefined +): { info: string[]; errors: string[] } { + const info: string[] = []; + const errors: string[] = []; + if (!report || typeof report !== "object") return { info, errors }; + const catchUp = report.catch_up; + if (catchUp && catchUp.applied.length > 0) { + const first = catchUp.applied[0]; + const last = catchUp.applied[catchUp.applied.length - 1]; + info.push( + `Profile ${dbFilename}: database updated with versions ${first} to ${last} in ${report.elapsed_ms} ms` + + (catchUp.backup_file ? `, copy of the previous state kept as ${catchUp.backup_file}` : "") + ); + } else if (catchUp && catchUp.recorded.length > 0) { + info.push( + `Profile ${dbFilename}: migration history recorded up to version ${catchUp.recorded[catchUp.recorded.length - 1]}` + ); + } + for (const [table, rows] of catchUp?.fk_repaired ?? []) { + info.push(`Profile ${dbFilename}: ${rows} row(s) of ${table} detached from a missing parent`); + } + for (const [table, rows] of catchUp?.fk_violations ?? []) { + info.push(`Profile ${dbFilename}: ${rows} row(s) of ${table} point to a missing row`); + } + if (report.catch_up_error) { + errors.push(`Profile ${dbFilename}: database update stopped: ${report.catch_up_error}`); + } + if (report.repaired_flags > 0) { + info.push(`Profile ${dbFilename}: ${report.repaired_flags} manual categorization flag(s) repaired`); + } + return { info, errors }; +} + export async function connectToProfile(dbFilename: string): Promise { if (rawInstance) { await rawInstance.close(); @@ -118,6 +174,19 @@ export async function connectToProfile(dbFilename: string): Promise { } catch (e) { console.error("Migration repair failed:", e); } + // Catch a secondary profile up on the migrations tauri-plugin-sql never + // runs for it, and repair legacy values in every profile (#390). Fails + // open, like the repair above: the profile still opens. + try { + const report = await invoke("prepare_profile_db", { dbFilename }); + const lines = describePreparation(dbFilename, report); + lines.info.forEach((line) => logInfo(line)); + lines.errors.forEach((line) => logError(line)); + } catch (e) { + logError( + `Profile ${dbFilename}: database preparation failed: ${e instanceof Error ? e.message : String(e)}` + ); + } rawInstance = await Database.load(`sqlite:${dbFilename}`); serializedInstance = serialize(rawInstance); } -- 2.45.2 From 02fca2ad3c20348c97ff65ae8b99b659b7c098e5 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 6 Oct 2026 20:35:00 -0400 Subject: [PATCH 3/5] docs: ADR 0021, guide and changelog for the profile catch-up (#390) ADR 0021 records the catch-up rules and amends ADR 0003 and 0005, whose claims about secondary profiles were false. architecture.md, CLAUDE.md and the migration rule describe the two migration paths; the guide tells where the pre-update copy lives and how to remove a database a deletion on Linux left behind. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/sql-migrations.md | 15 +++- CHANGELOG.fr.md | 6 ++ CHANGELOG.md | 6 ++ CLAUDE.md | 7 +- docs/adr/0003-sqlx-migrations.md | 4 + docs/adr/0005-multi-profile-db.md | 9 ++ .../0021-migrations-profils-secondaires.md | 90 +++++++++++++++++++ docs/architecture.md | 34 +++++-- docs/guide-utilisateur.md | 2 + src/i18n/locales/en.json | 4 +- src/i18n/locales/fr.json | 4 +- 11 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0021-migrations-profils-secondaires.md diff --git a/.claude/rules/sql-migrations.md b/.claude/rules/sql-migrations.md index 97ae1f3..3c90f2d 100644 --- a/.claude/rules/sql-migrations.md +++ b/.claude/rules/sql-migrations.md @@ -1,7 +1,16 @@ --- -paths: ["**/migrations/**", "**/*.sql", "**/lib.rs"] +paths: ["**/migrations/**", "**/*.sql", "**/lib.rs", "**/profile_db.rs"] --- Ne JAMAIS modifier une migration SQL existante. Toujours creer une nouvelle migration. -Les checksums sont verifies au demarrage (SHA-384 dans `_sqlx_migrations`). -Les migrations sont inline dans `lib.rs` via `tauri_plugin_sql::Migration`. +Les checksums sont verifies au demarrage (SHA-384 dans `_sqlx_migrations`) et epingles par le test `migration_checksums_match_the_released_ones`. +Les migrations sont inline dans `lib.rs`, rendues par `migrations()` via `tauri_plugin_sql::Migration`. Le schema consolide (`consolidated_schema.sql`) sert uniquement pour les nouveaux profils. + +Depuis #390 (ADR 0021), chaque nouvelle migration atteint deux chemins : +- le profil par defaut, migre par tauri-plugin-sql (sqlx) ; +- les profils secondaires, rattrapes a l'ouverture par `database::profile_db` (rusqlite), cles etrangeres actives comme sous sqlx. + +Une migration doit donc rester correcte sur les deux, et : +- tout changement de schema se reporte dans `consolidated_schema.sql` (le test des etats publies compare chaque profil rattrape a un profil neuf) ; +- toute correction de donnees (seed, mots-cles, categories) se reporte AUSSI dans `consolidated_schema.sql` : un nouveau profil nait avec l'historique complet et ne rejoue jamais les migrations ; +- ne pas ajouter de marqueur a `infer_floor` : il ne sert qu'aux bases creees avant #390. diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index ae3745c..4bd75aa 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,12 @@ ## [Non publié] +### Corrigé + +- Classer une transaction à la main sur la page Transactions coche désormais l'étape « Classer une transaction à la main » de la liste de démarrage. L'application enregistrait cette marque sous une forme que la liste ne savait pas compter. Les transactions classées à la main avant cette version sont comptées dès l'ouverture du profil, et une sauvegarde exportée entre-temps les restaure correctement (#390). +- Les profils autres que le profil par défaut reçoivent désormais les mises à jour de leur base de données. Jusqu'ici, chacun gardait la structure de base de la version qui l'avait créé, et des fonctions ajoutées depuis pouvaient y échouer : dans un profil créé avant la 0.15.0, par exemple, le format d'import enregistré sur une source. À l'ouverture d'un tel profil, l'application garde d'abord une copie de sa base dans le dossier `migration-backups`, à côté des bases, puis applique les mises à jour manquantes. La copie est supprimée avec le profil, et chaque mise à jour est inscrite dans les journaux (Paramètres → Système) (#390). +- Sous Linux, supprimer un profil supprime désormais sa base de données, qui restait sur le disque dans `~/.config/com.simpl.resultat/` ; le fichier d'un profil supprimé avec la version 0.17.0 ou antérieure peut être supprimé à la main. Les colonnes de rendement des comptes du Bilan affichent désormais les rendements sous Linux, où chaque cellule affichait un tiret (#390). + ## [0.17.0] - 2026-10-05 ### Ajouté diff --git a/CHANGELOG.md b/CHANGELOG.md index 8872b84..331bcd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Fixed + +- Categorizing a transaction by hand on the Transactions page now ticks the "Categorize a transaction by hand" step of the getting-started checklist. The app stored that mark in a form the checklist could not count. Transactions you categorized by hand before this version are counted as soon as the profile is opened, and a backup exported in the meantime restores them correctly (#390). +- Profiles other than the default one now receive database updates. Until now each one kept the database layout of the version that created it, so features added since could fail in it: in a profile created before 0.15.0, for instance, the import format saved on a source. When such a profile is opened, the app first keeps a copy of its database in the `migration-backups` folder next to the databases, then applies the missing updates. The copy is deleted with the profile, and each update is written to the logs (Settings → System) (#390). +- On Linux, deleting a profile now deletes its database, which used to stay on disk in `~/.config/com.simpl.resultat/`; the file of a profile deleted with version 0.17.0 or earlier can be deleted by hand. The return columns of the Balance sheet accounts now show the returns on Linux, where every cell showed a dash (#390). + ## [0.17.0] - 2026-10-05 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 07b5cdb..b3b253e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,11 +65,12 @@ src-tauri/ │ ├── commands/ # 3 modules, 17 commandes Tauri │ │ ├── fs_commands.rs # Système de fichiers (6 commandes) │ │ ├── export_import_commands.rs # Export/import chiffré (5 commandes) -│ │ └── profile_commands.rs # Gestion des profils (6 commandes) +│ │ └── profile_commands.rs # Gestion des profils (8 commandes) │ ├── database/ # Schémas SQL et migrations │ │ ├── schema.sql # Schéma initial (v1) │ │ ├── seed_categories.sql # Seed catégories (v2) -│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils) +│ │ ├── consolidated_schema.sql # Schéma complet (nouveaux profils) +│ │ └── profile_db.rs # Rattrapage des profils secondaires (ADR 0021) │ ├── lib.rs # Point d'entrée, 17 migrations inline, plugins │ └── main.rs └── Cargo.toml @@ -120,7 +121,7 @@ src-tauri/ - **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index. Ces deux comptes sont inchangés depuis v13 : v14-v16 ajoutent 2 tables + 2 index (déjà comptés), v17 est un `ALTER TABLE` pur - **17 migrations inline** dans `lib.rs` (v1→v17, via `tauri_plugin_sql::Migration`). Étape 2 du Bilan (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position) — voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md). Import : v17 (`import_sources.amount_mode` + `sign_convention`, les deux avec `CHECK`, + `header_signature` + `template_id`) — voir [ADR 0019](docs/adr/0019-format-import-persiste.md) -- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils +- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils, suivi de l'historique complet des migrations. tauri-plugin-sql ne migre que le profil par défaut : les profils secondaires rattrapent leurs migrations à chaque ouverture (`prepare_profile_db`, copie préalable, clés étrangères actives) — voir [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md). Les bases vivent sous `app_config_dir()` (`profile_db_path`), qui n'est pas `app_data_dir()` sous Linux - Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle --- diff --git a/docs/adr/0003-sqlx-migrations.md b/docs/adr/0003-sqlx-migrations.md index 663f528..a1ec739 100644 --- a/docs/adr/0003-sqlx-migrations.md +++ b/docs/adr/0003-sqlx-migrations.md @@ -40,3 +40,7 @@ Les 6 migrations actuelles : - **Immutabilité** : une migration appliquée ne peut jamais être modifiée (erreur de checksum), ce qui force la création de nouvelles migrations correctives - **Synchronisation manuelle** : le fichier `consolidated_schema.sql` doit être mis à jour manuellement après chaque nouvelle migration - **Pas de rollback** : les migrations sont unidirectionnelles (pas de `down`) + +## Amendement #390 (2026-10-06) + +Le schéma consolidé **crée** un profil secondaire, il ne le tient pas à jour. tauri-plugin-sql n'applique les migrations qu'à la base enregistrée par `add_migrations`, celle du profil par défaut (`simpl_resultat.db`). Jusqu'à #390, un profil secondaire gardait le schéma de la version qui l'avait créé. Depuis #390, un nouveau profil reçoit à sa création l'historique complet de `_sqlx_migrations`, et la commande `prepare_profile_db` applique à chaque ouverture les versions qui manquent à un profil secondaire. Voir l'[ADR 0021](0021-migrations-profils-secondaires.md). Les six migrations listées plus haut sont celles de 2025 ; il y en a dix-sept à la v0.17.0. diff --git a/docs/adr/0005-multi-profile-db.md b/docs/adr/0005-multi-profile-db.md index 4db9f7e..150175a 100644 --- a/docs/adr/0005-multi-profile-db.md +++ b/docs/adr/0005-multi-profile-db.md @@ -38,3 +38,12 @@ Architecture : - **Pas de vue agrégée** : impossible de voir les données de tous les profils en même temps - **Synchronisation du schéma** : chaque base doit être au même niveau de migration (géré par le plugin sql au démarrage) - **Fichier `profiles.json` externe** : les métadonnées des profils ne sont pas dans SQLite, ce qui crée une dépendance à un fichier JSON séparé + +## Amendement #390 (2026-10-06) + +Deux affirmations des conséquences étaient fausses jusqu'à #390 : + +- « Les migrations s'appliquent indépendamment à chaque base » et « géré par le plugin sql au démarrage » : le plugin ne migre que la base du profil par défaut. Un profil secondaire gardait le schéma de la version qui l'avait créé. Il rattrape désormais ses migrations à chaque ouverture ([ADR 0021](0021-migrations-profils-secondaires.md)). +- « Supprimer un profil = supprimer un fichier `.db` » : sous Linux, la commande cherchait la base sous `app_data_dir()` alors que le plugin la range sous `app_config_dir()`, et le fichier restait sur le disque. La suppression vise désormais le dossier du plugin et emporte aussi les fichiers annexes de SQLite et les copies prises avant un rattrapage. + +`profiles.json` reste dans le répertoire de données de l'application (`app_data_dir()`) ; les bases sont dans le répertoire de configuration (`app_config_dir()`), qui n'est le même dossier que sous Windows et macOS. diff --git a/docs/adr/0021-migrations-profils-secondaires.md b/docs/adr/0021-migrations-profils-secondaires.md new file mode 100644 index 0000000..d31178f --- /dev/null +++ b/docs/adr/0021-migrations-profils-secondaires.md @@ -0,0 +1,90 @@ +# ADR 0021 — Les profils secondaires rattrapent leurs migrations à l'ouverture + +- Status: **Accepted** +- Date: 2026-10-06 +- Issues: #390 +- Modifie l'[ADR 0003](0003-sqlx-migrations.md) (le schéma consolidé crée un profil, il ne le tient pas à jour) et l'[ADR 0005](0005-multi-profile-db.md) (« les migrations s'appliquent indépendamment à chaque base » était faux pour les profils secondaires) + +## Contexte + +Le constat de départ est un bug d'onboarding : classer une transaction à la main ne cochait pas la case 5 de la liste de démarrage. `updateTransactionCategory` liait le booléen JS `true`, que tauri-plugin-sql 2.3.2 transmet à sqlx comme une valeur JSON, donc stockée en texte `'true'`. La liste compte `is_manually_categorized = 1` et ne trouvait rien. Corriger les valeurs déjà en base par une migration v18 a fait apparaître deux défauts plus anciens. + +**Les profils secondaires ne reçoivent aucune migration.** Le plugin associe la liste des migrations à une URL (`add_migrations("sqlite:simpl_resultat.db", …)`) et la retire au premier `Database.load`. Un profil secondaire (`profile_.db`, depuis la v0.3.0) est créé par `initializeNewProfileDb` avec le schéma consolidé de la version qui le crée, puis n'en reçoit plus aucune. Un profil secondaire créé avant la v0.15.0 n'a donc pas les colonnes de format de `import_sources` (v17), et un profil créé avant la v0.10.0 n'a ni `balance_securities` ni `balance_accounts.kind`. Les commentaires de `consolidated_schema.sql` et de `get_new_profile_init_sql`, ainsi que l'ADR 0005, affirmaient le contraire. + +**Trois commandes cherchaient les bases au mauvais endroit sous Linux.** Le plugin range les bases sous `app_config_dir()` (`wrapper.rs` : `DbPool::connect` puis `path_mapper`). `repair_migrations`, `delete_profile_db` et `compute_account_return` les cherchaient sous `app_data_dir()`, là où vivent `profiles.json` et les fichiers de licence. C'est le même dossier sous Windows et macOS, pas sous Linux (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`). Sous Linux, `repair_migrations` ne faisait donc rien, supprimer un profil laissait sa base sur le disque, et le rendement d'un compte du Bilan échouait. + +## Décision + +**À chaque ouverture d'un profil, avant `Database.load`, la commande `prepare_profile_db` amène un profil secondaire à la dernière migration, avec la même liste que le profil par défaut, et répare les drapeaux texte de tous les profils.** Neuf règles la tiennent ; le code est dans `src-tauri/src/database/profile_db.rs`. + +### 1. Une seule liste de migrations + +`migrations()` (lib.rs) sert le plugin et le rattrapage. Le test `migration_checksums_match_the_released_ones` épingle le SHA-384 de chaque version, calculé avant que la liste soit sortie de `run()`. L'historique d'un profil secondaire vit dans la même table `_sqlx_migrations` que celui du profil par défaut, au format sqlx : même DDL, même checksum. + +### 2. Un nouveau profil naît avec son historique + +`get_new_profile_init_sql` renvoie le schéma consolidé, puis `history_sql`, qui inscrit toutes les versions avec leur checksum. Le plugin exécute les deux dans le même flux. Un profil créé depuis #390 n'a donc jamais rien à déduire : les versions suivantes l'atteignent par le rattrapage. + +### 3. Un profil sans historique : un plancher déduit du schéma + +Les profils créés avant #390 n'ont pas d'historique. Ils forment un ensemble fermé, un état par schéma consolidé qu'une version publiée a livré, et chaque état est conservé en fixture sous `src-tauri/src/database/fixtures/` : + +| État | Versions qui l'ont créé | Plancher | +|---|---|---| +| A1 | v0.3.0 à v0.8.3, sauf v0.4.0 | 6 | +| A2 | v0.4.0 (seed à trois niveaux, retiré en v0.4.1) | 6 | +| B | v0.8.4 | 8 | +| C et D | v0.9.0, v0.9.1 (même schéma ; v11 ne touche que des données) | 10 | +| E | v0.10.0 à v0.14.0 | 16 | +| F | v0.15.0 à v0.17.x | 17 | + +`infer_floor` reconnaît l'état par la dernière colonne ou table ajoutée, en partant de la plus récente. Le plancher vient du schéma, jamais de l'historique : une première écriture interrompue ne peut donc pas faire passer une version inférieure pour une version à appliquer. C'est ce qui interdit de rejouer le seed v2 (`DELETE FROM categories`) sur une base remplie. Un schéma qu'aucun marqueur ne reconnaît n'est pas touché. Aucun marqueur ne s'ajoute après la v17, puisqu'aucune base sans historique ne peut porter une version postérieure. + +### 4. Une copie avant la première écriture + +Quand une version reste à appliquer, la base est d'abord copiée par `VACUUM INTO` vers `/migration-backups/-before-v.db`. Le chemin est lié, jamais formaté dans le SQL, ce qui tient un dossier comme `C:\Users\L'Heureux`. La copie est écrite en `.tmp`, synchronisée sur disque, puis renommée : un fichier final existant est donc complet, et il est gardé tel quel. Une copie qui échoue arrête tout avant la moindre écriture. La copie est supprimée avec le profil. + +Elle reste dans le dossier des bases, pas dans `~/Documents/Simpl-Resultat/backups/` que la sauvegarde manuelle utilise : ce dossier est souvent synchronisé vers un service en ligne (OneDrive sous Windows), et une copie automatique en clair d'une base financière n'a rien à y faire. + +### 5. Des écritures atomiques + +L'historique, soit la table et chaque version jusqu'au plancher, est écrit en une transaction. Chaque version à appliquer s'exécute ensuite dans sa propre transaction. La première erreur arrête le rattrapage et les versions déjà appliquées restent inscrites. + +### 6. Les clés étrangères restent actives + +Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étrangère est reprise une fois sans elles. Les lignes qu'elle laisse sans parent repassent à NULL quand leur clé déclare `ON DELETE SET NULL` ; toute autre violation annule la version. Le cas réel est la v7, qui ajoute trois sous-catégories sous la catégorie 31 : un profil A dont les catégories viennent d'une restauration plus récente n'a plus de catégorie 31, et les trois sous-catégories deviennent des catégories racines. Couper les clés pour tout le rattrapage aurait laissé ces parents introuvables, et la restauration d'une sauvegarde de ce profil aurait échoué. + +### 7. L'échec reste ouvert + +Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). + +### 8. Un seul dossier des bases + +`profile_db_path` résout `app_config_dir()`, le dossier du plugin, pour les quatre commandes qui ouvrent une base. Il n'accepte que les deux formes de nom que l'application a jamais données : `simpl_resultat.db` et `profile_.db`. + +### 9. Les drapeaux texte sont réparés partout + +À chaque ouverture, dans tous les profils, `'true'` devient 1 et tout autre texte 0. L'opération est idempotente. Côté TypeScript, `toSqlFlag` empêche d'écrire de nouveau un booléen, y compris à la restauration d'une sauvegarde qui porte encore `"true"`. + +## Conséquences + +### Positives + +- Un profil secondaire ancien retrouve le schéma courant à sa prochaine ouverture, et chaque migration future l'atteint. +- Sous Linux, supprimer un profil supprime enfin sa base, ses fichiers annexes et ses copies. +- Les règles sont éprouvées sur les sept états réels, avec des données d'utilisateur : comptes du Bilan convertis par v12 à v16, source d'import complétée par v17, drapeau réparé. + +### Négatives et points de vigilance + +- **Une correction de données livrée seulement en migration n'atteint pas un nouveau profil**, qui naît avec l'historique complet. Elle doit aussi entrer dans `consolidated_schema.sql` (règle `.claude/rules/sql-migrations.md`). Le test qui compare chaque état rattrapé à un profil neuf ne regarde que le schéma. +- **Deux moteurs appliquent les mêmes migrations** : sqlx pour le profil par défaut, rusqlite pour les autres. Le SQL est le même et les clés étrangères sont actives des deux côtés. La reprise sans clés étrangères n'existe que du côté rusqlite. +- **La commande est synchrone.** Tauri l'exécute sur le fil principal, ce qui sérialise deux ouvertures du même profil (React StrictMode monte deux fois en développement). L'interface attend pendant un rattrapage, une fois par profil ancien, et la durée est écrite au Journal. +- **Le test qui distingue les deux dossiers ne voit l'écart que sous Linux**, la plateforme de la CI. La vérification à la main sous Linux reste due. +- **Les profils supprimés sous Linux avant ce correctif** ont laissé leur base dans `~/.config/com.simpl.resultat/`. Rien ne supprime automatiquement un fichier que `profiles.json` ne cite pas : un `profiles.json` abîmé coûterait alors de vrais profils. + +## Alternatives écartées + +- **Une migration v18 de données**, proposée par le corps de #390 : elle n'aurait réparé que le profil par défaut. +- **Enregistrer chaque profil auprès du plugin au démarrage** (`add_migrations` par URL dans `setup`) : l'état des migrations du plugin est privé et figé au démarrage, un profil créé en cours de session n'y figurerait pas, et sqlx aurait rejoué v1 et v2 sur une base sans historique. +- **Un rattrapage côté TypeScript** : le plugin n'expose aucune transaction (voir `db.ts`), et ni `VACUUM INTO` ni les clés étrangères ne seraient testables hors de Tauri. +- **Des clés étrangères coupées pendant tout le rattrapage** : les actions `ON DELETE` ne joueraient plus comme sur le profil par défaut, et les lignes sans parent feraient échouer la restauration d'une sauvegarde de ce profil. diff --git a/docs/architecture.md b/docs/architecture.md index e4a0482..8f67f29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ simpl-resultat/ │ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── services/ # 25 services métier (19 détaillés dans la section « Services TypeScript ») │ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts) -│ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts) +│ ├── utils/ # 14 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1) │ ├── i18n/ # Config i18next + locales FR/EN │ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust │ ├── App.tsx # Router principal @@ -61,7 +61,9 @@ simpl-resultat/ │ │ ├── database/ # Schémas SQL et migrations │ │ │ ├── schema.sql │ │ │ ├── seed_categories.sql -│ │ │ └── consolidated_schema.sql +│ │ │ ├── consolidated_schema.sql +│ │ │ ├── profile_db.rs # Rattrapage des profils secondaires (#390, ADR 0021) +│ │ │ └── fixtures/ # Schémas de création des profils publiés (tests) │ │ ├── lib.rs # Point d'entrée, migrations, plugins │ │ └── main.rs │ ├── capabilities/ # Permissions Tauri @@ -164,13 +166,22 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug v17 est purement additive : elle n'ajoute **ni table ni index** (20 tables / 24 index inchangés). Le `CHECK` d'`amount_mode` admet `absolute_indicator` dès maintenant, pour que le troisième mode de montant puisse être livré sans nouvelle migration, alors que l'application le **refuse** encore explicitement (voir plus bas). `LIKE` plutôt que `json_extract` : la migration ne dépend d'aucune extension JSON1 dans le SQLite embarqué. `sign_convention` n'est délibérément pas backfillée — son `DEFAULT` restitue la valeur que le code codait en dur, la seule convention passée inférable. -Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations). +La liste est rendue par `migrations()` (lib.rs) ; tauri-plugin-sql ne l'applique qu'à la base du **profil par défaut** (`simpl_resultat.db`, seule URL passée à `add_migrations`). Un test épingle le SHA-384 de chaque version. + +Les **profils secondaires** (`profile_.db`) sont créés par `get_new_profile_init_sql` : le fichier `consolidated_schema.sql`, qui porte le schéma complet, puis `history_sql`, qui inscrit toutes les versions dans `_sqlx_migrations` au format sqlx. Le plugin ne les migre jamais. Depuis #390, `prepare_profile_db` les rattrape à chaque ouverture, avant `Database.load` ([ADR 0021](adr/0021-migrations-profils-secondaires.md), `src-tauri/src/database/profile_db.rs`) : + +- une base sans historique (créée avant #390) reçoit un **plancher** déduit de son schéma (`infer_floor`) : 6, 8, 10, 16 ou 17 selon la version qui l'a créée, tous rejoués en test depuis `database/fixtures/`. Aucune version au plus égale au plancher n'est rejouée ; +- avant la première version à appliquer, une copie `VACUUM INTO` est prise dans `migration-backups/` à côté des bases, puis supprimée avec le profil ; +- chaque version s'applique dans sa transaction, clés étrangères actives comme sous sqlx. Une version qui bute sur une clé est reprise une fois sans elles, et ses lignes sans parent repassent à NULL si la clé déclare `ON DELETE SET NULL` ; +- la même commande répare dans tous les profils les drapeaux `is_manually_categorized` stockés en texte (`'true'` → 1). + +Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`). ## Services TypeScript (19) | Service | Responsabilité | |---------|---------------| -| `db.ts` | Wrapper de connexion (tauri-plugin-sql) | +| `db.ts` | Wrapper de connexion (tauri-plugin-sql). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même | | `profileService.ts` | Gestion des profils | | `categoryService.ts` | CRUD catégories hiérarchiques | | `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) | @@ -340,7 +351,7 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide. -## Commandes Tauri (38) +## Commandes Tauri (39) ### `fs_commands.rs` — Système de fichiers (7) @@ -360,15 +371,18 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac - `read_import_file` — Lecture fichier chiffré - `is_file_encrypted` — Vérification magic SREF -### `profile_commands.rs` — Gestion des profils (7) +### `profile_commands.rs` — Gestion des profils (8) + +Les commandes qui ouvrent une base passent par `profile_db_path` : dossier du plugin (`app_config_dir()`) et nom de fichier validé (`simpl_resultat.db` ou `profile_.db`, #390). - `load_profiles` — Chargement depuis `profiles.json` - `save_profiles` — Sauvegarde de la configuration -- `delete_profile_db` — Suppression du fichier de base de données -- `get_new_profile_init_sql` — Récupération du schéma consolidé +- `delete_profile_db` — Suppression de la base d'un profil secondaire, de ses fichiers annexes SQLite (`-wal`, `-shm`, `-journal`) et de ses copies `migration-backups/` (#390) +- `get_new_profile_init_sql` — Schéma consolidé suivi de l'historique complet des migrations (`history_sql`, #390) - `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`) - `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité) - `repair_migrations` — Réparation des checksums de migration (rusqlite) +- `prepare_profile_db` — Avant chaque ouverture : rattrapage des migrations d'un profil secondaire (copie préalable) et réparation des drapeaux de classement manuel de tout profil. Rend un rapport sans chemin (`ProfilePreparation`). Synchrone, donc sérialisée sur le fil principal — [ADR 0021](adr/0021-migrations-profils-secondaires.md) ### `license_commands.rs` — Licence et activation machine (11) @@ -427,7 +441,7 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs` ### `balance_commands.rs` — Bilan (1) -- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif, lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md). +- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif (résolu par `profile_db_path` ; sous Linux, l'ancien chemin `app_data_dir()` faisait échouer le calcul, #390), lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md). Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation). @@ -480,6 +494,7 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF - **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue - **Retry au démarrage** : `connectActiveProfile()` réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur - **Réparation de migrations** : `repair_migrations` (Rust/rusqlite) supprime les checksums invalides de `_sqlx_migrations` avant le chargement de la DB +- **Préparation du profil** : `prepare_profile_db` (Rust/rusqlite) rattrape les migrations d'un profil secondaire et répare les drapeaux texte avant le chargement ; un échec est écrit au Journal, le profil s'ouvre quand même (#390) - **Log viewer** : `logService.ts` capture les `console.log/warn/error` dans un buffer circulaire (500 entrées, persisté en `sessionStorage`), affiché dans la page Paramètres via `LogViewerCard` | Route | Page | Description | @@ -590,3 +605,4 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d | [0018](adr/0018-suppression-advisories-non-atteignables.md) | Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant | 2026-07-27 | Accepted | | [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted | | [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted | +| [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted | diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index 31a4266..8ac96ab 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -61,6 +61,8 @@ Gérez plusieurs profils indépendants, chacun avec sa propre base de données. - Un profil par défaut est créé automatiquement au premier lancement - Le NIP est demandé à chaque fois que vous accédez à un profil protégé - La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible +- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). La copie est supprimée avec le profil +- Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (`~/.config/com.simpl.resultat/profile_….db`) ; vous pouvez supprimer ce fichier à la main --- diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e5180da..259d87e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -874,7 +874,9 @@ "tips": [ "A default profile is automatically created on first launch", "The PIN is requested each time you access a protected profile", - "Deleting a profile permanently removes all its data — this action cannot be undone" + "Deleting a profile permanently removes all its data — this action cannot be undone", + "When you open a profile created by an earlier version, the app updates its database after keeping a copy in the migration-backups folder, next to the databases (on Linux ~/.config/com.simpl.resultat/, on Windows %APPDATA%\\com.simpl.resultat\\). The copy is deleted with the profile", + "On Linux, versions up to 0.17.0 left a deleted profile's database on disk (~/.config/com.simpl.resultat/profile_….db); you can delete that file by hand" ] }, "dashboard": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e7adb2f..b7ac9da 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -874,7 +874,9 @@ "tips": [ "Un profil par défaut est créé automatiquement au premier lancement", "Le NIP est demandé à chaque fois que vous accédez à un profil protégé", - "La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible" + "La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible", + "À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier migration-backups, à côté des bases (sous Linux ~/.config/com.simpl.resultat/, sous Windows %APPDATA%\\com.simpl.resultat\\). La copie est supprimée avec le profil", + "Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (~/.config/com.simpl.resultat/profile_….db) ; vous pouvez supprimer ce fichier à la main" ] }, "dashboard": { -- 2.45.2 From 71147c5083c15573a395960ca41725d300222504 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 6 Oct 2026 20:54:38 -0400 Subject: [PATCH 4/5] test(profiles): cover a v0.15-v0.17 secondary profile without history (#390) The released-states loop stops at v0.10-v0.14 (state E). State F, the consolidated schema of v0.15.0 to v0.17.0 without a history, only had its floor checked; this test runs the whole preparation on it: history recorded up to v17, nothing applied, no copy, flag repaired. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/database/profile_db.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src-tauri/src/database/profile_db.rs b/src-tauri/src/database/profile_db.rs index 9614d39..199eb83 100644 --- a/src-tauri/src/database/profile_db.rs +++ b/src-tauri/src/database/profile_db.rs @@ -851,6 +851,30 @@ mod tests { } } + #[test] + fn a_profile_from_v0_15_to_v0_17_gets_its_history_and_nothing_else() { + // State F: created with the current consolidated schema, before #390 + // wrote a history. Nothing to apply, so no copy either; the flag is + // still repaired. + let dir = unique_dir("state-f"); + let path = create_db(&dir, SECONDARY, &[crate::database::CONSOLIDATED_SCHEMA]); + let category = seed_user_rows(&open(&path)); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + assert_eq!(report.catch_up_error, None); + let done = report.catch_up.unwrap(); + assert_eq!(done.floor, Some(17)); + assert_eq!(done.recorded, versions(1, max_version())); + assert!(done.applied.is_empty()); + assert_eq!(done.backup_file, None); + assert_eq!(report.repaired_flags, 1); + let conn = open(&path); + assert_history_complete(&conn, "state F"); + assert_eq!(count(&conn, "SELECT category_id FROM transactions"), category); + assert!(!dir.join(MIGRATION_BACKUP_DIR).exists()); + } + #[test] fn the_bilan_data_of_a_v0_9_profile_is_converted() { for state in released_states().into_iter().filter(|s| s.floor == 10) { -- 2.45.2 From 5910387eb74ac00fc89caaae0f97192c3c2e20ec Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 6 Oct 2026 21:46:26 -0400 Subject: [PATCH 5/5] test(profiles): pin the review's quick suggestions on the catch-up (#390) From the /pr-review of PR #394: - the profile database opens only once prepare_profile_db has resolved (deferred preparation; a load started early used to pass, RT1); - a real VACUUM INTO failure, through a dangling symlink, names the full path and comes out scrubbed (RV6); the test first checks the raw message does name the folder; - deleting a profile removes the -journal side file, the only one this app produces (RV5); - test folders are removed when each test ends, a failing one included (TestDir guard), instead of 42 folders left in /tmp per run. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/commands/profile_commands.rs | 7 +- src-tauri/src/database/profile_db.rs | 89 ++++++++++++++++++---- src/services/db.test.ts | 22 ++++++ 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index 49f99a4..5872da0 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -496,12 +496,14 @@ mod tests { #[test] fn deleting_a_profile_removes_its_files_and_only_its_files() { - let dir = std::env::temp_dir().join(format!("simpl-resultat-delete-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); + let dir = profile_db::TestDir::new("delete"); let backups = dir.join(profile_db::MIGRATION_BACKUP_DIR); fs::create_dir_all(&backups).unwrap(); for name in [ "profile_0a1b2c3d.db", + // The only side file this app produces: sqlx leaves SQLite's + // rollback journal by default, `-wal`/`-shm` come with WAL. + "profile_0a1b2c3d.db-journal", "profile_0a1b2c3d.db-wal", "profile_0a1b2c3d.db-shm", "profile_99999999.db", @@ -528,6 +530,5 @@ mod tests { "profile_99999999.db".to_string(), ] ); - let _ = fs::remove_dir_all(&dir); } } diff --git a/src-tauri/src/database/profile_db.rs b/src-tauri/src/database/profile_db.rs index 199eb83..25bbaf4 100644 --- a/src-tauri/src/database/profile_db.rs +++ b/src-tauri/src/database/profile_db.rs @@ -556,12 +556,56 @@ pub fn prepare_at(db_path: &Path, db_filename: &str, migrations: &[Migration]) - Ok(report) } +/// A folder under the system temp dir for one test, removed when the guard +/// drops, a failing test included. Cargo runs tests in parallel, so each gets +/// its own name. +#[cfg(test)] +pub(crate) struct TestDir(std::path::PathBuf); + +#[cfg(test)] +impl TestDir { + pub(crate) fn new(tag: &str) -> Self { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "simpl-resultat-{}-{}-{}", + tag, + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + TestDir(dir) + } +} + +#[cfg(test)] +impl std::ops::Deref for TestDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} + +#[cfg(test)] +impl AsRef for TestDir { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +#[cfg(test)] +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; use std::path::PathBuf; - use std::sync::atomic::{AtomicUsize, Ordering}; // The SQL each release ran to create a secondary profile // (`get_new_profile_init_sql` of that tag), copied verbatim from git. @@ -604,18 +648,9 @@ mod tests { (from..=to).collect() } - /// Cargo runs tests in parallel: every test gets its own folder. - fn unique_dir(tag: &str) -> PathBuf { - static COUNTER: AtomicUsize = AtomicUsize::new(0); - let dir = std::env::temp_dir().join(format!( - "simpl-resultat-profile-db-{}-{}-{}", - std::process::id(), - tag, - COUNTER.fetch_add(1, Ordering::SeqCst) - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - dir + /// Every test gets its own folder, removed when the test ends. + fn unique_dir(tag: &str) -> TestDir { + TestDir::new(&format!("profile-db-{}", tag)) } fn create_db(dir: &Path, name: &str, init: &[&str]) -> PathBuf { @@ -1288,6 +1323,34 @@ mod tests { assert!(!column_exists(&conn, "import_sources", "header_signature").unwrap()); } + #[cfg(unix)] + #[test] + fn a_real_backup_failure_names_no_folder() { + // `VACUUM INTO` through a dangling symlink fails even as root (a + // read-only folder would not: CI runs as root), and SQLite's message + // names the full path. Only `scrub` keeps it out of the report. + let dir = unique_dir("leak"); + let path = create_db(&dir, SECONDARY, &[CONSOLIDATED_V0_10_0]); + let backups = dir.join(MIGRATION_BACKUP_DIR); + fs::create_dir_all(&backups).unwrap(); + let tmp = backups.join("profile_0a1b2c3d-before-v17.db.tmp"); + std::os::unix::fs::symlink(dir.join("missing").join("target.db"), &tmp).unwrap(); + let folder = dir.to_str().unwrap().to_string(); + + // The probe proves something only if the raw message names the folder. + let raw = backup_to(&open(&path), &backups, "profile_0a1b2c3d", 17).unwrap_err(); + assert!(raw.starts_with("backup_failed"), "{}", raw); + assert!(raw.contains(&folder), "{}", raw); + + let report = prepare_at(&path, SECONDARY, &migrations()).unwrap(); + + let error = report.catch_up_error.unwrap(); + assert!(error.starts_with("backup_failed"), "{}", error); + assert!(!error.contains(&folder), "{}", error); + assert!(error.contains(""), "{}", error); + assert!(!has_table(&open(&path), "_sqlx_migrations")); + } + #[test] fn remove_backups_takes_only_this_profile() { let dir = unique_dir("cleanup"); diff --git a/src/services/db.test.ts b/src/services/db.test.ts index 9672428..2f7d5d1 100644 --- a/src/services/db.test.ts +++ b/src/services/db.test.ts @@ -178,6 +178,28 @@ describe("a profile is prepared before the plugin opens it (#390)", () => { ); }); + it("opens the database only once the preparation has finished", async () => { + // In production the command runs on the main thread while Database.load + // is asynchronous: started early, the two would overlap on the same file. + let finish: (report: ProfilePreparation | null) => void = () => {}; + h.prepare = () => + new Promise((resolve) => { + finish = resolve; + }); + + const connecting = connectToProfile("profile_0a1b2c3d.db"); + await vi.waitFor(() => + expect(invoke).toHaveBeenCalledWith("prepare_profile_db", { dbFilename: "profile_0a1b2c3d.db" }) + ); + // Give a load started too early every chance to show up. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(Database.load).not.toHaveBeenCalled(); + + finish(null); + await connecting; + expect(Database.load).toHaveBeenCalledWith("sqlite:profile_0a1b2c3d.db"); + }); + it("still opens the profile when the preparation fails, and says so in the Journal", async () => { h.prepare = async () => { throw "invalid_profile_db_filename"; -- 2.45.2