Merge pull request 'feat(balance): schema migration v9 + service skeleton + AccountsPage (#138)' (#147) from issue-138-bilan-1a into main

This commit is contained in:
maximus 2026-04-26 13:25:09 +00:00
commit b6387f4b31
15 changed files with 2594 additions and 0 deletions

View file

@ -2,6 +2,9 @@
## [Non publié] ## [Non publié]
### Ajouté
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
### Corrigé ### Corrigé
- **Rapport Zoom catégorie** (`/reports/category`) : la liste déroulante du combobox des catégories affiche désormais la liste complète dans un ordre hiérarchique DFS correct — chaque racine est émise avant ses descendants, et les frères et sœurs sont triés par `sort_order` puis nom affiché. Auparavant la liste était triée globalement par `sort_order` (via un `ORDER BY sort_order, name` SQL), ce qui entrelaçait des parents et enfants de sous-arbres différents partageant le même `sort_order`, d'où l'indentation incohérente et l'impression d'arbre cassé. La recherche filtrée (insensible aux accents) conserve le même comportement (#126) - **Rapport Zoom catégorie** (`/reports/category`) : la liste déroulante du combobox des catégories affiche désormais la liste complète dans un ordre hiérarchique DFS correct — chaque racine est émise avant ses descendants, et les frères et sœurs sont triés par `sort_order` puis nom affiché. Auparavant la liste était triée globalement par `sort_order` (via un `ORDER BY sort_order, name` SQL), ce qui entrelaçait des parents et enfants de sous-arbres différents partageant le même `sort_order`, d'où l'indentation incohérente et l'impression d'arbre cassé. La recherche filtrée (insensible aux accents) conserve le même comportement (#126)

View file

@ -2,6 +2,9 @@
## [Unreleased] ## [Unreleased]
### Added
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
### Fixed ### Fixed
- **Category zoom report** (`/reports/category`): the category combobox dropdown now renders the full list in proper hierarchical DFS order — each root is emitted before its descendants, with siblings sorted by `sort_order` then display name. Previously the list was ordered by `sort_order` globally (from a SQL `ORDER BY sort_order, name`), which interleaved parents and children from different sub-trees that shared the same `sort_order`, producing scrambled indentation and a mis-leading tree. Filtering (accent-insensitive search) still behaves identically (#126) - **Category zoom report** (`/reports/category`): the category combobox dropdown now renders the full list in proper hierarchical DFS order — each root is emitted before its descendants, with siblings sorted by `sort_order` then display name. Previously the list was ordered by `sort_order` globally (from a SQL `ORDER BY sort_order, name`), which interleaved parents and children from different sub-trees that shared the same `sort_order`, producing scrambled indentation and a mis-leading tree. Filtering (accent-insensitive search) still behaves identically (#126)

View file

@ -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);

View file

@ -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_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_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) -- 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 ('language', 'fr');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');

View file

@ -1,3 +1,4 @@
pub const SCHEMA: &str = include_str!("schema.sql"); pub const SCHEMA: &str = include_str!("schema.sql");
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql"); pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql"); pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");

View file

@ -95,6 +95,19 @@ pub fn run() {
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');", INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
kind: MigrationKind::Up, 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() tauri::Builder::default()
@ -240,3 +253,443 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
} }
None 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<String> = 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");
}
}

View file

@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage"; import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import AccountsPage from "./pages/AccountsPage";
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage"; import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage"; import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
import DocsPage from "./pages/DocsPage"; import DocsPage from "./pages/DocsPage";
@ -114,6 +115,7 @@ export default function App() {
<Route path="/reports/category" element={<ReportsCategoryPage />} /> <Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} /> <Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/balance/accounts" element={<AccountsPage />} />
<Route <Route
path="/settings/categories/standard" path="/settings/categories/standard"
element={<CategoriesStandardGuidePage />} element={<CategoriesStandardGuidePage />}

View file

@ -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> | 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<AccountFormValues>(() =>
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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-category">
{t("balance.account.form.category")}
</label>
<select
id="account-category"
value={values.balance_category_id}
onChange={(e) =>
setValues({
...values,
balance_category_id: Number(e.target.value),
})
}
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)]"
>
{categories.length === 0 ? (
<option value={0}>{t("balance.account.form.noCategory")}</option>
) : (
categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{renderCategoryLabel(cat)}
</option>
))
)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-name">
{t("balance.account.form.name")}
</label>
<input
id="account-name"
type="text"
value={values.name}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.nameRequired")}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-symbol">
{t("balance.account.form.symbol")}
{isPriced && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
({t("balance.account.form.symbolPricedHint")})
</span>
)}
</label>
<input
id="account-symbol"
type="text"
value={values.symbol}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-notes">
{t("balance.account.form.notes")}
</label>
<textarea
id="account-notes"
value={values.notes}
onChange={(e) => setValues({ ...values, notes: e.target.value })}
rows={2}
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)] resize-none"
/>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{t("balance.account.form.currencyMvpNotice")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={isSaving || !trimmedName || categories.length === 0}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{isEditing
? t("balance.account.form.save")
: t("balance.account.form.create")}
</button>
</div>
</form>
);
}

View file

@ -0,0 +1,276 @@
// useBalanceAccounts — scoped useReducer hook backing AccountsPage.
//
// Domain coverage (per spec-plan-bilan.md v2): the AccountsPage CRUD over
// `balance_accounts` AND `balance_categories`. Snapshots, lines, transfers,
// and returns are out of scope here — they belong to `useSnapshotEditor`
// (Issue #146 / Bilan #1b) and `useBalanceOverview` (Issue #141 / Bilan #3).
import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
BalanceAccountWithCategory,
BalanceCategory,
BalanceCategoryKind,
} from "../shared/types";
import {
listBalanceAccounts,
listBalanceCategories,
createBalanceAccount,
updateBalanceAccount,
archiveBalanceAccount,
unarchiveBalanceAccount,
createBalanceCategory,
updateBalanceCategory,
deleteBalanceCategory,
BalanceServiceError,
type CreateBalanceAccountInput,
type CreateBalanceCategoryInput,
type UpdateBalanceAccountInput,
type UpdateBalanceCategoryInput,
} from "../services/balance.service";
interface State {
accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[];
includeArchived: boolean;
isLoading: boolean;
isSaving: boolean;
error: string | null;
/** Stable error code for UIs that want to localize via i18n (e.g. seed protection). */
errorCode: string | null;
}
type Action =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: { message: string | null; code: string | null } }
| {
type: "SET_DATA";
payload: {
accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[];
};
}
| { type: "SET_INCLUDE_ARCHIVED"; payload: boolean };
function initialState(): State {
return {
accounts: [],
categories: [],
includeArchived: false,
isLoading: false,
isSaving: false,
error: null,
errorCode: null,
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_SAVING":
return { ...state, isSaving: action.payload };
case "SET_ERROR":
return {
...state,
error: action.payload.message,
errorCode: action.payload.code,
isLoading: false,
isSaving: false,
};
case "SET_DATA":
return {
...state,
accounts: action.payload.accounts,
categories: action.payload.categories,
isLoading: false,
error: null,
errorCode: null,
};
case "SET_INCLUDE_ARCHIVED":
return { ...state, includeArchived: action.payload };
default:
return state;
}
}
function describeError(e: unknown): { message: string; code: string | null } {
if (e instanceof BalanceServiceError) {
return { message: e.message, code: e.code };
}
return {
message: e instanceof Error ? e.message : String(e),
code: null,
};
}
export function useBalanceAccounts() {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0);
const refreshData = useCallback(async (includeArchived: boolean) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
try {
const [accounts, categories] = await Promise.all([
listBalanceAccounts({ includeArchived }),
listBalanceCategories(),
]);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { accounts, categories } });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: describeError(e) });
}
}, []);
useEffect(() => {
refreshData(state.includeArchived);
}, [state.includeArchived, refreshData]);
const setIncludeArchived = useCallback((next: boolean) => {
dispatch({ type: "SET_INCLUDE_ARCHIVED", payload: next });
}, []);
// ---------------------------------------------------------------------------
// Account mutations
// ---------------------------------------------------------------------------
const addAccount = useCallback(
async (input: CreateBalanceAccountInput) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await createBalanceAccount(input);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const editAccount = useCallback(
async (id: number, input: UpdateBalanceAccountInput) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await updateBalanceAccount(id, input);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const archiveAccount = useCallback(
async (id: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await archiveBalanceAccount(id);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const unarchiveAccount = useCallback(
async (id: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await unarchiveBalanceAccount(id);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
// ---------------------------------------------------------------------------
// Category mutations
// ---------------------------------------------------------------------------
/**
* Issue #138 keeps the AccountsPage Categories tab to user-created
* `simple` kind only. The priced creation UI lands in #140 until then,
* callers should pass kind = 'simple'.
*/
const addCategory = useCallback(
async (input: CreateBalanceCategoryInput) => {
const kind: BalanceCategoryKind = input.kind ?? "simple";
dispatch({ type: "SET_SAVING", payload: true });
try {
await createBalanceCategory({ ...input, kind });
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const editCategory = useCallback(
async (id: number, input: UpdateBalanceCategoryInput) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await updateBalanceCategory(id, input);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const removeCategory = useCallback(
async (id: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteBalanceCategory(id);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
return {
state,
setIncludeArchived,
refresh: () => refreshData(state.includeArchived),
// Account ops
addAccount,
editAccount,
archiveAccount,
unarchiveAccount,
// Category ops
addCategory,
editCategory,
removeCategory,
};
}

View file

@ -1449,5 +1449,100 @@
} }
} }
} }
},
"balance": {
"accountsPage": {
"title": "Balance accounts",
"tabs": {
"accounts": "Accounts",
"categories": "Categories"
},
"newAccount": "New account",
"includeArchived": "Show archived accounts",
"empty": "No accounts yet. Click “New account” to start."
},
"account": {
"fields": {
"name": "Name",
"category": "Category",
"symbol": "Symbol",
"currency": "Currency",
"status": "Status",
"actions": "Actions"
},
"status": {
"active": "Active",
"archived": "Archived"
},
"actions": {
"archive": "Archive",
"unarchive": "Restore"
},
"form": {
"createTitle": "New account",
"editTitle": "Edit account",
"category": "Category",
"noCategory": "(no category available)",
"name": "Account name",
"nameRequired": "Name is required.",
"symbol": "Symbol",
"symbolPricedHint": "required for priced categories",
"symbolPlaceholderSimple": "Optional",
"symbolPlaceholderPriced": "e.g. AAPL, BTC-USD",
"notes": "Notes",
"currencyMvpNotice": "At the MVP, all accounts are in CAD. Multi-currency support will land in a later version.",
"save": "Save",
"create": "Create account"
}
},
"category": {
"intro": "Seeded categories (TFSA, RRSP, Cash, etc.) ship with the app. You can create your own for special cases.",
"fields": {
"name": "Name",
"key": "Key",
"kind": "Kind",
"origin": "Origin",
"actions": "Actions"
},
"kind": {
"simple": "Direct amount",
"priced": "Quantity × price"
},
"origin": {
"seeded": "Standard",
"user": "Custom"
},
"actions": {
"create": "New category",
"renamePrompt": "New label for this category",
"deleteConfirm": "Delete this category? This cannot be undone.",
"deleteSeedHint": "Standard categories cannot be deleted."
},
"form": {
"createTitle": "New category",
"key": "Key",
"keyPlaceholder": "e.g. lira, prpp",
"label": "Label",
"labelPlaceholder": "e.g. LIRA, PRPP",
"simpleOnlyNotice": "Priced categories (stocks, crypto) will be available in a future release.",
"create": "Create category"
},
"cash": "Cash",
"tfsa": "TFSA",
"rrsp": "RRSP",
"fund": "Mutual fund",
"other": "Other",
"stock": "Stock",
"crypto": "Crypto"
},
"errors": {
"currency_unsupported": "Only CAD is supported at the MVP.",
"category_seed_protected": "Standard categories cannot be deleted.",
"category_has_accounts": "Cannot delete a category with linked accounts. Move or archive linked accounts first.",
"category_not_found": "Category not found.",
"account_not_found": "Account not found.",
"name_required": "Name is required.",
"kind_invalid": "Invalid category kind."
}
} }
} }

View file

@ -1449,5 +1449,100 @@
} }
} }
} }
},
"balance": {
"accountsPage": {
"title": "Comptes du bilan",
"tabs": {
"accounts": "Comptes",
"categories": "Catégories"
},
"newAccount": "Nouveau compte",
"includeArchived": "Afficher les comptes archivés",
"empty": "Aucun compte pour l'instant. Cliquez sur « Nouveau compte » pour commencer."
},
"account": {
"fields": {
"name": "Nom",
"category": "Catégorie",
"symbol": "Symbole",
"currency": "Devise",
"status": "Statut",
"actions": "Actions"
},
"status": {
"active": "Actif",
"archived": "Archivé"
},
"actions": {
"archive": "Archiver",
"unarchive": "Restaurer"
},
"form": {
"createTitle": "Nouveau compte",
"editTitle": "Modifier le compte",
"category": "Catégorie",
"noCategory": "(aucune catégorie disponible)",
"name": "Nom du compte",
"nameRequired": "Le nom est obligatoire.",
"symbol": "Symbole",
"symbolPricedHint": "obligatoire pour cette catégorie cotée",
"symbolPlaceholderSimple": "Optionnel",
"symbolPlaceholderPriced": "ex. AAPL, BTC-USD",
"notes": "Notes",
"currencyMvpNotice": "Au MVP, tous les comptes sont en CAD. Le support multi-devises arrivera dans une version ultérieure.",
"save": "Enregistrer",
"create": "Créer le compte"
}
},
"category": {
"intro": "Les catégories seedées (CELI, REER, Encaisse, etc.) sont fournies par l'application. Vous pouvez en créer de nouvelles pour vos cas particuliers.",
"fields": {
"name": "Nom",
"key": "Clé",
"kind": "Type",
"origin": "Origine",
"actions": "Actions"
},
"kind": {
"simple": "Montant direct",
"priced": "Quantité × prix"
},
"origin": {
"seeded": "Standard",
"user": "Personnalisée"
},
"actions": {
"create": "Nouvelle catégorie",
"renamePrompt": "Nouveau libellé pour cette catégorie",
"deleteConfirm": "Supprimer cette catégorie ? Cette action est irréversible.",
"deleteSeedHint": "Les catégories standard ne peuvent pas être supprimées."
},
"form": {
"createTitle": "Nouvelle catégorie",
"key": "Clé",
"keyPlaceholder": "ex. ferr, rpdb",
"label": "Libellé",
"labelPlaceholder": "ex. FERR, RPDB",
"simpleOnlyNotice": "Les catégories cotées (actions, crypto) seront disponibles dans une prochaine version.",
"create": "Créer la catégorie"
},
"cash": "Encaisse",
"tfsa": "CELI",
"rrsp": "REER",
"fund": "Fonds commun",
"other": "Autre",
"stock": "Action",
"crypto": "Cryptomonnaie"
},
"errors": {
"currency_unsupported": "Seul le CAD est supporté au MVP.",
"category_seed_protected": "Les catégories standard ne peuvent pas être supprimées.",
"category_has_accounts": "Impossible de supprimer une catégorie avec des comptes liés. Déplacez ou archivez d'abord les comptes liés.",
"category_not_found": "Catégorie introuvable.",
"account_not_found": "Compte introuvable.",
"name_required": "Le nom est obligatoire.",
"kind_invalid": "Type de catégorie invalide."
}
} }
} }

473
src/pages/AccountsPage.tsx Normal file
View file

@ -0,0 +1,473 @@
// AccountsPage — CRUD UI for balance accounts and balance categories.
//
// Issue #138 (Bilan #1a) ships the route `/balance/accounts` with two tabs:
// - Comptes : full CRUD over balance_accounts (create/edit/archive)
// - Catégories : list of seeded + user-created categories. Users can add
// simple-kind categories (the priced toggle lands in #140),
// rename them, and delete the ones they created (the seeded
// ones are protected at the service layer).
//
// The sidebar entry "Bilan" is intentionally NOT added here — per spec-plan
// v2 it lands in Issue #141 (Bilan #3) when the `/balance` overview page
// becomes navigable. Until then the route is reachable directly via URL.
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArchiveRestore, Edit2, Plus, Trash2, Wallet } from "lucide-react";
import type {
BalanceAccountWithCategory,
BalanceCategory,
} from "../shared/types";
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
import AccountForm from "../components/balance/AccountForm";
type Tab = "accounts" | "categories";
export default function AccountsPage() {
const { t } = useTranslation();
const {
state,
setIncludeArchived,
addAccount,
editAccount,
archiveAccount,
unarchiveAccount,
addCategory,
editCategory,
removeCategory,
} = useBalanceAccounts();
const [activeTab, setActiveTab] = useState<Tab>("accounts");
const [showAccountForm, setShowAccountForm] = useState(false);
const [editingAccount, setEditingAccount] =
useState<BalanceAccountWithCategory | null>(null);
const [showCategoryForm, setShowCategoryForm] = useState(false);
const [newCategoryKey, setNewCategoryKey] = useState("");
const [newCategoryLabel, setNewCategoryLabel] = useState("");
const activeCategories = useMemo(
() => state.categories.filter((c) => c.is_active),
[state.categories]
);
const renderCategoryLabel = (cat: BalanceCategory) =>
t(cat.i18n_key, { defaultValue: cat.key });
const closeAccountForm = () => {
setShowAccountForm(false);
setEditingAccount(null);
};
const handleAccountSubmit = async (
payload:
| Parameters<typeof addAccount>[0]
| Parameters<typeof editAccount>[1]
) => {
try {
if (editingAccount) {
await editAccount(editingAccount.id, payload as Parameters<typeof editAccount>[1]);
} else {
await addAccount(payload as Parameters<typeof addAccount>[0]);
}
closeAccountForm();
} catch {
// Error already surfaced via state.error
}
};
const handleCreateCategory = async () => {
const key = newCategoryKey.trim();
const label = newCategoryLabel.trim();
if (!key) return;
// For user-created categories we use the literal label as the i18n_key
// fallback — they don't ship in the locale bundle, so renderers default
// to this string. (The CategoryCombobox does the same for legacy v2 rows.)
const i18nKey = label || key;
try {
await addCategory({
key,
i18n_key: i18nKey,
kind: "simple",
sort_order: 100, // user-created categories sort after seeded ones
});
setNewCategoryKey("");
setNewCategoryLabel("");
setShowCategoryForm(false);
} catch {
// Error already surfaced via state.error
}
};
return (
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="flex items-center gap-3 mb-6">
<Wallet size={24} className="text-[var(--primary)]" />
<h1 className="text-2xl font-bold">{t("balance.accountsPage.title")}</h1>
</div>
{state.error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{state.errorCode
? t(`balance.errors.${state.errorCode}`, {
defaultValue: state.error,
})
: state.error}
</div>
)}
<div className="flex border-b border-[var(--border)] mb-6">
<button
type="button"
onClick={() => setActiveTab("accounts")}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === "accounts"
? "border-[var(--primary)] text-[var(--primary)]"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{t("balance.accountsPage.tabs.accounts")}
</button>
<button
type="button"
onClick={() => setActiveTab("categories")}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === "categories"
? "border-[var(--primary)] text-[var(--primary)]"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{t("balance.accountsPage.tabs.categories")}
</button>
</div>
{activeTab === "accounts" && (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={state.includeArchived}
onChange={(e) => setIncludeArchived(e.target.checked)}
/>
{t("balance.accountsPage.includeArchived")}
</label>
<button
type="button"
onClick={() => {
setEditingAccount(null);
setShowAccountForm(true);
}}
disabled={activeCategories.length === 0}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
<Plus size={16} />
{t("balance.accountsPage.newAccount")}
</button>
</div>
{showAccountForm ? (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
<h2 className="text-lg font-semibold mb-4">
{editingAccount
? t("balance.account.form.editTitle")
: t("balance.account.form.createTitle")}
</h2>
<AccountForm
initialAccount={editingAccount ?? null}
categories={activeCategories}
isSaving={state.isSaving}
onSubmit={handleAccountSubmit}
onCancel={closeAccountForm}
/>
</div>
) : null}
{state.accounts.length === 0 ? (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
{t("balance.accountsPage.empty")}
</div>
) : (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]">
<tr>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.name")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.category")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.symbol")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.currency")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.status")}
</th>
<th className="text-right px-4 py-2 font-medium">
{t("balance.account.fields.actions")}
</th>
</tr>
</thead>
<tbody>
{state.accounts.map((acc) => {
const isArchived = !!acc.archived_at;
return (
<tr
key={acc.id}
className="border-t border-[var(--border)]"
>
<td className="px-4 py-2">
<span className={isArchived ? "opacity-60" : ""}>
{acc.name}
</span>
</td>
<td className="px-4 py-2">
{t(acc.category_i18n_key, {
defaultValue: acc.category_key,
})}
</td>
<td className="px-4 py-2 text-[var(--muted-foreground)]">
{acc.symbol ?? "—"}
</td>
<td className="px-4 py-2 text-[var(--muted-foreground)]">
{acc.currency}
</td>
<td className="px-4 py-2">
{isArchived ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--muted)] text-[var(--muted-foreground)]">
{t("balance.account.status.archived")}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--positive)]/10 text-[var(--positive)]">
{t("balance.account.status.active")}
</span>
)}
</td>
<td className="px-4 py-2 text-right">
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => {
setEditingAccount(acc);
setShowAccountForm(true);
}}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title={t("common.edit")}
>
<Edit2 size={14} />
</button>
{isArchived ? (
<button
type="button"
onClick={() => unarchiveAccount(acc.id)}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title={t("balance.account.actions.unarchive")}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
type="button"
onClick={() => archiveAccount(acc.id)}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)]"
title={t("balance.account.actions.archive")}
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === "categories" && (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.category.intro")}
</p>
<button
type="button"
onClick={() => setShowCategoryForm((prev) => !prev)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
<Plus size={16} />
{t("balance.category.actions.create")}
</button>
</div>
{showCategoryForm && (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
<h2 className="text-lg font-semibold mb-4">
{t("balance.category.form.createTitle")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-key"
>
{t("balance.category.form.key")}
</label>
<input
id="category-key"
type="text"
value={newCategoryKey}
onChange={(e) => setNewCategoryKey(e.target.value)}
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)]"
placeholder={t("balance.category.form.keyPlaceholder")}
autoComplete="off"
/>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-label"
>
{t("balance.category.form.label")}
</label>
<input
id="category-label"
type="text"
value={newCategoryLabel}
onChange={(e) => setNewCategoryLabel(e.target.value)}
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)]"
placeholder={t("balance.category.form.labelPlaceholder")}
autoComplete="off"
/>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)] mt-3">
{t("balance.category.form.simpleOnlyNotice")}
</p>
<div className="flex justify-end gap-2 mt-4">
<button
type="button"
onClick={() => {
setShowCategoryForm(false);
setNewCategoryKey("");
setNewCategoryLabel("");
}}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleCreateCategory}
disabled={state.isSaving || !newCategoryKey.trim()}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("balance.category.form.create")}
</button>
</div>
</div>
)}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]">
<tr>
<th className="text-left px-4 py-2 font-medium">
{t("balance.category.fields.name")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.category.fields.key")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.category.fields.kind")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.category.fields.origin")}
</th>
<th className="text-right px-4 py-2 font-medium">
{t("balance.category.fields.actions")}
</th>
</tr>
</thead>
<tbody>
{state.categories.map((cat) => (
<tr key={cat.id} className="border-t border-[var(--border)]">
<td className="px-4 py-2">{renderCategoryLabel(cat)}</td>
<td className="px-4 py-2 text-[var(--muted-foreground)]">
<code className="text-xs">{cat.key}</code>
</td>
<td className="px-4 py-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--muted)]">
{t(`balance.category.kind.${cat.kind}`)}
</span>
</td>
<td className="px-4 py-2">
{cat.is_seed ? (
<span className="text-xs text-[var(--muted-foreground)]">
{t("balance.category.origin.seeded")}
</span>
) : (
<span className="text-xs text-[var(--muted-foreground)]">
{t("balance.category.origin.user")}
</span>
)}
</td>
<td className="px-4 py-2 text-right">
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => {
const next = window.prompt(
t("balance.category.actions.renamePrompt"),
renderCategoryLabel(cat)
);
if (next && next.trim()) {
editCategory(cat.id, { i18n_key: next.trim() });
}
}}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title={t("common.edit")}
>
<Edit2 size={14} />
</button>
<button
type="button"
onClick={() => {
if (cat.is_seed) return;
if (
window.confirm(
t("balance.category.actions.deleteConfirm")
)
) {
removeCategory(cat.id);
}
}}
disabled={cat.is_seed}
title={
cat.is_seed
? t("balance.category.actions.deleteSeedHint")
: t("common.delete")
}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}

View file

@ -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");
});
});

View file

@ -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<BalanceCategory[]> {
const db = await getDb();
return db.select<BalanceCategory[]>(
`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<BalanceCategory | null> {
const db = await getDb();
const rows = await db.select<BalanceCategory[]>(
`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<number> {
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<void> {
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<void> {
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<Array<{ count: number }>>(
`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<BalanceAccountWithCategory[]> {
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<BalanceAccountWithCategory[]>(
`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<BalanceAccount | null> {
const db = await getDb();
const rows = await db.select<BalanceAccount[]>(
`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<number> {
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<void> {
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<void> {
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<void> {
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]
);
}

View file

@ -555,3 +555,49 @@ export interface TransactionPageResult {
incomeTotal: number; incomeTotal: number;
expenseTotal: 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;
}