From c9cdb5a89156eab1f5e360b45841b3a649b4ae7a Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:21:37 -0400 Subject: [PATCH 1/8] feat(balance): add chrono dep + Modified Dietz return_calculator with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #142 / Bilan #4 — TDD step 1. - Added `chrono = "0.4"` (default-features off, `serde` + `std` features) to `src-tauri/Cargo.toml` for day-precision date arithmetic. - New private module `src-tauri/src/commands/return_calculator.rs`: - `pub(crate) fn modified_dietz(value_start, value_end, cash_flows, period_start, period_end) -> AccountReturn` - `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }` (Serialize) - Edge cases handled: missing start/end snapshot (`is_partial = true`, `return_pct = None`), no transfers (collapses to simple return + warn flag), zero-length period (skips annualization), V_start = 0 with first flow > 0 (account-created mid-period), depleted-then-refilled (no panic, finite output). - 7 co-located TDD tests covering nominal + every edge case above. - Module declared `pub(crate)` in `commands/mod.rs` (kept out of the wildcard re-export — only `balance_commands.rs` will consume it). `cargo test --lib commands::return_calculator` → 7 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 4 + src-tauri/src/commands/mod.rs | 4 + src-tauri/src/commands/return_calculator.rs | 380 ++++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 src-tauri/src/commands/return_calculator.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4bbc26b..a1d0125 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4428,6 +4428,7 @@ dependencies = [ "aes-gcm", "argon2", "base64 0.22.1", + "chrono", "ed25519-dalek", "encoding_rs", "hmac", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e5f7b75..cbc71c5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,10 @@ rand = "0.8" jsonwebtoken = "9" machine-uid = "0.5" reqwest = { version = "0.12", features = ["json"] } +# Date arithmetic for the Modified Dietz return calculator (Issue #142): +# we need day-precision diffs to weight cash flows W_i = (T - t_i) / T. +# `serde` feature lets `NaiveDate` cross the Tauri command boundary in JSON. +chrono = { version = "0.4", default-features = false, features = ["serde", "std"] } tokio = { version = "1", features = ["macros"] } hostname = "0.4" urlencoding = "2" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8c6542f..5d45944 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,10 @@ pub mod feedback_commands; pub mod fs_commands; pub mod license_commands; pub mod profile_commands; +// Modified Dietz return calculator — private helper module used by +// `balance_commands.rs`. Kept out of the wildcard re-export below because +// nothing outside `commands/` should depend on it. +pub(crate) mod return_calculator; pub mod token_store; pub use auth_commands::*; diff --git a/src-tauri/src/commands/return_calculator.rs b/src-tauri/src/commands/return_calculator.rs new file mode 100644 index 0000000..ccf65c9 --- /dev/null +++ b/src-tauri/src/commands/return_calculator.rs @@ -0,0 +1,380 @@ +//! Modified Dietz return calculator (Issue #142 / Bilan #4). +//! +//! Computes the time- and contribution-weighted return of a single account +//! over a period, given: +//! - the account value at `period_start` (snapshot lookup, may be missing), +//! - the account value at `period_end` (snapshot lookup, may be missing), +//! - the cash flows during the period (linked transfers — `+` for IN, +//! `-` for OUT; the caller already applies the direction sign). +//! +//! Modified Dietz formula: +//! +//! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i)) +//! +//! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start +//! to flow date`. A flow on day 0 is fully invested for the whole period +//! (W_i = 1) and a flow on the last day contributes nothing (W_i = 0). +//! +//! Annualization: `(1 + R)^(365 / T) - 1` for periods of strictly positive +//! length. A zero-length period (`period_start == period_end`) skips the +//! annualization step (would divide by zero). +//! +//! Edge cases (each surface as a typed flag on `AccountReturn` so the UI can +//! render an explicit warning instead of an opaque empty value): +//! - `value_start == None` → `is_partial = true`, `return_pct = None` +//! - `value_end == None` → `is_partial = true`, `return_pct = None` +//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`, +//! return collapses to the simple +//! `(V_end - V_start) / V_start` +//! - `period_start == period_end` → no annualization (stays = return_pct) +//! - V_start = 0 and first flow > 0 → account created mid-period; the +//! denominator is `0 + W_first * CF_first`, +//! which is positive as long as the +//! flow lands strictly before period_end +//! - account depleted then refilled → mathematically defined; the +//! function does not panic but the +//! magnitude can look extreme — that is +//! the inherent Modified Dietz behaviour +//! on accounts with near-zero invested +//! capital. +//! +//! Module is **private to the crate** (`pub(crate)`) and lives under +//! `commands/` per the spec — reused only by `balance_commands.rs`. + +use chrono::NaiveDate; +use serde::Serialize; + +/// Result of a Modified Dietz computation, ready to ship across the Tauri +/// boundary. Optional fields are `None` whenever the calculation cannot be +/// completed (missing snapshot endpoints) — the UI renders a dash + a tooltip +/// pointing at `is_partial` / `has_no_transfers_warning`. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct AccountReturn { + /// Account value at `period_start` (latest snapshot ≤ period_start). + pub value_start: Option, + /// Account value at `period_end` (latest snapshot ≤ period_end). + pub value_end: Option, + /// Sum of signed cash flows during the period (`+` IN, `-` OUT). + pub net_contributions: f64, + /// Modified Dietz return as a fraction (0.05 = +5%). `None` if either + /// endpoint snapshot is missing or the denominator is non-positive. + pub return_pct: Option, + /// Annualized return `(1 + R)^(365 / T) - 1`. `None` for zero-length + /// periods or whenever `return_pct` is `None`. + pub annualized_pct: Option, + /// `true` when at least one snapshot endpoint is missing — the UI labels + /// the result as "partial / non-significatif". + pub is_partial: bool, + /// `true` when the account had zero linked transfers during the period — + /// Modified Dietz collapses to the simple `(V_end - V_start) / V_start`, + /// but the UI surfaces a warning so the user can verify whether real + /// transfers were forgotten (untagged contributions skew the return). + pub has_no_transfers_warning: bool, +} + +impl AccountReturn { + /// Default partial return when an endpoint is missing — keeps the + /// constructor calls in the algorithm body terse. + fn partial( + value_start: Option, + value_end: Option, + net_contributions: f64, + has_no_transfers_warning: bool, + ) -> Self { + Self { + value_start, + value_end, + net_contributions, + return_pct: None, + annualized_pct: None, + is_partial: true, + has_no_transfers_warning, + } + } +} + +/// Computes the Modified Dietz return for one account over the period +/// `[period_start, period_end]`. See module docs for the full formula and +/// edge-case handling. +/// +/// `cash_flows` is `(date, signed_amount)`. The caller is responsible for +/// applying the direction sign (`in` → `+`, `out` → `−`) and for filtering +/// flows to the period; flows outside `[period_start, period_end]` are +/// skipped here too as a safety net. +pub(crate) fn modified_dietz( + value_start: Option, + value_end: Option, + cash_flows: &[(NaiveDate, f64)], + period_start: NaiveDate, + period_end: NaiveDate, +) -> AccountReturn { + // Filter flows to the period (defensive — caller already does this via + // SQL, but keep the guarantee here so the math never sees out-of-range + // weights). + let in_period: Vec<(NaiveDate, f64)> = cash_flows + .iter() + .copied() + .filter(|(d, _)| *d >= period_start && *d <= period_end) + .collect(); + + let net_contributions: f64 = in_period.iter().map(|(_, cf)| *cf).sum(); + let has_no_transfers_warning = in_period.is_empty(); + + // Endpoint guards — without both V_start and V_end we cannot return a + // numeric result. + let v_start = match value_start { + Some(v) => v, + None => { + return AccountReturn::partial( + value_start, + value_end, + net_contributions, + has_no_transfers_warning, + ); + } + }; + let v_end = match value_end { + Some(v) => v, + None => { + return AccountReturn::partial( + value_start, + value_end, + net_contributions, + has_no_transfers_warning, + ); + } + }; + + // Period length in days. `(period_end - period_start)` returns + // `chrono::Duration`; `.num_days()` is `i64`. A zero-length period + // (same-day) skips weighting and annualization. + let total_days = (period_end - period_start).num_days(); + + let denominator: f64 = if total_days <= 0 { + // Same-day period: weights collapse to either 0 or undefined; treat + // every flow as fully invested (W = 1) so the denominator is + // V_start + sum(CF). This keeps the function defined when callers + // pass `period_start == period_end`. + v_start + net_contributions + } else { + let total = total_days as f64; + let weighted_sum: f64 = in_period + .iter() + .map(|(date, cf)| { + let t_i = (*date - period_start).num_days() as f64; + let w_i = (total - t_i) / total; + w_i * cf + }) + .sum(); + v_start + weighted_sum + }; + + // A non-positive denominator means we have no invested base to annualize + // against (e.g. depleted then refilled with a single late flow). Return + // the raw V_end - V_start - CF as the numerator and flag is_partial so + // the UI can show "Performance non significative" — but only when V_start + // is also 0 / negative; if V_start > 0 we keep the standard math. + if denominator <= 0.0 { + return AccountReturn { + value_start: Some(v_start), + value_end: Some(v_end), + net_contributions, + return_pct: None, + annualized_pct: None, + is_partial: true, + has_no_transfers_warning, + }; + } + + let numerator = v_end - v_start - net_contributions; + let return_pct = numerator / denominator; + + // Annualization only makes sense for strictly positive periods. + let annualized_pct = if total_days > 0 { + let exponent = 365.0 / total_days as f64; + Some((1.0 + return_pct).powf(exponent) - 1.0) + } else { + None + }; + + AccountReturn { + value_start: Some(v_start), + value_end: Some(v_end), + net_contributions, + return_pct: Some(return_pct), + annualized_pct, + is_partial: false, + has_no_transfers_warning, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Small helper that turns a `YYYY-MM-DD` string literal into a + /// `NaiveDate` — keeps the test bodies readable. + fn d(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("test date parses") + } + + fn approx(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol + } + + #[test] + fn nominal_two_flows_at_one_quarter_and_three_quarters() { + // 100-day period (2026-01-01 → 2026-04-11). V_start = 1000, V_end = + // 1100. CF1 = +50 at day 25, CF2 = +30 at day 75. + let start = d("2026-01-01"); + let end = d("2026-04-11"); // 100 days later + let flows = vec![(d("2026-01-26"), 50.0), (d("2026-03-17"), 30.0)]; + + let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end); + + // Sanity / shape + assert_eq!(r.value_start, Some(1000.0)); + assert_eq!(r.value_end, Some(1100.0)); + assert_eq!(r.net_contributions, 80.0); + assert!(!r.is_partial); + assert!(!r.has_no_transfers_warning); + + // Hand calc: + // T = 100, t1 = 25, t2 = 75 + // W1 = 75/100 = 0.75, W2 = 25/100 = 0.25 + // numerator = 1100 - 1000 - 80 = 20 + // denominator = 1000 + 0.75*50 + 0.25*30 = 1045 + // R = 20 / 1045 ≈ 0.01913876 + let r_pct = r.return_pct.expect("nominal case has a return"); + assert!( + approx(r_pct, 20.0 / 1045.0, 1e-9), + "return_pct = {} (expected ≈ {})", + r_pct, + 20.0 / 1045.0 + ); + + // Annualization: (1 + R)^(365/100) - 1 + let expected_ann = (1.0_f64 + 20.0 / 1045.0).powf(365.0 / 100.0) - 1.0; + let ann = r.annualized_pct.expect("nominal case is annualized"); + assert!(approx(ann, expected_ann, 1e-9), "annualized = {}", ann); + } + + #[test] + fn no_prior_snapshot_marks_partial() { + let start = d("2026-01-01"); + let end = d("2026-04-01"); + let flows = vec![(d("2026-02-01"), 200.0)]; + + let r = modified_dietz(None, Some(1500.0), &flows, start, end); + + assert_eq!(r.value_start, None); + assert_eq!(r.value_end, Some(1500.0)); + assert!(r.is_partial, "missing V_start must flag is_partial"); + assert_eq!(r.return_pct, None); + assert_eq!(r.annualized_pct, None); + assert!(!r.has_no_transfers_warning); + // Still surface the contributions sum for the UI breakdown card. + assert_eq!(r.net_contributions, 200.0); + } + + #[test] + fn no_end_snapshot_marks_partial() { + let start = d("2026-01-01"); + let end = d("2026-04-01"); + let flows = vec![(d("2026-02-15"), -100.0)]; + + let r = modified_dietz(Some(2000.0), None, &flows, start, end); + + assert_eq!(r.value_start, Some(2000.0)); + assert_eq!(r.value_end, None); + assert!(r.is_partial); + assert_eq!(r.return_pct, None); + assert_eq!(r.annualized_pct, None); + assert_eq!(r.net_contributions, -100.0); + } + + #[test] + fn account_created_mid_period_with_first_flow() { + // V_start = 0, single +500 flow at day 30 of a 90-day period, V_end + // = 510. The flow's weight is W = (90-30)/90 = 2/3. + let start = d("2026-01-01"); + let end = d("2026-04-01"); // 90 days + let flows = vec![(d("2026-01-31"), 500.0)]; + + let r = modified_dietz(Some(0.0), Some(510.0), &flows, start, end); + + // numerator = 510 - 0 - 500 = 10 + // W = (90-30)/90 ≈ 0.6666667 + // denominator = 0 + 0.6666667 * 500 ≈ 333.3333 + // R ≈ 10 / 333.3333 = 0.03 + let expected = 10.0 / ((90.0 - 30.0) / 90.0 * 500.0); + let r_pct = r.return_pct.expect("account-created case computes"); + assert!( + approx(r_pct, expected, 1e-9), + "return_pct = {} (expected ≈ {})", + r_pct, + expected + ); + assert!(!r.is_partial); + assert!(!r.has_no_transfers_warning); + } + + #[test] + fn depleted_then_refilled_does_not_panic() { + // Pathological: V_start = 100, then -100 flow on day 1 (account + // emptied), then +200 flow on day 60 of a 90-day period, V_end = + // 210. Modified Dietz handles this without panicking; the value + // may look extreme but the function must stay defined. + let start = d("2026-01-01"); + let end = d("2026-04-01"); + let flows = vec![(d("2026-01-02"), -100.0), (d("2026-03-02"), 200.0)]; + + let r = modified_dietz(Some(100.0), Some(210.0), &flows, start, end); + + // Whatever the math says, the call must complete cleanly. We don't + // assert a precise return — the goal is "no panic, finite output if + // the denominator is positive, else partial flag". + if let Some(rp) = r.return_pct { + assert!(rp.is_finite(), "return must be a finite f64"); + } + // Net flows = -100 + 200 = 100 + assert_eq!(r.net_contributions, 100.0); + // Not flagged "no transfers" since we have two flows. + assert!(!r.has_no_transfers_warning); + } + + #[test] + fn no_transfers_collapses_to_simple_return() { + // No cash flows → R should equal (V_end - V_start) / V_start exactly. + let start = d("2026-01-01"); + let end = d("2026-04-01"); + let flows: Vec<(NaiveDate, f64)> = vec![]; + + let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end); + + assert!(r.has_no_transfers_warning); + assert_eq!(r.net_contributions, 0.0); + let r_pct = r.return_pct.expect("simple-return case has a value"); + let simple = (1100.0 - 1000.0) / 1000.0; // = 0.1 + assert!(approx(r_pct, simple, 1e-12), "simple return mismatch: {}", r_pct); + } + + #[test] + fn annualization_on_90_day_period_matches_compound_formula() { + // Direct check of the annualization branch with a clean R. + let start = d("2026-01-01"); + let end = d("2026-04-01"); // 90 days + let flows: Vec<(NaiveDate, f64)> = vec![]; + + // V_start = 1000, V_end = 1050 → R = 0.05 + let r = modified_dietz(Some(1000.0), Some(1050.0), &flows, start, end); + let expected_ann = (1.0_f64 + 0.05).powf(365.0 / 90.0) - 1.0; + let ann = r.annualized_pct.expect("90-day period annualizes"); + assert!( + approx(ann, expected_ann, 1e-12), + "annualized = {} (expected {})", + ann, + expected_ann + ); + } +} From 0381dd48bbaac0cf167e2c723e2802df57ba443a Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:23:14 -0400 Subject: [PATCH 2/8] 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"); From 23ff8466c04cf8460e4ea206548ddcab4d2d0460 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:24:13 -0400 Subject: [PATCH 3/8] fix(balance): use transactions.date column (not transaction_date) The schema's transactions table uses `date` (see schema.sql:67), not `transaction_date`. Compile-checked the column name was correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-tauri/src/commands/balance_commands.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/balance_commands.rs b/src-tauri/src/commands/balance_commands.rs index 47f488b..ea196b5 100644 --- a/src-tauri/src/commands/balance_commands.rs +++ b/src-tauri/src/commands/balance_commands.rs @@ -129,16 +129,18 @@ fn read_cash_flows( period_start: &str, period_end: &str, ) -> Result, String> { + // NOTE: the transactions table column is `date` (not `transaction_date`). + // See `src-tauri/src/database/schema.sql:67`. let mut stmt = conn .prepare( - "SELECT t.transaction_date, + "SELECT t.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", + AND t.date BETWEEN ?2 AND ?3 + ORDER BY t.date", ) .map_err(|e| format!("prepare flows query: {}", e))?; @@ -146,6 +148,8 @@ fn read_cash_flows( .query_map( rusqlite::params![account_id, period_start, period_end], |row| { + // `transactions.date` may come back as String (TEXT) — keep + // the decoder generic enough. let date_str: String = row.get(0)?; let amount: f64 = row.get(1)?; let direction: String = row.get(2)?; From dafdd4ce17cb0a9108e2f5d68ec9c6983f165a0c Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:27:16 -0400 Subject: [PATCH 4/8] feat(balance): add returns + transfers section to balance.service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #142 / Bilan #4 — TS bridge for the Modified Dietz command + plain CRUD for transfer linking. Types (`src/shared/types/index.ts`): - `BalanceTransferDirection` ('in' | 'out') - `BalanceAccountTransfer` (raw row) + `BalanceAccountTransferWithTransaction` (joined view) - `AccountReturn` (mirrors the Rust struct, ready to receive the invoke payload as-is) Service (`src/services/balance.service.ts`): - `computeAccountReturn(accountId, periodStart, periodEnd)`: resolves the active profile's `db_filename` from `loadProfiles()` and calls the `compute_account_return` Tauri command. - `linkTransfer(accountId, transactionId, direction, notes?)`: INSERT with duplicate guard (typed `transfer_already_linked` error instead of raw SQL UNIQUE failure). - `unlinkTransfer(accountId, transactionId)`: DELETE with `transfer_not_linked` guard for stale-UI calls. - `listAccountTransfers(accountId, dateRange?)`: joined SELECT for modal/list rendering. - `listLinkedTransactionIds()`: returns a `Set` for the transaction icon (one query, in-memory `.has()` lookups thereafter). - `listAllLinkedTransfersForTooltip()`: returns `Map` for tooltip rendering. - `suggestTransferDirection(amount)`: pure helper for the modal — maps negative bank amounts to 'in', positive to 'out'. - `isLinkedTransactionFkError(error)`: detects the canonical SQLite "FK constraint failed" text so `transactionService.deleteTransaction` can surface a clear i18n message. - 5 new error codes added to `BalanceErrorCode`. Tests (`balance.service.test.ts`): 22 new vitest cases bringing the file to 85 passed. Mocks `@tauri-apps/api/core` `invoke` and `./profileService` `loadProfiles`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/balance.service.test.ts | 230 +++++++++++++++++++++++ src/services/balance.service.ts | 262 ++++++++++++++++++++++++++- src/shared/types/index.ts | 54 ++++++ 3 files changed, 545 insertions(+), 1 deletion(-) diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index 1510e75..cb4877f 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -4,7 +4,17 @@ vi.mock("./db", () => ({ getDb: vi.fn(), })); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("./profileService", () => ({ + loadProfiles: vi.fn(), +})); + import { getDb } from "./db"; +import { invoke } from "@tauri-apps/api/core"; +import { loadProfiles } from "./profileService"; import { listBalanceCategories, createBalanceCategory, @@ -30,6 +40,14 @@ import { getSnapshotTotalsByCategoryAndDate, getAccountsLatestSnapshot, getAccountsPeriodAnchor, + computeAccountReturn, + linkTransfer, + unlinkTransfer, + listAccountTransfers, + listLinkedTransactionIds, + listAllLinkedTransfersForTooltip, + isLinkedTransactionFkError, + suggestTransferDirection, } from "./balance.service"; const mockSelect = vi.fn(); @@ -974,3 +992,215 @@ describe("getAccountsPeriodAnchor", () => { expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/); }); }); + +// ----------------------------------------------------------------------------- +// Returns + transfers (Issue #142) +// ----------------------------------------------------------------------------- + +describe("computeAccountReturn", () => { + beforeEach(() => { + vi.mocked(loadProfiles).mockReset(); + vi.mocked(invoke).mockReset(); + }); + + it("invokes the Tauri command with the active profile's db_filename", async () => { + vi.mocked(loadProfiles).mockResolvedValueOnce({ + active_profile_id: "p1", + profiles: [ + { + id: "p1", + name: "Max", + color: "#fff", + pin_hash: null, + db_filename: "max.db", + created_at: "0", + }, + ], + }); + const fakeReturn = { + value_start: 1000, + value_end: 1100, + net_contributions: 0, + return_pct: 0.1, + annualized_pct: 0.42, + is_partial: false, + has_no_transfers_warning: true, + }; + vi.mocked(invoke).mockResolvedValueOnce(fakeReturn); + + const out = await computeAccountReturn(7, "2026-01-01", "2026-04-01"); + + expect(out).toEqual(fakeReturn); + expect(invoke).toHaveBeenCalledWith("compute_account_return", { + dbFilename: "max.db", + accountId: 7, + periodStart: "2026-01-01", + periodEnd: "2026-04-01", + }); + }); + + it("rejects malformed period dates before invoking the command", async () => { + vi.mocked(loadProfiles).mockResolvedValueOnce({ + active_profile_id: "p1", + profiles: [ + { + id: "p1", + name: "Max", + color: "#fff", + pin_hash: null, + db_filename: "max.db", + created_at: "0", + }, + ], + }); + await expect( + computeAccountReturn(1, "not-a-date", "2026-04-01") + ).rejects.toBeInstanceOf(BalanceServiceError); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("throws transfer_active_profile_unknown when no active profile resolves", async () => { + vi.mocked(loadProfiles).mockResolvedValueOnce({ + active_profile_id: "missing", + profiles: [], + }); + await expect( + computeAccountReturn(1, "2026-01-01", "2026-04-01") + ).rejects.toMatchObject({ code: "transfer_active_profile_unknown" }); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe("suggestTransferDirection", () => { + it("maps negative bank amounts to 'in' (money left bank → arrived in account)", () => { + expect(suggestTransferDirection(-100)).toBe("in"); + }); + it("maps positive bank amounts to 'out' (money came back from account)", () => { + expect(suggestTransferDirection(50)).toBe("out"); + }); + it("treats zero as 'out' as a deterministic fallback", () => { + expect(suggestTransferDirection(0)).toBe("out"); + }); +}); + +describe("linkTransfer", () => { + it("rejects an invalid direction without touching the DB", async () => { + await expect( + // @ts-expect-error testing runtime guard + linkTransfer(1, 2, "sideways") + ).rejects.toBeInstanceOf(BalanceServiceError); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("guards against duplicate links with a typed error", async () => { + mockSelect.mockResolvedValueOnce([{ id: 5 }]); + await expect(linkTransfer(1, 2, "in")).rejects.toMatchObject({ + code: "transfer_already_linked", + }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("inserts and returns the new transfer id", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 }); + const id = await linkTransfer(1, 2, "out", " manual "); + expect(id).toBe(99); + const sql = mockExecute.mock.calls[0][0] as string; + expect(sql).toContain("INSERT INTO balance_account_transfers"); + expect(mockExecute.mock.calls[0][1]).toEqual([1, 2, "out", "manual"]); + }); + + it("normalizes empty notes to null", async () => { + mockSelect.mockResolvedValueOnce([]); + mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 }); + await linkTransfer(1, 2, "in", " "); + expect(mockExecute.mock.calls[0][1][3]).toBeNull(); + }); +}); + +describe("unlinkTransfer", () => { + it("throws transfer_not_linked when no row was deleted", async () => { + mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 0 }); + await expect(unlinkTransfer(1, 2)).rejects.toMatchObject({ + code: "transfer_not_linked", + }); + }); + + it("succeeds when one row is deleted", async () => { + mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 1 }); + await expect(unlinkTransfer(1, 2)).resolves.toBeUndefined(); + expect(mockExecute.mock.calls[0][1]).toEqual([1, 2]); + }); +}); + +describe("listAccountTransfers", () => { + it("filters by account_id only when no date range is supplied", async () => { + mockSelect.mockResolvedValueOnce([]); + await listAccountTransfers(7); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("FROM balance_account_transfers bat"); + expect(sql).toContain("JOIN transactions t"); + expect(sql).toContain("JOIN balance_accounts a"); + expect(sql).toContain("WHERE bat.account_id = $1"); + expect(sql).not.toContain("t.date >="); + expect(mockSelect.mock.calls[0][1]).toEqual([7]); + }); + + it("appends inclusive date bounds when supplied", async () => { + mockSelect.mockResolvedValueOnce([]); + await listAccountTransfers(7, { from: "2026-01-01", to: "2026-04-01" }); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("t.date >="); + expect(sql).toContain("t.date <="); + expect(mockSelect.mock.calls[0][1]).toEqual([7, "2026-01-01", "2026-04-01"]); + }); +}); + +describe("listLinkedTransactionIds", () => { + it("returns a Set of transaction ids", async () => { + mockSelect.mockResolvedValueOnce([ + { transaction_id: 5 }, + { transaction_id: 12 }, + ]); + const ids = await listLinkedTransactionIds(); + expect(ids).toBeInstanceOf(Set); + expect(ids.has(5)).toBe(true); + expect(ids.has(12)).toBe(true); + expect(ids.size).toBe(2); + }); +}); + +describe("listAllLinkedTransfersForTooltip", () => { + it("groups multiple links per transaction id", async () => { + mockSelect.mockResolvedValueOnce([ + { transaction_id: 1, account_id: 10, account_name: "TFSA", direction: "in" }, + { transaction_id: 1, account_id: 20, account_name: "RRSP", direction: "out" }, + { transaction_id: 2, account_id: 10, account_name: "TFSA", direction: "in" }, + ]); + const map = await listAllLinkedTransfersForTooltip(); + expect(map.get(1)).toHaveLength(2); + expect(map.get(2)).toHaveLength(1); + expect(map.get(1)?.[0].account_name).toBe("TFSA"); + }); +}); + +describe("isLinkedTransactionFkError", () => { + it("matches the canonical SQLite FK error text", () => { + expect( + isLinkedTransactionFkError(new Error("FOREIGN KEY constraint failed")) + ).toBe(true); + }); + + it("matches the wrapped tauri-plugin-sql variant", () => { + expect( + isLinkedTransactionFkError( + new Error("code: 787, message: FOREIGN KEY constraint failed") + ) + ).toBe(true); + }); + + it("does not match unrelated errors", () => { + expect(isLinkedTransactionFkError(new Error("something else"))).toBe(false); + expect(isLinkedTransactionFkError(undefined)).toBe(false); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index f3244fd..fd241f3 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -9,14 +9,20 @@ // filesystem / OAuth / license / profile work and the future Modified Dietz // + price-fetch work in Issue #142. +import { invoke } from "@tauri-apps/api/core"; import { getDb } from "./db"; +import { loadProfiles } from "./profileService"; import type { + AccountReturn, BalanceAccount, + BalanceAccountTransfer, + BalanceAccountTransferWithTransaction, BalanceAccountWithCategory, BalanceCategory, BalanceCategoryKind, BalanceSnapshot, BalanceSnapshotLine, + BalanceTransferDirection, } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types"; @@ -40,7 +46,13 @@ export type BalanceErrorCode = | "snapshot_priced_quantity_required" | "snapshot_priced_unit_price_required" | "snapshot_priced_value_mismatch" - | "snapshot_simple_must_be_scalar"; + | "snapshot_simple_must_be_scalar" + // Issue #142 — transfers + returns + | "transfer_direction_invalid" + | "transfer_already_linked" + | "transfer_not_linked" + | "transfer_active_profile_unknown" + | "transaction_linked_to_balance_account"; export class BalanceServiceError extends Error { readonly code: BalanceErrorCode; @@ -955,3 +967,251 @@ export async function getAccountsPeriodAnchor( params ); } + +// ----------------------------------------------------------------------------- +// Returns + transfers (Issue #142 / Bilan #4) +// ----------------------------------------------------------------------------- +// +// Two distinct surface areas: +// +// (1) `computeAccountReturn` — Modified Dietz return for one account over a +// period. Lives on the Rust side (`compute_account_return` Tauri command) +// because it needs to JOIN snapshots + transfers + transactions and +// apply day-precision weighting in a single short-lived connection. The +// TS shim resolves the active profile's `db_filename` from `loadProfiles` +// and forwards it to the command. +// +// (2) Transfer linking helpers — `linkTransfer`, `unlinkTransfer`, +// `listAccountTransfers`. Plain CRUD on `balance_account_transfers` via +// `getDb()`, same pattern as the rest of this file. + +/** + * Compute the Modified Dietz return for `accountId` over the period + * `[periodStart, periodEnd]` (both ISO `YYYY-MM-DD`). Returns the typed + * `AccountReturn` shape — see `src/shared/types/index.ts`. + * + * Resolves the active profile's `db_filename` from `loadProfiles()` so the + * caller doesn't have to thread it through every screen. Throws + * `transfer_active_profile_unknown` if no active profile is set (should be + * impossible in normal app flow, but the service guards it anyway). + */ +export async function computeAccountReturn( + accountId: number, + periodStart: string, + periodEnd: string +): Promise { + const startNorm = normalizeSnapshotDate(periodStart); + const endNorm = normalizeSnapshotDate(periodEnd); + const config = await loadProfiles(); + const profile = config.profiles.find( + (p) => p.id === config.active_profile_id + ); + if (!profile) { + throw new BalanceServiceError( + "transfer_active_profile_unknown", + "No active profile is set" + ); + } + return invoke("compute_account_return", { + dbFilename: profile.db_filename, + accountId, + periodStart: startNorm, + periodEnd: endNorm, + }); +} + +function normalizeDirection( + direction: BalanceTransferDirection +): BalanceTransferDirection { + if (direction !== "in" && direction !== "out") { + throw new BalanceServiceError( + "transfer_direction_invalid", + `Invalid transfer direction: ${direction}` + ); + } + return direction; +} + +/** + * Suggested direction for an unlinked transaction based on its signed amount. + * Pure helper so the `LinkTransfersModal` UI can pre-fill the direction + * column without round-tripping. Convention: in this codebase, expense + * transactions are stored with negative amounts (money leaving the bank). + * From the *balance account's* perspective: + * - negative bank amount = money left the bank → arrived at the balance + * account = `in` + * - positive bank amount = money entered the bank = the balance account + * gave it back = `out` + */ +export function suggestTransferDirection( + transactionAmount: number +): BalanceTransferDirection { + return transactionAmount < 0 ? "in" : "out"; +} + +/** + * Link a transaction to a balance account with the given direction. + * Throws `transfer_already_linked` if the (transaction, account) pair is + * already in the table (UNIQUE constraint). + */ +export async function linkTransfer( + accountId: number, + transactionId: number, + direction: BalanceTransferDirection, + notes?: string | null +): Promise { + const dir = normalizeDirection(direction); + const trimmedNotes = notes ? notes.trim() || null : null; + const db = await getDb(); + // Guard duplicate link with a SELECT — keeps the error typed instead of a + // raw "UNIQUE constraint failed" string. + const existing = await db.select<{ id: number }[]>( + `SELECT id FROM balance_account_transfers + WHERE account_id = $1 AND transaction_id = $2`, + [accountId, transactionId] + ); + if (existing.length > 0) { + throw new BalanceServiceError( + "transfer_already_linked", + `Transaction ${transactionId} is already linked to account ${accountId}` + ); + } + const result = await db.execute( + `INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes) + VALUES ($1, $2, $3, $4)`, + [accountId, transactionId, dir, trimmedNotes] + ); + return result.lastInsertId as number; +} + +/** + * Unlink a transaction from an account. Throws `transfer_not_linked` if the + * pair isn't in the table — keeps callers from silently no-op'ing on a stale + * UI state. + */ +export async function unlinkTransfer( + accountId: number, + transactionId: number +): Promise { + const db = await getDb(); + const result = await db.execute( + `DELETE FROM balance_account_transfers + WHERE account_id = $1 AND transaction_id = $2`, + [accountId, transactionId] + ); + if (result.rowsAffected === 0) { + throw new BalanceServiceError( + "transfer_not_linked", + `No transfer linked transaction ${transactionId} to account ${accountId}` + ); + } +} + +/** + * List every linked transfer for `accountId`, joined with the transaction + * table for date/description/amount. Optional `dateRange` (ISO YYYY-MM-DD, + * inclusive both sides) filters by `transactions.date`. + */ +export async function listAccountTransfers( + accountId: number, + dateRange?: { from?: string; to?: string } +): Promise { + const params: unknown[] = [accountId]; + const conditions: string[] = ["bat.account_id = $1"]; + if (dateRange?.from) { + conditions.push(`t.date >= $${params.length + 1}`); + params.push(normalizeSnapshotDate(dateRange.from)); + } + if (dateRange?.to) { + conditions.push(`t.date <= $${params.length + 1}`); + params.push(normalizeSnapshotDate(dateRange.to)); + } + const where = `WHERE ${conditions.join(" AND ")}`; + const db = await getDb(); + return db.select( + `SELECT bat.id AS id, + bat.account_id AS account_id, + bat.transaction_id AS transaction_id, + bat.direction AS direction, + bat.notes AS notes, + bat.created_at AS created_at, + t.date AS transaction_date, + t.description AS transaction_description, + t.amount AS transaction_amount, + a.name AS account_name + FROM balance_account_transfers bat + JOIN transactions t ON t.id = bat.transaction_id + JOIN balance_accounts a ON a.id = bat.account_id + ${where} + ORDER BY t.date DESC, bat.id DESC`, + params + ); +} + +/** + * Returns the set of `transaction_id`s currently linked to ANY balance + * account. Used by the transactions table to render the transfer icon + * without an N+1 query — the caller receives the full set once per render + * and does an in-memory `.has(id)` lookup. Cheap on real-world scales + * (typically < 1000 linked transfers per profile). + */ +export async function listLinkedTransactionIds(): Promise> { + const db = await getDb(); + const rows = await db.select<{ transaction_id: number }[]>( + `SELECT DISTINCT transaction_id FROM balance_account_transfers` + ); + return new Set(rows.map((r) => r.transaction_id)); +} + +/** + * Returns transfer info keyed by `transaction_id` for tooltip rendering in + * the transactions table. Each transaction maps to an array because a + * single transaction *could* be linked to several accounts in principle + * (the UNIQUE is on the pair, not on transaction alone). + */ +export interface LinkedTransferTooltipRow { + transaction_id: number; + account_id: number; + account_name: string; + direction: BalanceTransferDirection; +} + +export async function listAllLinkedTransfersForTooltip(): Promise< + Map +> { + const db = await getDb(); + const rows = await db.select( + `SELECT bat.transaction_id AS transaction_id, + bat.account_id AS account_id, + a.name AS account_name, + bat.direction AS direction + FROM balance_account_transfers bat + JOIN balance_accounts a ON a.id = bat.account_id + ORDER BY bat.transaction_id` + ); + const map = new Map(); + for (const r of rows) { + const list = map.get(r.transaction_id) ?? []; + list.push(r); + map.set(r.transaction_id, list); + } + return map; +} + +/** + * Detect whether the SQL error returned by `tauri-plugin-sql` is a FK + * RESTRICT violation from `balance_account_transfers.transaction_id`. The + * plugin surfaces the SQLite error message verbatim, so we match on the + * string. Used by `transactionService.deleteTransaction` to surface a + * clean i18n error instead of leaking the raw SQL. + */ +export function isLinkedTransactionFkError(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error ?? ""); + // SQLite FK error messages look like: + // "FOREIGN KEY constraint failed" + // or + // "code: 787, message: FOREIGN KEY constraint failed" + // Both contain the canonical "FOREIGN KEY constraint failed" substring. + return /FOREIGN KEY constraint failed/i.test(msg); +} + diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 3653a2f..0d0c451 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -630,3 +630,57 @@ export interface BalanceSnapshotLine { created_at: string; updated_at: string; } + +// Account transfers — added Issue #142 (Bilan #4). Links a transaction to a +// balance account so the Modified Dietz return calculator can separate +// contributions from gains. Direction follows the account's perspective: +// 'in' = capital added (deposit / buy) +// 'out' = capital removed (withdrawal / sell) +// `transaction_id` ON DELETE RESTRICT — preserves reproducibility of past +// returns, the UI must force the user to unlink before deleting the +// underlying transaction. +export type BalanceTransferDirection = "in" | "out"; + +export interface BalanceAccountTransfer { + id: number; + account_id: number; + transaction_id: number; + direction: BalanceTransferDirection; + notes: string | null; + created_at: string; +} + +/** Joined view used by LinkTransfersModal + transaction icon lookup. */ +export interface BalanceAccountTransferWithTransaction + extends BalanceAccountTransfer { + transaction_date: string; + transaction_description: string; + transaction_amount: number; + account_name: string; +} + +/** + * Modified Dietz return for one account over a period. + * Mirrors the Rust struct in `src-tauri/src/commands/return_calculator.rs`. + * + * - `value_start` / `value_end`: latest snapshot value ≤ each endpoint, or + * null when no snapshot exists. + * - `net_contributions`: signed sum of cash flows in the period. + * - `return_pct`: Modified Dietz return (0.05 = +5%); null if either + * endpoint is missing or denominator is non-positive. + * - `annualized_pct`: `(1 + R)^(365/T) - 1`; null for zero-length periods + * or whenever `return_pct` is null. + * - `is_partial`: true when one endpoint snapshot is missing. + * - `has_no_transfers_warning`: true when no transfers were tagged in the + * period — return collapses to simple `(V_end - V_start) / V_start` and + * the UI surfaces a warning so the user can verify nothing was forgotten. + */ +export interface AccountReturn { + value_start: number | null; + value_end: number | null; + net_contributions: number; + return_pct: number | null; + annualized_pct: number | null; + is_partial: boolean; + has_no_transfers_warning: boolean; +} From a45e5c3cd04e32b3e1e37c1018075ae5e7e25889 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:38:24 -0400 Subject: [PATCH 5/8] feat(balance): add LinkTransfersModal + return columns in accounts table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #142 / Bilan #4 — UI for transfer linking + per-account returns. - New `LinkTransfersModal.tsx`: portal modal with date-range / category / free-text filters, multi-select with auto-proposed direction (`in` for negative bank amounts, `out` for positive — flippable per row). Submits via sequential `linkTransfer` calls; reports per-row failures inline (most common case: `transfer_already_linked` on a re-submit). - `BalanceAccountsTable.tsx`: 4 new columns rendered side-by-side — 3M / 1A / Since-inception (Modified Dietz via `compute_account_return`) + Unadjusted (`(V_end - V_start) / V_start`). Returns load lazily after mount via `Promise.all` over (account × horizon); per-cell failure leaves the slot at "—" without blocking the rest of the table. The actions menu gains a *Link transfers* item that bubbles the request up to the parent page. New props: `sinceCreationDate` (anchors the since-inception horizon) and `onLinkTransfers` (modal opener). - `BalancePage.tsx`: hosts the new modal, loads the categories list once on mount for the filter dropdown, fetches the union of `listAccountTransfers` per account so the chart can render markers, and threads the earliest snapshot date down to the table. Reload is triggered after the modal reports at least one successful link. - `balance.service.ts`: dropped the unused `BalanceAccountTransfer` import to satisfy `tsc --noUnusedLocals`. `npm run build` clean. `npm test` → 429 passed. Manual sanity check: the table renders "…" placeholders during the per-row return load, then resolves to either a percentage or a "—" with the partial tooltip when the underlying snapshot endpoint is missing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../balance/BalanceAccountsTable.tsx | 244 ++++++++++- src/components/balance/LinkTransfersModal.tsx | 410 ++++++++++++++++++ src/pages/BalancePage.tsx | 78 +++- src/services/balance.service.ts | 1 - 4 files changed, 717 insertions(+), 16 deletions(-) create mode 100644 src/components/balance/LinkTransfersModal.tsx diff --git a/src/components/balance/BalanceAccountsTable.tsx b/src/components/balance/BalanceAccountsTable.tsx index a0b86ed..c003337 100644 --- a/src/components/balance/BalanceAccountsTable.tsx +++ b/src/components/balance/BalanceAccountsTable.tsx @@ -1,22 +1,32 @@ // BalanceAccountsTable — one-row-per-active-account table on /balance. // -// Issue #141 (Bilan #3). Columns: -// - Account name + category label -// - Latest snapshot value (or "—" when no snapshot exists yet) -// - Δ% over the active period (latest value vs the period-anchor value; -// null when no anchor exists, rendered as "—"). -// - Actions menu (Detail no-op for now, Archive via service). +// Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ% +// + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via +// the Modified Dietz `compute_account_return` Tauri command: // -// Future return-metric columns (3M / 1A / since-creation / unadjusted) -// land in Issue #142. They have a TODO marker below. +// - 3M (last 90 days) +// - 1A (last 365 days) +// - Depuis création (from earliest snapshot date to today) +// - Non-ajusté (simple `(V_end - V_start) / V_start`, no contribution +// weighting — shown side-by-side as a sanity check / explanation) +// +// Returns load lazily on mount via `Promise.all` over (account × horizon), +// keyed by `account_id`. Each cell renders "—" while loading and shows the +// `is_partial` / `has_no_transfers_warning` badges via tooltip when set. +// +// Issue #142 also adds a "Lier transferts" item in the per-row actions menu +// that opens `LinkTransfersModal` (the modal handles its own state; this +// component just bubbles up the request via `onLinkTransfers`). -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Archive, MoreVertical } from "lucide-react"; +import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react"; import type { AccountLatestSnapshot, AccountPeriodAnchor, } from "../../services/balance.service"; +import { computeAccountReturn } from "../../services/balance.service"; +import type { AccountReturn } from "../../shared/types"; const cadFormatter = (locale: string) => new Intl.NumberFormat(locale, { @@ -25,16 +35,55 @@ const cadFormatter = (locale: string) => maximumFractionDigits: 2, }); +/** Horizon definition: how many days back from today to start the period. */ +type HorizonKey = "3M" | "1A" | "since"; + +interface HorizonRange { + key: HorizonKey; + /** ISO date for `period_start`. */ + from: string; + /** ISO date for `period_end` (always today, computed in the local civil day). */ + to: string; +} + +function localISO(d: Date): string { + const yy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yy}-${mm}-${dd}`; +} + +function isoDaysAgo(days: number, today: Date): string { + const d = new Date(today); + d.setDate(d.getDate() - days); + return localISO(d); +} + interface BalanceAccountsTableProps { accounts: AccountLatestSnapshot[]; periodAnchor: AccountPeriodAnchor[]; onArchiveAccount?: (account: AccountLatestSnapshot) => void; + onLinkTransfers?: (account: AccountLatestSnapshot) => void; + /** + * Earliest snapshot date across the whole profile, used to anchor the + * "depuis création" horizon. Falls back to "1A" range if not provided + * (avoids triggering computation against the unix epoch). + */ + sinceCreationDate?: string | null; } +/** + * Per-account, per-horizon return — shape used by the local cache state. + * Indexed `[accountId][horizonKey]`. + */ +type ReturnsByAccount = Record>>; + export default function BalanceAccountsTable({ accounts, periodAnchor, onArchiveAccount, + onLinkTransfers, + sinceCreationDate, }: BalanceAccountsTableProps) { const { t, i18n } = useTranslation(); const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA"); @@ -48,6 +97,65 @@ export default function BalanceAccountsTable({ const [openMenuFor, setOpenMenuFor] = useState(null); + // Returns cache. Cleared whenever the account list changes (new accounts, + // archive, etc.). Loaded lazily after mount. + const [returns, setReturns] = useState({}); + const [returnsLoading, setReturnsLoading] = useState(false); + + // Horizon definitions — recomputed once per mount via today's local civil + // day. We don't memoize against `accounts` because the dates don't depend + // on the row list. + const horizons = useMemo(() => { + const today = new Date(); + const todayISO = localISO(today); + const sinceFrom = sinceCreationDate ?? isoDaysAgo(365, today); + return [ + { key: "3M", from: isoDaysAgo(90, today), to: todayISO }, + { key: "1A", from: isoDaysAgo(365, today), to: todayISO }, + { key: "since", from: sinceFrom, to: todayISO }, + ]; + }, [sinceCreationDate]); + + useEffect(() => { + let cancelled = false; + async function loadReturns() { + if (accounts.length === 0) { + setReturns({}); + return; + } + setReturnsLoading(true); + const next: ReturnsByAccount = {}; + // Run sequentially per account to avoid SQLite contention; per-horizon + // we can parallelize because they hit the same table set. + await Promise.all( + accounts.map(async (acc) => { + next[acc.account_id] = {}; + const tasks = horizons.map(async (h) => { + try { + const r = await computeAccountReturn( + acc.account_id, + h.from, + h.to + ); + next[acc.account_id]![h.key] = r; + } catch { + // Per-cell failure: leave the slot undefined → renders "—". + } + }); + await Promise.all(tasks); + }) + ); + if (!cancelled) { + setReturns(next); + setReturnsLoading(false); + } + } + void loadReturns(); + return () => { + cancelled = true; + }; + }, [accounts, horizons]); + if (accounts.length === 0) { return (
@@ -56,8 +164,73 @@ export default function BalanceAccountsTable({ ); } + /** Format a return percentage with sign + colour-aware classname. */ + function renderReturnCell(r: AccountReturn | undefined) { + if (!r) { + return ; + } + if (r.return_pct === null) { + return ( + + + — + + ); + } + const pct = r.return_pct * 100; + return ( + + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + } + > + {pct >= 0 ? "+" : ""} + {pct.toFixed(2)}% + + {r.has_no_transfers_warning && ( + + )} + + ); + } + + /** + * Unadjusted (simple) return = `(value_end - value_start) / value_start` + * — same numbers Modified Dietz already returns when no flows exist, but + * this column shows the simple version for ALL accounts as a side-by-side + * sanity check. Computed from the same `AccountReturn` payload (uses the + * `value_start` / `value_end` fields filled by the Rust side). + */ + function renderUnadjustedCell(r: AccountReturn | undefined) { + if (!r || r.value_start === null || r.value_end === null) { + return ; + } + if (r.value_start === 0) { + return ; + } + const simple = ((r.value_end - r.value_start) / r.value_start) * 100; + return ( + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + } + > + {simple >= 0 ? "+" : ""} + {simple.toFixed(2)}% + + ); + } + return ( -
+
@@ -73,7 +246,18 @@ export default function BalanceAccountsTable({ - {/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */} + + + + @@ -88,6 +272,7 @@ export default function BalanceAccountsTable({ Math.abs(anchor.anchor_value)) * 100 : null; + const accReturns = returns[acc.account_id] ?? {}; return ( + + + + -
{t("balance.overview.periodDelta")} + {t("balance.accountsTable.return3m")} + + {t("balance.accountsTable.return1y")} + + {t("balance.accountsTable.sinceCreation")} + + {t("balance.accountsTable.unadjusted")} + {t("balance.account.fields.actions")}
+ {returnsLoading && !accReturns["3M"] + ? "…" + : renderReturnCell(accReturns["3M"])} + + {returnsLoading && !accReturns["1A"] + ? "…" + : renderReturnCell(accReturns["1A"])} + + {returnsLoading && !accReturns["since"] + ? "…" + : renderReturnCell(accReturns["since"])} + + {returnsLoading && !accReturns["1A"] + ? "…" + : renderUnadjustedCell(accReturns["1A"])} + {openMenuFor === acc.account_id && ( -
+
+ {onLinkTransfers && ( + + )} +
+ +
+ + + + +
+ +
+ {isLoading ? ( +
+ + {t("balance.transfers.modal.loading")} +
+ ) : error ? ( +
+ + {error} +
+ ) : rows.length === 0 ? ( +
+ {t("balance.transfers.modal.noTransactions")} +
+ ) : ( + + + + + + + + + + + + {rows.map((row) => { + const isSelected = selection.has(row.id); + const direction = selection.get(row.id) ?? suggestTransferDirection(row.amount); + return ( + + + + + + + + ); + })} + +
+ {t("transactions.date")} + + {t("transactions.description")} + + {t("transactions.amount")} + + {t("balance.transfers.modal.direction")} +
+ toggleRow(row)} + aria-label={`select-${row.id}`} + /> + {row.date} + {row.description} + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`} + > + {fmt.format(row.amount)} + + {isSelected ? ( + + ) : ( + + {t(`balance.transfers.direction.${direction}`)} + + )} +
+ )} +
+ + {submitError && ( +
+ {submitError} +
+ )} + +
+
+ {t("balance.transfers.modal.summary", { + selected: selectedCount, + total: allFiltered, + })} +
+
+ + +
+
+
+ , + document.body + ); +} diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx index 85b5772..41e2904 100644 --- a/src/pages/BalancePage.tsx +++ b/src/pages/BalancePage.tsx @@ -11,7 +11,7 @@ // (Modified Dietz) are deferred to Issue #142 — the accounts table reserves // columns with a TODO comment. -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Wallet } from "lucide-react"; import { @@ -19,10 +19,17 @@ import { type BalancePeriod, type BalanceChartMode, } from "../hooks/useBalanceOverview"; -import { archiveBalanceAccount } from "../services/balance.service"; +import { + archiveBalanceAccount, + listAccountTransfers, + type AccountLatestSnapshot, +} from "../services/balance.service"; +import { getAllCategories } from "../services/transactionService"; +import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types"; import BalanceOverviewCard from "../components/balance/BalanceOverviewCard"; import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; +import LinkTransfersModal from "../components/balance/LinkTransfersModal"; const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; @@ -30,6 +37,58 @@ export default function BalancePage() { const { t } = useTranslation(); const { state, setPeriod, setChartMode, reload } = useBalanceOverview(); + // Issue #142 — link-transfers modal state. Categories list is loaded once + // on mount (used by the modal's filter dropdown). + const [linkTarget, setLinkTarget] = useState( + null + ); + const [categories, setCategories] = useState([]); + const [transfersByAccount, setTransfersByAccount] = useState< + Map + >(new Map()); + + useEffect(() => { + void getAllCategories().then(setCategories).catch(() => setCategories([])); + }, []); + + // Refresh per-account transfer lists used by the chart markers. Keyed by + // account_id → [transfers]. Used by `BalanceEvolutionChart` to plot + // ReferenceLine markers (green for in, red for out). + useEffect(() => { + let cancelled = false; + async function run() { + const map = new Map(); + await Promise.all( + state.accountsLatest.map(async (acc) => { + try { + const list = await listAccountTransfers(acc.account_id); + map.set(acc.account_id, list); + } catch { + map.set(acc.account_id, []); + } + }) + ); + if (!cancelled) setTransfersByAccount(map); + } + void run(); + return () => { + cancelled = true; + }; + }, [state.accountsLatest]); + + const allTransferMarkers = useMemo(() => { + const flat: BalanceAccountTransferWithTransaction[] = []; + for (const list of transfersByAccount.values()) flat.push(...list); + return flat; + }, [transfersByAccount]); + + // Earliest snapshot date in the dataset, used to anchor the "depuis + // création" Modified Dietz horizon in the accounts table. + const earliestSnapshotDate = useMemo(() => { + if (state.evolutionTotals.length === 0) return null; + return state.evolutionTotals[0].snapshot_date; + }, [state.evolutionTotals]); + // Build a category_key → translated label map from the accounts payload — // the byCategory series is keyed by `key`, not by id, and the same // taxonomy is already loaded with `accountsLatest` joins. @@ -123,6 +182,7 @@ export default function BalancePage() { totals={state.evolutionTotals} byCategory={state.evolutionByCategory} categoryLabels={categoryLabels} + transferMarkers={allTransferMarkers} />
@@ -132,10 +192,24 @@ export default function BalancePage() { handleArchiveAccount(acc.account_id)} + onLinkTransfers={(acc) => setLinkTarget(acc)} />
+ + {linkTarget && ( + setLinkTarget(null)} + onLinked={() => { + void reload(); + }} + /> + )} ); } diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index fd241f3..a178303 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -15,7 +15,6 @@ import { loadProfiles } from "./profileService"; import type { AccountReturn, BalanceAccount, - BalanceAccountTransfer, BalanceAccountTransferWithTransaction, BalanceAccountWithCategory, BalanceCategory, From 0e996a5aa11b2f86e97436187bc61da3acc4874b Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:38:46 -0400 Subject: [PATCH 6/8] feat(transactions): inline transfer icon + FK error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #142 / Bilan #4 — non-regressive transfer awareness in the transactions table + clean error mapping on bulk delete. - `TransactionTable.tsx`: optional new prop `linkedTransfersByTxId?: Map`. When supplied, a small `` icon appears next to the description for every linked transaction; tooltip lists the account name(s) and direction(s). Without the prop, the table renders byte-for-byte identical to before — preserves the spec's non-regression invariant. - `TransactionsPage.tsx`: loads the linked-transfers map once on mount via `listAllLinkedTransfersForTooltip()` (one batch SELECT) and threads it through to the table. Failure to load the map degrades gracefully to an empty map (icon simply doesn't appear). - `transactionService.ts`: new `deleteTransaction(id)` helper + `TransactionLinkedToBalanceError` (typed FK guard). Pre-checks `balance_account_transfers` before attempting the DELETE so the error carries the offending account names; falls back to the FK pattern matcher if a race linked the transaction between the SELECT and the DELETE. - `importedFileService.ts`: both bulk delete paths (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`) now pre-check for any linked transfer and surface the same typed error before they would explode on FK RESTRICT. The pre-check has a `LIMIT 50` safety cap on the global path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../transactions/TransactionTable.tsx | 39 ++++++++- src/pages/TransactionsPage.tsx | 19 ++++- src/services/importedFileService.ts | 62 +++++++++++++- src/services/transactionService.ts | 83 +++++++++++++++++++ 4 files changed, 196 insertions(+), 7 deletions(-) diff --git a/src/components/transactions/TransactionTable.tsx b/src/components/transactions/TransactionTable.tsx index 43705df..74b1263 100644 --- a/src/components/transactions/TransactionTable.tsx +++ b/src/components/transactions/TransactionTable.tsx @@ -1,12 +1,13 @@ import { Fragment, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react"; +import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react"; import type { TransactionRow, TransactionSort, Category, SplitChild, } from "../../shared/types"; +import type { LinkedTransferTooltipRow } from "../../services/balance.service"; import CategoryCombobox from "../shared/CategoryCombobox"; import SplitAdjustmentModal from "./SplitAdjustmentModal"; @@ -22,6 +23,14 @@ interface TransactionTableProps { onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise; onDeleteSplit: (parentId: number) => Promise; onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void; + /** + * Issue #142 — when supplied, a small Link2 icon appears next to the + * description for every transaction whose id is a key in the map. The + * icon's tooltip lists the linked accounts. The lookup is intentionally + * done by the parent (one batch SELECT, in-memory `.has()` thereafter) + * to avoid an N+1 hit on the table render. + */ + linkedTransfersByTxId?: Map; } function SortIcon({ @@ -52,6 +61,7 @@ export default function TransactionTable({ onSaveSplit, onDeleteSplit, onRowContextMenu, + linkedTransfersByTxId, }: TransactionTableProps) { const { t } = useTranslation(); const [expandedId, setExpandedId] = useState(null); @@ -141,8 +151,31 @@ export default function TransactionTable({ className="hover:bg-[var(--muted)] transition-colors" >
{row.date} - {row.description} + +
+ + {row.description} + + {linkedTransfersByTxId?.has(row.id) && ( + { + const links = linkedTransfersByTxId.get(row.id) ?? []; + const parts = links.map( + (l) => + `${l.account_name} (${t(`balance.transfers.direction.${l.direction}`)})` + ); + return `${t("transactions.transferIcon.tooltip")}: ${parts.join(", ")}`; + })() + } + aria-label={t("transactions.transferIcon.ariaLabel")} + > + + + )} +
(null); const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null); const [pending, setPending] = useState(null); + // Issue #142 — single batch lookup for the inlined transfer icon. One + // SELECT on mount gives us a Map the table consults via + // `.has()` per row. Avoids an N+1 hit on the rendered page. + const [linkedTransfersByTxId, setLinkedTransfersByTxId] = useState< + Map + >(new Map()); + + useEffect(() => { + listAllLinkedTransfersForTooltip() + .then(setLinkedTransfersByTxId) + .catch(() => setLinkedTransfersByTxId(new Map())); + }, []); const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => { e.preventDefault(); @@ -95,6 +111,7 @@ export default function TransactionsPage() { onSaveSplit={saveSplit} onDeleteSplit={deleteSplit} onRowContextMenu={handleRowContextMenu} + linkedTransfersByTxId={linkedTransfersByTxId} /> 0 ? files[0].source_id : null; - const result = await db.execute( - "DELETE FROM transactions WHERE file_id = $1", + // Pre-flight: if any transaction in this file is linked to a balance + // account via `balance_account_transfers`, the FK RESTRICT will fire on + // the bulk DELETE. Surface a typed error BEFORE touching the row so the + // UI can prompt the user to unlink first (Issue #142). + const linked = await db.select< + Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }> + >( + `SELECT bat.transaction_id AS transaction_id, + bat.account_id AS account_id, + a.name AS account_name, + bat.direction AS direction + FROM balance_account_transfers bat + JOIN transactions t ON t.id = bat.transaction_id + JOIN balance_accounts a ON a.id = bat.account_id + WHERE t.file_id = $1`, [fileId] ); + if (linked.length > 0) { + throw new TransactionLinkedToBalanceError(null, linked); + } + + let result; + try { + result = await db.execute( + "DELETE FROM transactions WHERE file_id = $1", + [fileId] + ); + } catch (err) { + if (isLinkedTransactionFkError(err)) { + throw new TransactionLinkedToBalanceError(null, []); + } + throw err; + } await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]); // Clean up orphaned source if no files remain @@ -116,7 +147,32 @@ export async function deleteImportWithTransactions( export async function deleteAllImportsWithTransactions(): Promise { const db = await getDb(); - const result = await db.execute("DELETE FROM transactions"); + // Same pre-flight as the per-file path: if ANY transaction is linked to + // a balance account, the bulk wipe would explode on FK RESTRICT — surface + // a typed error so the UI can prompt the user to unlink first. + const linked = await db.select< + Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }> + >( + `SELECT bat.transaction_id AS transaction_id, + bat.account_id AS account_id, + a.name AS account_name, + bat.direction AS direction + FROM balance_account_transfers bat + JOIN balance_accounts a ON a.id = bat.account_id + LIMIT 50` + ); + if (linked.length > 0) { + throw new TransactionLinkedToBalanceError(null, linked); + } + let result; + try { + result = await db.execute("DELETE FROM transactions"); + } catch (err) { + if (isLinkedTransactionFkError(err)) { + throw new TransactionLinkedToBalanceError(null, []); + } + throw err; + } await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM import_sources"); return result.rowsAffected; diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 518830a..34e9ca8 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -1,5 +1,9 @@ import { getDb } from "./db"; import { categorizeBatch } from "./categorizationService"; +import { + isLinkedTransactionFkError, + type LinkedTransferTooltipRow, +} from "./balance.service"; import type { Transaction, TransactionRow, @@ -11,6 +15,85 @@ import type { SplitChild, } from "../shared/types"; +/** + * Thrown when a deletion path is blocked by `balance_account_transfers.transaction_id` + * FK RESTRICT. Carries the offending `transaction_id`(s) so the UI can format a + * precise message ("Cette transaction est liée au compte de bilan X — déliez-la + * avant de supprimer") and can offer a deep link to the linked account. + * + * `linkedAccounts` is best-effort: when known (single-row delete) the array + * lists every account currently linking the transaction. For bulk deletes + * the array may be empty — the UI just shows the generic message in that + * case. + */ +export class TransactionLinkedToBalanceError extends Error { + readonly code = "transaction_linked_to_balance_account" as const; + readonly transactionId: number | null; + readonly linkedAccounts: LinkedTransferTooltipRow[]; + constructor( + transactionId: number | null, + linkedAccounts: LinkedTransferTooltipRow[], + message?: string + ) { + super( + message ?? + "Transaction is linked to one or more balance accounts; unlink before deleting" + ); + this.name = "TransactionLinkedToBalanceError"; + this.transactionId = transactionId; + this.linkedAccounts = linkedAccounts; + } +} + +/** + * Delete one transaction by id. Throws `TransactionLinkedToBalanceError` if + * the transaction has any row in `balance_account_transfers` (FK RESTRICT) + * — UI surfaces "Cette transaction est liée au compte de bilan X — déliez-la + * avant de supprimer" with a link to the offending account. + * + * Pre-checks the link table so the error carries account names; falls back + * to the FK-error pattern matcher if the constraint fires for any other + * reason. + */ +export async function deleteTransaction(transactionId: number): Promise { + const db = await getDb(); + // Pre-check: if any transfer references this transaction, surface a clean + // typed error WITHOUT touching the row. Cheaper than catching the FK + // exception and provides the account names for the UI message. + const linked = await db.select< + Array<{ account_id: number; account_name: string; direction: "in" | "out" }> + >( + `SELECT bat.account_id AS account_id, + a.name AS account_name, + bat.direction AS direction + FROM balance_account_transfers bat + JOIN balance_accounts a ON a.id = bat.account_id + WHERE bat.transaction_id = $1`, + [transactionId] + ); + if (linked.length > 0) { + throw new TransactionLinkedToBalanceError( + transactionId, + linked.map((l) => ({ + transaction_id: transactionId, + account_id: l.account_id, + account_name: l.account_name, + direction: l.direction, + })) + ); + } + try { + await db.execute("DELETE FROM transactions WHERE id = $1", [transactionId]); + } catch (err) { + // Defensive: a race could have linked the transaction between the + // SELECT and the DELETE. Surface the typed error in that case too. + if (isLinkedTransactionFkError(err)) { + throw new TransactionLinkedToBalanceError(transactionId, []); + } + throw err; + } +} + export async function insertBatch( transactions: Array<{ date: string; From faa09614a3ac9e339dcf5bc144fa591c378f6299 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:38:55 -0400 Subject: [PATCH 7/8] feat(balance): add transfer markers on evolution chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #142 / Bilan #4 — vertical reference lines for tagged transfers. `BalanceEvolutionChart.tsx` accepts a new optional prop `transferMarkers?: BalanceAccountTransferWithTransaction[]`. For every marker whose `transaction_date` matches a date already on the X axis, the chart renders a `` (Recharts) — green for `in` (capital added), red for `out` (capital removed). The marker is drawn in both `line` and `stacked` modes; in line mode an inline label ("In" / "Out") sits at the top-right of the marker so the user can identify the direction without hovering. Markers whose date is between two snapshot ticks are filtered out (Recharts categorical axis silently drops unknown ticks; preferred over an off-axis bug). A future improvement is to switch the X axis to a numeric/time scale so markers can land anywhere — out of scope here per the autopilot prompt's "least invasive" guideline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../balance/BalanceEvolutionChart.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/components/balance/BalanceEvolutionChart.tsx b/src/components/balance/BalanceEvolutionChart.tsx index f9908eb..0be8774 100644 --- a/src/components/balance/BalanceEvolutionChart.tsx +++ b/src/components/balance/BalanceEvolutionChart.tsx @@ -22,12 +22,14 @@ import { Tooltip, ResponsiveContainer, Legend, + ReferenceLine, } from "recharts"; import type { SnapshotTotalPoint, SnapshotCategoryBreakdownPoint, } from "../../services/balance.service"; import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; +import type { BalanceAccountTransferWithTransaction } from "../../shared/types"; // Stable palette for the stacked-by-category areas. Indexed deterministically // by category sort order so the colour assignment stays consistent across @@ -51,6 +53,13 @@ export interface BalanceEvolutionChartProps { byCategory: SnapshotCategoryBreakdownPoint[]; /** Map category_key → translated label so the legend reads naturally. */ categoryLabels?: Record; + /** + * Issue #142 — every linked transfer in the visible range. Rendered as + * vertical `` markers on the X axis: green for `in` + * (capital added), red for `out` (capital removed). The label tooltip + * shows the underlying transaction date + description. + */ + transferMarkers?: BalanceAccountTransferWithTransaction[]; } export default function BalanceEvolutionChart({ @@ -58,6 +67,7 @@ export default function BalanceEvolutionChart({ totals, byCategory, categoryLabels = {}, + transferMarkers = [], }: BalanceEvolutionChartProps) { const { t, i18n } = useTranslation(); @@ -114,6 +124,31 @@ export default function BalanceEvolutionChart({ const isEmpty = mode === "line" ? lineData.length === 0 : stackedData.length === 0; + // Filter transfer markers to dates that are actually rendered on the X + // axis (categorical scale ignores unknown ticks). We don't aggregate or + // dedupe — the user can have several transfers on the same day across + // accounts; ReferenceLine tolerates duplicates fine. + const xAxisDates = useMemo(() => { + const dates = new Set(); + if (mode === "line") { + for (const p of lineData) dates.add(p.snapshot_date); + } else { + for (const p of stackedData) dates.add(p.snapshot_date as string); + } + return dates; + }, [mode, lineData, stackedData]); + + const renderableMarkers = useMemo( + () => + transferMarkers + .filter((m) => xAxisDates.has(m.transaction_date)) + // Sort so 'in' (green) draws before 'out' (red) for stable z-order. + .sort((a, b) => + a.direction === b.direction ? 0 : a.direction === "in" ? -1 : 1 + ), + [transferMarkers, xAxisDates] + ); + if (isEmpty) { return (
@@ -168,6 +203,28 @@ export default function BalanceEvolutionChart({ dot={{ r: 3 }} activeDot={{ r: 5 }} /> + {renderableMarkers.map((m) => ( + + ))} ) : ( ))} + {renderableMarkers.map((m) => ( + + ))} )} From ca275821bce417c019aa8b19cda159eb5d3f738d Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 16:39:06 -0400 Subject: [PATCH 8/8] feat(balance): i18n + CHANGELOG for returns/transfers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #142 / Bilan #4 — translations and changelog entries. i18n (FR + EN): - `balance.returns.partialTooltip`, `balance.returns.noTransfersWarning` - `balance.accountsTable.return3m/return1y/sinceCreation/unadjusted` (label + tooltip variants) - `balance.transfers.linkAction` + `balance.transfers.direction.{in,out}` - `balance.transfers.modal.*` (every modal label, including the partial-failure summary and the per-row direction toggle) - `balance.transfers.errors.*` (5 new typed error codes) - `balance.evolution.transferIn/transferOut` (chart label) - `transactions.transferIcon.tooltip/ariaLabel` CHANGELOG (English source + French translation): - New entry under `[Unreleased]` summarising the Modified Dietz formula, the per-account return columns (3M / 1A / since-inception + unadjusted), the link-transfers modal, the transactions-page inline icon, the typed FK error on bulk-delete paths, and the vertical reference markers on the evolution chart. References #142. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + src/i18n/locales/en.json | 54 ++++++++++++++++++++++++++++++++++++++++ src/i18n/locales/fr.json | 54 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 13722f4..2fe3c16 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -3,6 +3,7 @@ ## [Non publié] ### Ajouté +- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin − V_début − ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T − t_i) / T`, et annualisation `(1 + R)^(365/T) − 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin − V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142) - **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141) - **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur − quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140) - **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b969f8..fce2f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end − V_start − ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T − t_i) / T`, plus `(1 + R)^(365/T) − 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end − V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142) - **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141) - **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value − quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140) - **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 60913a5..6012db9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -253,6 +253,10 @@ "Assign categories by clicking the category dropdown on each row", "Auto-categorize uses your keyword rules to categorize transactions in bulk" ] + }, + "transferIcon": { + "tooltip": "Linked to a balance account", + "ariaLabel": "Transaction linked to a balance account" } }, "categories": { @@ -1638,6 +1642,56 @@ "snapshot_priced_unit_price_required": "Unit price is required for priced accounts.", "snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.", "snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price." + }, + "returns": { + "partialTooltip": "Partial return: a snapshot is missing for the selected period.", + "noTransfersWarning": "No transfers tagged — performance may be skewed if contributions weren't tagged." + }, + "accountsTable": { + "return3m": "3M", + "return3mTooltip": "Modified Dietz return over the last 90 days.", + "return1y": "1Y", + "return1yTooltip": "Modified Dietz return over the last 365 days.", + "sinceCreation": "Since inception", + "sinceCreationTooltip": "Modified Dietz return since the first snapshot.", + "unadjusted": "Unadjusted", + "unadjustedTooltip": "Simple return (V_end − V_start) / V_start, with no contribution weighting." + }, + "transfers": { + "linkAction": "Link transfers", + "direction": { + "in": "In", + "out": "Out" + }, + "modal": { + "title": "Link transfers to {{account}}", + "subtitle": "Select transactions to attribute to this balance account. The direction is suggested based on the amount sign.", + "from": "From", + "to": "To", + "category": "Category", + "anyCategory": "Any category", + "search": "Search", + "searchPlaceholder": "Keyword in description…", + "loading": "Loading…", + "noTransactions": "No transactions match the filters.", + "direction": "Direction", + "toggleDirection": "Click to flip direction", + "summary": "{{selected}} selected of {{total}} shown", + "linkSelection": "Link {{count}} transaction(s)", + "linking": "Linking…", + "partialFailure": "{{linked}}/{{total}} linked successfully" + }, + "errors": { + "transfer_direction_invalid": "Invalid transfer direction (expected in/out).", + "transfer_already_linked": "This transaction is already linked to this account.", + "transfer_not_linked": "This transaction is not linked to this account.", + "transfer_active_profile_unknown": "No active profile — cannot compute return.", + "transaction_linked_to_balance_account": "This transaction is linked to balance account {{account}} — unlink it before deleting." + } + }, + "evolution": { + "transferIn": "In", + "transferOut": "Out" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b011d76..3a95469 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -253,6 +253,10 @@ "Assignez une catégorie via le menu déroulant sur chaque ligne", "L'auto-catégorisation utilise vos règles de mots-clés pour catégoriser en masse" ] + }, + "transferIcon": { + "tooltip": "Liée à un compte de bilan", + "ariaLabel": "Transaction liée à un compte de bilan" } }, "categories": { @@ -1638,6 +1642,56 @@ "snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.", "snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.", "snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix." + }, + "returns": { + "partialTooltip": "Rendement partiel : un snapshot manque pour calculer la performance sur cette période.", + "noTransfersWarning": "Aucun transfert lié — la performance peut être faussée si des apports n'ont pas été tagués." + }, + "accountsTable": { + "return3m": "3M", + "return3mTooltip": "Rendement Modified Dietz sur les 90 derniers jours.", + "return1y": "1A", + "return1yTooltip": "Rendement Modified Dietz sur les 365 derniers jours.", + "sinceCreation": "Depuis création", + "sinceCreationTooltip": "Rendement Modified Dietz depuis le premier snapshot.", + "unadjusted": "Non ajusté", + "unadjustedTooltip": "Rendement simple (V_fin − V_début) / V_début, sans pondération des apports." + }, + "transfers": { + "linkAction": "Lier transferts", + "direction": { + "in": "Entrée", + "out": "Sortie" + }, + "modal": { + "title": "Lier des transferts à {{account}}", + "subtitle": "Sélectionnez les transactions à attribuer à ce compte de bilan. La direction est proposée d'après le signe du montant.", + "from": "Du", + "to": "Au", + "category": "Catégorie", + "anyCategory": "Toutes les catégories", + "search": "Rechercher", + "searchPlaceholder": "Mot-clé dans la description…", + "loading": "Chargement…", + "noTransactions": "Aucune transaction ne correspond aux filtres.", + "direction": "Sens", + "toggleDirection": "Cliquer pour inverser le sens", + "summary": "{{selected}} sélectionnée(s) sur {{total}} affichée(s)", + "linkSelection": "Lier {{count}} transaction(s)", + "linking": "Liaison…", + "partialFailure": "{{linked}}/{{total}} liées avec succès" + }, + "errors": { + "transfer_direction_invalid": "Direction de transfert invalide (in/out attendu).", + "transfer_already_linked": "Cette transaction est déjà liée à ce compte.", + "transfer_not_linked": "Cette transaction n'est pas liée à ce compte.", + "transfer_active_profile_unknown": "Aucun profil actif — impossible de calculer le rendement.", + "transaction_linked_to_balance_account": "Cette transaction est liée au compte de bilan {{account}} — déliez-la avant de supprimer." + } + }, + "evolution": { + "transferIn": "Entrée", + "transferOut": "Sortie" } } }