From 0381dd48bbaac0cf167e2c723e2802df57ba443a Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:23:14 -0400 Subject: [PATCH] feat(balance): add compute_account_return Tauri command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #142 / Bilan #4 — server-side Modified Dietz wrapper. - New `src-tauri/src/commands/balance_commands.rs` with single command `compute_account_return(db_filename, account_id, period_start, period_end)`: - Opens the active profile DB via `rusqlite::Connection::open(app_data_dir / db_filename)` — matches `repair_migrations` / `delete_profile_db`. - Reads `value_start` (latest snapshot ≤ period_start) + `value_end` (latest snapshot ≤ period_end) via correlated SELECT. - Reads cash flows via JOIN `balance_account_transfers` ⨝ `transactions` filtered by `transaction_date BETWEEN`. Sign applied per direction (`in` → +, `out` → −). - Calls `return_calculator::modified_dietz`, returns typed `AccountReturn`. - Registered in `commands/mod.rs` (pub use) and in `lib.rs`' `tauri::generate_handler!` array. `cargo check` clean. `cargo test --lib` → 54 passed (including the 7 return_calculator + 7 migration_v9 tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/commands/balance_commands.rs | 179 +++++++++++++++++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 1 + 3 files changed, 182 insertions(+) create mode 100644 src-tauri/src/commands/balance_commands.rs diff --git a/src-tauri/src/commands/balance_commands.rs b/src-tauri/src/commands/balance_commands.rs new file mode 100644 index 0000000..47f488b --- /dev/null +++ b/src-tauri/src/commands/balance_commands.rs @@ -0,0 +1,179 @@ +//! Tauri commands for the Bilan (balance sheet) feature — Issue #142. +//! +//! At Issue #142 the only command exposed is `compute_account_return`. The +//! Modified Dietz formula needs to read snapshot endpoints + linked transfer +//! amounts in a single Rust pass (the math itself lives in +//! `return_calculator.rs`); doing it server-side avoids 3 round-trips from +//! the renderer and keeps the calculation reproducible. +//! +//! Future commands (`fetch_price`, etc.) ship in Issue #143 / Bilan #5. +//! +//! Database access pattern: +//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`, +//! matching the existing `repair_migrations` helper in `profile_commands.rs`. +//! - The frontend passes `db_filename` (the active profile DB), exactly +//! like it does for `repair_migrations` and `delete_profile_db`. Keeps +//! the active-profile resolution where it already lives (in TS) and +//! avoids re-reading `profiles.json` on every call. +//! - Reads are short-lived: connection opens, runs ≤ 3 SQL statements, +//! drops at end of function. No connection pooling needed (commands run +//! on the Tauri async runtime, one at a time per invocation). + +use chrono::NaiveDate; +use rusqlite::Connection; +use tauri::Manager; + +use crate::commands::return_calculator::{modified_dietz, AccountReturn}; + +/// Compute the Modified Dietz return for one account over the period +/// `[period_start, period_end]`. Reads: +/// - `value_start`: latest snapshot line for the account whose +/// `snapshot_date <= period_start` (None if no prior snapshot). +/// - `value_end`: latest snapshot line for the account whose +/// `snapshot_date <= period_end` (None if no snapshot in range). +/// - cash flows: every linked transfer in `[period_start, period_end]`, +/// sign applied per direction (`in` → `+`, `out` → `−`). +/// +/// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn` +/// (Serialize) ready to ship across the Tauri boundary. +#[tauri::command] +pub fn compute_account_return( + app: tauri::AppHandle, + db_filename: String, + account_id: i64, + period_start: String, + period_end: String, +) -> Result { + let start_date = parse_iso_date(&period_start, "period_start")?; + let end_date = parse_iso_date(&period_end, "period_end")?; + + let app_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Cannot get app data dir: {}", e))?; + let db_path = app_dir.join(&db_filename); + if !db_path.exists() { + return Err(format!( + "Profile database not found: {}", + db_path.display() + )); + } + + let conn = Connection::open(&db_path) + .map_err(|e| format!("Cannot open database: {}", e))?; + + let value_start = read_value_at_or_before(&conn, account_id, &period_start)?; + let value_end = read_value_at_or_before(&conn, account_id, &period_end)?; + let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?; + + Ok(modified_dietz( + value_start, + value_end, + &cash_flows, + start_date, + end_date, + )) +} + +// ----------------------------------------------------------------------------- +// Internal helpers +// ----------------------------------------------------------------------------- + +fn parse_iso_date(input: &str, field: &str) -> Result { + NaiveDate::parse_from_str(input, "%Y-%m-%d") + .map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e)) +} + +/// Reads the value of the snapshot line for `account_id` at the most recent +/// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no +/// such snapshot exists for this account. +fn read_value_at_or_before( + conn: &Connection, + account_id: i64, + as_of_date: &str, +) -> Result, String> { + // Single-row query: pick the latest snapshot date for this account that + // is on or before `as_of_date`, then return that line's value. Indexed + // on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`. + let mut stmt = conn + .prepare( + "SELECT l.value + FROM balance_snapshot_lines l + JOIN balance_snapshots s ON s.id = l.snapshot_id + WHERE l.account_id = ?1 + AND s.snapshot_date <= ?2 + ORDER BY s.snapshot_date DESC + LIMIT 1", + ) + .map_err(|e| format!("prepare value query: {}", e))?; + + let mut rows = stmt + .query(rusqlite::params![account_id, as_of_date]) + .map_err(|e| format!("execute value query: {}", e))?; + + match rows.next().map_err(|e| format!("read value row: {}", e))? { + Some(row) => Ok(Some( + row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?, + )), + None => Ok(None), + } +} + +/// Reads every linked transfer for `account_id` whose underlying +/// transaction's `transaction_date` falls inside `[period_start, period_end]`. +/// Returns `(NaiveDate, signed_amount)` — sign applied per `direction` +/// (`in` → `+`, `out` → `−`). Amounts come from the linked transaction. +fn read_cash_flows( + conn: &Connection, + account_id: i64, + period_start: &str, + period_end: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT t.transaction_date, + ABS(t.amount) AS abs_amount, + bat.direction + FROM balance_account_transfers bat + JOIN transactions t ON t.id = bat.transaction_id + WHERE bat.account_id = ?1 + AND t.transaction_date BETWEEN ?2 AND ?3 + ORDER BY t.transaction_date", + ) + .map_err(|e| format!("prepare flows query: {}", e))?; + + let rows = stmt + .query_map( + rusqlite::params![account_id, period_start, period_end], + |row| { + let date_str: String = row.get(0)?; + let amount: f64 = row.get(1)?; + let direction: String = row.get(2)?; + Ok((date_str, amount, direction)) + }, + ) + .map_err(|e| format!("execute flows query: {}", e))?; + + let mut flows: Vec<(NaiveDate, f64)> = Vec::new(); + for row_result in rows { + let (date_str, amount, direction) = + row_result.map_err(|e| format!("decode flow row: {}", e))?; + // `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column — + // see consolidated_schema.sql). Defensive trim of any trailing + // time component just in case. + let iso = date_str.split('T').next().unwrap_or(&date_str).to_string(); + let date = parse_iso_date(&iso, "transaction_date")?; + let signed = match direction.as_str() { + "in" => amount, + "out" => -amount, + other => { + return Err(format!( + "Invalid transfer direction stored in DB: {}", + other + )); + } + }; + flows.push((date, signed)); + } + Ok(flows) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5d45944..33d4d7f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod account_cache; pub mod auth_commands; pub mod backup_commands; +pub mod balance_commands; pub mod entitlements; pub mod export_import_commands; pub mod feedback_commands; @@ -15,6 +16,7 @@ pub mod token_store; pub use auth_commands::*; pub use backup_commands::*; +pub use balance_commands::*; pub use entitlements::*; pub use export_import_commands::*; pub use feedback_commands::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4dc3a55..3b50b03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -216,6 +216,7 @@ pub fn run() { commands::ensure_backup_dir, commands::get_file_size, commands::file_exists, + commands::compute_account_return, ]) .run(tauri::generate_context!()) .expect("error while running tauri application");