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-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/balance_commands.rs b/src-tauri/src/commands/balance_commands.rs new file mode 100644 index 0000000..ea196b5 --- /dev/null +++ b/src-tauri/src/commands/balance_commands.rs @@ -0,0 +1,183 @@ +//! 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> { + // 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.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.date BETWEEN ?2 AND ?3 + ORDER BY t.date", + ) + .map_err(|e| format!("prepare flows query: {}", e))?; + + let rows = stmt + .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)?; + 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 8c6542f..33d4d7f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,16 +1,22 @@ 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; 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::*; 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/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 + ); + } +} 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"); 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/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 [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/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index 9d425a4..0b846fd 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Wand2, Tag } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; @@ -9,6 +9,10 @@ import TransactionTable from "../components/transactions/TransactionTable"; import TransactionPagination from "../components/transactions/TransactionPagination"; import ContextMenu from "../components/shared/ContextMenu"; import AddKeywordDialog from "../components/categories/AddKeywordDialog"; +import { + listAllLinkedTransfersForTooltip, + type LinkedTransferTooltipRow, +} from "../services/balance.service"; import type { TransactionRow } from "../shared/types"; export default function TransactionsPage() { @@ -18,6 +22,18 @@ export default function TransactionsPage() { const [resultMessage, setResultMessage] = useState(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} /> ({ 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..a178303 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -9,14 +9,19 @@ // 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, + BalanceAccountTransferWithTransaction, BalanceAccountWithCategory, BalanceCategory, BalanceCategoryKind, BalanceSnapshot, BalanceSnapshotLine, + BalanceTransferDirection, } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types"; @@ -40,7 +45,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 +966,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/services/importedFileService.ts b/src/services/importedFileService.ts index bb2c9df..57f7a32 100644 --- a/src/services/importedFileService.ts +++ b/src/services/importedFileService.ts @@ -1,4 +1,6 @@ import { getDb } from "./db"; +import { isLinkedTransactionFkError } from "./balance.service"; +import { TransactionLinkedToBalanceError } from "./transactionService"; import type { ImportedFile, ImportedFileWithSource } from "../shared/types"; export async function getFilesBySourceId( @@ -94,10 +96,39 @@ export async function deleteImportWithTransactions( ); const sourceId = files.length > 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; 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; +}