feat(balance): Modified Dietz returns + transfer linking (#142) #151
21 changed files with 2211 additions and 23 deletions
|
|
@ -3,6 +3,7 @@
|
|||
## [Non publié]
|
||||
|
||||
### Ajouté
|
||||
- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin − V_début − ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T − t_i) / T`, et annualisation `(1 + R)^(365/T) − 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin − V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
|
||||
- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<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 — é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)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end − V_start − ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T − t_i) / T`, plus `(1 + R)^(365/T) − 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end − V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
|
||||
- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<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 — 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
1
src-tauri/Cargo.lock
generated
|
|
@ -4428,6 +4428,7 @@ dependencies = [
|
|||
"aes-gcm",
|
||||
"argon2",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"ed25519-dalek",
|
||||
"encoding_rs",
|
||||
"hmac",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ rand = "0.8"
|
|||
jsonwebtoken = "9"
|
||||
machine-uid = "0.5"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
# Date arithmetic for the Modified Dietz return calculator (Issue #142):
|
||||
# we need day-precision diffs to weight cash flows W_i = (T - t_i) / T.
|
||||
# `serde` feature lets `NaiveDate` cross the Tauri command boundary in JSON.
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde", "std"] }
|
||||
tokio = { version = "1", features = ["macros"] }
|
||||
hostname = "0.4"
|
||||
urlencoding = "2"
|
||||
|
|
|
|||
183
src-tauri/src/commands/balance_commands.rs
Normal file
183
src-tauri/src/commands/balance_commands.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -1,16 +1,22 @@
|
|||
pub mod account_cache;
|
||||
pub mod auth_commands;
|
||||
pub mod backup_commands;
|
||||
pub mod balance_commands;
|
||||
pub mod entitlements;
|
||||
pub mod export_import_commands;
|
||||
pub mod feedback_commands;
|
||||
pub mod fs_commands;
|
||||
pub mod license_commands;
|
||||
pub mod profile_commands;
|
||||
// Modified Dietz return calculator — private helper module used by
|
||||
// `balance_commands.rs`. Kept out of the wildcard re-export below because
|
||||
// nothing outside `commands/` should depend on it.
|
||||
pub(crate) mod return_calculator;
|
||||
pub mod token_store;
|
||||
|
||||
pub use auth_commands::*;
|
||||
pub use backup_commands::*;
|
||||
pub use balance_commands::*;
|
||||
pub use entitlements::*;
|
||||
pub use export_import_commands::*;
|
||||
pub use feedback_commands::*;
|
||||
|
|
|
|||
380
src-tauri/src/commands/return_calculator.rs
Normal file
380
src-tauri/src/commands/return_calculator.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -216,6 +216,7 @@ pub fn run() {
|
|||
commands::ensure_backup_dir,
|
||||
commands::get_file_size,
|
||||
commands::file_exists,
|
||||
commands::compute_account_return,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
// BalanceAccountsTable — one-row-per-active-account table on /balance.
|
||||
//
|
||||
// Issue #141 (Bilan #3). Columns:
|
||||
// - Account name + category label
|
||||
// - Latest snapshot value (or "—" when no snapshot exists yet)
|
||||
// - Δ% over the active period (latest value vs the period-anchor value;
|
||||
// null when no anchor exists, rendered as "—").
|
||||
// - Actions menu (Detail no-op for now, Archive via service).
|
||||
// Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ%
|
||||
// + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via
|
||||
// the Modified Dietz `compute_account_return` Tauri command:
|
||||
//
|
||||
// Future return-metric columns (3M / 1A / since-creation / unadjusted)
|
||||
// land in Issue #142. They have a TODO marker below.
|
||||
// - 3M (last 90 days)
|
||||
// - 1A (last 365 days)
|
||||
// - Depuis création (from earliest snapshot date to today)
|
||||
// - Non-ajusté (simple `(V_end - V_start) / V_start`, no contribution
|
||||
// weighting — shown side-by-side as a sanity check / explanation)
|
||||
//
|
||||
// Returns load lazily on mount via `Promise.all` over (account × horizon),
|
||||
// keyed by `account_id`. Each cell renders "—" while loading and shows the
|
||||
// `is_partial` / `has_no_transfers_warning` badges via tooltip when set.
|
||||
//
|
||||
// Issue #142 also adds a "Lier transferts" item in the per-row actions menu
|
||||
// that opens `LinkTransfersModal` (the modal handles its own state; this
|
||||
// component just bubbles up the request via `onLinkTransfers`).
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, MoreVertical } from "lucide-react";
|
||||
import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react";
|
||||
import type {
|
||||
AccountLatestSnapshot,
|
||||
AccountPeriodAnchor,
|
||||
} from "../../services/balance.service";
|
||||
import { computeAccountReturn } from "../../services/balance.service";
|
||||
import type { AccountReturn } from "../../shared/types";
|
||||
|
||||
const cadFormatter = (locale: string) =>
|
||||
new Intl.NumberFormat(locale, {
|
||||
|
|
@ -25,16 +35,55 @@ const cadFormatter = (locale: string) =>
|
|||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/** Horizon definition: how many days back from today to start the period. */
|
||||
type HorizonKey = "3M" | "1A" | "since";
|
||||
|
||||
interface HorizonRange {
|
||||
key: HorizonKey;
|
||||
/** ISO date for `period_start`. */
|
||||
from: string;
|
||||
/** ISO date for `period_end` (always today, computed in the local civil day). */
|
||||
to: string;
|
||||
}
|
||||
|
||||
function localISO(d: Date): string {
|
||||
const yy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${yy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function isoDaysAgo(days: number, today: Date): string {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - days);
|
||||
return localISO(d);
|
||||
}
|
||||
|
||||
interface BalanceAccountsTableProps {
|
||||
accounts: AccountLatestSnapshot[];
|
||||
periodAnchor: AccountPeriodAnchor[];
|
||||
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
||||
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
|
||||
/**
|
||||
* Earliest snapshot date across the whole profile, used to anchor the
|
||||
* "depuis création" horizon. Falls back to "1A" range if not provided
|
||||
* (avoids triggering computation against the unix epoch).
|
||||
*/
|
||||
sinceCreationDate?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-account, per-horizon return — shape used by the local cache state.
|
||||
* Indexed `[accountId][horizonKey]`.
|
||||
*/
|
||||
type ReturnsByAccount = Record<number, Partial<Record<HorizonKey, AccountReturn>>>;
|
||||
|
||||
export default function BalanceAccountsTable({
|
||||
accounts,
|
||||
periodAnchor,
|
||||
onArchiveAccount,
|
||||
onLinkTransfers,
|
||||
sinceCreationDate,
|
||||
}: BalanceAccountsTableProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
||||
|
|
@ -48,6 +97,65 @@ export default function BalanceAccountsTable({
|
|||
|
||||
const [openMenuFor, setOpenMenuFor] = useState<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) {
|
||||
return (
|
||||
<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 (
|
||||
<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-hidden">
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--muted)]/30">
|
||||
<tr>
|
||||
|
|
@ -73,7 +246,18 @@ export default function BalanceAccountsTable({
|
|||
<th className="text-right px-4 py-3 font-medium">
|
||||
{t("balance.overview.periodDelta")}
|
||||
</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">
|
||||
{t("balance.account.fields.actions")}
|
||||
</th>
|
||||
|
|
@ -88,6 +272,7 @@ export default function BalanceAccountsTable({
|
|||
Math.abs(anchor.anchor_value)) *
|
||||
100
|
||||
: null;
|
||||
const accReturns = returns[acc.account_id] ?? {};
|
||||
return (
|
||||
<tr
|
||||
key={acc.account_id}
|
||||
|
|
@ -123,6 +308,26 @@ export default function BalanceAccountsTable({
|
|||
"—"
|
||||
)}
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -137,7 +342,7 @@ export default function BalanceAccountsTable({
|
|||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{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
|
||||
type="button"
|
||||
disabled
|
||||
|
|
@ -146,6 +351,19 @@ export default function BalanceAccountsTable({
|
|||
>
|
||||
{t("balance.overview.detailAction")}
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ import {
|
|||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import type {
|
||||
SnapshotTotalPoint,
|
||||
SnapshotCategoryBreakdownPoint,
|
||||
} from "../../services/balance.service";
|
||||
import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
|
||||
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
|
||||
|
||||
// Stable palette for the stacked-by-category areas. Indexed deterministically
|
||||
// by category sort order so the colour assignment stays consistent across
|
||||
|
|
@ -51,6 +53,13 @@ export interface BalanceEvolutionChartProps {
|
|||
byCategory: SnapshotCategoryBreakdownPoint[];
|
||||
/** Map category_key → translated label so the legend reads naturally. */
|
||||
categoryLabels?: Record<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({
|
||||
|
|
@ -58,6 +67,7 @@ export default function BalanceEvolutionChart({
|
|||
totals,
|
||||
byCategory,
|
||||
categoryLabels = {},
|
||||
transferMarkers = [],
|
||||
}: BalanceEvolutionChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
|
|
@ -114,6 +124,31 @@ export default function BalanceEvolutionChart({
|
|||
const isEmpty =
|
||||
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
|
||||
|
||||
// Filter transfer markers to dates that are actually rendered on the X
|
||||
// axis (categorical scale ignores unknown ticks). We don't aggregate or
|
||||
// dedupe — the user can have several transfers on the same day across
|
||||
// accounts; ReferenceLine tolerates duplicates fine.
|
||||
const xAxisDates = useMemo(() => {
|
||||
const dates = new Set<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) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
|
||||
|
|
@ -168,6 +203,28 @@ export default function BalanceEvolutionChart({
|
|||
dot={{ r: 3 }}
|
||||
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>
|
||||
) : (
|
||||
<AreaChart
|
||||
|
|
@ -210,6 +267,18 @@ export default function BalanceEvolutionChart({
|
|||
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>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
410
src/components/balance/LinkTransfersModal.tsx
Normal file
410
src/components/balance/LinkTransfersModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { Fragment, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react";
|
||||
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react";
|
||||
import type {
|
||||
TransactionRow,
|
||||
TransactionSort,
|
||||
Category,
|
||||
SplitChild,
|
||||
} from "../../shared/types";
|
||||
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
|
||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
||||
|
||||
|
|
@ -22,6 +23,14 @@ interface TransactionTableProps {
|
|||
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
||||
onDeleteSplit: (parentId: number) => Promise<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({
|
||||
|
|
@ -52,6 +61,7 @@ export default function TransactionTable({
|
|||
onSaveSplit,
|
||||
onDeleteSplit,
|
||||
onRowContextMenu,
|
||||
linkedTransfersByTxId,
|
||||
}: TransactionTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
|
|
@ -141,8 +151,31 @@ export default function TransactionTable({
|
|||
className="hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
||||
<td className="px-3 py-2 max-w-xs truncate" title={row.description}>
|
||||
{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}
|
||||
</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
|
||||
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${
|
||||
|
|
|
|||
|
|
@ -253,6 +253,10 @@
|
|||
"Assign categories by clicking the category dropdown on each row",
|
||||
"Auto-categorize uses your keyword rules to categorize transactions in bulk"
|
||||
]
|
||||
},
|
||||
"transferIcon": {
|
||||
"tooltip": "Linked to a balance account",
|
||||
"ariaLabel": "Transaction linked to a balance account"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
|
|
@ -1638,6 +1642,56 @@
|
|||
"snapshot_priced_unit_price_required": "Unit price is required for priced accounts.",
|
||||
"snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.",
|
||||
"snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price."
|
||||
},
|
||||
"returns": {
|
||||
"partialTooltip": "Partial return: a snapshot is missing for the selected period.",
|
||||
"noTransfersWarning": "No transfers tagged — performance may be skewed if contributions weren't tagged."
|
||||
},
|
||||
"accountsTable": {
|
||||
"return3m": "3M",
|
||||
"return3mTooltip": "Modified Dietz return over the last 90 days.",
|
||||
"return1y": "1Y",
|
||||
"return1yTooltip": "Modified Dietz return over the last 365 days.",
|
||||
"sinceCreation": "Since inception",
|
||||
"sinceCreationTooltip": "Modified Dietz return since the first snapshot.",
|
||||
"unadjusted": "Unadjusted",
|
||||
"unadjustedTooltip": "Simple return (V_end − V_start) / V_start, with no contribution weighting."
|
||||
},
|
||||
"transfers": {
|
||||
"linkAction": "Link transfers",
|
||||
"direction": {
|
||||
"in": "In",
|
||||
"out": "Out"
|
||||
},
|
||||
"modal": {
|
||||
"title": "Link transfers to {{account}}",
|
||||
"subtitle": "Select transactions to attribute to this balance account. The direction is suggested based on the amount sign.",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"category": "Category",
|
||||
"anyCategory": "Any category",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Keyword in description…",
|
||||
"loading": "Loading…",
|
||||
"noTransactions": "No transactions match the filters.",
|
||||
"direction": "Direction",
|
||||
"toggleDirection": "Click to flip direction",
|
||||
"summary": "{{selected}} selected of {{total}} shown",
|
||||
"linkSelection": "Link {{count}} transaction(s)",
|
||||
"linking": "Linking…",
|
||||
"partialFailure": "{{linked}}/{{total}} linked successfully"
|
||||
},
|
||||
"errors": {
|
||||
"transfer_direction_invalid": "Invalid transfer direction (expected in/out).",
|
||||
"transfer_already_linked": "This transaction is already linked to this account.",
|
||||
"transfer_not_linked": "This transaction is not linked to this account.",
|
||||
"transfer_active_profile_unknown": "No active profile — cannot compute return.",
|
||||
"transaction_linked_to_balance_account": "This transaction is linked to balance account {{account}} — unlink it before deleting."
|
||||
}
|
||||
},
|
||||
"evolution": {
|
||||
"transferIn": "In",
|
||||
"transferOut": "Out"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -253,6 +253,10 @@
|
|||
"Assignez une catégorie via le menu déroulant sur chaque ligne",
|
||||
"L'auto-catégorisation utilise vos règles de mots-clés pour catégoriser en masse"
|
||||
]
|
||||
},
|
||||
"transferIcon": {
|
||||
"tooltip": "Liée à un compte de bilan",
|
||||
"ariaLabel": "Transaction liée à un compte de bilan"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
|
|
@ -1638,6 +1642,56 @@
|
|||
"snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.",
|
||||
"snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.",
|
||||
"snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix."
|
||||
},
|
||||
"returns": {
|
||||
"partialTooltip": "Rendement partiel : un snapshot manque pour calculer la performance sur cette période.",
|
||||
"noTransfersWarning": "Aucun transfert lié — la performance peut être faussée si des apports n'ont pas été tagués."
|
||||
},
|
||||
"accountsTable": {
|
||||
"return3m": "3M",
|
||||
"return3mTooltip": "Rendement Modified Dietz sur les 90 derniers jours.",
|
||||
"return1y": "1A",
|
||||
"return1yTooltip": "Rendement Modified Dietz sur les 365 derniers jours.",
|
||||
"sinceCreation": "Depuis création",
|
||||
"sinceCreationTooltip": "Rendement Modified Dietz depuis le premier snapshot.",
|
||||
"unadjusted": "Non ajusté",
|
||||
"unadjustedTooltip": "Rendement simple (V_fin − V_début) / V_début, sans pondération des apports."
|
||||
},
|
||||
"transfers": {
|
||||
"linkAction": "Lier transferts",
|
||||
"direction": {
|
||||
"in": "Entrée",
|
||||
"out": "Sortie"
|
||||
},
|
||||
"modal": {
|
||||
"title": "Lier des transferts à {{account}}",
|
||||
"subtitle": "Sélectionnez les transactions à attribuer à ce compte de bilan. La direction est proposée d'après le signe du montant.",
|
||||
"from": "Du",
|
||||
"to": "Au",
|
||||
"category": "Catégorie",
|
||||
"anyCategory": "Toutes les catégories",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Mot-clé dans la description…",
|
||||
"loading": "Chargement…",
|
||||
"noTransactions": "Aucune transaction ne correspond aux filtres.",
|
||||
"direction": "Sens",
|
||||
"toggleDirection": "Cliquer pour inverser le sens",
|
||||
"summary": "{{selected}} sélectionnée(s) sur {{total}} affichée(s)",
|
||||
"linkSelection": "Lier {{count}} transaction(s)",
|
||||
"linking": "Liaison…",
|
||||
"partialFailure": "{{linked}}/{{total}} liées avec succès"
|
||||
},
|
||||
"errors": {
|
||||
"transfer_direction_invalid": "Direction de transfert invalide (in/out attendu).",
|
||||
"transfer_already_linked": "Cette transaction est déjà liée à ce compte.",
|
||||
"transfer_not_linked": "Cette transaction n'est pas liée à ce compte.",
|
||||
"transfer_active_profile_unknown": "Aucun profil actif — impossible de calculer le rendement.",
|
||||
"transaction_linked_to_balance_account": "Cette transaction est liée au compte de bilan {{account}} — déliez-la avant de supprimer."
|
||||
}
|
||||
},
|
||||
"evolution": {
|
||||
"transferIn": "Entrée",
|
||||
"transferOut": "Sortie"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves
|
||||
// columns with a TODO comment.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wallet } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -19,10 +19,17 @@ import {
|
|||
type BalancePeriod,
|
||||
type BalanceChartMode,
|
||||
} from "../hooks/useBalanceOverview";
|
||||
import { archiveBalanceAccount } from "../services/balance.service";
|
||||
import {
|
||||
archiveBalanceAccount,
|
||||
listAccountTransfers,
|
||||
type AccountLatestSnapshot,
|
||||
} from "../services/balance.service";
|
||||
import { getAllCategories } from "../services/transactionService";
|
||||
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
||||
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
||||
|
||||
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
||||
|
||||
|
|
@ -30,6 +37,58 @@ export default function BalancePage() {
|
|||
const { t } = useTranslation();
|
||||
const { state, setPeriod, setChartMode, reload } = useBalanceOverview();
|
||||
|
||||
// Issue #142 — link-transfers modal state. Categories list is loaded once
|
||||
// on mount (used by the modal's filter dropdown).
|
||||
const [linkTarget, setLinkTarget] = useState<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 —
|
||||
// the byCategory series is keyed by `key`, not by id, and the same
|
||||
// taxonomy is already loaded with `accountsLatest` joins.
|
||||
|
|
@ -123,6 +182,7 @@ export default function BalancePage() {
|
|||
totals={state.evolutionTotals}
|
||||
byCategory={state.evolutionByCategory}
|
||||
categoryLabels={categoryLabels}
|
||||
transferMarkers={allTransferMarkers}
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
|
@ -132,10 +192,24 @@ export default function BalancePage() {
|
|||
<BalanceAccountsTable
|
||||
accounts={state.accountsLatest}
|
||||
periodAnchor={state.accountsPeriodAnchor}
|
||||
sinceCreationDate={earliestSnapshotDate}
|
||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{linkTarget && (
|
||||
<LinkTransfersModal
|
||||
accountId={linkTarget.account_id}
|
||||
accountName={linkTarget.account_name}
|
||||
categories={categories}
|
||||
onClose={() => setLinkTarget(null)}
|
||||
onLinked={() => {
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2, Tag } from "lucide-react";
|
||||
import { PageHelp } from "../components/shared/PageHelp";
|
||||
|
|
@ -9,6 +9,10 @@ import TransactionTable from "../components/transactions/TransactionTable";
|
|||
import TransactionPagination from "../components/transactions/TransactionPagination";
|
||||
import ContextMenu from "../components/shared/ContextMenu";
|
||||
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
|
||||
import {
|
||||
listAllLinkedTransfersForTooltip,
|
||||
type LinkedTransferTooltipRow,
|
||||
} from "../services/balance.service";
|
||||
import type { TransactionRow } from "../shared/types";
|
||||
|
||||
export default function TransactionsPage() {
|
||||
|
|
@ -18,6 +22,18 @@ export default function TransactionsPage() {
|
|||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; row: 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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -95,6 +111,7 @@ export default function TransactionsPage() {
|
|||
onSaveSplit={saveSplit}
|
||||
onDeleteSplit={deleteSplit}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
linkedTransfersByTxId={linkedTransfersByTxId}
|
||||
/>
|
||||
|
||||
<TransactionPagination
|
||||
|
|
|
|||
|
|
@ -4,7 +4,17 @@ vi.mock("./db", () => ({
|
|||
getDb: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./profileService", () => ({
|
||||
loadProfiles: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getDb } from "./db";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { loadProfiles } from "./profileService";
|
||||
import {
|
||||
listBalanceCategories,
|
||||
createBalanceCategory,
|
||||
|
|
@ -30,6 +40,14 @@ import {
|
|||
getSnapshotTotalsByCategoryAndDate,
|
||||
getAccountsLatestSnapshot,
|
||||
getAccountsPeriodAnchor,
|
||||
computeAccountReturn,
|
||||
linkTransfer,
|
||||
unlinkTransfer,
|
||||
listAccountTransfers,
|
||||
listLinkedTransactionIds,
|
||||
listAllLinkedTransfersForTooltip,
|
||||
isLinkedTransactionFkError,
|
||||
suggestTransferDirection,
|
||||
} from "./balance.service";
|
||||
|
||||
const mockSelect = vi.fn();
|
||||
|
|
@ -974,3 +992,215 @@ describe("getAccountsPeriodAnchor", () => {
|
|||
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Returns + transfers (Issue #142)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("computeAccountReturn", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadProfiles).mockReset();
|
||||
vi.mocked(invoke).mockReset();
|
||||
});
|
||||
|
||||
it("invokes the Tauri command with the active profile's db_filename", async () => {
|
||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||
active_profile_id: "p1",
|
||||
profiles: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Max",
|
||||
color: "#fff",
|
||||
pin_hash: null,
|
||||
db_filename: "max.db",
|
||||
created_at: "0",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fakeReturn = {
|
||||
value_start: 1000,
|
||||
value_end: 1100,
|
||||
net_contributions: 0,
|
||||
return_pct: 0.1,
|
||||
annualized_pct: 0.42,
|
||||
is_partial: false,
|
||||
has_no_transfers_warning: true,
|
||||
};
|
||||
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
|
||||
|
||||
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-01");
|
||||
|
||||
expect(out).toEqual(fakeReturn);
|
||||
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
|
||||
dbFilename: "max.db",
|
||||
accountId: 7,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-04-01",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed period dates before invoking the command", async () => {
|
||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||
active_profile_id: "p1",
|
||||
profiles: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Max",
|
||||
color: "#fff",
|
||||
pin_hash: null,
|
||||
db_filename: "max.db",
|
||||
created_at: "0",
|
||||
},
|
||||
],
|
||||
});
|
||||
await expect(
|
||||
computeAccountReturn(1, "not-a-date", "2026-04-01")
|
||||
).rejects.toBeInstanceOf(BalanceServiceError);
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws transfer_active_profile_unknown when no active profile resolves", async () => {
|
||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||
active_profile_id: "missing",
|
||||
profiles: [],
|
||||
});
|
||||
await expect(
|
||||
computeAccountReturn(1, "2026-01-01", "2026-04-01")
|
||||
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestTransferDirection", () => {
|
||||
it("maps negative bank amounts to 'in' (money left bank → arrived in account)", () => {
|
||||
expect(suggestTransferDirection(-100)).toBe("in");
|
||||
});
|
||||
it("maps positive bank amounts to 'out' (money came back from account)", () => {
|
||||
expect(suggestTransferDirection(50)).toBe("out");
|
||||
});
|
||||
it("treats zero as 'out' as a deterministic fallback", () => {
|
||||
expect(suggestTransferDirection(0)).toBe("out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkTransfer", () => {
|
||||
it("rejects an invalid direction without touching the DB", async () => {
|
||||
await expect(
|
||||
// @ts-expect-error testing runtime guard
|
||||
linkTransfer(1, 2, "sideways")
|
||||
).rejects.toBeInstanceOf(BalanceServiceError);
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("guards against duplicate links with a typed error", async () => {
|
||||
mockSelect.mockResolvedValueOnce([{ id: 5 }]);
|
||||
await expect(linkTransfer(1, 2, "in")).rejects.toMatchObject({
|
||||
code: "transfer_already_linked",
|
||||
});
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inserts and returns the new transfer id", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
|
||||
const id = await linkTransfer(1, 2, "out", " manual ");
|
||||
expect(id).toBe(99);
|
||||
const sql = mockExecute.mock.calls[0][0] as string;
|
||||
expect(sql).toContain("INSERT INTO balance_account_transfers");
|
||||
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2, "out", "manual"]);
|
||||
});
|
||||
|
||||
it("normalizes empty notes to null", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
|
||||
await linkTransfer(1, 2, "in", " ");
|
||||
expect(mockExecute.mock.calls[0][1][3]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlinkTransfer", () => {
|
||||
it("throws transfer_not_linked when no row was deleted", async () => {
|
||||
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 0 });
|
||||
await expect(unlinkTransfer(1, 2)).rejects.toMatchObject({
|
||||
code: "transfer_not_linked",
|
||||
});
|
||||
});
|
||||
|
||||
it("succeeds when one row is deleted", async () => {
|
||||
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 1 });
|
||||
await expect(unlinkTransfer(1, 2)).resolves.toBeUndefined();
|
||||
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAccountTransfers", () => {
|
||||
it("filters by account_id only when no date range is supplied", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
await listAccountTransfers(7);
|
||||
const sql = mockSelect.mock.calls[0][0] as string;
|
||||
expect(sql).toContain("FROM balance_account_transfers bat");
|
||||
expect(sql).toContain("JOIN transactions t");
|
||||
expect(sql).toContain("JOIN balance_accounts a");
|
||||
expect(sql).toContain("WHERE bat.account_id = $1");
|
||||
expect(sql).not.toContain("t.date >=");
|
||||
expect(mockSelect.mock.calls[0][1]).toEqual([7]);
|
||||
});
|
||||
|
||||
it("appends inclusive date bounds when supplied", async () => {
|
||||
mockSelect.mockResolvedValueOnce([]);
|
||||
await listAccountTransfers(7, { from: "2026-01-01", to: "2026-04-01" });
|
||||
const sql = mockSelect.mock.calls[0][0] as string;
|
||||
expect(sql).toContain("t.date >=");
|
||||
expect(sql).toContain("t.date <=");
|
||||
expect(mockSelect.mock.calls[0][1]).toEqual([7, "2026-01-01", "2026-04-01"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listLinkedTransactionIds", () => {
|
||||
it("returns a Set of transaction ids", async () => {
|
||||
mockSelect.mockResolvedValueOnce([
|
||||
{ transaction_id: 5 },
|
||||
{ transaction_id: 12 },
|
||||
]);
|
||||
const ids = await listLinkedTransactionIds();
|
||||
expect(ids).toBeInstanceOf(Set);
|
||||
expect(ids.has(5)).toBe(true);
|
||||
expect(ids.has(12)).toBe(true);
|
||||
expect(ids.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAllLinkedTransfersForTooltip", () => {
|
||||
it("groups multiple links per transaction id", async () => {
|
||||
mockSelect.mockResolvedValueOnce([
|
||||
{ transaction_id: 1, account_id: 10, account_name: "TFSA", direction: "in" },
|
||||
{ transaction_id: 1, account_id: 20, account_name: "RRSP", direction: "out" },
|
||||
{ transaction_id: 2, account_id: 10, account_name: "TFSA", direction: "in" },
|
||||
]);
|
||||
const map = await listAllLinkedTransfersForTooltip();
|
||||
expect(map.get(1)).toHaveLength(2);
|
||||
expect(map.get(2)).toHaveLength(1);
|
||||
expect(map.get(1)?.[0].account_name).toBe("TFSA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLinkedTransactionFkError", () => {
|
||||
it("matches the canonical SQLite FK error text", () => {
|
||||
expect(
|
||||
isLinkedTransactionFkError(new Error("FOREIGN KEY constraint failed"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the wrapped tauri-plugin-sql variant", () => {
|
||||
expect(
|
||||
isLinkedTransactionFkError(
|
||||
new Error("code: 787, message: FOREIGN KEY constraint failed")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match unrelated errors", () => {
|
||||
expect(isLinkedTransactionFkError(new Error("something else"))).toBe(false);
|
||||
expect(isLinkedTransactionFkError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,14 +9,19 @@
|
|||
// filesystem / OAuth / license / profile work and the future Modified Dietz
|
||||
// + price-fetch work in Issue #142.
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getDb } from "./db";
|
||||
import { loadProfiles } from "./profileService";
|
||||
import type {
|
||||
AccountReturn,
|
||||
BalanceAccount,
|
||||
BalanceAccountTransferWithTransaction,
|
||||
BalanceAccountWithCategory,
|
||||
BalanceCategory,
|
||||
BalanceCategoryKind,
|
||||
BalanceSnapshot,
|
||||
BalanceSnapshotLine,
|
||||
BalanceTransferDirection,
|
||||
} from "../shared/types";
|
||||
import { BALANCE_CURRENCY_CAD } from "../shared/types";
|
||||
|
||||
|
|
@ -40,7 +45,13 @@ export type BalanceErrorCode =
|
|||
| "snapshot_priced_quantity_required"
|
||||
| "snapshot_priced_unit_price_required"
|
||||
| "snapshot_priced_value_mismatch"
|
||||
| "snapshot_simple_must_be_scalar";
|
||||
| "snapshot_simple_must_be_scalar"
|
||||
// Issue #142 — transfers + returns
|
||||
| "transfer_direction_invalid"
|
||||
| "transfer_already_linked"
|
||||
| "transfer_not_linked"
|
||||
| "transfer_active_profile_unknown"
|
||||
| "transaction_linked_to_balance_account";
|
||||
|
||||
export class BalanceServiceError extends Error {
|
||||
readonly code: BalanceErrorCode;
|
||||
|
|
@ -955,3 +966,251 @@ export async function getAccountsPeriodAnchor(
|
|||
params
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Returns + transfers (Issue #142 / Bilan #4)
|
||||
// -----------------------------------------------------------------------------
|
||||
//
|
||||
// Two distinct surface areas:
|
||||
//
|
||||
// (1) `computeAccountReturn` — Modified Dietz return for one account over a
|
||||
// period. Lives on the Rust side (`compute_account_return` Tauri command)
|
||||
// because it needs to JOIN snapshots + transfers + transactions and
|
||||
// apply day-precision weighting in a single short-lived connection. The
|
||||
// TS shim resolves the active profile's `db_filename` from `loadProfiles`
|
||||
// and forwards it to the command.
|
||||
//
|
||||
// (2) Transfer linking helpers — `linkTransfer`, `unlinkTransfer`,
|
||||
// `listAccountTransfers`. Plain CRUD on `balance_account_transfers` via
|
||||
// `getDb()`, same pattern as the rest of this file.
|
||||
|
||||
/**
|
||||
* Compute the Modified Dietz return for `accountId` over the period
|
||||
* `[periodStart, periodEnd]` (both ISO `YYYY-MM-DD`). Returns the typed
|
||||
* `AccountReturn` shape — see `src/shared/types/index.ts`.
|
||||
*
|
||||
* Resolves the active profile's `db_filename` from `loadProfiles()` so the
|
||||
* caller doesn't have to thread it through every screen. Throws
|
||||
* `transfer_active_profile_unknown` if no active profile is set (should be
|
||||
* impossible in normal app flow, but the service guards it anyway).
|
||||
*/
|
||||
export async function computeAccountReturn(
|
||||
accountId: number,
|
||||
periodStart: string,
|
||||
periodEnd: string
|
||||
): Promise<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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { getDb } from "./db";
|
||||
import { isLinkedTransactionFkError } from "./balance.service";
|
||||
import { TransactionLinkedToBalanceError } from "./transactionService";
|
||||
import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
|
||||
|
||||
export async function getFilesBySourceId(
|
||||
|
|
@ -94,10 +96,39 @@ export async function deleteImportWithTransactions(
|
|||
);
|
||||
const sourceId = files.length > 0 ? files[0].source_id : null;
|
||||
|
||||
const result = await db.execute(
|
||||
"DELETE FROM transactions WHERE file_id = $1",
|
||||
// Pre-flight: if any transaction in this file is linked to a balance
|
||||
// account via `balance_account_transfers`, the FK RESTRICT will fire on
|
||||
// the bulk DELETE. Surface a typed error BEFORE touching the row so the
|
||||
// UI can prompt the user to unlink first (Issue #142).
|
||||
const linked = await db.select<
|
||||
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
|
||||
>(
|
||||
`SELECT bat.transaction_id AS transaction_id,
|
||||
bat.account_id AS account_id,
|
||||
a.name AS account_name,
|
||||
bat.direction AS direction
|
||||
FROM balance_account_transfers bat
|
||||
JOIN transactions t ON t.id = bat.transaction_id
|
||||
JOIN balance_accounts a ON a.id = bat.account_id
|
||||
WHERE t.file_id = $1`,
|
||||
[fileId]
|
||||
);
|
||||
if (linked.length > 0) {
|
||||
throw new TransactionLinkedToBalanceError(null, linked);
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await db.execute(
|
||||
"DELETE FROM transactions WHERE file_id = $1",
|
||||
[fileId]
|
||||
);
|
||||
} catch (err) {
|
||||
if (isLinkedTransactionFkError(err)) {
|
||||
throw new TransactionLinkedToBalanceError(null, []);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]);
|
||||
|
||||
// Clean up orphaned source if no files remain
|
||||
|
|
@ -116,7 +147,32 @@ export async function deleteImportWithTransactions(
|
|||
|
||||
export async function deleteAllImportsWithTransactions(): Promise<number> {
|
||||
const db = await getDb();
|
||||
const result = await db.execute("DELETE FROM transactions");
|
||||
// Same pre-flight as the per-file path: if ANY transaction is linked to
|
||||
// a balance account, the bulk wipe would explode on FK RESTRICT — surface
|
||||
// a typed error so the UI can prompt the user to unlink first.
|
||||
const linked = await db.select<
|
||||
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
|
||||
>(
|
||||
`SELECT bat.transaction_id AS transaction_id,
|
||||
bat.account_id AS account_id,
|
||||
a.name AS account_name,
|
||||
bat.direction AS direction
|
||||
FROM balance_account_transfers bat
|
||||
JOIN balance_accounts a ON a.id = bat.account_id
|
||||
LIMIT 50`
|
||||
);
|
||||
if (linked.length > 0) {
|
||||
throw new TransactionLinkedToBalanceError(null, linked);
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await db.execute("DELETE FROM transactions");
|
||||
} catch (err) {
|
||||
if (isLinkedTransactionFkError(err)) {
|
||||
throw new TransactionLinkedToBalanceError(null, []);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await db.execute("DELETE FROM imported_files");
|
||||
await db.execute("DELETE FROM import_sources");
|
||||
return result.rowsAffected;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { getDb } from "./db";
|
||||
import { categorizeBatch } from "./categorizationService";
|
||||
import {
|
||||
isLinkedTransactionFkError,
|
||||
type LinkedTransferTooltipRow,
|
||||
} from "./balance.service";
|
||||
import type {
|
||||
Transaction,
|
||||
TransactionRow,
|
||||
|
|
@ -11,6 +15,85 @@ import type {
|
|||
SplitChild,
|
||||
} from "../shared/types";
|
||||
|
||||
/**
|
||||
* Thrown when a deletion path is blocked by `balance_account_transfers.transaction_id`
|
||||
* FK RESTRICT. Carries the offending `transaction_id`(s) so the UI can format a
|
||||
* precise message ("Cette transaction est liée au compte de bilan X — déliez-la
|
||||
* avant de supprimer") and can offer a deep link to the linked account.
|
||||
*
|
||||
* `linkedAccounts` is best-effort: when known (single-row delete) the array
|
||||
* lists every account currently linking the transaction. For bulk deletes
|
||||
* the array may be empty — the UI just shows the generic message in that
|
||||
* case.
|
||||
*/
|
||||
export class TransactionLinkedToBalanceError extends Error {
|
||||
readonly code = "transaction_linked_to_balance_account" as const;
|
||||
readonly transactionId: number | null;
|
||||
readonly linkedAccounts: LinkedTransferTooltipRow[];
|
||||
constructor(
|
||||
transactionId: number | null,
|
||||
linkedAccounts: LinkedTransferTooltipRow[],
|
||||
message?: string
|
||||
) {
|
||||
super(
|
||||
message ??
|
||||
"Transaction is linked to one or more balance accounts; unlink before deleting"
|
||||
);
|
||||
this.name = "TransactionLinkedToBalanceError";
|
||||
this.transactionId = transactionId;
|
||||
this.linkedAccounts = linkedAccounts;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one transaction by id. Throws `TransactionLinkedToBalanceError` if
|
||||
* the transaction has any row in `balance_account_transfers` (FK RESTRICT)
|
||||
* — UI surfaces "Cette transaction est liée au compte de bilan X — déliez-la
|
||||
* avant de supprimer" with a link to the offending account.
|
||||
*
|
||||
* Pre-checks the link table so the error carries account names; falls back
|
||||
* to the FK-error pattern matcher if the constraint fires for any other
|
||||
* reason.
|
||||
*/
|
||||
export async function deleteTransaction(transactionId: number): Promise<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(
|
||||
transactions: Array<{
|
||||
date: string;
|
||||
|
|
|
|||
|
|
@ -630,3 +630,57 @@ export interface BalanceSnapshotLine {
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Account transfers — added Issue #142 (Bilan #4). Links a transaction to a
|
||||
// balance account so the Modified Dietz return calculator can separate
|
||||
// contributions from gains. Direction follows the account's perspective:
|
||||
// 'in' = capital added (deposit / buy)
|
||||
// 'out' = capital removed (withdrawal / sell)
|
||||
// `transaction_id` ON DELETE RESTRICT — preserves reproducibility of past
|
||||
// returns, the UI must force the user to unlink before deleting the
|
||||
// underlying transaction.
|
||||
export type BalanceTransferDirection = "in" | "out";
|
||||
|
||||
export interface BalanceAccountTransfer {
|
||||
id: number;
|
||||
account_id: number;
|
||||
transaction_id: number;
|
||||
direction: BalanceTransferDirection;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Joined view used by LinkTransfersModal + transaction icon lookup. */
|
||||
export interface BalanceAccountTransferWithTransaction
|
||||
extends BalanceAccountTransfer {
|
||||
transaction_date: string;
|
||||
transaction_description: string;
|
||||
transaction_amount: number;
|
||||
account_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modified Dietz return for one account over a period.
|
||||
* Mirrors the Rust struct in `src-tauri/src/commands/return_calculator.rs`.
|
||||
*
|
||||
* - `value_start` / `value_end`: latest snapshot value ≤ each endpoint, or
|
||||
* null when no snapshot exists.
|
||||
* - `net_contributions`: signed sum of cash flows in the period.
|
||||
* - `return_pct`: Modified Dietz return (0.05 = +5%); null if either
|
||||
* endpoint is missing or denominator is non-positive.
|
||||
* - `annualized_pct`: `(1 + R)^(365/T) - 1`; null for zero-length periods
|
||||
* or whenever `return_pct` is null.
|
||||
* - `is_partial`: true when one endpoint snapshot is missing.
|
||||
* - `has_no_transfers_warning`: true when no transfers were tagged in the
|
||||
* period — return collapses to simple `(V_end - V_start) / V_start` and
|
||||
* the UI surfaces a warning so the user can verify nothing was forgotten.
|
||||
*/
|
||||
export interface AccountReturn {
|
||||
value_start: number | null;
|
||||
value_end: number | null;
|
||||
net_contributions: number;
|
||||
return_pct: number | null;
|
||||
annualized_pct: number | null;
|
||||
is_partial: boolean;
|
||||
has_no_transfers_warning: boolean;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue