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é]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
- **Rapport Zoom catégorie** (`/reports/category`) : la liste déroulante du combobox des catégories affiche désormais la liste complète dans un ordre hiérarchique DFS correct — chaque racine est émise avant ses descendants, et les frères et sœurs sont triés par `sort_order` puis nom affiché. Auparavant la liste était triée globalement par `sort_order` (via un `ORDER BY sort_order, name` SQL), ce qui entrelaçait des parents et enfants de sous-arbres différents partageant le même `sort_order`, d'où l'indentation incohérente et l'impression d'arbre cassé. La recherche filtrée (insensible aux accents) conserve le même comportement (#126)
|
- **Rapport Zoom catégorie** (`/reports/category`) : la liste déroulante du combobox des catégories affiche désormais la liste complète dans un ordre hiérarchique DFS correct — chaque racine est émise avant ses descendants, et les frères et sœurs sont triés par `sort_order` puis nom affiché. Auparavant la liste était triée globalement par `sort_order` (via un `ORDER BY sort_order, name` SQL), ce qui entrelaçait des parents et enfants de sous-arbres différents partageant le même `sort_order`, d'où l'indentation incohérente et l'impression d'arbre cassé. La recherche filtrée (insensible aux accents) conserve le même comportement (#126)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Category zoom report** (`/reports/category`): the category combobox dropdown now renders the full list in proper hierarchical DFS order — each root is emitted before its descendants, with siblings sorted by `sort_order` then display name. Previously the list was ordered by `sort_order` globally (from a SQL `ORDER BY sort_order, name`), which interleaved parents and children from different sub-trees that shared the same `sort_order`, producing scrambled indentation and a mis-leading tree. Filtering (accent-insensitive search) still behaves identically (#126)
|
- **Category zoom report** (`/reports/category`): the category combobox dropdown now renders the full list in proper hierarchical DFS order — each root is emitted before its descendants, with siblings sorted by `sort_order` then display name. Previously the list was ordered by `sort_order` globally (from a SQL `ORDER BY sort_order, name`), which interleaved parents and children from different sub-trees that shared the same `sort_order`, producing scrambled indentation and a mis-leading tree. Filtering (accent-insensitive search) still behaves identically (#126)
|
||||||
|
|
||||||
|
|
|
||||||
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_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
|
||||||
|
-- ============================================================================
|
||||||
|
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
|
||||||
|
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
|
||||||
|
-- New profiles created from this consolidated schema get the balance feature
|
||||||
|
-- preinstalled without needing to replay v9 separately.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
i18n_key TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_seed INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
balance_category_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
symbol TEXT,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
|
||||||
|
notes TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
archived_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_date DATE NOT NULL UNIQUE,
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_id INTEGER NOT NULL,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
quantity REAL,
|
||||||
|
unit_price REAL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
price_source TEXT,
|
||||||
|
price_fetched_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE(snapshot_id, account_id),
|
||||||
|
CHECK (
|
||||||
|
(quantity IS NULL AND unit_price IS NULL)
|
||||||
|
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
transaction_id INTEGER NOT NULL,
|
||||||
|
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE(transaction_id, account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
|
||||||
|
('cash', 'balance.category.cash', 'simple', 10, 1),
|
||||||
|
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
|
||||||
|
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
|
||||||
|
('fund', 'balance.category.fund', 'simple', 40, 1),
|
||||||
|
('other', 'balance.category.other', 'simple', 50, 1),
|
||||||
|
('stock', 'balance.category.stock', 'priced', 60, 1),
|
||||||
|
('crypto', 'balance.category.crypto', 'priced', 70, 1);
|
||||||
|
|
||||||
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
pub const SCHEMA: &str = include_str!("schema.sql");
|
pub const SCHEMA: &str = include_str!("schema.sql");
|
||||||
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
||||||
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
||||||
|
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,19 @@ pub fn run() {
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
},
|
},
|
||||||
|
// Migration v9 — Bilan (balance sheet) schema:
|
||||||
|
// 5 tables (balance_categories, balance_accounts, balance_snapshots,
|
||||||
|
// balance_snapshot_lines, balance_account_transfers) + 7 indexes +
|
||||||
|
// 7 seeded categories (5 simple + 2 priced).
|
||||||
|
// CHECK(currency = 'CAD') is hardcoded for the MVP (will be lifted in v2
|
||||||
|
// with a multi-currency rate table). FK transaction_id ON DELETE
|
||||||
|
// RESTRICT preserves reproducibility of Modified Dietz returns.
|
||||||
|
Migration {
|
||||||
|
version: 9,
|
||||||
|
description: "create balance schema",
|
||||||
|
sql: database::BALANCE_SCHEMA,
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -240,3 +253,443 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests for migration v9 — balance schema
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9
|
||||||
|
// migration) on a fresh in-memory SQLite database and assert that:
|
||||||
|
// - the schema applies cleanly (all 5 tables + 7 indexes created)
|
||||||
|
// - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1
|
||||||
|
// - CHECK constraints reject invalid kind / direction / currency / kind invariants
|
||||||
|
// - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) /
|
||||||
|
// (transaction_id,account_id) / category key
|
||||||
|
// - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT
|
||||||
|
// on transaction_id and on category with linked accounts)
|
||||||
|
//
|
||||||
|
// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep
|
||||||
|
// required. The migration v9 SQL is the source of truth; v1-v8 are not
|
||||||
|
// required here because v9 is additive and only references the existing
|
||||||
|
// `transactions` table for the FK on balance_account_transfers — we mirror
|
||||||
|
// that with a minimal `transactions` table for the integration scenarios.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
/// Apply the v9 schema on a fresh in-memory DB. Includes a minimal
|
||||||
|
/// `transactions` table because balance_account_transfers references it.
|
||||||
|
fn fresh_db() -> Connection {
|
||||||
|
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||||
|
// FKs must be enabled per-connection in SQLite.
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||||
|
.expect("enable FKs");
|
||||||
|
// Minimal transactions table mirroring the relevant columns the FK
|
||||||
|
// references (id is the only column we need at the SQL level).
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL
|
||||||
|
);",
|
||||||
|
)
|
||||||
|
.expect("create stub transactions table");
|
||||||
|
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
||||||
|
.expect("apply BALANCE_SCHEMA");
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_applies_cleanly() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
// 5 expected tables
|
||||||
|
let tables: Vec<String> = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'balance_%' ORDER BY name",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |row| row.get::<_, String>(0))
|
||||||
|
.unwrap()
|
||||||
|
.map(|r| r.unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
tables,
|
||||||
|
vec![
|
||||||
|
"balance_account_transfers",
|
||||||
|
"balance_accounts",
|
||||||
|
"balance_categories",
|
||||||
|
"balance_snapshot_lines",
|
||||||
|
"balance_snapshots",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// 7 expected indexes
|
||||||
|
let index_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_balance_%'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(index_count, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_seeds_7_categories() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 7);
|
||||||
|
|
||||||
|
let simple_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'simple' AND is_seed = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(simple_count, 5);
|
||||||
|
|
||||||
|
let priced_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'priced' AND is_seed = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(priced_count, 2);
|
||||||
|
|
||||||
|
// Seeded keys are stable
|
||||||
|
let stock_kind: String = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT kind FROM balance_categories WHERE key = 'stock'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stock_kind, "priced");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_rejects_non_cad_currency() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
// 'cash' category exists from seed; try to insert a non-CAD account.
|
||||||
|
let result = conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name, currency)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'USD account', 'USD')",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"CHECK(currency='CAD') should reject 'USD'"
|
||||||
|
);
|
||||||
|
let err_msg = result.unwrap_err().to_string();
|
||||||
|
assert!(err_msg.to_lowercase().contains("check"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_accepts_default_cad_currency() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
let inserted = conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("CAD default insert should succeed");
|
||||||
|
assert_eq!(inserted, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_rejects_invalid_kind() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
let result = conn.execute(
|
||||||
|
"INSERT INTO balance_categories (key, i18n_key, kind) VALUES ('bogus', 'x.bogus', 'unknown')",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert!(result.is_err(), "kind CHECK should reject 'unknown'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_unique_snapshot_date() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert!(result.is_err(), "UNIQUE(snapshot_date) should reject dup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_kind_invariants_check() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
// Setup: a snapshot + an account
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snap_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let acct_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// OK: simple kind (quantity + unit_price both NULL)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||||
|
VALUES (?1, ?2, 1234.56)",
|
||||||
|
rusqlite::params![snap_id, acct_id],
|
||||||
|
)
|
||||||
|
.expect("simple kind row (qty/price both NULL) should be accepted");
|
||||||
|
|
||||||
|
// OK: priced kind (both set) — needs second account on a priced category
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name, symbol)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='stock'), 'AAPL', 'AAPL')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let acct2_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM balance_accounts WHERE name='AAPL'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value)
|
||||||
|
VALUES (?1, ?2, 10.0, 200.0, 2000.0)",
|
||||||
|
rusqlite::params![snap_id, acct2_id],
|
||||||
|
)
|
||||||
|
.expect("priced kind row (both set) should be accepted");
|
||||||
|
|
||||||
|
// KO: only quantity set, unit_price NULL → CHECK violation
|
||||||
|
let bad = conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, value)
|
||||||
|
VALUES (?1, ?2, 10.0, 0.0)",
|
||||||
|
rusqlite::params![snap_id, acct_id],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
bad.is_err(),
|
||||||
|
"kind invariants CHECK should reject (qty set, price NULL)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// KO: only unit_price set, quantity NULL → CHECK violation
|
||||||
|
let bad2 = conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, unit_price, value)
|
||||||
|
VALUES (?1, ?2, 200.0, 0.0)",
|
||||||
|
rusqlite::params![snap_id, acct_id],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
bad2.is_err(),
|
||||||
|
"kind invariants CHECK should reject (price set, qty NULL)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_unique_snapshot_account_pair() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snap_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let acct_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||||
|
VALUES (?1, ?2, 100.0)",
|
||||||
|
rusqlite::params![snap_id, acct_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let dup = conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||||
|
VALUES (?1, ?2, 200.0)",
|
||||||
|
rusqlite::params![snap_id, acct_id],
|
||||||
|
);
|
||||||
|
assert!(dup.is_err(), "UNIQUE(snapshot_id, account_id) should reject dup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_fk_cascade_on_snapshot_delete() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snap_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let acct_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||||
|
VALUES (?1, ?2, 100.0)",
|
||||||
|
rusqlite::params![snap_id, acct_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM balance_snapshots WHERE id = ?1",
|
||||||
|
rusqlite::params![snap_id],
|
||||||
|
)
|
||||||
|
.expect("delete snapshot should cascade");
|
||||||
|
let remaining: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(remaining, 0, "snapshot delete should cascade lines");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_fk_restrict_on_transaction_delete() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let acct_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let tx_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
||||||
|
VALUES (?1, ?2, 'in')",
|
||||||
|
rusqlite::params![acct_id, tx_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Attempting to delete the linked transaction must be rejected (RESTRICT)
|
||||||
|
let result = conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"FK RESTRICT should block deleting linked transaction"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Once the transfer is removed, deletion is allowed again
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM balance_account_transfers WHERE transaction_id = ?1",
|
||||||
|
rusqlite::params![tx_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id])
|
||||||
|
.expect("after unlink, transaction can be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_fk_restrict_on_category_with_accounts() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Try to delete the seeded 'cash' category while an account references it
|
||||||
|
let result = conn.execute(
|
||||||
|
"DELETE FROM balance_categories WHERE key = 'cash'",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"FK RESTRICT should block deleting category with linked accounts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_unique_transaction_account_transfer() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||||
|
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let acct_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
let tx_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
||||||
|
VALUES (?1, ?2, 'in')",
|
||||||
|
rusqlite::params![acct_id, tx_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let dup = conn.execute(
|
||||||
|
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
||||||
|
VALUES (?1, ?2, 'out')",
|
||||||
|
rusqlite::params![acct_id, tx_id],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
dup.is_err(),
|
||||||
|
"UNIQUE(transaction_id, account_id) should reject dup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v9_seed_idempotent_on_replay() {
|
||||||
|
// The migration uses `INSERT OR IGNORE` keyed by `key`, so applying
|
||||||
|
// the schema twice on the same DB must not duplicate seeded rows.
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
||||||
|
.expect("apply BALANCE_SCHEMA twice");
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 7, "seed must remain idempotent on replay");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage";
|
||||||
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
import AccountsPage from "./pages/AccountsPage";
|
||||||
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
||||||
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
|
|
@ -114,6 +115,7 @@ export default function App() {
|
||||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/balance/accounts" element={<AccountsPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/settings/categories/standard"
|
path="/settings/categories/standard"
|
||||||
element={<CategoriesStandardGuidePage />}
|
element={<CategoriesStandardGuidePage />}
|
||||||
|
|
|
||||||
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;
|
incomeTotal: number;
|
||||||
expenseTotal: number;
|
expenseTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Balance (Bilan) types ---
|
||||||
|
// Backed by migration v9 (see src-tauri/src/database/balance_schema.sql).
|
||||||
|
// MVP scope (Issue #138 / #1a): categories + accounts CRUD only. Snapshots,
|
||||||
|
// snapshot lines and transfers ship in subsequent issues (#1b / #2 / #4).
|
||||||
|
|
||||||
|
export type BalanceCategoryKind = "simple" | "priced";
|
||||||
|
|
||||||
|
export const BALANCE_CURRENCY_CAD = "CAD";
|
||||||
|
|
||||||
|
export interface BalanceCategory {
|
||||||
|
id: number;
|
||||||
|
/** Stable lookup key (e.g. 'cash', 'tfsa', 'stock'). UNIQUE NOT NULL. */
|
||||||
|
key: string;
|
||||||
|
/** Translation key into i18n locales (e.g. 'balance.category.cash'). */
|
||||||
|
i18n_key: string;
|
||||||
|
/** simple = direct value entry; priced = quantity x unit_price. */
|
||||||
|
kind: BalanceCategoryKind;
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
/** True when seeded by Migration v9 — cannot be deleted, can be renamed. */
|
||||||
|
is_seed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceAccount {
|
||||||
|
id: number;
|
||||||
|
balance_category_id: number;
|
||||||
|
name: string;
|
||||||
|
/** Symbol (e.g. 'AAPL', 'BTC-USD'); NULL for simple-kind accounts. */
|
||||||
|
symbol: string | null;
|
||||||
|
/** ISO 4217. MVP: hardcoded 'CAD' (CHECK enforced server-side). */
|
||||||
|
currency: string;
|
||||||
|
notes: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
/** Soft-delete timestamp; archived accounts hide from new snapshots. */
|
||||||
|
archived_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Joined view used by AccountsPage tables. */
|
||||||
|
export interface BalanceAccountWithCategory extends BalanceAccount {
|
||||||
|
category_key: string;
|
||||||
|
category_i18n_key: string;
|
||||||
|
category_kind: BalanceCategoryKind;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue