Merge pull request 'feat(balance): Modified Dietz returns + transfer linking (#142)' (#151) from issue-142-bilan-4 into main

This commit is contained in:
maximus 2026-04-26 13:25:32 +00:00
commit 8df1aed258
21 changed files with 2211 additions and 23 deletions

View file

@ -3,6 +3,7 @@
## [Non publié] ## [Non publié]
### Ajouté ### 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 `<Area stackId>` 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 — 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 `<Area stackId>` 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 — 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) - **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)

View file

@ -3,6 +3,7 @@
## [Unreleased] ## [Unreleased]
### Added ### 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 `<Area stackId>` 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 — `/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 `<Area stackId>` 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 — 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) - **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)

1
src-tauri/Cargo.lock generated
View file

@ -4428,6 +4428,7 @@ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",
"base64 0.22.1", "base64 0.22.1",
"chrono",
"ed25519-dalek", "ed25519-dalek",
"encoding_rs", "encoding_rs",
"hmac", "hmac",

View file

@ -41,6 +41,10 @@ rand = "0.8"
jsonwebtoken = "9" jsonwebtoken = "9"
machine-uid = "0.5" machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] } 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"] } tokio = { version = "1", features = ["macros"] }
hostname = "0.4" hostname = "0.4"
urlencoding = "2" urlencoding = "2"

View file

@ -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<AccountReturn, String> {
let start_date = parse_iso_date(&period_start, "period_start")?;
let end_date = parse_iso_date(&period_end, "period_end")?;
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
let db_path = app_dir.join(&db_filename);
if !db_path.exists() {
return Err(format!(
"Profile database not found: {}",
db_path.display()
));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Cannot open database: {}", e))?;
let value_start = read_value_at_or_before(&conn, account_id, &period_start)?;
let value_end = read_value_at_or_before(&conn, account_id, &period_end)?;
let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?;
Ok(modified_dietz(
value_start,
value_end,
&cash_flows,
start_date,
end_date,
))
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------
fn parse_iso_date(input: &str, field: &str) -> Result<NaiveDate, String> {
NaiveDate::parse_from_str(input, "%Y-%m-%d")
.map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e))
}
/// Reads the value of the snapshot line for `account_id` at the most recent
/// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no
/// such snapshot exists for this account.
fn read_value_at_or_before(
conn: &Connection,
account_id: i64,
as_of_date: &str,
) -> Result<Option<f64>, String> {
// Single-row query: pick the latest snapshot date for this account that
// is on or before `as_of_date`, then return that line's value. Indexed
// on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
let mut stmt = conn
.prepare(
"SELECT l.value
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
WHERE l.account_id = ?1
AND s.snapshot_date <= ?2
ORDER BY s.snapshot_date DESC
LIMIT 1",
)
.map_err(|e| format!("prepare value query: {}", e))?;
let mut rows = stmt
.query(rusqlite::params![account_id, as_of_date])
.map_err(|e| format!("execute value query: {}", e))?;
match rows.next().map_err(|e| format!("read value row: {}", e))? {
Some(row) => Ok(Some(
row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?,
)),
None => Ok(None),
}
}
/// Reads every linked transfer for `account_id` whose underlying
/// transaction's `transaction_date` falls inside `[period_start, period_end]`.
/// Returns `(NaiveDate, signed_amount)` — sign applied per `direction`
/// (`in` → `+`, `out` → ``). Amounts come from the linked transaction.
fn read_cash_flows(
conn: &Connection,
account_id: i64,
period_start: &str,
period_end: &str,
) -> Result<Vec<(NaiveDate, f64)>, String> {
// 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)
}

View file

@ -1,16 +1,22 @@
pub mod account_cache; pub mod account_cache;
pub mod auth_commands; pub mod auth_commands;
pub mod backup_commands; pub mod backup_commands;
pub mod balance_commands;
pub mod entitlements; pub mod entitlements;
pub mod export_import_commands; pub mod export_import_commands;
pub mod feedback_commands; pub mod feedback_commands;
pub mod fs_commands; pub mod fs_commands;
pub mod license_commands; pub mod license_commands;
pub mod profile_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 mod token_store;
pub use auth_commands::*; pub use auth_commands::*;
pub use backup_commands::*; pub use backup_commands::*;
pub use balance_commands::*;
pub use entitlements::*; pub use entitlements::*;
pub use export_import_commands::*; pub use export_import_commands::*;
pub use feedback_commands::*; pub use feedback_commands::*;

View file

@ -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<f64>,
/// Account value at `period_end` (latest snapshot ≤ period_end).
pub value_end: Option<f64>,
/// 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<f64>,
/// Annualized return `(1 + R)^(365 / T) - 1`. `None` for zero-length
/// periods or whenever `return_pct` is `None`.
pub annualized_pct: Option<f64>,
/// `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<f64>,
value_end: Option<f64>,
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<f64>,
value_end: Option<f64>,
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
);
}
}

View file

@ -216,6 +216,7 @@ pub fn run() {
commands::ensure_backup_dir, commands::ensure_backup_dir,
commands::get_file_size, commands::get_file_size,
commands::file_exists, commands::file_exists,
commands::compute_account_return,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View file

@ -1,22 +1,32 @@
// BalanceAccountsTable — one-row-per-active-account table on /balance. // BalanceAccountsTable — one-row-per-active-account table on /balance.
// //
// Issue #141 (Bilan #3). Columns: // Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ%
// - Account name + category label // + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via
// - Latest snapshot value (or "—" when no snapshot exists yet) // the Modified Dietz `compute_account_return` Tauri command:
// - Δ% 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).
// //
// Future return-metric columns (3M / 1A / since-creation / unadjusted) // - 3M (last 90 days)
// land in Issue #142. They have a TODO marker below. // - 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 { useTranslation } from "react-i18next";
import { Archive, MoreVertical } from "lucide-react"; import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react";
import type { import type {
AccountLatestSnapshot, AccountLatestSnapshot,
AccountPeriodAnchor, AccountPeriodAnchor,
} from "../../services/balance.service"; } from "../../services/balance.service";
import { computeAccountReturn } from "../../services/balance.service";
import type { AccountReturn } from "../../shared/types";
const cadFormatter = (locale: string) => const cadFormatter = (locale: string) =>
new Intl.NumberFormat(locale, { new Intl.NumberFormat(locale, {
@ -25,16 +35,55 @@ const cadFormatter = (locale: string) =>
maximumFractionDigits: 2, 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 { interface BalanceAccountsTableProps {
accounts: AccountLatestSnapshot[]; accounts: AccountLatestSnapshot[];
periodAnchor: AccountPeriodAnchor[]; periodAnchor: AccountPeriodAnchor[];
onArchiveAccount?: (account: AccountLatestSnapshot) => void; 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<number, Partial<Record<HorizonKey, AccountReturn>>>;
export default function BalanceAccountsTable({ export default function BalanceAccountsTable({
accounts, accounts,
periodAnchor, periodAnchor,
onArchiveAccount, onArchiveAccount,
onLinkTransfers,
sinceCreationDate,
}: BalanceAccountsTableProps) { }: BalanceAccountsTableProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA"); const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
@ -48,6 +97,65 @@ export default function BalanceAccountsTable({
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null); const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
// Returns cache. Cleared whenever the account list changes (new accounts,
// archive, etc.). Loaded lazily after mount.
const [returns, setReturns] = useState<ReturnsByAccount>({});
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<HorizonRange[]>(() => {
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) { if (accounts.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
@ -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 <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.return_pct === null) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden"> <span
className="text-[var(--muted-foreground)] inline-flex items-center gap-1"
title={t("balance.returns.partialTooltip")}
>
<AlertTriangle size={12} />
</span>
);
}
const pct = r.return_pct * 100;
return (
<span className="inline-flex items-center gap-1">
<span
className={
pct >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{pct >= 0 ? "+" : ""}
{pct.toFixed(2)}%
</span>
{r.has_no_transfers_warning && (
<AlertTriangle
size={12}
className="text-amber-500"
aria-label={t("balance.returns.noTransfersWarning")}
/>
)}
</span>
);
}
/**
* 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 <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.value_start === 0) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
const simple = ((r.value_end - r.value_start) / r.value_start) * 100;
return (
<span
className={
simple >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{simple >= 0 ? "+" : ""}
{simple.toFixed(2)}%
</span>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30"> <thead className="bg-[var(--muted)]/30">
<tr> <tr>
@ -73,7 +246,18 @@ export default function BalanceAccountsTable({
<th className="text-right px-4 py-3 font-medium"> <th className="text-right px-4 py-3 font-medium">
{t("balance.overview.periodDelta")} {t("balance.overview.periodDelta")}
</th> </th>
{/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */} <th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
{t("balance.accountsTable.return3m")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
{t("balance.accountsTable.return1y")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
{t("balance.accountsTable.sinceCreation")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
{t("balance.accountsTable.unadjusted")}
</th>
<th className="text-right px-4 py-3 font-medium w-12"> <th className="text-right px-4 py-3 font-medium w-12">
{t("balance.account.fields.actions")} {t("balance.account.fields.actions")}
</th> </th>
@ -88,6 +272,7 @@ export default function BalanceAccountsTable({
Math.abs(anchor.anchor_value)) * Math.abs(anchor.anchor_value)) *
100 100
: null; : null;
const accReturns = returns[acc.account_id] ?? {};
return ( return (
<tr <tr
key={acc.account_id} key={acc.account_id}
@ -123,6 +308,26 @@ export default function BalanceAccountsTable({
"—" "—"
)} )}
</td> </td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["3M"]
? "…"
: renderReturnCell(accReturns["3M"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderReturnCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["since"]
? "…"
: renderReturnCell(accReturns["since"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderUnadjustedCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right relative"> <td className="px-4 py-3 text-right relative">
<button <button
type="button" type="button"
@ -137,7 +342,7 @@ export default function BalanceAccountsTable({
<MoreVertical size={16} /> <MoreVertical size={16} />
</button> </button>
{openMenuFor === acc.account_id && ( {openMenuFor === acc.account_id && (
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[160px] text-left"> <div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
<button <button
type="button" type="button"
disabled disabled
@ -146,6 +351,19 @@ export default function BalanceAccountsTable({
> >
{t("balance.overview.detailAction")} {t("balance.overview.detailAction")}
</button> </button>
{onLinkTransfers && (
<button
type="button"
onClick={() => {
setOpenMenuFor(null);
onLinkTransfers(acc);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
>
<LinkIcon size={14} />
{t("balance.transfers.linkAction")}
</button>
)}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {

View file

@ -22,12 +22,14 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
ReferenceLine,
} from "recharts"; } from "recharts";
import type { import type {
SnapshotTotalPoint, SnapshotTotalPoint,
SnapshotCategoryBreakdownPoint, SnapshotCategoryBreakdownPoint,
} from "../../services/balance.service"; } from "../../services/balance.service";
import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
// Stable palette for the stacked-by-category areas. Indexed deterministically // Stable palette for the stacked-by-category areas. Indexed deterministically
// by category sort order so the colour assignment stays consistent across // by category sort order so the colour assignment stays consistent across
@ -51,6 +53,13 @@ export interface BalanceEvolutionChartProps {
byCategory: SnapshotCategoryBreakdownPoint[]; byCategory: SnapshotCategoryBreakdownPoint[];
/** Map category_key → translated label so the legend reads naturally. */ /** Map category_key → translated label so the legend reads naturally. */
categoryLabels?: Record<string, string>; categoryLabels?: Record<string, string>;
/**
* Issue #142 every linked transfer in the visible range. Rendered as
* vertical `<ReferenceLine>` 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({ export default function BalanceEvolutionChart({
@ -58,6 +67,7 @@ export default function BalanceEvolutionChart({
totals, totals,
byCategory, byCategory,
categoryLabels = {}, categoryLabels = {},
transferMarkers = [],
}: BalanceEvolutionChartProps) { }: BalanceEvolutionChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -114,6 +124,31 @@ export default function BalanceEvolutionChart({
const isEmpty = const isEmpty =
mode === "line" ? lineData.length === 0 : stackedData.length === 0; 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<string>();
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) { if (isEmpty) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
@ -168,6 +203,28 @@ export default function BalanceEvolutionChart({
dot={{ r: 3 }} dot={{ r: 3 }}
activeDot={{ r: 5 }} activeDot={{ r: 5 }}
/> />
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
label={{
value: t(
m.direction === "in"
? "balance.evolution.transferIn"
: "balance.evolution.transferOut"
),
position: "insideTopRight",
fontSize: 9,
fill: m.direction === "in" ? "var(--positive)" : "var(--negative)",
}}
/>
))}
</LineChart> </LineChart>
) : ( ) : (
<AreaChart <AreaChart
@ -210,6 +267,18 @@ export default function BalanceEvolutionChart({
name={key} name={key}
/> />
))} ))}
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
/>
))}
</AreaChart> </AreaChart>
)} )}
</ResponsiveContainer> </ResponsiveContainer>

View file

@ -0,0 +1,410 @@
// LinkTransfersModal — multi-select transactions and link them to a balance
// account in one shot. Issue #142 / Bilan #4.
//
// Filters available:
// - Period (from / to ISO dates) — default: last 90 days.
// - Category dropdown.
// - Free-text search on description.
//
// Each row shows: date, description, amount, suggested direction
// (auto-proposed via `suggestTransferDirection` from the signed amount,
// can be flipped per row), and a checkbox.
//
// On submit, calls `linkTransfer` for every selected row in sequence and
// reports any failures (most likely `transfer_already_linked` if the user
// double-clicked or another tab linked them already).
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Loader2, AlertCircle } from "lucide-react";
import { getTransactionPage } from "../../services/transactionService";
import {
linkTransfer,
suggestTransferDirection,
BalanceServiceError,
} from "../../services/balance.service";
import type {
Category,
TransactionRow,
BalanceTransferDirection,
} from "../../shared/types";
const DEFAULT_PAGE_SIZE = 100;
function isoDaysAgo(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return localISO(d);
}
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}`;
}
export interface LinkTransfersModalProps {
/** Account that the selected transfers will be attached to. */
accountId: number;
accountName: string;
/** Full category list for the filter dropdown. */
categories: Category[];
/** Optional pre-fill date bounds (defaults to last 90 days). */
initialFrom?: string;
initialTo?: string;
onClose: () => void;
/** Fired after at least one transfer was linked (parent typically reloads). */
onLinked?: (linkedCount: number) => void;
}
export default function LinkTransfersModal({
accountId,
accountName,
categories,
initialFrom,
initialTo,
onClose,
onLinked,
}: LinkTransfersModalProps) {
const { t, i18n } = useTranslation();
const [from, setFrom] = useState(initialFrom ?? isoDaysAgo(90));
const [to, setTo] = useState(initialTo ?? localISO(new Date()));
const [categoryId, setCategoryId] = useState<number | null>(null);
const [search, setSearch] = useState("");
const [rows, setRows] = useState<TransactionRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Selection state: id → direction. Presence in the map = selected.
const [selection, setSelection] = useState<
Map<number, BalanceTransferDirection>
>(new Map());
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const fmt = useMemo(
() =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
}),
[i18n.language]
);
// Re-fetch whenever the filters change. Debounced via React's render cycle
// — typing in the search box re-runs the SQL but at < 500 rows that's fine.
useEffect(() => {
let cancelled = false;
async function run() {
setIsLoading(true);
setError(null);
try {
const result = await getTransactionPage(
{
search: search.trim(),
categoryId,
sourceId: null,
dateFrom: from || null,
dateTo: to || null,
uncategorizedOnly: false,
},
{ column: "date", direction: "desc" },
1,
DEFAULT_PAGE_SIZE
);
if (!cancelled) {
setRows(result.rows);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void run();
return () => {
cancelled = true;
};
}, [from, to, categoryId, search]);
function toggleRow(row: TransactionRow) {
setSelection((prev) => {
const next = new Map(prev);
if (next.has(row.id)) {
next.delete(row.id);
} else {
next.set(row.id, suggestTransferDirection(row.amount));
}
return next;
});
}
function flipDirection(rowId: number) {
setSelection((prev) => {
const next = new Map(prev);
const current = next.get(rowId);
if (current === undefined) return prev;
next.set(rowId, current === "in" ? "out" : "in");
return next;
});
}
async function handleSubmit() {
if (selection.size === 0) return;
setSubmitting(true);
setSubmitError(null);
let linked = 0;
const failures: string[] = [];
for (const [transactionId, direction] of selection.entries()) {
try {
await linkTransfer(accountId, transactionId, direction);
linked += 1;
} catch (e) {
if (e instanceof BalanceServiceError) {
failures.push(`${transactionId}: ${t(`balance.transfers.errors.${e.code}`, { defaultValue: e.message })}`);
} else {
failures.push(`${transactionId}: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
setSubmitting(false);
if (failures.length > 0) {
setSubmitError(
`${t("balance.transfers.modal.partialFailure", { linked, total: selection.size })} — ${failures.join("; ")}`
);
}
if (linked > 0) {
onLinked?.(linked);
if (failures.length === 0) {
onClose();
}
}
}
const allFiltered = rows.length;
const selectedCount = selection.size;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
<div>
<h2 className="text-lg font-semibold">
{t("balance.transfers.modal.title", { account: accountName })}
</h2>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
{t("balance.transfers.modal.subtitle")}
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-[var(--muted)]/40"
aria-label={t("common.close")}
>
<X size={18} />
</button>
</div>
<div className="px-5 py-3 border-b border-[var(--border)] grid grid-cols-1 md:grid-cols-4 gap-3">
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.from")}
</span>
<input
type="date"
value={from}
onChange={(e) => setFrom(e.target.value)}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.to")}
</span>
<input
type="date"
value={to}
onChange={(e) => setTo(e.target.value)}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.category")}
</span>
<select
value={categoryId === null ? "" : String(categoryId)}
onChange={(e) =>
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
>
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.search")}
</span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("balance.transfers.modal.searchPlaceholder")}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/>
</label>
</div>
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="p-8 text-center text-[var(--muted-foreground)] flex items-center justify-center gap-2">
<Loader2 className="animate-spin" size={16} />
{t("balance.transfers.modal.loading")}
</div>
) : error ? (
<div className="p-8 text-center text-[var(--negative)] flex items-center justify-center gap-2">
<AlertCircle size={16} />
{error}
</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-[var(--muted-foreground)] italic">
{t("balance.transfers.modal.noTransactions")}
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30 sticky top-0">
<tr>
<th className="w-10 px-3 py-2"></th>
<th className="text-left px-3 py-2 font-medium">
{t("transactions.date")}
</th>
<th className="text-left px-3 py-2 font-medium">
{t("transactions.description")}
</th>
<th className="text-right px-3 py-2 font-medium">
{t("transactions.amount")}
</th>
<th className="text-center px-3 py-2 font-medium">
{t("balance.transfers.modal.direction")}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isSelected = selection.has(row.id);
const direction = selection.get(row.id) ?? suggestTransferDirection(row.amount);
return (
<tr
key={row.id}
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(row)}
aria-label={`select-${row.id}`}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-3 py-2 max-w-md truncate" title={row.description}>
{row.description}
</td>
<td
className={`px-3 py-2 text-right font-mono ${row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}
>
{fmt.format(row.amount)}
</td>
<td className="px-3 py-2 text-center">
{isSelected ? (
<button
type="button"
onClick={() => flipDirection(row.id)}
className={`px-2 py-0.5 text-xs rounded font-medium ${
direction === "in"
? "bg-[var(--positive)]/15 text-[var(--positive)]"
: "bg-[var(--negative)]/15 text-[var(--negative)]"
}`}
title={t("balance.transfers.modal.toggleDirection")}
>
{t(`balance.transfers.direction.${direction}`)}
</button>
) : (
<span className="text-xs text-[var(--muted-foreground)]">
{t(`balance.transfers.direction.${direction}`)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{submitError && (
<div className="px-5 py-2 border-t border-[var(--border)] text-xs text-[var(--negative)]">
{submitError}
</div>
)}
<div className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-between">
<div className="text-xs text-[var(--muted-foreground)]">
{t("balance.transfers.modal.summary", {
selected: selectedCount,
total: allFiltered,
})}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] hover:bg-[var(--muted)]/30"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={submitting || selectedCount === 0}
className="px-3 py-1.5 text-sm rounded bg-[var(--primary)] text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<span className="flex items-center gap-1.5">
<Loader2 className="animate-spin" size={14} />
{t("balance.transfers.modal.linking")}
</span>
) : (
t("balance.transfers.modal.linkSelection", { count: selectedCount })
)}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}

View file

@ -1,12 +1,13 @@
import { Fragment, useState, useMemo } from "react"; import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 { import type {
TransactionRow, TransactionRow,
TransactionSort, TransactionSort,
Category, Category,
SplitChild, SplitChild,
} from "../../shared/types"; } from "../../shared/types";
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
import CategoryCombobox from "../shared/CategoryCombobox"; import CategoryCombobox from "../shared/CategoryCombobox";
import SplitAdjustmentModal from "./SplitAdjustmentModal"; import SplitAdjustmentModal from "./SplitAdjustmentModal";
@ -22,6 +23,14 @@ interface TransactionTableProps {
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>; onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>; onDeleteSplit: (parentId: number) => Promise<void>;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void; 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<number, LinkedTransferTooltipRow[]>;
} }
function SortIcon({ function SortIcon({
@ -52,6 +61,7 @@ export default function TransactionTable({
onSaveSplit, onSaveSplit,
onDeleteSplit, onDeleteSplit,
onRowContextMenu, onRowContextMenu,
linkedTransfersByTxId,
}: TransactionTableProps) { }: TransactionTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
@ -141,8 +151,31 @@ export default function TransactionTable({
className="hover:bg-[var(--muted)] transition-colors" className="hover:bg-[var(--muted)] transition-colors"
> >
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td> <td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-3 py-2 max-w-xs truncate" title={row.description}> <td className="px-3 py-2 max-w-xs">
<div className="flex items-center gap-1.5">
<span className="truncate" title={row.description}>
{row.description} {row.description}
</span>
{linkedTransfersByTxId?.has(row.id) && (
<span
className="inline-flex items-center text-[var(--primary)] shrink-0"
title={
// Build a human-readable list: "TFSA (in), RRSP (out)".
(() => {
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")}
>
<Link2 size={12} />
</span>
)}
</div>
</td> </td>
<td <td
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${ className={`px-3 py-2 text-right font-mono whitespace-nowrap ${

View file

@ -253,6 +253,10 @@
"Assign categories by clicking the category dropdown on each row", "Assign categories by clicking the category dropdown on each row",
"Auto-categorize uses your keyword rules to categorize transactions in bulk" "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": { "categories": {
@ -1638,6 +1642,56 @@
"snapshot_priced_unit_price_required": "Unit price is required for priced accounts.", "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_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." "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"
} }
} }
} }

View file

@ -253,6 +253,10 @@
"Assignez une catégorie via le menu déroulant sur chaque ligne", "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" "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": { "categories": {
@ -1638,6 +1642,56 @@
"snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.", "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_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." "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"
} }
} }
} }

View file

@ -11,7 +11,7 @@
// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves // (Modified Dietz) are deferred to Issue #142 — the accounts table reserves
// columns with a TODO comment. // columns with a TODO comment.
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wallet } from "lucide-react"; import { Wallet } from "lucide-react";
import { import {
@ -19,10 +19,17 @@ import {
type BalancePeriod, type BalancePeriod,
type BalanceChartMode, type BalanceChartMode,
} from "../hooks/useBalanceOverview"; } 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 BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
@ -30,6 +37,58 @@ export default function BalancePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, setPeriod, setChartMode, reload } = useBalanceOverview(); 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<AccountLatestSnapshot | null>(
null
);
const [categories, setCategories] = useState<Category[]>([]);
const [transfersByAccount, setTransfersByAccount] = useState<
Map<number, BalanceAccountTransferWithTransaction[]>
>(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<number, BalanceAccountTransferWithTransaction[]>();
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 — // Build a category_key → translated label map from the accounts payload —
// the byCategory series is keyed by `key`, not by id, and the same // the byCategory series is keyed by `key`, not by id, and the same
// taxonomy is already loaded with `accountsLatest` joins. // taxonomy is already loaded with `accountsLatest` joins.
@ -123,6 +182,7 @@ export default function BalancePage() {
totals={state.evolutionTotals} totals={state.evolutionTotals}
byCategory={state.evolutionByCategory} byCategory={state.evolutionByCategory}
categoryLabels={categoryLabels} categoryLabels={categoryLabels}
transferMarkers={allTransferMarkers}
/> />
<div> <div>
@ -132,10 +192,24 @@ export default function BalancePage() {
<BalanceAccountsTable <BalanceAccountsTable
accounts={state.accountsLatest} accounts={state.accountsLatest}
periodAnchor={state.accountsPeriodAnchor} periodAnchor={state.accountsPeriodAnchor}
sinceCreationDate={earliestSnapshotDate}
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)} onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
onLinkTransfers={(acc) => setLinkTarget(acc)}
/> />
</div> </div>
</div> </div>
{linkTarget && (
<LinkTransfersModal
accountId={linkTarget.account_id}
accountName={linkTarget.account_name}
categories={categories}
onClose={() => setLinkTarget(null)}
onLinked={() => {
void reload();
}}
/>
)}
</div> </div>
); );
} }

View file

@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wand2, Tag } from "lucide-react"; import { Wand2, Tag } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
@ -9,6 +9,10 @@ import TransactionTable from "../components/transactions/TransactionTable";
import TransactionPagination from "../components/transactions/TransactionPagination"; import TransactionPagination from "../components/transactions/TransactionPagination";
import ContextMenu from "../components/shared/ContextMenu"; import ContextMenu from "../components/shared/ContextMenu";
import AddKeywordDialog from "../components/categories/AddKeywordDialog"; import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import {
listAllLinkedTransfersForTooltip,
type LinkedTransferTooltipRow,
} from "../services/balance.service";
import type { TransactionRow } from "../shared/types"; import type { TransactionRow } from "../shared/types";
export default function TransactionsPage() { export default function TransactionsPage() {
@ -18,6 +22,18 @@ export default function TransactionsPage() {
const [resultMessage, setResultMessage] = useState<string | null>(null); const [resultMessage, setResultMessage] = useState<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null); const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
const [pending, setPending] = useState<TransactionRow | null>(null); const [pending, setPending] = useState<TransactionRow | null>(null);
// Issue #142 — single batch lookup for the inlined transfer icon. One
// SELECT on mount gives us a Map<txId, links[]> the table consults via
// `.has()` per row. Avoids an N+1 hit on the rendered page.
const [linkedTransfersByTxId, setLinkedTransfersByTxId] = useState<
Map<number, LinkedTransferTooltipRow[]>
>(new Map());
useEffect(() => {
listAllLinkedTransfersForTooltip()
.then(setLinkedTransfersByTxId)
.catch(() => setLinkedTransfersByTxId(new Map()));
}, []);
const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => { const handleRowContextMenu = (e: React.MouseEvent, row: TransactionRow) => {
e.preventDefault(); e.preventDefault();
@ -95,6 +111,7 @@ export default function TransactionsPage() {
onSaveSplit={saveSplit} onSaveSplit={saveSplit}
onDeleteSplit={deleteSplit} onDeleteSplit={deleteSplit}
onRowContextMenu={handleRowContextMenu} onRowContextMenu={handleRowContextMenu}
linkedTransfersByTxId={linkedTransfersByTxId}
/> />
<TransactionPagination <TransactionPagination

View file

@ -4,7 +4,17 @@ vi.mock("./db", () => ({
getDb: vi.fn(), getDb: vi.fn(),
})); }));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("./profileService", () => ({
loadProfiles: vi.fn(),
}));
import { getDb } from "./db"; import { getDb } from "./db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "./profileService";
import { import {
listBalanceCategories, listBalanceCategories,
createBalanceCategory, createBalanceCategory,
@ -30,6 +40,14 @@ import {
getSnapshotTotalsByCategoryAndDate, getSnapshotTotalsByCategoryAndDate,
getAccountsLatestSnapshot, getAccountsLatestSnapshot,
getAccountsPeriodAnchor, getAccountsPeriodAnchor,
computeAccountReturn,
linkTransfer,
unlinkTransfer,
listAccountTransfers,
listLinkedTransactionIds,
listAllLinkedTransfersForTooltip,
isLinkedTransactionFkError,
suggestTransferDirection,
} from "./balance.service"; } from "./balance.service";
const mockSelect = vi.fn(); const mockSelect = vi.fn();
@ -974,3 +992,215 @@ describe("getAccountsPeriodAnchor", () => {
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/); 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);
});
});

View file

@ -9,14 +9,19 @@
// filesystem / OAuth / license / profile work and the future Modified Dietz // filesystem / OAuth / license / profile work and the future Modified Dietz
// + price-fetch work in Issue #142. // + price-fetch work in Issue #142.
import { invoke } from "@tauri-apps/api/core";
import { getDb } from "./db"; import { getDb } from "./db";
import { loadProfiles } from "./profileService";
import type { import type {
AccountReturn,
BalanceAccount, BalanceAccount,
BalanceAccountTransferWithTransaction,
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceCategory, BalanceCategory,
BalanceCategoryKind, BalanceCategoryKind,
BalanceSnapshot, BalanceSnapshot,
BalanceSnapshotLine, BalanceSnapshotLine,
BalanceTransferDirection,
} from "../shared/types"; } from "../shared/types";
import { BALANCE_CURRENCY_CAD } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types";
@ -40,7 +45,13 @@ export type BalanceErrorCode =
| "snapshot_priced_quantity_required" | "snapshot_priced_quantity_required"
| "snapshot_priced_unit_price_required" | "snapshot_priced_unit_price_required"
| "snapshot_priced_value_mismatch" | "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 { export class BalanceServiceError extends Error {
readonly code: BalanceErrorCode; readonly code: BalanceErrorCode;
@ -955,3 +966,251 @@ export async function getAccountsPeriodAnchor(
params 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<AccountReturn> {
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<AccountReturn>("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<number> {
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<void> {
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<BalanceAccountTransferWithTransaction[]> {
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<BalanceAccountTransferWithTransaction[]>(
`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<Set<number>> {
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<number, LinkedTransferTooltipRow[]>
> {
const db = await getDb();
const rows = await db.select<LinkedTransferTooltipRow[]>(
`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<number, LinkedTransferTooltipRow[]>();
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);
}

View file

@ -1,4 +1,6 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { isLinkedTransactionFkError } from "./balance.service";
import { TransactionLinkedToBalanceError } from "./transactionService";
import type { ImportedFile, ImportedFileWithSource } from "../shared/types"; import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
export async function getFilesBySourceId( export async function getFilesBySourceId(
@ -94,10 +96,39 @@ export async function deleteImportWithTransactions(
); );
const sourceId = files.length > 0 ? files[0].source_id : null; const sourceId = files.length > 0 ? files[0].source_id : null;
const result = await db.execute( // 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", "DELETE FROM transactions WHERE file_id = $1",
[fileId] [fileId]
); );
} catch (err) {
if (isLinkedTransactionFkError(err)) {
throw new TransactionLinkedToBalanceError(null, []);
}
throw err;
}
await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]); await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]);
// Clean up orphaned source if no files remain // Clean up orphaned source if no files remain
@ -116,7 +147,32 @@ export async function deleteImportWithTransactions(
export async function deleteAllImportsWithTransactions(): Promise<number> { export async function deleteAllImportsWithTransactions(): Promise<number> {
const db = await getDb(); 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 imported_files");
await db.execute("DELETE FROM import_sources"); await db.execute("DELETE FROM import_sources");
return result.rowsAffected; return result.rowsAffected;

View file

@ -1,5 +1,9 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { categorizeBatch } from "./categorizationService"; import { categorizeBatch } from "./categorizationService";
import {
isLinkedTransactionFkError,
type LinkedTransferTooltipRow,
} from "./balance.service";
import type { import type {
Transaction, Transaction,
TransactionRow, TransactionRow,
@ -11,6 +15,85 @@ import type {
SplitChild, SplitChild,
} from "../shared/types"; } 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<void> {
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( export async function insertBatch(
transactions: Array<{ transactions: Array<{
date: string; date: string;

View file

@ -630,3 +630,57 @@ export interface BalanceSnapshotLine {
created_at: string; created_at: string;
updated_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;
}