feat(balance): Modified Dietz returns + transfer linking (#142) #151

Merged
maximus merged 8 commits from issue-142-bilan-4 into main 2026-04-26 13:25:32 +00:00
3 changed files with 182 additions and 0 deletions
Showing only changes of commit 0381dd48bb - Show all commits

View 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)
}

View file

@ -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::*;

View file

@ -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");