feat(balance): add compute_account_return Tauri command
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) <noreply@anthropic.com>
This commit is contained in:
parent
c9cdb5a891
commit
0381dd48bb
3 changed files with 182 additions and 0 deletions
179
src-tauri/src/commands/balance_commands.rs
Normal file
179
src-tauri/src/commands/balance_commands.rs
Normal file
|
|
@ -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<AccountReturn, String> {
|
||||||
|
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, String> {
|
||||||
|
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<Option<f64>, 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<Vec<(NaiveDate, f64)>, 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)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
pub mod account_cache;
|
pub mod account_cache;
|
||||||
pub mod auth_commands;
|
pub mod auth_commands;
|
||||||
pub mod backup_commands;
|
pub mod backup_commands;
|
||||||
|
pub mod balance_commands;
|
||||||
pub mod entitlements;
|
pub mod entitlements;
|
||||||
pub mod export_import_commands;
|
pub mod export_import_commands;
|
||||||
pub mod feedback_commands;
|
pub mod feedback_commands;
|
||||||
|
|
@ -15,6 +16,7 @@ pub mod token_store;
|
||||||
|
|
||||||
pub use auth_commands::*;
|
pub use auth_commands::*;
|
||||||
pub use backup_commands::*;
|
pub use backup_commands::*;
|
||||||
|
pub use balance_commands::*;
|
||||||
pub use entitlements::*;
|
pub use entitlements::*;
|
||||||
pub use export_import_commands::*;
|
pub use export_import_commands::*;
|
||||||
pub use feedback_commands::*;
|
pub use feedback_commands::*;
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,7 @@ pub fn run() {
|
||||||
commands::ensure_backup_dir,
|
commands::ensure_backup_dir,
|
||||||
commands::get_file_size,
|
commands::get_file_size,
|
||||||
commands::file_exists,
|
commands::file_exists,
|
||||||
|
commands::compute_account_return,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue