From a6787adef0d06298a2c672bd47168643f9b59593 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:31:50 -0400 Subject: [PATCH 1/4] feat(balance): add migration v9 schema (5 tables, 7 indexes, seed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SQL foundation for the Bilan (balance sheet) feature: - 5 new tables: balance_categories, balance_accounts, balance_snapshots, balance_snapshot_lines, balance_account_transfers - 7 indexes (category, active partial, snapshot, accounts x2, transaction, snapshot_date) - Seed of 7 standard categories (5 simple + 2 priced) marked is_seed=1 - CHECK(currency = 'CAD') on balance_accounts (MVP — v2 lifts the constraint with a multi-currency rate table) - CHECK kind invariants on balance_snapshot_lines (quantity/unit_price both NULL OR both NOT NULL) - FK transaction_id ON DELETE RESTRICT to preserve reproducibility of Modified Dietz returns calculated on past periods Migration v9 is added inline to the lib.rs Vec via a new constant database::BALANCE_SCHEMA backed by balance_schema.sql. The schema is mirrored in consolidated_schema.sql so brand-new profiles get the feature preinstalled without replaying v9. 13 new co-located rusqlite tests validate the migration on a fresh in-memory DB: schema applies cleanly, 7 categories seeded with correct kinds, CHECK rejects invalid currency/kind/direction, UNIQUE rejects duplicate snapshot_date / (snapshot_id,account_id) / (transaction_id, account_id), FK CASCADE on snapshot delete, FK RESTRICT on transaction delete and on category with linked accounts, seed idempotent on replay. Refs #138 Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/database/balance_schema.sql | 153 ++++++ .../src/database/consolidated_schema.sql | 89 ++++ src-tauri/src/database/mod.rs | 1 + src-tauri/src/lib.rs | 453 ++++++++++++++++++ 4 files changed, 696 insertions(+) create mode 100644 src-tauri/src/database/balance_schema.sql diff --git a/src-tauri/src/database/balance_schema.sql b/src-tauri/src/database/balance_schema.sql new file mode 100644 index 0000000..e1e420d --- /dev/null +++ b/src-tauri/src/database/balance_schema.sql @@ -0,0 +1,153 @@ +-- Balance sheet schema (Bilan) — Migration v9 +-- Created: 2026-04-25 +-- Issue: #138 (Bilan #1a — Schema migration + balance.service skeleton + AccountsPage) +-- +-- Adds 5 tables, 7 indexes, and seeds 7 standard categories (5 simple + 2 priced). +-- Conventions aligned with consolidated_schema.sql: +-- - INTEGER PRIMARY KEY AUTOINCREMENT +-- - REAL for monetary amounts (matches transactions.amount) +-- - snake_case +-- - FK with explicit ON DELETE policies +-- - is_* INTEGER NOT NULL DEFAULT for booleans +-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps +-- +-- MVP constraints (decisions-log + spec-decisions-bilan.md): +-- - balance_accounts.currency hardcoded to 'CAD' via CHECK — v2 will lift this +-- - balance_account_transfers.transaction_id ON DELETE RESTRICT (preserves +-- reproducibility of Modified Dietz returns calculated on past periods) +-- - balance_snapshot_lines kind invariants: (quantity, unit_price) both NULL +-- (simple kind) OR both NOT NULL (priced kind) + + +-- ========================================================================= +-- balance_categories — taxonomy of asset types +-- ========================================================================= +-- Seeded with 7 standard categories (is_seed = 1). Users can add custom +-- categories with their own kind ('simple' or 'priced'). Seeded categories +-- can be renamed but never deleted. +CREATE TABLE IF NOT EXISTS balance_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, -- 'cash', 'tfsa', 'rrsp', 'fund', 'stock', 'crypto', 'other' + i18n_key TEXT NOT NULL, -- 'balance.category.cash', etc. + kind TEXT NOT NULL CHECK(kind IN ('simple','priced')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_seed INTEGER NOT NULL DEFAULT 0 +); + + +-- ========================================================================= +-- balance_accounts — user's specific holdings +-- ========================================================================= +-- A "TFSA at Wealthsimple", a "BTC in Ledger", etc. +-- For priced categories, `symbol` identifies the security/coin. +-- For simple categories, `symbol` is NULL. +-- MVP: currency hardcoded to 'CAD' — v2 lifts the CHECK and adds a rate table. +CREATE TABLE IF NOT EXISTS balance_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + balance_category_id INTEGER NOT NULL, + name TEXT NOT NULL, + symbol TEXT, + currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'), + notes TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + archived_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT +); + + +-- ========================================================================= +-- balance_snapshots — point-in-time captures +-- ========================================================================= +-- One snapshot per `snapshot_date` (UNIQUE). Editing a snapshot = updating +-- its lines, not creating a duplicate. +CREATE TABLE IF NOT EXISTS balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date DATE NOT NULL UNIQUE, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + +-- ========================================================================= +-- balance_snapshot_lines — one row per (snapshot, account) +-- ========================================================================= +-- Storage shape: +-- - simple kind: value is set, quantity/unit_price are NULL +-- - priced kind: quantity + unit_price are set, value = quantity * unit_price +-- Stored denormalized (value always set, even for priced rows) so reports +-- are reproducible without re-fetching prices and the user can override a +-- fetched price. +-- The CHECK enforces kind invariants at SQL level for direct-write safety. +CREATE TABLE IF NOT EXISTS balance_snapshot_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + quantity REAL, + unit_price REAL, + value REAL NOT NULL, + price_source TEXT, -- 'manual' | 'maximus-api' | NULL for simple + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT, + UNIQUE(snapshot_id, account_id), + CHECK ( + (quantity IS NULL AND unit_price IS NULL) + OR (quantity IS NOT NULL AND unit_price IS NOT NULL) + ) +); + + +-- ========================================================================= +-- balance_account_transfers — links transactions to accounts (capital flows) +-- ========================================================================= +-- Used by the Modified Dietz return calculator (Issue #142 / Bilan #4) to +-- separate contributions from gains. Direction follows the account's +-- perspective: 'in' = capital added (deposit/buy), 'out' = capital removed +-- (withdrawal/sell). The amount is taken from the linked transaction (no +-- duplication). +-- +-- transaction_id ON DELETE RESTRICT: preserves reproducibility of past +-- Modified Dietz returns. The UI must force unlink before allowing the +-- transaction to be deleted. +CREATE TABLE IF NOT EXISTS balance_account_transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT, + UNIQUE(transaction_id, account_id) +); + + +-- ========================================================================= +-- Indexes (7 total) +-- ========================================================================= +CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id); +CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1; +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); + + +-- ========================================================================= +-- Seed (7 standard categories — idempotent via INSERT OR IGNORE on `key`) +-- ========================================================================= +INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES + ('cash', 'balance.category.cash', 'simple', 10, 1), + ('tfsa', 'balance.category.tfsa', 'simple', 20, 1), + ('rrsp', 'balance.category.rrsp', 'simple', 30, 1), + ('fund', 'balance.category.fund', 'simple', 40, 1), + ('other', 'balance.category.other', 'simple', 50, 1), + ('stock', 'balance.category.stock', 'priced', 60, 1), + ('crypto', 'balance.category.crypto', 'priced', 70, 1); diff --git a/src-tauri/src/database/consolidated_schema.sql b/src-tauri/src/database/consolidated_schema.sql index cb593ec..4009287 100644 --- a/src-tauri/src/database/consolidated_schema.sql +++ b/src-tauri/src/database/consolidated_schema.sql @@ -181,6 +181,95 @@ CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, mon CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); +-- ============================================================================ +-- Balance sheet (Bilan) — Migration v9 mirror for new profiles +-- ============================================================================ +-- 5 tables + 7 indexes + seeded categories. Kept in sync with +-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs). +-- New profiles created from this consolidated schema get the balance feature +-- preinstalled without needing to replay v9 separately. + +CREATE TABLE IF NOT EXISTS balance_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + i18n_key TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('simple','priced')), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_seed INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS balance_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + balance_category_id INTEGER NOT NULL, + name TEXT NOT NULL, + symbol TEXT, + currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'), + notes TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + archived_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS balance_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_date DATE NOT NULL UNIQUE, + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS balance_snapshot_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + snapshot_id INTEGER NOT NULL, + account_id INTEGER NOT NULL, + quantity REAL, + unit_price REAL, + value REAL NOT NULL, + price_source TEXT, + price_fetched_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT, + UNIQUE(snapshot_id, account_id), + CHECK ( + (quantity IS NULL AND unit_price IS NULL) + OR (quantity IS NOT NULL AND unit_price IS NOT NULL) + ) +); + +CREATE TABLE IF NOT EXISTS balance_account_transfers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('in','out')), + notes TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT, + UNIQUE(transaction_id, account_id) +); + +CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id); +CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1; +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id); +CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id); +CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date); + +INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES + ('cash', 'balance.category.cash', 'simple', 10, 1), + ('tfsa', 'balance.category.tfsa', 'simple', 20, 1), + ('rrsp', 'balance.category.rrsp', 'simple', 30, 1), + ('fund', 'balance.category.fund', 'simple', 40, 1), + ('other', 'balance.category.other', 'simple', 50, 1), + ('stock', 'balance.category.stock', 'priced', 60, 1), + ('crypto', 'balance.category.crypto', 'priced', 70, 1); + -- Default preferences (new profiles ship with the v1 IPC taxonomy) INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 2560b01..5711298 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -1,3 +1,4 @@ pub const SCHEMA: &str = include_str!("schema.sql"); pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql"); pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql"); +pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 56e88eb..4dc3a55 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -95,6 +95,19 @@ pub fn run() { INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');", kind: MigrationKind::Up, }, + // Migration v9 — Bilan (balance sheet) schema: + // 5 tables (balance_categories, balance_accounts, balance_snapshots, + // balance_snapshot_lines, balance_account_transfers) + 7 indexes + + // 7 seeded categories (5 simple + 2 priced). + // CHECK(currency = 'CAD') is hardcoded for the MVP (will be lifted in v2 + // with a multi-currency rate table). FK transaction_id ON DELETE + // RESTRICT preserves reproducibility of Modified Dietz returns. + Migration { + version: 9, + description: "create balance schema", + sql: database::BALANCE_SCHEMA, + kind: MigrationKind::Up, + }, ]; tauri::Builder::default() @@ -240,3 +253,443 @@ fn extract_query_param(url: &str, key: &str) -> Option { } None } + +// ============================================================================= +// Tests for migration v9 — balance schema +// ----------------------------------------------------------------------------- +// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9 +// migration) on a fresh in-memory SQLite database and assert that: +// - the schema applies cleanly (all 5 tables + 7 indexes created) +// - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1 +// - CHECK constraints reject invalid kind / direction / currency / kind invariants +// - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) / +// (transaction_id,account_id) / category key +// - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT +// on transaction_id and on category with linked accounts) +// +// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep +// required. The migration v9 SQL is the source of truth; v1-v8 are not +// required here because v9 is additive and only references the existing +// `transactions` table for the FK on balance_account_transfers — we mirror +// that with a minimal `transactions` table for the integration scenarios. +#[cfg(test)] +mod tests { + use rusqlite::Connection; + + /// Apply the v9 schema on a fresh in-memory DB. Includes a minimal + /// `transactions` table because balance_account_transfers references it. + fn fresh_db() -> Connection { + let conn = Connection::open_in_memory().expect("open in-memory db"); + // FKs must be enabled per-connection in SQLite. + conn.execute("PRAGMA foreign_keys = ON;", []) + .expect("enable FKs"); + // Minimal transactions table mirroring the relevant columns the FK + // references (id is the only column we need at the SQL level). + conn.execute_batch( + "CREATE TABLE transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + description TEXT NOT NULL, + amount REAL NOT NULL + );", + ) + .expect("create stub transactions table"); + conn.execute_batch(crate::database::BALANCE_SCHEMA) + .expect("apply BALANCE_SCHEMA"); + conn + } + + #[test] + fn migration_v9_applies_cleanly() { + let conn = fresh_db(); + // 5 expected tables + let tables: Vec = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'balance_%' ORDER BY name", + ) + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + assert_eq!( + tables, + vec![ + "balance_account_transfers", + "balance_accounts", + "balance_categories", + "balance_snapshot_lines", + "balance_snapshots", + ] + ); + // 7 expected indexes + let index_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_balance_%'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(index_count, 7); + } + + #[test] + fn migration_v9_seeds_7_categories() { + let conn = fresh_db(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 7); + + let simple_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE kind = 'simple' AND is_seed = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(simple_count, 5); + + let priced_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE kind = 'priced' AND is_seed = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(priced_count, 2); + + // Seeded keys are stable + let stock_kind: String = conn + .query_row( + "SELECT kind FROM balance_categories WHERE key = 'stock'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stock_kind, "priced"); + } + + #[test] + fn migration_v9_rejects_non_cad_currency() { + let conn = fresh_db(); + // 'cash' category exists from seed; try to insert a non-CAD account. + let result = conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, currency) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'USD account', 'USD')", + [], + ); + assert!( + result.is_err(), + "CHECK(currency='CAD') should reject 'USD'" + ); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.to_lowercase().contains("check")); + } + + #[test] + fn migration_v9_accepts_default_cad_currency() { + let conn = fresh_db(); + let inserted = conn + .execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .expect("CAD default insert should succeed"); + assert_eq!(inserted, 1); + } + + #[test] + fn migration_v9_rejects_invalid_kind() { + let conn = fresh_db(); + let result = conn.execute( + "INSERT INTO balance_categories (key, i18n_key, kind) VALUES ('bogus', 'x.bogus', 'unknown')", + [], + ); + assert!(result.is_err(), "kind CHECK should reject 'unknown'"); + } + + #[test] + fn migration_v9_unique_snapshot_date() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ) + .unwrap(); + let result = conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ); + assert!(result.is_err(), "UNIQUE(snapshot_date) should reject dup"); + } + + #[test] + fn migration_v9_kind_invariants_check() { + let conn = fresh_db(); + // Setup: a snapshot + an account + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + let snap_id: i64 = conn + .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + + // OK: simple kind (quantity + unit_price both NULL) + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) + VALUES (?1, ?2, 1234.56)", + rusqlite::params![snap_id, acct_id], + ) + .expect("simple kind row (qty/price both NULL) should be accepted"); + + // OK: priced kind (both set) — needs second account on a priced category + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name, symbol) + VALUES ((SELECT id FROM balance_categories WHERE key='stock'), 'AAPL', 'AAPL')", + [], + ) + .unwrap(); + let acct2_id: i64 = conn + .query_row( + "SELECT id FROM balance_accounts WHERE name='AAPL'", + [], + |r| r.get(0), + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value) + VALUES (?1, ?2, 10.0, 200.0, 2000.0)", + rusqlite::params![snap_id, acct2_id], + ) + .expect("priced kind row (both set) should be accepted"); + + // KO: only quantity set, unit_price NULL → CHECK violation + let bad = conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, value) + VALUES (?1, ?2, 10.0, 0.0)", + rusqlite::params![snap_id, acct_id], + ); + assert!( + bad.is_err(), + "kind invariants CHECK should reject (qty set, price NULL)" + ); + + // KO: only unit_price set, quantity NULL → CHECK violation + let bad2 = conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, unit_price, value) + VALUES (?1, ?2, 200.0, 0.0)", + rusqlite::params![snap_id, acct_id], + ); + assert!( + bad2.is_err(), + "kind invariants CHECK should reject (price set, qty NULL)" + ); + } + + #[test] + fn migration_v9_unique_snapshot_account_pair() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + let snap_id: i64 = conn + .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) + VALUES (?1, ?2, 100.0)", + rusqlite::params![snap_id, acct_id], + ) + .unwrap(); + let dup = conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) + VALUES (?1, ?2, 200.0)", + rusqlite::params![snap_id, acct_id], + ); + assert!(dup.is_err(), "UNIQUE(snapshot_id, account_id) should reject dup"); + } + + #[test] + fn migration_v9_fk_cascade_on_snapshot_delete() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + let snap_id: i64 = conn + .query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value) + VALUES (?1, ?2, 100.0)", + rusqlite::params![snap_id, acct_id], + ) + .unwrap(); + conn.execute( + "DELETE FROM balance_snapshots WHERE id = ?1", + rusqlite::params![snap_id], + ) + .expect("delete snapshot should cascade"); + let remaining: i64 = conn + .query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0)) + .unwrap(); + assert_eq!(remaining, 0, "snapshot delete should cascade lines"); + } + + #[test] + fn migration_v9_fk_restrict_on_transaction_delete() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)", + [], + ) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let tx_id: i64 = conn + .query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) + VALUES (?1, ?2, 'in')", + rusqlite::params![acct_id, tx_id], + ) + .unwrap(); + + // Attempting to delete the linked transaction must be rejected (RESTRICT) + let result = conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]); + assert!( + result.is_err(), + "FK RESTRICT should block deleting linked transaction" + ); + + // Once the transfer is removed, deletion is allowed again + conn.execute( + "DELETE FROM balance_account_transfers WHERE transaction_id = ?1", + rusqlite::params![tx_id], + ) + .unwrap(); + conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]) + .expect("after unlink, transaction can be deleted"); + } + + #[test] + fn migration_v9_fk_restrict_on_category_with_accounts() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + // Try to delete the seeded 'cash' category while an account references it + let result = conn.execute( + "DELETE FROM balance_categories WHERE key = 'cash'", + [], + ); + assert!( + result.is_err(), + "FK RESTRICT should block deleting category with linked accounts" + ); + } + + #[test] + fn migration_v9_unique_transaction_account_transfer() { + let conn = fresh_db(); + conn.execute( + "INSERT INTO balance_accounts (balance_category_id, name) + VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)", + [], + ) + .unwrap(); + let acct_id: i64 = conn + .query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0)) + .unwrap(); + let tx_id: i64 = conn + .query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0)) + .unwrap(); + conn.execute( + "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) + VALUES (?1, ?2, 'in')", + rusqlite::params![acct_id, tx_id], + ) + .unwrap(); + let dup = conn.execute( + "INSERT INTO balance_account_transfers (account_id, transaction_id, direction) + VALUES (?1, ?2, 'out')", + rusqlite::params![acct_id, tx_id], + ); + assert!( + dup.is_err(), + "UNIQUE(transaction_id, account_id) should reject dup" + ); + } + + #[test] + fn migration_v9_seed_idempotent_on_replay() { + // The migration uses `INSERT OR IGNORE` keyed by `key`, so applying + // the schema twice on the same DB must not duplicate seeded rows. + let conn = fresh_db(); + conn.execute_batch(crate::database::BALANCE_SCHEMA) + .expect("apply BALANCE_SCHEMA twice"); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 7, "seed must remain idempotent on replay"); + } +} + -- 2.45.2 From 58d3c86336cea1bde5a77421924af2062b1b4155 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:33:39 -0400 Subject: [PATCH 2/4] feat(balance): add balance.service CRUD section + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the TypeScript service layer for the Bilan feature, scoped to Issue #138 (Bilan #1a) — categories + accounts CRUD only. Snapshots, snapshot lines, transfers and price-fetching land in subsequent issues. The service uses `getDb()` + tauri-plugin-sql directly per project convention (96 occurrences across 15 services). No new Tauri commands introduced — the only future Rust commands are `compute_account_return` (Issue #142) and `fetch_price` (Issue #144). API surface: - listBalanceCategories / getBalanceCategory / createBalanceCategory / updateBalanceCategory / deleteBalanceCategory (with seed + has-accounts guards) - listBalanceAccounts (excludes archived by default) / getBalanceAccount / createBalanceAccount (CAD-only at MVP) / updateBalanceAccount / archiveBalanceAccount / unarchiveBalanceAccount (soft delete) Typed errors via BalanceServiceError + BalanceErrorCode union so the UI can render distinct i18n messages. Domain types added under `src/shared/types/index.ts`: BalanceCategoryKind, BalanceCategory, BalanceAccount, BalanceAccountWithCategory, BALANCE_CURRENCY_CAD. 19 vitest cases cover: ordering, kind validation, seed protection, linked-account guard, currency rejection, missing-category lookup, soft delete + restore round-trip, symbol/notes normalization. Refs #138 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/balance.service.test.ts | 316 +++++++++++++++++++++++ src/services/balance.service.ts | 360 +++++++++++++++++++++++++++ src/shared/types/index.ts | 46 ++++ 3 files changed, 722 insertions(+) create mode 100644 src/services/balance.service.test.ts create mode 100644 src/services/balance.service.ts diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts new file mode 100644 index 0000000..11981aa --- /dev/null +++ b/src/services/balance.service.test.ts @@ -0,0 +1,316 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./db", () => ({ + getDb: vi.fn(), +})); + +import { getDb } from "./db"; +import { + listBalanceCategories, + createBalanceCategory, + updateBalanceCategory, + deleteBalanceCategory, + listBalanceAccounts, + createBalanceAccount, + updateBalanceAccount, + archiveBalanceAccount, + unarchiveBalanceAccount, + BalanceServiceError, +} from "./balance.service"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); +}); + +// ----------------------------------------------------------------------------- +// Categories +// ----------------------------------------------------------------------------- + +describe("listBalanceCategories", () => { + it("orders by sort_order then key", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceCategories(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("FROM balance_categories"); + expect(sql).toContain("ORDER BY sort_order, key"); + }); +}); + +describe("createBalanceCategory", () => { + it("rejects an empty key", async () => { + await expect( + createBalanceCategory({ key: " ", i18n_key: "x", kind: "simple" }) + ).rejects.toBeInstanceOf(BalanceServiceError); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects an invalid kind", async () => { + await expect( + createBalanceCategory({ + key: "custom", + i18n_key: "balance.category.custom", + // @ts-expect-error testing runtime guard + kind: "weird", + }) + ).rejects.toBeInstanceOf(BalanceServiceError); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("inserts with is_seed = 0 and returns lastInsertId", async () => { + mockExecute.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }); + const id = await createBalanceCategory({ + key: "ferr", + i18n_key: "balance.category.ferr", + kind: "simple", + sort_order: 35, + }); + expect(id).toBe(42); + const sql = mockExecute.mock.calls[0][0] as string; + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(sql).toContain("INSERT INTO balance_categories"); + expect(sql).toContain("is_seed"); + expect(sql).toMatch(/0\)$/); // is_seed hardcoded to 0 + expect(params).toEqual(["ferr", "balance.category.ferr", "simple", 35]); + }); +}); + +describe("deleteBalanceCategory", () => { + it("refuses to delete a seeded category", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + }, + ]); + await expect(deleteBalanceCategory(1)).rejects.toMatchObject({ + code: "category_seed_protected", + }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("refuses to delete a category with linked accounts", async () => { + // 1st select = getBalanceCategory; 2nd select = COUNT(*) accounts linked + mockSelect + .mockResolvedValueOnce([ + { + id: 8, + key: "ferr", + i18n_key: "balance.category.ferr", + kind: "simple", + sort_order: 35, + is_active: 1, + is_seed: 0, + }, + ]) + .mockResolvedValueOnce([{ count: 2 }]); + await expect(deleteBalanceCategory(8)).rejects.toMatchObject({ + code: "category_has_accounts", + }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("deletes a user-created category with no linked accounts", async () => { + mockSelect + .mockResolvedValueOnce([ + { + id: 8, + key: "ferr", + i18n_key: "balance.category.ferr", + kind: "simple", + sort_order: 35, + is_active: 1, + is_seed: 0, + }, + ]) + .mockResolvedValueOnce([{ count: 0 }]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await deleteBalanceCategory(8); + expect(mockExecute).toHaveBeenCalledWith( + "DELETE FROM balance_categories WHERE id = $1", + [8] + ); + }); +}); + +describe("updateBalanceCategory", () => { + it("renames a seeded category (allowed)", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceCategory(1, { i18n_key: "balance.category.cash_renamed" }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[0]).toBe("balance.category.cash_renamed"); + }); + + it("rejects update on missing category", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect(updateBalanceCategory(999, { sort_order: 5 })).rejects.toMatchObject({ + code: "category_not_found", + }); + }); +}); + +// ----------------------------------------------------------------------------- +// Accounts +// ----------------------------------------------------------------------------- + +describe("listBalanceAccounts", () => { + it("excludes archived accounts by default", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceAccounts(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("a.is_active = 1"); + expect(sql).toContain("a.archived_at IS NULL"); + }); + + it("includes archived accounts when requested", async () => { + mockSelect.mockResolvedValueOnce([]); + await listBalanceAccounts({ includeArchived: true }); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).not.toContain("archived_at IS NULL"); + }); +}); + +describe("createBalanceAccount", () => { + it("rejects empty name", async () => { + await expect( + createBalanceAccount({ balance_category_id: 1, name: " " }) + ).rejects.toMatchObject({ code: "name_required" }); + }); + + it("rejects non-CAD currency at the MVP", async () => { + await expect( + createBalanceAccount({ + balance_category_id: 1, + name: "USD account", + currency: "USD", + }) + ).rejects.toMatchObject({ code: "currency_unsupported" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects when the category does not exist", async () => { + mockSelect.mockResolvedValueOnce([]); // getBalanceCategory returns null + await expect( + createBalanceAccount({ balance_category_id: 999, name: "Mystery" }) + ).rejects.toMatchObject({ code: "category_not_found" }); + }); + + it("inserts with default CAD currency", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 1, + key: "cash", + i18n_key: "balance.category.cash", + kind: "simple", + sort_order: 10, + is_active: 1, + is_seed: 1, + }, + ]); + mockExecute.mockResolvedValueOnce({ lastInsertId: 7, rowsAffected: 1 }); + const id = await createBalanceAccount({ + balance_category_id: 1, + name: "Encaisse Wealthsimple", + }); + expect(id).toBe(7); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null]); + }); +}); + +describe("updateBalanceAccount", () => { + it("rejects when account does not exist", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect(updateBalanceAccount(42, { name: "x" })).rejects.toMatchObject({ + code: "account_not_found", + }); + }); + + it("normalizes empty symbol to null", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Encaisse", + symbol: "OLD", + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateBalanceAccount(7, { symbol: " " }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[2]).toBeNull(); // symbol + }); +}); + +describe("archiveBalanceAccount / unarchiveBalanceAccount", () => { + it("archives an existing account", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Encaisse", + symbol: null, + currency: "CAD", + notes: null, + is_active: 1, + archived_at: null, + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await archiveBalanceAccount(7); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("archived_at = CURRENT_TIMESTAMP"); + expect(sql).toContain("is_active = 0"); + }); + + it("unarchives an existing account", async () => { + mockSelect.mockResolvedValueOnce([ + { + id: 7, + balance_category_id: 1, + name: "Encaisse", + symbol: null, + currency: "CAD", + notes: null, + is_active: 0, + archived_at: "2026-04-25 10:00:00", + created_at: "", + updated_at: "", + }, + ]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await unarchiveBalanceAccount(7); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("archived_at = NULL"); + expect(sql).toContain("is_active = 1"); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts new file mode 100644 index 0000000..0547670 --- /dev/null +++ b/src/services/balance.service.ts @@ -0,0 +1,360 @@ +// balance.service.ts — domain service for the Bilan (balance sheet) feature. +// +// Scope at Issue #138 (Bilan #1a): CRUD for `balance_categories` and +// `balance_accounts` only. Snapshots, snapshot lines, transfers and price +// fetching ship in subsequent issues (#1b / #2 / #4 / #5). +// +// CRUD goes through `getDb()` + tauri-plugin-sql directly — the project +// convention for database operations. Tauri commands are reserved for +// filesystem / OAuth / license / profile work and the future Modified Dietz +// + price-fetch work in Issue #142. + +import { getDb } from "./db"; +import type { + BalanceAccount, + BalanceAccountWithCategory, + BalanceCategory, + BalanceCategoryKind, +} from "../shared/types"; +import { BALANCE_CURRENCY_CAD } from "../shared/types"; + +// ----------------------------------------------------------------------------- +// Errors — typed so the UI can show distinct i18n messages. +// ----------------------------------------------------------------------------- + +export type BalanceErrorCode = + | "currency_unsupported" + | "category_seed_protected" + | "category_has_accounts" + | "category_not_found" + | "account_not_found" + | "name_required" + | "kind_invalid"; + +export class BalanceServiceError extends Error { + readonly code: BalanceErrorCode; + constructor(code: BalanceErrorCode, message: string) { + super(message); + this.name = "BalanceServiceError"; + this.code = code; + } +} + +// ----------------------------------------------------------------------------- +// Categories +// ----------------------------------------------------------------------------- + +export async function listBalanceCategories(): Promise { + const db = await getDb(); + return db.select( + `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed + FROM balance_categories + ORDER BY sort_order, key` + ); +} + +export async function getBalanceCategory( + id: number +): Promise { + const db = await getDb(); + const rows = await db.select( + `SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed + FROM balance_categories + WHERE id = $1`, + [id] + ); + return rows[0] ?? null; +} + +export interface CreateBalanceCategoryInput { + key: string; + i18n_key: string; + kind: BalanceCategoryKind; + sort_order?: number; +} + +/** + * Create a user-defined balance category. The seed categories are created by + * Migration v9 — never call this for seeded keys (UNIQUE will reject the + * insert anyway). + * + * Note (Issue #138): the AccountsPage UI restricts user-created categories to + * `kind = 'simple'`. The service still accepts both because the priced UI + * lands in Issue #140. + */ +export async function createBalanceCategory( + input: CreateBalanceCategoryInput +): Promise { + if (!input.key || input.key.trim().length === 0) { + throw new BalanceServiceError("name_required", "Category key is required"); + } + if (input.kind !== "simple" && input.kind !== "priced") { + throw new BalanceServiceError("kind_invalid", "Invalid category kind"); + } + const db = await getDb(); + const result = await db.execute( + `INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed) + VALUES ($1, $2, $3, $4, 1, 0)`, + [ + input.key.trim(), + input.i18n_key.trim(), + input.kind, + input.sort_order ?? 0, + ] + ); + return result.lastInsertId as number; +} + +export interface UpdateBalanceCategoryInput { + i18n_key?: string; + sort_order?: number; + is_active?: boolean; +} + +/** + * Rename / re-order / toggle active state of a category. Seeded categories + * are renamable. Changing `kind` is intentionally not supported (would + * invalidate existing snapshot lines). + */ +export async function updateBalanceCategory( + id: number, + input: UpdateBalanceCategoryInput +): Promise { + const existing = await getBalanceCategory(id); + if (!existing) { + throw new BalanceServiceError( + "category_not_found", + `Category ${id} not found` + ); + } + const db = await getDb(); + const i18n = input.i18n_key !== undefined ? input.i18n_key : existing.i18n_key; + const sortOrder = + input.sort_order !== undefined ? input.sort_order : existing.sort_order; + const isActive = + input.is_active !== undefined ? (input.is_active ? 1 : 0) : existing.is_active ? 1 : 0; + await db.execute( + `UPDATE balance_categories + SET i18n_key = $1, sort_order = $2, is_active = $3 + WHERE id = $4`, + [i18n, sortOrder, isActive, id] + ); +} + +/** + * Delete a user-created category. Refuses to delete: + * - seeded categories (`is_seed = 1`) — UI must disable the button; + * - categories with linked accounts — FK RESTRICT would also reject, but + * we pre-check to surface a clean i18n message. + */ +export async function deleteBalanceCategory(id: number): Promise { + const existing = await getBalanceCategory(id); + if (!existing) { + throw new BalanceServiceError( + "category_not_found", + `Category ${id} not found` + ); + } + if (existing.is_seed) { + throw new BalanceServiceError( + "category_seed_protected", + "Seeded categories cannot be deleted" + ); + } + const db = await getDb(); + const linked = await db.select>( + `SELECT COUNT(*) AS count FROM balance_accounts WHERE balance_category_id = $1`, + [id] + ); + if ((linked[0]?.count ?? 0) > 0) { + throw new BalanceServiceError( + "category_has_accounts", + "Cannot delete a category with linked accounts" + ); + } + await db.execute("DELETE FROM balance_categories WHERE id = $1", [id]); +} + +// ----------------------------------------------------------------------------- +// Accounts +// ----------------------------------------------------------------------------- + +export async function listBalanceAccounts(options?: { + includeArchived?: boolean; +}): Promise { + const includeArchived = options?.includeArchived ?? false; + const db = await getDb(); + const where = includeArchived + ? "" + : "WHERE a.is_active = 1 AND a.archived_at IS NULL"; + return db.select( + `SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency, + a.notes, a.is_active, a.archived_at, a.created_at, a.updated_at, + c.key AS category_key, c.i18n_key AS category_i18n_key, c.kind AS category_kind + FROM balance_accounts a + INNER JOIN balance_categories c ON c.id = a.balance_category_id + ${where} + ORDER BY c.sort_order, a.name` + ); +} + +export async function getBalanceAccount( + id: number +): Promise { + const db = await getDb(); + const rows = await db.select( + `SELECT id, balance_category_id, name, symbol, currency, notes, + is_active, archived_at, created_at, updated_at + FROM balance_accounts + WHERE id = $1`, + [id] + ); + return rows[0] ?? null; +} + +export interface CreateBalanceAccountInput { + balance_category_id: number; + name: string; + symbol?: string | null; + /** Defaults to 'CAD'. MVP rejects any other value. */ + currency?: string; + notes?: string | null; +} + +/** + * Create an account. Currency must be 'CAD' at the MVP — the SQL CHECK + * would reject anything else, but we pre-check to surface a clean i18n + * message instead of a raw SQL error. + */ +export async function createBalanceAccount( + input: CreateBalanceAccountInput +): Promise { + if (!input.name || input.name.trim().length === 0) { + throw new BalanceServiceError("name_required", "Account name is required"); + } + const currency = input.currency ?? BALANCE_CURRENCY_CAD; + if (currency !== BALANCE_CURRENCY_CAD) { + throw new BalanceServiceError( + "currency_unsupported", + "Only CAD is supported at the MVP" + ); + } + const cat = await getBalanceCategory(input.balance_category_id); + if (!cat) { + throw new BalanceServiceError( + "category_not_found", + "Linked balance category not found" + ); + } + const db = await getDb(); + const result = await db.execute( + `INSERT INTO balance_accounts (balance_category_id, name, symbol, currency, notes, is_active) + VALUES ($1, $2, $3, $4, $5, 1)`, + [ + input.balance_category_id, + input.name.trim(), + input.symbol ? input.symbol.trim() : null, + currency, + input.notes ? input.notes.trim() : null, + ] + ); + return result.lastInsertId as number; +} + +export interface UpdateBalanceAccountInput { + balance_category_id?: number; + name?: string; + symbol?: string | null; + notes?: string | null; + is_active?: boolean; +} + +export async function updateBalanceAccount( + id: number, + input: UpdateBalanceAccountInput +): Promise { + const existing = await getBalanceAccount(id); + if (!existing) { + throw new BalanceServiceError( + "account_not_found", + `Account ${id} not found` + ); + } + const name = input.name !== undefined ? input.name.trim() : existing.name; + if (!name) { + throw new BalanceServiceError("name_required", "Account name is required"); + } + const categoryId = + input.balance_category_id !== undefined + ? input.balance_category_id + : existing.balance_category_id; + const symbol = + input.symbol !== undefined + ? input.symbol === null + ? null + : input.symbol.trim() || null + : existing.symbol; + const notes = + input.notes !== undefined + ? input.notes === null + ? null + : input.notes.trim() || null + : existing.notes; + const isActive = + input.is_active !== undefined + ? input.is_active + ? 1 + : 0 + : existing.is_active + ? 1 + : 0; + const db = await getDb(); + await db.execute( + `UPDATE balance_accounts + SET balance_category_id = $1, name = $2, symbol = $3, notes = $4, + is_active = $5, updated_at = CURRENT_TIMESTAMP + WHERE id = $6`, + [categoryId, name, symbol, notes, isActive, id] + ); +} + +/** + * Soft-delete an account: stamp `archived_at` and set `is_active = 0`. + * Archived accounts are hidden from new snapshots but kept in the historic + * snapshot lines (which is why we never hard-delete here). + */ +export async function archiveBalanceAccount(id: number): Promise { + const existing = await getBalanceAccount(id); + if (!existing) { + throw new BalanceServiceError( + "account_not_found", + `Account ${id} not found` + ); + } + const db = await getDb(); + await db.execute( + `UPDATE balance_accounts + SET archived_at = CURRENT_TIMESTAMP, is_active = 0, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [id] + ); +} + +export async function unarchiveBalanceAccount(id: number): Promise { + const existing = await getBalanceAccount(id); + if (!existing) { + throw new BalanceServiceError( + "account_not_found", + `Account ${id} not found` + ); + } + const db = await getDb(); + await db.execute( + `UPDATE balance_accounts + SET archived_at = NULL, is_active = 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [id] + ); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index c9dc052..004a122 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -555,3 +555,49 @@ export interface TransactionPageResult { incomeTotal: number; expenseTotal: number; } + +// --- Balance (Bilan) types --- +// Backed by migration v9 (see src-tauri/src/database/balance_schema.sql). +// MVP scope (Issue #138 / #1a): categories + accounts CRUD only. Snapshots, +// snapshot lines and transfers ship in subsequent issues (#1b / #2 / #4). + +export type BalanceCategoryKind = "simple" | "priced"; + +export const BALANCE_CURRENCY_CAD = "CAD"; + +export interface BalanceCategory { + id: number; + /** Stable lookup key (e.g. 'cash', 'tfsa', 'stock'). UNIQUE NOT NULL. */ + key: string; + /** Translation key into i18n locales (e.g. 'balance.category.cash'). */ + i18n_key: string; + /** simple = direct value entry; priced = quantity x unit_price. */ + kind: BalanceCategoryKind; + sort_order: number; + is_active: boolean; + /** True when seeded by Migration v9 — cannot be deleted, can be renamed. */ + is_seed: boolean; +} + +export interface BalanceAccount { + id: number; + balance_category_id: number; + name: string; + /** Symbol (e.g. 'AAPL', 'BTC-USD'); NULL for simple-kind accounts. */ + symbol: string | null; + /** ISO 4217. MVP: hardcoded 'CAD' (CHECK enforced server-side). */ + currency: string; + notes: string | null; + is_active: boolean; + /** Soft-delete timestamp; archived accounts hide from new snapshots. */ + archived_at: string | null; + created_at: string; + updated_at: string; +} + +/** Joined view used by AccountsPage tables. */ +export interface BalanceAccountWithCategory extends BalanceAccount { + category_key: string; + category_i18n_key: string; + category_kind: BalanceCategoryKind; +} -- 2.45.2 From fccc8e4fa29a4096c8296dfa0ba67d1ce2407bf7 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:37:30 -0400 Subject: [PATCH 3/4] feat(balance): add useBalanceAccounts hook + AccountsPage + AccountForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the AccountsPage end-to-end with a new scoped useReducer hook, the page itself (accessible at /balance/accounts) and the account form. useBalanceAccounts (src/hooks/useBalanceAccounts.ts): - Loads accounts (excludes archived by default) + categories in parallel - Surfaces typed errors from balance.service via state.errorCode so the UI can localize them (e.g. seed protection, currency rejection) - CRUD operations on both domains: addAccount/editAccount/archive/ unarchiveAccount + addCategory/editCategory/removeCategory AccountsPage (src/pages/AccountsPage.tsx): - Two tabs: Comptes + Catégories - Accounts tab: archive toggle, table of (name, category, symbol, currency, status), inline edit/archive/restore - Categories tab: full list of seeded + user categories. Add new simple-kind category (priced creation lands in #140). Rename via inline prompt; delete disabled on seeded rows. Errors surfaced via i18n keys keyed on BalanceErrorCode. AccountForm (src/components/balance/AccountForm.tsx): - Variant=account only (category variant lands in #140) - Auto-detects priced category to hint the symbol field - Full FR/EN coverage of labels and validation messages Per spec-plan-bilan.md v2 the sidebar entry "Bilan" is intentionally not added in this issue — it lands in #141 (Bilan #3) when the /balance overview becomes navigable. Until then the route is reachable directly via URL. Refs #138 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 2 + src/components/balance/AccountForm.tsx | 229 ++++++++++++ src/hooks/useBalanceAccounts.ts | 276 +++++++++++++++ src/pages/AccountsPage.tsx | 473 +++++++++++++++++++++++++ 4 files changed, 980 insertions(+) create mode 100644 src/components/balance/AccountForm.tsx create mode 100644 src/hooks/useBalanceAccounts.ts create mode 100644 src/pages/AccountsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index dd6677b..10a099a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCartesPage from "./pages/ReportsCartesPage"; import SettingsPage from "./pages/SettingsPage"; +import AccountsPage from "./pages/AccountsPage"; import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage"; import CategoriesMigrationPage from "./pages/CategoriesMigrationPage"; import DocsPage from "./pages/DocsPage"; @@ -114,6 +115,7 @@ export default function App() { } /> } /> } /> + } /> } diff --git a/src/components/balance/AccountForm.tsx b/src/components/balance/AccountForm.tsx new file mode 100644 index 0000000..a56efb0 --- /dev/null +++ b/src/components/balance/AccountForm.tsx @@ -0,0 +1,229 @@ +// AccountForm — variant=account (Issue #138 / Bilan #1a). +// +// The category variant lands in Issue #140 (Bilan #2) when the priced-kind +// switch becomes available. For now this component focuses on creating / +// editing a `balance_account` record bound to an existing category. + +import { FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + BalanceAccount, + BalanceCategory, +} from "../../shared/types"; +import type { + CreateBalanceAccountInput, + UpdateBalanceAccountInput, +} from "../../services/balance.service"; + +export interface AccountFormValues { + balance_category_id: number; + name: string; + symbol: string; + notes: string; +} + +interface Props { + /** When provided, the form is in edit mode; otherwise creation. */ + initialAccount?: BalanceAccount | null; + categories: BalanceCategory[]; + isSaving: boolean; + onSubmit: ( + values: CreateBalanceAccountInput | UpdateBalanceAccountInput + ) => Promise | void; + onCancel: () => void; +} + +function defaultValues( + initial: BalanceAccount | null | undefined, + categories: BalanceCategory[] +): AccountFormValues { + if (initial) { + return { + balance_category_id: initial.balance_category_id, + name: initial.name, + symbol: initial.symbol ?? "", + notes: initial.notes ?? "", + }; + } + // First active category as a sane default + const first = categories.find((c) => c.is_active) ?? categories[0]; + return { + balance_category_id: first?.id ?? 0, + name: "", + symbol: "", + notes: "", + }; +} + +export default function AccountForm({ + initialAccount, + categories, + isSaving, + onSubmit, + onCancel, +}: Props) { + const { t } = useTranslation(); + const [values, setValues] = useState(() => + defaultValues(initialAccount, categories) + ); + const [touched, setTouched] = useState(false); + + // Reset form when target account changes (edit different row). + useEffect(() => { + setValues(defaultValues(initialAccount, categories)); + setTouched(false); + }, [initialAccount, categories]); + + const isEditing = !!initialAccount; + const selectedCategory = categories.find( + (c) => c.id === values.balance_category_id + ); + const isPriced = selectedCategory?.kind === "priced"; + const trimmedName = values.name.trim(); + const nameInvalid = touched && trimmedName.length === 0; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setTouched(true); + if (!trimmedName) return; + + const payload: CreateBalanceAccountInput = { + balance_category_id: values.balance_category_id, + name: trimmedName, + symbol: values.symbol.trim() || null, + notes: values.notes.trim() || null, + }; + + if (isEditing) { + const updatePayload: UpdateBalanceAccountInput = { + balance_category_id: payload.balance_category_id, + name: payload.name, + symbol: payload.symbol, + notes: payload.notes, + }; + await onSubmit(updatePayload); + } else { + await onSubmit(payload); + } + }; + + const renderCategoryLabel = (cat: BalanceCategory) => + t(cat.i18n_key, { defaultValue: cat.key }); + + return ( +
+
+ + +
+ +
+ + setValues({ ...values, name: e.target.value })} + onBlur={() => setTouched(true)} + className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${ + nameInvalid + ? "border-[var(--negative)]" + : "border-[var(--border)]" + }`} + autoFocus + autoComplete="off" + /> + {nameInvalid && ( +

+ {t("balance.account.form.nameRequired")} +

+ )} +
+ +
+ + setValues({ ...values, symbol: e.target.value })} + placeholder={ + isPriced + ? t("balance.account.form.symbolPlaceholderPriced") + : t("balance.account.form.symbolPlaceholderSimple") + } + className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + autoComplete="off" + /> +
+ +
+ +