feat(balance): add migration v9 schema (5 tables, 7 indexes, seed)

Adds the SQL foundation for the Bilan (balance sheet) feature:

- 5 new tables: balance_categories, balance_accounts, balance_snapshots,
  balance_snapshot_lines, balance_account_transfers
- 7 indexes (category, active partial, snapshot, accounts x2, transaction,
  snapshot_date)
- Seed of 7 standard categories (5 simple + 2 priced) marked is_seed=1
- CHECK(currency = 'CAD') on balance_accounts (MVP — v2 lifts the constraint
  with a multi-currency rate table)
- CHECK kind invariants on balance_snapshot_lines (quantity/unit_price both
  NULL OR both NOT NULL)
- FK transaction_id ON DELETE RESTRICT to preserve reproducibility of
  Modified Dietz returns calculated on past periods

Migration v9 is added inline to the lib.rs Vec<Migration> via a new
constant database::BALANCE_SCHEMA backed by balance_schema.sql. The
schema is mirrored in consolidated_schema.sql so brand-new profiles
get the feature preinstalled without replaying v9.

13 new co-located rusqlite tests validate the migration on a fresh
in-memory DB: schema applies cleanly, 7 categories seeded with correct
kinds, CHECK rejects invalid currency/kind/direction, UNIQUE rejects
duplicate snapshot_date / (snapshot_id,account_id) / (transaction_id,
account_id), FK CASCADE on snapshot delete, FK RESTRICT on transaction
delete and on category with linked accounts, seed idempotent on replay.

Refs #138

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-04-25 14:31:50 -04:00
parent 1f506fb171
commit a6787adef0
4 changed files with 696 additions and 0 deletions

View file

@ -0,0 +1,153 @@
-- Balance sheet schema (Bilan) — Migration v9
-- Created: 2026-04-25
-- Issue: #138 (Bilan #1a — Schema migration + balance.service skeleton + AccountsPage)
--
-- Adds 5 tables, 7 indexes, and seeds 7 standard categories (5 simple + 2 priced).
-- Conventions aligned with consolidated_schema.sql:
-- - INTEGER PRIMARY KEY AUTOINCREMENT
-- - REAL for monetary amounts (matches transactions.amount)
-- - snake_case
-- - FK with explicit ON DELETE policies
-- - is_* INTEGER NOT NULL DEFAULT for booleans
-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps
--
-- MVP constraints (decisions-log + spec-decisions-bilan.md):
-- - balance_accounts.currency hardcoded to 'CAD' via CHECK — v2 will lift this
-- - balance_account_transfers.transaction_id ON DELETE RESTRICT (preserves
-- reproducibility of Modified Dietz returns calculated on past periods)
-- - balance_snapshot_lines kind invariants: (quantity, unit_price) both NULL
-- (simple kind) OR both NOT NULL (priced kind)
-- =========================================================================
-- balance_categories — taxonomy of asset types
-- =========================================================================
-- Seeded with 7 standard categories (is_seed = 1). Users can add custom
-- categories with their own kind ('simple' or 'priced'). Seeded categories
-- can be renamed but never deleted.
CREATE TABLE IF NOT EXISTS balance_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE, -- 'cash', 'tfsa', 'rrsp', 'fund', 'stock', 'crypto', 'other'
i18n_key TEXT NOT NULL, -- 'balance.category.cash', etc.
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0
);
-- =========================================================================
-- balance_accounts — user's specific holdings
-- =========================================================================
-- A "TFSA at Wealthsimple", a "BTC in Ledger", etc.
-- For priced categories, `symbol` identifies the security/coin.
-- For simple categories, `symbol` is NULL.
-- MVP: currency hardcoded to 'CAD' — v2 lifts the CHECK and adds a rate table.
CREATE TABLE IF NOT EXISTS balance_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
balance_category_id INTEGER NOT NULL,
name TEXT NOT NULL,
symbol TEXT,
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
);
-- =========================================================================
-- balance_snapshots — point-in-time captures
-- =========================================================================
-- One snapshot per `snapshot_date` (UNIQUE). Editing a snapshot = updating
-- its lines, not creating a duplicate.
CREATE TABLE IF NOT EXISTS balance_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date DATE NOT NULL UNIQUE,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- =========================================================================
-- balance_snapshot_lines — one row per (snapshot, account)
-- =========================================================================
-- Storage shape:
-- - simple kind: value is set, quantity/unit_price are NULL
-- - priced kind: quantity + unit_price are set, value = quantity * unit_price
-- Stored denormalized (value always set, even for priced rows) so reports
-- are reproducible without re-fetching prices and the user can override a
-- fetched price.
-- The CHECK enforces kind invariants at SQL level for direct-write safety.
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
quantity REAL,
unit_price REAL,
value REAL NOT NULL,
price_source TEXT, -- 'manual' | 'maximus-api' | NULL for simple
price_fetched_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
UNIQUE(snapshot_id, account_id),
CHECK (
(quantity IS NULL AND unit_price IS NULL)
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
)
);
-- =========================================================================
-- balance_account_transfers — links transactions to accounts (capital flows)
-- =========================================================================
-- Used by the Modified Dietz return calculator (Issue #142 / Bilan #4) to
-- separate contributions from gains. Direction follows the account's
-- perspective: 'in' = capital added (deposit/buy), 'out' = capital removed
-- (withdrawal/sell). The amount is taken from the linked transaction (no
-- duplication).
--
-- transaction_id ON DELETE RESTRICT: preserves reproducibility of past
-- Modified Dietz returns. The UI must force unlink before allowing the
-- transaction to be deleted.
CREATE TABLE IF NOT EXISTS balance_account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
UNIQUE(transaction_id, account_id)
);
-- =========================================================================
-- Indexes (7 total)
-- =========================================================================
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
-- =========================================================================
-- Seed (7 standard categories — idempotent via INSERT OR IGNORE on `key`)
-- =========================================================================
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
('fund', 'balance.category.fund', 'simple', 40, 1),
('other', 'balance.category.other', 'simple', 50, 1),
('stock', 'balance.category.stock', 'priced', 60, 1),
('crypto', 'balance.category.crypto', 'priced', 70, 1);

View file

@ -181,6 +181,95 @@ CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, mon
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
-- ============================================================================
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
-- ============================================================================
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
-- New profiles created from this consolidated schema get the balance feature
-- preinstalled without needing to replay v9 separately.
CREATE TABLE IF NOT EXISTS balance_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
i18n_key TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS balance_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
balance_category_id INTEGER NOT NULL,
name TEXT NOT NULL,
symbol TEXT,
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS balance_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date DATE NOT NULL UNIQUE,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
quantity REAL,
unit_price REAL,
value REAL NOT NULL,
price_source TEXT,
price_fetched_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
UNIQUE(snapshot_id, account_id),
CHECK (
(quantity IS NULL AND unit_price IS NULL)
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS balance_account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
UNIQUE(transaction_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
('fund', 'balance.category.fund', 'simple', 40, 1),
('other', 'balance.category.other', 'simple', 50, 1),
('stock', 'balance.category.stock', 'priced', 60, 1),
('crypto', 'balance.category.crypto', 'priced', 70, 1);
-- Default preferences (new profiles ship with the v1 IPC taxonomy) -- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');

View file

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

View file

@ -95,6 +95,19 @@ pub fn run() {
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');", INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
kind: MigrationKind::Up, kind: MigrationKind::Up,
}, },
// Migration v9 — Bilan (balance sheet) schema:
// 5 tables (balance_categories, balance_accounts, balance_snapshots,
// balance_snapshot_lines, balance_account_transfers) + 7 indexes +
// 7 seeded categories (5 simple + 2 priced).
// CHECK(currency = 'CAD') is hardcoded for the MVP (will be lifted in v2
// with a multi-currency rate table). FK transaction_id ON DELETE
// RESTRICT preserves reproducibility of Modified Dietz returns.
Migration {
version: 9,
description: "create balance schema",
sql: database::BALANCE_SCHEMA,
kind: MigrationKind::Up,
},
]; ];
tauri::Builder::default() tauri::Builder::default()
@ -240,3 +253,443 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
} }
None None
} }
// =============================================================================
// Tests for migration v9 — balance schema
// -----------------------------------------------------------------------------
// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9
// migration) on a fresh in-memory SQLite database and assert that:
// - the schema applies cleanly (all 5 tables + 7 indexes created)
// - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1
// - CHECK constraints reject invalid kind / direction / currency / kind invariants
// - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) /
// (transaction_id,account_id) / category key
// - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT
// on transaction_id and on category with linked accounts)
//
// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep
// required. The migration v9 SQL is the source of truth; v1-v8 are not
// required here because v9 is additive and only references the existing
// `transactions` table for the FK on balance_account_transfers — we mirror
// that with a minimal `transactions` table for the integration scenarios.
#[cfg(test)]
mod tests {
use rusqlite::Connection;
/// Apply the v9 schema on a fresh in-memory DB. Includes a minimal
/// `transactions` table because balance_account_transfers references it.
fn fresh_db() -> Connection {
let conn = Connection::open_in_memory().expect("open in-memory db");
// FKs must be enabled per-connection in SQLite.
conn.execute("PRAGMA foreign_keys = ON;", [])
.expect("enable FKs");
// Minimal transactions table mirroring the relevant columns the FK
// references (id is the only column we need at the SQL level).
conn.execute_batch(
"CREATE TABLE transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
description TEXT NOT NULL,
amount REAL NOT NULL
);",
)
.expect("create stub transactions table");
conn.execute_batch(crate::database::BALANCE_SCHEMA)
.expect("apply BALANCE_SCHEMA");
conn
}
#[test]
fn migration_v9_applies_cleanly() {
let conn = fresh_db();
// 5 expected tables
let tables: Vec<String> = conn
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'balance_%' ORDER BY name",
)
.unwrap()
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert_eq!(
tables,
vec![
"balance_account_transfers",
"balance_accounts",
"balance_categories",
"balance_snapshot_lines",
"balance_snapshots",
]
);
// 7 expected indexes
let index_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_balance_%'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(index_count, 7);
}
#[test]
fn migration_v9_seeds_7_categories() {
let conn = fresh_db();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 7);
let simple_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'simple' AND is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(simple_count, 5);
let priced_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'priced' AND is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(priced_count, 2);
// Seeded keys are stable
let stock_kind: String = conn
.query_row(
"SELECT kind FROM balance_categories WHERE key = 'stock'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stock_kind, "priced");
}
#[test]
fn migration_v9_rejects_non_cad_currency() {
let conn = fresh_db();
// 'cash' category exists from seed; try to insert a non-CAD account.
let result = conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, currency)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'USD account', 'USD')",
[],
);
assert!(
result.is_err(),
"CHECK(currency='CAD') should reject 'USD'"
);
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.to_lowercase().contains("check"));
}
#[test]
fn migration_v9_accepts_default_cad_currency() {
let conn = fresh_db();
let inserted = conn
.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.expect("CAD default insert should succeed");
assert_eq!(inserted, 1);
}
#[test]
fn migration_v9_rejects_invalid_kind() {
let conn = fresh_db();
let result = conn.execute(
"INSERT INTO balance_categories (key, i18n_key, kind) VALUES ('bogus', 'x.bogus', 'unknown')",
[],
);
assert!(result.is_err(), "kind CHECK should reject 'unknown'");
}
#[test]
fn migration_v9_unique_snapshot_date() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
)
.unwrap();
let result = conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
);
assert!(result.is_err(), "UNIQUE(snapshot_date) should reject dup");
}
#[test]
fn migration_v9_kind_invariants_check() {
let conn = fresh_db();
// Setup: a snapshot + an account
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
let snap_id: i64 = conn
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
// OK: simple kind (quantity + unit_price both NULL)
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 1234.56)",
rusqlite::params![snap_id, acct_id],
)
.expect("simple kind row (qty/price both NULL) should be accepted");
// OK: priced kind (both set) — needs second account on a priced category
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name, symbol)
VALUES ((SELECT id FROM balance_categories WHERE key='stock'), 'AAPL', 'AAPL')",
[],
)
.unwrap();
let acct2_id: i64 = conn
.query_row(
"SELECT id FROM balance_accounts WHERE name='AAPL'",
[],
|r| r.get(0),
)
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value)
VALUES (?1, ?2, 10.0, 200.0, 2000.0)",
rusqlite::params![snap_id, acct2_id],
)
.expect("priced kind row (both set) should be accepted");
// KO: only quantity set, unit_price NULL → CHECK violation
let bad = conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, value)
VALUES (?1, ?2, 10.0, 0.0)",
rusqlite::params![snap_id, acct_id],
);
assert!(
bad.is_err(),
"kind invariants CHECK should reject (qty set, price NULL)"
);
// KO: only unit_price set, quantity NULL → CHECK violation
let bad2 = conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, unit_price, value)
VALUES (?1, ?2, 200.0, 0.0)",
rusqlite::params![snap_id, acct_id],
);
assert!(
bad2.is_err(),
"kind invariants CHECK should reject (price set, qty NULL)"
);
}
#[test]
fn migration_v9_unique_snapshot_account_pair() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
let snap_id: i64 = conn
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 100.0)",
rusqlite::params![snap_id, acct_id],
)
.unwrap();
let dup = conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 200.0)",
rusqlite::params![snap_id, acct_id],
);
assert!(dup.is_err(), "UNIQUE(snapshot_id, account_id) should reject dup");
}
#[test]
fn migration_v9_fk_cascade_on_snapshot_delete() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
let snap_id: i64 = conn
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
VALUES (?1, ?2, 100.0)",
rusqlite::params![snap_id, acct_id],
)
.unwrap();
conn.execute(
"DELETE FROM balance_snapshots WHERE id = ?1",
rusqlite::params![snap_id],
)
.expect("delete snapshot should cascade");
let remaining: i64 = conn
.query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0))
.unwrap();
assert_eq!(remaining, 0, "snapshot delete should cascade lines");
}
#[test]
fn migration_v9_fk_restrict_on_transaction_delete() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
[],
)
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
let tx_id: i64 = conn
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
VALUES (?1, ?2, 'in')",
rusqlite::params![acct_id, tx_id],
)
.unwrap();
// Attempting to delete the linked transaction must be rejected (RESTRICT)
let result = conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]);
assert!(
result.is_err(),
"FK RESTRICT should block deleting linked transaction"
);
// Once the transfer is removed, deletion is allowed again
conn.execute(
"DELETE FROM balance_account_transfers WHERE transaction_id = ?1",
rusqlite::params![tx_id],
)
.unwrap();
conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id])
.expect("after unlink, transaction can be deleted");
}
#[test]
fn migration_v9_fk_restrict_on_category_with_accounts() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
// Try to delete the seeded 'cash' category while an account references it
let result = conn.execute(
"DELETE FROM balance_categories WHERE key = 'cash'",
[],
);
assert!(
result.is_err(),
"FK RESTRICT should block deleting category with linked accounts"
);
}
#[test]
fn migration_v9_unique_transaction_account_transfer() {
let conn = fresh_db();
conn.execute(
"INSERT INTO balance_accounts (balance_category_id, name)
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
[],
)
.unwrap();
let acct_id: i64 = conn
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
.unwrap();
let tx_id: i64 = conn
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
.unwrap();
conn.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
VALUES (?1, ?2, 'in')",
rusqlite::params![acct_id, tx_id],
)
.unwrap();
let dup = conn.execute(
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
VALUES (?1, ?2, 'out')",
rusqlite::params![acct_id, tx_id],
);
assert!(
dup.is_err(),
"UNIQUE(transaction_id, account_id) should reject dup"
);
}
#[test]
fn migration_v9_seed_idempotent_on_replay() {
// The migration uses `INSERT OR IGNORE` keyed by `key`, so applying
// the schema twice on the same DB must not duplicate seeded rows.
let conn = fresh_db();
conn.execute_batch(crate::database::BALANCE_SCHEMA)
.expect("apply BALANCE_SCHEMA twice");
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 7, "seed must remain idempotent on replay");
}
}