feat(balance): schema migration v9 + service skeleton + AccountsPage (#138) #147
4 changed files with 696 additions and 0 deletions
153
src-tauri/src/database/balance_schema.sql
Normal file
153
src-tauri/src/database/balance_schema.sql
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
-- Balance sheet schema (Bilan) — Migration v9
|
||||
-- Created: 2026-04-25
|
||||
-- Issue: #138 (Bilan #1a — Schema migration + balance.service skeleton + AccountsPage)
|
||||
--
|
||||
-- Adds 5 tables, 7 indexes, and seeds 7 standard categories (5 simple + 2 priced).
|
||||
-- Conventions aligned with consolidated_schema.sql:
|
||||
-- - INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
-- - REAL for monetary amounts (matches transactions.amount)
|
||||
-- - snake_case
|
||||
-- - FK with explicit ON DELETE policies
|
||||
-- - is_* INTEGER NOT NULL DEFAULT for booleans
|
||||
-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps
|
||||
--
|
||||
-- MVP constraints (decisions-log + spec-decisions-bilan.md):
|
||||
-- - balance_accounts.currency hardcoded to 'CAD' via CHECK — v2 will lift this
|
||||
-- - balance_account_transfers.transaction_id ON DELETE RESTRICT (preserves
|
||||
-- reproducibility of Modified Dietz returns calculated on past periods)
|
||||
-- - balance_snapshot_lines kind invariants: (quantity, unit_price) both NULL
|
||||
-- (simple kind) OR both NOT NULL (priced kind)
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- balance_categories — taxonomy of asset types
|
||||
-- =========================================================================
|
||||
-- Seeded with 7 standard categories (is_seed = 1). Users can add custom
|
||||
-- categories with their own kind ('simple' or 'priced'). Seeded categories
|
||||
-- can be renamed but never deleted.
|
||||
CREATE TABLE IF NOT EXISTS balance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE, -- 'cash', 'tfsa', 'rrsp', 'fund', 'stock', 'crypto', 'other'
|
||||
i18n_key TEXT NOT NULL, -- 'balance.category.cash', etc.
|
||||
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_seed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- balance_accounts — user's specific holdings
|
||||
-- =========================================================================
|
||||
-- A "TFSA at Wealthsimple", a "BTC in Ledger", etc.
|
||||
-- For priced categories, `symbol` identifies the security/coin.
|
||||
-- For simple categories, `symbol` is NULL.
|
||||
-- MVP: currency hardcoded to 'CAD' — v2 lifts the CHECK and adds a rate table.
|
||||
CREATE TABLE IF NOT EXISTS balance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
balance_category_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT,
|
||||
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
archived_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- balance_snapshots — point-in-time captures
|
||||
-- =========================================================================
|
||||
-- One snapshot per `snapshot_date` (UNIQUE). Editing a snapshot = updating
|
||||
-- its lines, not creating a duplicate.
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date DATE NOT NULL UNIQUE,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- balance_snapshot_lines — one row per (snapshot, account)
|
||||
-- =========================================================================
|
||||
-- Storage shape:
|
||||
-- - simple kind: value is set, quantity/unit_price are NULL
|
||||
-- - priced kind: quantity + unit_price are set, value = quantity * unit_price
|
||||
-- Stored denormalized (value always set, even for priced rows) so reports
|
||||
-- are reproducible without re-fetching prices and the user can override a
|
||||
-- fetched price.
|
||||
-- The CHECK enforces kind invariants at SQL level for direct-write safety.
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
quantity REAL,
|
||||
unit_price REAL,
|
||||
value REAL NOT NULL,
|
||||
price_source TEXT, -- 'manual' | 'maximus-api' | NULL for simple
|
||||
price_fetched_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
|
||||
UNIQUE(snapshot_id, account_id),
|
||||
CHECK (
|
||||
(quantity IS NULL AND unit_price IS NULL)
|
||||
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- balance_account_transfers — links transactions to accounts (capital flows)
|
||||
-- =========================================================================
|
||||
-- Used by the Modified Dietz return calculator (Issue #142 / Bilan #4) to
|
||||
-- separate contributions from gains. Direction follows the account's
|
||||
-- perspective: 'in' = capital added (deposit/buy), 'out' = capital removed
|
||||
-- (withdrawal/sell). The amount is taken from the linked transaction (no
|
||||
-- duplication).
|
||||
--
|
||||
-- transaction_id ON DELETE RESTRICT: preserves reproducibility of past
|
||||
-- Modified Dietz returns. The UI must force unlink before allowing the
|
||||
-- transaction to be deleted.
|
||||
CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
|
||||
UNIQUE(transaction_id, account_id)
|
||||
);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- Indexes (7 total)
|
||||
-- =========================================================================
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
||||
|
||||
|
||||
-- =========================================================================
|
||||
-- Seed (7 standard categories — idempotent via INSERT OR IGNORE on `key`)
|
||||
-- =========================================================================
|
||||
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
|
||||
('cash', 'balance.category.cash', 'simple', 10, 1),
|
||||
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
|
||||
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
|
||||
('fund', 'balance.category.fund', 'simple', 40, 1),
|
||||
('other', 'balance.category.other', 'simple', 50, 1),
|
||||
('stock', 'balance.category.stock', 'priced', 60, 1),
|
||||
('crypto', 'balance.category.crypto', 'priced', 70, 1);
|
||||
|
|
@ -181,6 +181,95 @@ CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, mon
|
|||
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
|
||||
-- ============================================================================
|
||||
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
|
||||
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
|
||||
-- New profiles created from this consolidated schema get the balance feature
|
||||
-- preinstalled without needing to replay v9 separately.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
i18n_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_seed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
balance_category_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT,
|
||||
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
archived_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date DATE NOT NULL UNIQUE,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
quantity REAL,
|
||||
unit_price REAL,
|
||||
value REAL NOT NULL,
|
||||
price_source TEXT,
|
||||
price_fetched_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
|
||||
UNIQUE(snapshot_id, account_id),
|
||||
CHECK (
|
||||
(quantity IS NULL AND unit_price IS NULL)
|
||||
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
|
||||
UNIQUE(transaction_id, account_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
||||
|
||||
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
|
||||
('cash', 'balance.category.cash', 'simple', 10, 1),
|
||||
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
|
||||
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
|
||||
('fund', 'balance.category.fund', 'simple', 40, 1),
|
||||
('other', 'balance.category.other', 'simple', 50, 1),
|
||||
('stock', 'balance.category.stock', 'priced', 60, 1),
|
||||
('crypto', 'balance.category.crypto', 'priced', 70, 1);
|
||||
|
||||
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub const SCHEMA: &str = include_str!("schema.sql");
|
||||
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
||||
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
||||
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");
|
||||
|
|
|
|||
|
|
@ -95,6 +95,19 @@ pub fn run() {
|
|||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
// Migration v9 — Bilan (balance sheet) schema:
|
||||
// 5 tables (balance_categories, balance_accounts, balance_snapshots,
|
||||
// balance_snapshot_lines, balance_account_transfers) + 7 indexes +
|
||||
// 7 seeded categories (5 simple + 2 priced).
|
||||
// CHECK(currency = 'CAD') is hardcoded for the MVP (will be lifted in v2
|
||||
// with a multi-currency rate table). FK transaction_id ON DELETE
|
||||
// RESTRICT preserves reproducibility of Modified Dietz returns.
|
||||
Migration {
|
||||
version: 9,
|
||||
description: "create balance schema",
|
||||
sql: database::BALANCE_SCHEMA,
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
];
|
||||
|
||||
tauri::Builder::default()
|
||||
|
|
@ -240,3 +253,443 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
|
|||
}
|
||||
None
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests for migration v9 — balance schema
|
||||
// -----------------------------------------------------------------------------
|
||||
// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9
|
||||
// migration) on a fresh in-memory SQLite database and assert that:
|
||||
// - the schema applies cleanly (all 5 tables + 7 indexes created)
|
||||
// - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1
|
||||
// - CHECK constraints reject invalid kind / direction / currency / kind invariants
|
||||
// - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) /
|
||||
// (transaction_id,account_id) / category key
|
||||
// - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT
|
||||
// on transaction_id and on category with linked accounts)
|
||||
//
|
||||
// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep
|
||||
// required. The migration v9 SQL is the source of truth; v1-v8 are not
|
||||
// required here because v9 is additive and only references the existing
|
||||
// `transactions` table for the FK on balance_account_transfers — we mirror
|
||||
// that with a minimal `transactions` table for the integration scenarios.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Apply the v9 schema on a fresh in-memory DB. Includes a minimal
|
||||
/// `transactions` table because balance_account_transfers references it.
|
||||
fn fresh_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||
// FKs must be enabled per-connection in SQLite.
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.expect("enable FKs");
|
||||
// Minimal transactions table mirroring the relevant columns the FK
|
||||
// references (id is the only column we need at the SQL level).
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create stub transactions table");
|
||||
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
||||
.expect("apply BALANCE_SCHEMA");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_applies_cleanly() {
|
||||
let conn = fresh_db();
|
||||
// 5 expected tables
|
||||
let tables: Vec<String> = conn
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'balance_%' ORDER BY name",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
tables,
|
||||
vec![
|
||||
"balance_account_transfers",
|
||||
"balance_accounts",
|
||||
"balance_categories",
|
||||
"balance_snapshot_lines",
|
||||
"balance_snapshots",
|
||||
]
|
||||
);
|
||||
// 7 expected indexes
|
||||
let index_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_balance_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(index_count, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_seeds_7_categories() {
|
||||
let conn = fresh_db();
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 7);
|
||||
|
||||
let simple_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'simple' AND is_seed = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(simple_count, 5);
|
||||
|
||||
let priced_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'priced' AND is_seed = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(priced_count, 2);
|
||||
|
||||
// Seeded keys are stable
|
||||
let stock_kind: String = conn
|
||||
.query_row(
|
||||
"SELECT kind FROM balance_categories WHERE key = 'stock'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stock_kind, "priced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_rejects_non_cad_currency() {
|
||||
let conn = fresh_db();
|
||||
// 'cash' category exists from seed; try to insert a non-CAD account.
|
||||
let result = conn.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name, currency)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'USD account', 'USD')",
|
||||
[],
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"CHECK(currency='CAD') should reject 'USD'"
|
||||
);
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.to_lowercase().contains("check"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_accepts_default_cad_currency() {
|
||||
let conn = fresh_db();
|
||||
let inserted = conn
|
||||
.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||
[],
|
||||
)
|
||||
.expect("CAD default insert should succeed");
|
||||
assert_eq!(inserted, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_rejects_invalid_kind() {
|
||||
let conn = fresh_db();
|
||||
let result = conn.execute(
|
||||
"INSERT INTO balance_categories (key, i18n_key, kind) VALUES ('bogus', 'x.bogus', 'unknown')",
|
||||
[],
|
||||
);
|
||||
assert!(result.is_err(), "kind CHECK should reject 'unknown'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_unique_snapshot_date() {
|
||||
let conn = fresh_db();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let result = conn.execute(
|
||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||
[],
|
||||
);
|
||||
assert!(result.is_err(), "UNIQUE(snapshot_date) should reject dup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_kind_invariants_check() {
|
||||
let conn = fresh_db();
|
||||
// Setup: a snapshot + an account
|
||||
conn.execute(
|
||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let snap_id: i64 = conn
|
||||
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let acct_id: i64 = conn
|
||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
|
||||
// OK: simple kind (quantity + unit_price both NULL)
|
||||
conn.execute(
|
||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||
VALUES (?1, ?2, 1234.56)",
|
||||
rusqlite::params![snap_id, acct_id],
|
||||
)
|
||||
.expect("simple kind row (qty/price both NULL) should be accepted");
|
||||
|
||||
// OK: priced kind (both set) — needs second account on a priced category
|
||||
conn.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name, symbol)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='stock'), 'AAPL', 'AAPL')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let acct2_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM balance_accounts WHERE name='AAPL'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value)
|
||||
VALUES (?1, ?2, 10.0, 200.0, 2000.0)",
|
||||
rusqlite::params![snap_id, acct2_id],
|
||||
)
|
||||
.expect("priced kind row (both set) should be accepted");
|
||||
|
||||
// KO: only quantity set, unit_price NULL → CHECK violation
|
||||
let bad = conn.execute(
|
||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, value)
|
||||
VALUES (?1, ?2, 10.0, 0.0)",
|
||||
rusqlite::params![snap_id, acct_id],
|
||||
);
|
||||
assert!(
|
||||
bad.is_err(),
|
||||
"kind invariants CHECK should reject (qty set, price NULL)"
|
||||
);
|
||||
|
||||
// KO: only unit_price set, quantity NULL → CHECK violation
|
||||
let bad2 = conn.execute(
|
||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, unit_price, value)
|
||||
VALUES (?1, ?2, 200.0, 0.0)",
|
||||
rusqlite::params![snap_id, acct_id],
|
||||
);
|
||||
assert!(
|
||||
bad2.is_err(),
|
||||
"kind invariants CHECK should reject (price set, qty NULL)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_unique_snapshot_account_pair() {
|
||||
let conn = fresh_db();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let snap_id: i64 = conn
|
||||
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let acct_id: i64 = conn
|
||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||
VALUES (?1, ?2, 100.0)",
|
||||
rusqlite::params![snap_id, acct_id],
|
||||
)
|
||||
.unwrap();
|
||||
let dup = conn.execute(
|
||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||
VALUES (?1, ?2, 200.0)",
|
||||
rusqlite::params![snap_id, acct_id],
|
||||
);
|
||||
assert!(dup.is_err(), "UNIQUE(snapshot_id, account_id) should reject dup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_fk_cascade_on_snapshot_delete() {
|
||||
let conn = fresh_db();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let snap_id: i64 = conn
|
||||
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let acct_id: i64 = conn
|
||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
||||
VALUES (?1, ?2, 100.0)",
|
||||
rusqlite::params![snap_id, acct_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM balance_snapshots WHERE id = ?1",
|
||||
rusqlite::params![snap_id],
|
||||
)
|
||||
.expect("delete snapshot should cascade");
|
||||
let remaining: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(remaining, 0, "snapshot delete should cascade lines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_fk_restrict_on_transaction_delete() {
|
||||
let conn = fresh_db();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let acct_id: i64 = conn
|
||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let tx_id: i64 = conn
|
||||
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
||||
VALUES (?1, ?2, 'in')",
|
||||
rusqlite::params![acct_id, tx_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Attempting to delete the linked transaction must be rejected (RESTRICT)
|
||||
let result = conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"FK RESTRICT should block deleting linked transaction"
|
||||
);
|
||||
|
||||
// Once the transfer is removed, deletion is allowed again
|
||||
conn.execute(
|
||||
"DELETE FROM balance_account_transfers WHERE transaction_id = ?1",
|
||||
rusqlite::params![tx_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id])
|
||||
.expect("after unlink, transaction can be deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_fk_restrict_on_category_with_accounts() {
|
||||
let conn = fresh_db();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Try to delete the seeded 'cash' category while an account references it
|
||||
let result = conn.execute(
|
||||
"DELETE FROM balance_categories WHERE key = 'cash'",
|
||||
[],
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"FK RESTRICT should block deleting category with linked accounts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_unique_transaction_account_transfer() {
|
||||
let conn = fresh_db();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let acct_id: i64 = conn
|
||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let tx_id: i64 = conn
|
||||
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
||||
VALUES (?1, ?2, 'in')",
|
||||
rusqlite::params![acct_id, tx_id],
|
||||
)
|
||||
.unwrap();
|
||||
let dup = conn.execute(
|
||||
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
||||
VALUES (?1, ?2, 'out')",
|
||||
rusqlite::params![acct_id, tx_id],
|
||||
);
|
||||
assert!(
|
||||
dup.is_err(),
|
||||
"UNIQUE(transaction_id, account_id) should reject dup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v9_seed_idempotent_on_replay() {
|
||||
// The migration uses `INSERT OR IGNORE` keyed by `key`, so applying
|
||||
// the schema twice on the same DB must not duplicate seeded rows.
|
||||
let conn = fresh_db();
|
||||
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
||||
.expect("apply BALANCE_SCHEMA twice");
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 7, "seed must remain idempotent on replay");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue