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:
commit
b6387f4b31
15 changed files with 2594 additions and 0 deletions
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
## [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é
|
||||
- **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)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
## [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
|
||||
- **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)
|
||||
|
||||
|
|
|
|||
153
src-tauri/src/database/balance_schema.sql
Normal file
153
src-tauri/src/database/balance_schema.sql
Normal 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);
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
|||
}
|
||||
None
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests for migration v9 — balance schema
|
||||
// -----------------------------------------------------------------------------
|
||||
// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9
|
||||
// migration) on a fresh in-memory SQLite database and assert that:
|
||||
// - the schema applies cleanly (all 5 tables + 7 indexes created)
|
||||
// - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1
|
||||
// - CHECK constraints reject invalid kind / direction / currency / kind invariants
|
||||
// - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) /
|
||||
// (transaction_id,account_id) / category key
|
||||
// - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT
|
||||
// on transaction_id and on category with linked accounts)
|
||||
//
|
||||
// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep
|
||||
// required. The migration v9 SQL is the source of truth; v1-v8 are not
|
||||
// required here because v9 is additive and only references the existing
|
||||
// `transactions` table for the FK on balance_account_transfers — we mirror
|
||||
// that with a minimal `transactions` table for the integration scenarios.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Apply the v9 schema on a fresh in-memory DB. Includes a minimal
|
||||
/// `transactions` table because balance_account_transfers references it.
|
||||
fn fresh_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||
// FKs must be enabled per-connection in SQLite.
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.expect("enable FKs");
|
||||
// Minimal transactions table mirroring the relevant columns the FK
|
||||
// references (id is the only column we need at the SQL level).
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create stub transactions table");
|
||||
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
||||
.expect("apply BALANCE_SCHEMA");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_applies_cleanly() {
|
||||
let conn = fresh_db();
|
||||
// 5 expected tables
|
||||
let tables: Vec<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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/balance/accounts" element={<AccountsPage />} />
|
||||
<Route
|
||||
path="/settings/categories/standard"
|
||||
element={<CategoriesStandardGuidePage />}
|
||||
|
|
|
|||
229
src/components/balance/AccountForm.tsx
Normal file
229
src/components/balance/AccountForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
src/hooks/useBalanceAccounts.ts
Normal file
276
src/hooks/useBalanceAccounts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
473
src/pages/AccountsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
src/services/balance.service.test.ts
Normal file
316
src/services/balance.service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
360
src/services/balance.service.ts
Normal file
360
src/services/balance.service.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue