Compare commits

..

8 commits

Author SHA1 Message Date
le king fu
ca275821bc feat(balance): i18n + CHANGELOG for returns/transfers
All checks were successful
PR Check / rust (push) Successful in 22m37s
PR Check / frontend (push) Successful in 2m30s
Issue #142 / Bilan #4 — translations and changelog entries.

i18n (FR + EN):
- `balance.returns.partialTooltip`, `balance.returns.noTransfersWarning`
- `balance.accountsTable.return3m/return1y/sinceCreation/unadjusted`
  (label + tooltip variants)
- `balance.transfers.linkAction` + `balance.transfers.direction.{in,out}`
- `balance.transfers.modal.*` (every modal label, including the
  partial-failure summary and the per-row direction toggle)
- `balance.transfers.errors.*` (5 new typed error codes)
- `balance.evolution.transferIn/transferOut` (chart label)
- `transactions.transferIcon.tooltip/ariaLabel`

CHANGELOG (English source + French translation):
- New entry under `[Unreleased]` summarising the Modified Dietz
  formula, the per-account return columns (3M / 1A / since-inception
  + unadjusted), the link-transfers modal, the transactions-page
  inline icon, the typed FK error on bulk-delete paths, and the
  vertical reference markers on the evolution chart. References #142.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:39:06 -04:00
le king fu
faa09614a3 feat(balance): add transfer markers on evolution chart
Issue #142 / Bilan #4 — vertical reference lines for tagged transfers.

`BalanceEvolutionChart.tsx` accepts a new optional prop
`transferMarkers?: BalanceAccountTransferWithTransaction[]`. For every
marker whose `transaction_date` matches a date already on the X axis,
the chart renders a `<ReferenceLine>` (Recharts) — green for `in`
(capital added), red for `out` (capital removed). The marker is drawn
in both `line` and `stacked` modes; in line mode an inline label
("In" / "Out") sits at the top-right of the marker so the user can
identify the direction without hovering.

Markers whose date is between two snapshot ticks are filtered out
(Recharts categorical axis silently drops unknown ticks; preferred
over an off-axis bug). A future improvement is to switch the X axis
to a numeric/time scale so markers can land anywhere — out of scope
here per the autopilot prompt's "least invasive" guideline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:55 -04:00
le king fu
0e996a5aa1 feat(transactions): inline transfer icon + FK error message
Issue #142 / Bilan #4 — non-regressive transfer awareness in the
transactions table + clean error mapping on bulk delete.

- `TransactionTable.tsx`: optional new prop
  `linkedTransfersByTxId?: Map<txId, links[]>`. When supplied, a small
  `<Link2>` icon appears next to the description for every linked
  transaction; tooltip lists the account name(s) and direction(s).
  Without the prop, the table renders byte-for-byte identical to
  before — preserves the spec's non-regression invariant.
- `TransactionsPage.tsx`: loads the linked-transfers map once on mount
  via `listAllLinkedTransfersForTooltip()` (one batch SELECT) and
  threads it through to the table. Failure to load the map degrades
  gracefully to an empty map (icon simply doesn't appear).
- `transactionService.ts`: new `deleteTransaction(id)` helper +
  `TransactionLinkedToBalanceError` (typed FK guard). Pre-checks
  `balance_account_transfers` before attempting the DELETE so the
  error carries the offending account names; falls back to the FK
  pattern matcher if a race linked the transaction between the
  SELECT and the DELETE.
- `importedFileService.ts`: both bulk delete paths
  (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`)
  now pre-check for any linked transfer and surface the same typed
  error before they would explode on FK RESTRICT. The pre-check has
  a `LIMIT 50` safety cap on the global path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:46 -04:00
le king fu
a45e5c3cd0 feat(balance): add LinkTransfersModal + return columns in accounts table
Issue #142 / Bilan #4 — UI for transfer linking + per-account returns.

- New `LinkTransfersModal.tsx`: portal modal with date-range / category /
  free-text filters, multi-select with auto-proposed direction (`in` for
  negative bank amounts, `out` for positive — flippable per row).
  Submits via sequential `linkTransfer` calls; reports per-row failures
  inline (most common case: `transfer_already_linked` on a re-submit).
- `BalanceAccountsTable.tsx`: 4 new columns rendered side-by-side —
  3M / 1A / Since-inception (Modified Dietz via `compute_account_return`)
  + Unadjusted (`(V_end - V_start) / V_start`). Returns load lazily
  after mount via `Promise.all` over (account × horizon); per-cell
  failure leaves the slot at "—" without blocking the rest of the
  table. The actions menu gains a *Link transfers* item that bubbles
  the request up to the parent page. New props:
  `sinceCreationDate` (anchors the since-inception horizon) and
  `onLinkTransfers` (modal opener).
- `BalancePage.tsx`: hosts the new modal, loads the categories list
  once on mount for the filter dropdown, fetches the union of
  `listAccountTransfers` per account so the chart can render markers,
  and threads the earliest snapshot date down to the table. Reload
  is triggered after the modal reports at least one successful link.
- `balance.service.ts`: dropped the unused `BalanceAccountTransfer`
  import to satisfy `tsc --noUnusedLocals`.

`npm run build` clean. `npm test` → 429 passed. Manual sanity check:
the table renders "…" placeholders during the per-row return load,
then resolves to either a percentage or a "—" with the partial
tooltip when the underlying snapshot endpoint is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:38:24 -04:00
le king fu
dafdd4ce17 feat(balance): add returns + transfers section to balance.service
Issue #142 / Bilan #4 — TS bridge for the Modified Dietz command + plain
CRUD for transfer linking.

Types (`src/shared/types/index.ts`):
- `BalanceTransferDirection` ('in' | 'out')
- `BalanceAccountTransfer` (raw row) +
  `BalanceAccountTransferWithTransaction` (joined view)
- `AccountReturn` (mirrors the Rust struct, ready to receive the invoke
  payload as-is)

Service (`src/services/balance.service.ts`):
- `computeAccountReturn(accountId, periodStart, periodEnd)`: resolves the
  active profile's `db_filename` from `loadProfiles()` and calls the
  `compute_account_return` Tauri command.
- `linkTransfer(accountId, transactionId, direction, notes?)`: INSERT
  with duplicate guard (typed `transfer_already_linked` error instead of
  raw SQL UNIQUE failure).
- `unlinkTransfer(accountId, transactionId)`: DELETE with
  `transfer_not_linked` guard for stale-UI calls.
- `listAccountTransfers(accountId, dateRange?)`: joined SELECT for
  modal/list rendering.
- `listLinkedTransactionIds()`: returns a `Set<number>` for the
  transaction icon (one query, in-memory `.has()` lookups thereafter).
- `listAllLinkedTransfersForTooltip()`: returns
  `Map<transactionId, links[]>` for tooltip rendering.
- `suggestTransferDirection(amount)`: pure helper for the modal — maps
  negative bank amounts to 'in', positive to 'out'.
- `isLinkedTransactionFkError(error)`: detects the canonical SQLite "FK
  constraint failed" text so `transactionService.deleteTransaction` can
  surface a clear i18n message.
- 5 new error codes added to `BalanceErrorCode`.

Tests (`balance.service.test.ts`): 22 new vitest cases bringing the file
to 85 passed. Mocks `@tauri-apps/api/core` `invoke` and
`./profileService` `loadProfiles`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:27:16 -04:00
le king fu
23ff8466c0 fix(balance): use transactions.date column (not transaction_date)
The schema's transactions table uses `date` (see schema.sql:67), not
`transaction_date`. Compile-checked the column name was correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:24:13 -04:00
le king fu
0381dd48bb feat(balance): add compute_account_return Tauri command
Issue #142 / Bilan #4 — server-side Modified Dietz wrapper.

- New `src-tauri/src/commands/balance_commands.rs` with single command
  `compute_account_return(db_filename, account_id, period_start, period_end)`:
  - Opens the active profile DB via `rusqlite::Connection::open(app_data_dir
    / db_filename)` — matches `repair_migrations` / `delete_profile_db`.
  - Reads `value_start` (latest snapshot ≤ period_start) + `value_end`
    (latest snapshot ≤ period_end) via correlated SELECT.
  - Reads cash flows via JOIN `balance_account_transfers` ⨝
    `transactions` filtered by `transaction_date BETWEEN`. Sign applied
    per direction (`in` → +, `out` → −).
  - Calls `return_calculator::modified_dietz`, returns typed
    `AccountReturn`.
- Registered in `commands/mod.rs` (pub use) and in `lib.rs`'
  `tauri::generate_handler!` array.

`cargo check` clean. `cargo test --lib` → 54 passed (including the 7
return_calculator + 7 migration_v9 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:23:14 -04:00
le king fu
c9cdb5a891 feat(balance): add chrono dep + Modified Dietz return_calculator with tests
Issue #142 / Bilan #4 — TDD step 1.

- Added `chrono = "0.4"` (default-features off, `serde` + `std` features)
  to `src-tauri/Cargo.toml` for day-precision date arithmetic.
- New private module `src-tauri/src/commands/return_calculator.rs`:
  - `pub(crate) fn modified_dietz(value_start, value_end, cash_flows,
    period_start, period_end) -> AccountReturn`
  - `AccountReturn { value_start, value_end, net_contributions, return_pct,
    annualized_pct, is_partial, has_no_transfers_warning }` (Serialize)
  - Edge cases handled: missing start/end snapshot (`is_partial = true`,
    `return_pct = None`), no transfers (collapses to simple return + warn
    flag), zero-length period (skips annualization), V_start = 0 with first
    flow > 0 (account-created mid-period), depleted-then-refilled (no
    panic, finite output).
- 7 co-located TDD tests covering nominal + every edge case above.
- Module declared `pub(crate)` in `commands/mod.rs` (kept out of the
  wildcard re-export — only `balance_commands.rs` will consume it).

`cargo test --lib commands::return_calculator` → 7 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:21:37 -04:00
21 changed files with 2211 additions and 23 deletions

View file

@ -3,6 +3,7 @@
## [Non publié] ## [Non publié]
### Ajouté ### Ajouté
- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin V_début ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T t_i) / T`, et annualisation `(1 + R)^(365/T) 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<Area stackId>` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141) - **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<Area stackId>` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140) - **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146) - **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)

View file

@ -3,6 +3,7 @@
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end V_start ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T t_i) / T`, plus `(1 + R)^(365/T) 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<Area stackId>` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141) - **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<Area stackId>` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140) - **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146) - **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)

1
src-tauri/Cargo.lock generated
View file

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

View file

@ -41,6 +41,10 @@ rand = "0.8"
jsonwebtoken = "9" jsonwebtoken = "9"
machine-uid = "0.5" machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
# Date arithmetic for the Modified Dietz return calculator (Issue #142):
# we need day-precision diffs to weight cash flows W_i = (T - t_i) / T.
# `serde` feature lets `NaiveDate` cross the Tauri command boundary in JSON.
chrono = { version = "0.4", default-features = false, features = ["serde", "std"] }
tokio = { version = "1", features = ["macros"] } tokio = { version = "1", features = ["macros"] }
hostname = "0.4" hostname = "0.4"
urlencoding = "2" urlencoding = "2"

View file

@ -0,0 +1,183 @@
//! Tauri commands for the Bilan (balance sheet) feature — Issue #142.
//!
//! At Issue #142 the only command exposed is `compute_account_return`. The
//! Modified Dietz formula needs to read snapshot endpoints + linked transfer
//! amounts in a single Rust pass (the math itself lives in
//! `return_calculator.rs`); doing it server-side avoids 3 round-trips from
//! the renderer and keeps the calculation reproducible.
//!
//! Future commands (`fetch_price`, etc.) ship in Issue #143 / Bilan #5.
//!
//! Database access pattern:
//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
//! matching the existing `repair_migrations` helper in `profile_commands.rs`.
//! - The frontend passes `db_filename` (the active profile DB), exactly
//! like it does for `repair_migrations` and `delete_profile_db`. Keeps
//! the active-profile resolution where it already lives (in TS) and
//! avoids re-reading `profiles.json` on every call.
//! - Reads are short-lived: connection opens, runs ≤ 3 SQL statements,
//! drops at end of function. No connection pooling needed (commands run
//! on the Tauri async runtime, one at a time per invocation).
use chrono::NaiveDate;
use rusqlite::Connection;
use tauri::Manager;
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
/// Compute the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. Reads:
/// - `value_start`: latest snapshot line for the account whose
/// `snapshot_date <= period_start` (None if no prior snapshot).
/// - `value_end`: latest snapshot line for the account whose
/// `snapshot_date <= period_end` (None if no snapshot in range).
/// - cash flows: every linked transfer in `[period_start, period_end]`,
/// sign applied per direction (`in` → `+`, `out` → ``).
///
/// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn`
/// (Serialize) ready to ship across the Tauri boundary.
#[tauri::command]
pub fn compute_account_return(
app: tauri::AppHandle,
db_filename: String,
account_id: i64,
period_start: String,
period_end: String,
) -> Result<AccountReturn, String> {
let start_date = parse_iso_date(&period_start, "period_start")?;
let end_date = parse_iso_date(&period_end, "period_end")?;
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
let db_path = app_dir.join(&db_filename);
if !db_path.exists() {
return Err(format!(
"Profile database not found: {}",
db_path.display()
));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Cannot open database: {}", e))?;
let value_start = read_value_at_or_before(&conn, account_id, &period_start)?;
let value_end = read_value_at_or_before(&conn, account_id, &period_end)?;
let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?;
Ok(modified_dietz(
value_start,
value_end,
&cash_flows,
start_date,
end_date,
))
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------
fn parse_iso_date(input: &str, field: &str) -> Result<NaiveDate, String> {
NaiveDate::parse_from_str(input, "%Y-%m-%d")
.map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e))
}
/// Reads the value of the snapshot line for `account_id` at the most recent
/// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no
/// such snapshot exists for this account.
fn read_value_at_or_before(
conn: &Connection,
account_id: i64,
as_of_date: &str,
) -> Result<Option<f64>, String> {
// Single-row query: pick the latest snapshot date for this account that
// is on or before `as_of_date`, then return that line's value. Indexed
// on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
let mut stmt = conn
.prepare(
"SELECT l.value
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
WHERE l.account_id = ?1
AND s.snapshot_date <= ?2
ORDER BY s.snapshot_date DESC
LIMIT 1",
)
.map_err(|e| format!("prepare value query: {}", e))?;
let mut rows = stmt
.query(rusqlite::params![account_id, as_of_date])
.map_err(|e| format!("execute value query: {}", e))?;
match rows.next().map_err(|e| format!("read value row: {}", e))? {
Some(row) => Ok(Some(
row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?,
)),
None => Ok(None),
}
}
/// Reads every linked transfer for `account_id` whose underlying
/// transaction's `transaction_date` falls inside `[period_start, period_end]`.
/// Returns `(NaiveDate, signed_amount)` — sign applied per `direction`
/// (`in` → `+`, `out` → ``). Amounts come from the linked transaction.
fn read_cash_flows(
conn: &Connection,
account_id: i64,
period_start: &str,
period_end: &str,
) -> Result<Vec<(NaiveDate, f64)>, String> {
// NOTE: the transactions table column is `date` (not `transaction_date`).
// See `src-tauri/src/database/schema.sql:67`.
let mut stmt = conn
.prepare(
"SELECT t.date,
ABS(t.amount) AS abs_amount,
bat.direction
FROM balance_account_transfers bat
JOIN transactions t ON t.id = bat.transaction_id
WHERE bat.account_id = ?1
AND t.date BETWEEN ?2 AND ?3
ORDER BY t.date",
)
.map_err(|e| format!("prepare flows query: {}", e))?;
let rows = stmt
.query_map(
rusqlite::params![account_id, period_start, period_end],
|row| {
// `transactions.date` may come back as String (TEXT) — keep
// the decoder generic enough.
let date_str: String = row.get(0)?;
let amount: f64 = row.get(1)?;
let direction: String = row.get(2)?;
Ok((date_str, amount, direction))
},
)
.map_err(|e| format!("execute flows query: {}", e))?;
let mut flows: Vec<(NaiveDate, f64)> = Vec::new();
for row_result in rows {
let (date_str, amount, direction) =
row_result.map_err(|e| format!("decode flow row: {}", e))?;
// `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column —
// see consolidated_schema.sql). Defensive trim of any trailing
// time component just in case.
let iso = date_str.split('T').next().unwrap_or(&date_str).to_string();
let date = parse_iso_date(&iso, "transaction_date")?;
let signed = match direction.as_str() {
"in" => amount,
"out" => -amount,
other => {
return Err(format!(
"Invalid transfer direction stored in DB: {}",
other
));
}
};
flows.push((date, signed));
}
Ok(flows)
}

View file

@ -1,16 +1,22 @@
pub mod account_cache; pub mod account_cache;
pub mod auth_commands; pub mod auth_commands;
pub mod backup_commands; pub mod backup_commands;
pub mod balance_commands;
pub mod entitlements; pub mod entitlements;
pub mod export_import_commands; pub mod export_import_commands;
pub mod feedback_commands; pub mod feedback_commands;
pub mod fs_commands; pub mod fs_commands;
pub mod license_commands; pub mod license_commands;
pub mod profile_commands; pub mod profile_commands;
// Modified Dietz return calculator — private helper module used by
// `balance_commands.rs`. Kept out of the wildcard re-export below because
// nothing outside `commands/` should depend on it.
pub(crate) mod return_calculator;
pub mod token_store; pub mod token_store;
pub use auth_commands::*; pub use auth_commands::*;
pub use backup_commands::*; pub use backup_commands::*;
pub use balance_commands::*;
pub use entitlements::*; pub use entitlements::*;
pub use export_import_commands::*; pub use export_import_commands::*;
pub use feedback_commands::*; pub use feedback_commands::*;

View file

@ -0,0 +1,380 @@
//! Modified Dietz return calculator (Issue #142 / Bilan #4).
//!
//! Computes the time- and contribution-weighted return of a single account
//! over a period, given:
//! - the account value at `period_start` (snapshot lookup, may be missing),
//! - the account value at `period_end` (snapshot lookup, may be missing),
//! - the cash flows during the period (linked transfers — `+` for IN,
//! `-` for OUT; the caller already applies the direction sign).
//!
//! Modified Dietz formula:
//!
//! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i))
//!
//! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start
//! to flow date`. A flow on day 0 is fully invested for the whole period
//! (W_i = 1) and a flow on the last day contributes nothing (W_i = 0).
//!
//! Annualization: `(1 + R)^(365 / T) - 1` for periods of strictly positive
//! length. A zero-length period (`period_start == period_end`) skips the
//! annualization step (would divide by zero).
//!
//! Edge cases (each surface as a typed flag on `AccountReturn` so the UI can
//! render an explicit warning instead of an opaque empty value):
//! - `value_start == None` → `is_partial = true`, `return_pct = None`
//! - `value_end == None` → `is_partial = true`, `return_pct = None`
//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`,
//! return collapses to the simple
//! `(V_end - V_start) / V_start`
//! - `period_start == period_end` → no annualization (stays = return_pct)
//! - V_start = 0 and first flow > 0 → account created mid-period; the
//! denominator is `0 + W_first * CF_first`,
//! which is positive as long as the
//! flow lands strictly before period_end
//! - account depleted then refilled → mathematically defined; the
//! function does not panic but the
//! magnitude can look extreme — that is
//! the inherent Modified Dietz behaviour
//! on accounts with near-zero invested
//! capital.
//!
//! Module is **private to the crate** (`pub(crate)`) and lives under
//! `commands/` per the spec — reused only by `balance_commands.rs`.
use chrono::NaiveDate;
use serde::Serialize;
/// Result of a Modified Dietz computation, ready to ship across the Tauri
/// boundary. Optional fields are `None` whenever the calculation cannot be
/// completed (missing snapshot endpoints) — the UI renders a dash + a tooltip
/// pointing at `is_partial` / `has_no_transfers_warning`.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct AccountReturn {
/// Account value at `period_start` (latest snapshot ≤ period_start).
pub value_start: Option<f64>,
/// Account value at `period_end` (latest snapshot ≤ period_end).
pub value_end: Option<f64>,
/// Sum of signed cash flows during the period (`+` IN, `-` OUT).
pub net_contributions: f64,
/// Modified Dietz return as a fraction (0.05 = +5%). `None` if either
/// endpoint snapshot is missing or the denominator is non-positive.
pub return_pct: Option<f64>,
/// Annualized return `(1 + R)^(365 / T) - 1`. `None` for zero-length
/// periods or whenever `return_pct` is `None`.
pub annualized_pct: Option<f64>,
/// `true` when at least one snapshot endpoint is missing — the UI labels
/// the result as "partial / non-significatif".
pub is_partial: bool,
/// `true` when the account had zero linked transfers during the period —
/// Modified Dietz collapses to the simple `(V_end - V_start) / V_start`,
/// but the UI surfaces a warning so the user can verify whether real
/// transfers were forgotten (untagged contributions skew the return).
pub has_no_transfers_warning: bool,
}
impl AccountReturn {
/// Default partial return when an endpoint is missing — keeps the
/// constructor calls in the algorithm body terse.
fn partial(
value_start: Option<f64>,
value_end: Option<f64>,
net_contributions: f64,
has_no_transfers_warning: bool,
) -> Self {
Self {
value_start,
value_end,
net_contributions,
return_pct: None,
annualized_pct: None,
is_partial: true,
has_no_transfers_warning,
}
}
}
/// Computes the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. See module docs for the full formula and
/// edge-case handling.
///
/// `cash_flows` is `(date, signed_amount)`. The caller is responsible for
/// applying the direction sign (`in` → `+`, `out` → ``) and for filtering
/// flows to the period; flows outside `[period_start, period_end]` are
/// skipped here too as a safety net.
pub(crate) fn modified_dietz(
value_start: Option<f64>,
value_end: Option<f64>,
cash_flows: &[(NaiveDate, f64)],
period_start: NaiveDate,
period_end: NaiveDate,
) -> AccountReturn {
// Filter flows to the period (defensive — caller already does this via
// SQL, but keep the guarantee here so the math never sees out-of-range
// weights).
let in_period: Vec<(NaiveDate, f64)> = cash_flows
.iter()
.copied()
.filter(|(d, _)| *d >= period_start && *d <= period_end)
.collect();
let net_contributions: f64 = in_period.iter().map(|(_, cf)| *cf).sum();
let has_no_transfers_warning = in_period.is_empty();
// Endpoint guards — without both V_start and V_end we cannot return a
// numeric result.
let v_start = match value_start {
Some(v) => v,
None => {
return AccountReturn::partial(
value_start,
value_end,
net_contributions,
has_no_transfers_warning,
);
}
};
let v_end = match value_end {
Some(v) => v,
None => {
return AccountReturn::partial(
value_start,
value_end,
net_contributions,
has_no_transfers_warning,
);
}
};
// Period length in days. `(period_end - period_start)` returns
// `chrono::Duration`; `.num_days()` is `i64`. A zero-length period
// (same-day) skips weighting and annualization.
let total_days = (period_end - period_start).num_days();
let denominator: f64 = if total_days <= 0 {
// Same-day period: weights collapse to either 0 or undefined; treat
// every flow as fully invested (W = 1) so the denominator is
// V_start + sum(CF). This keeps the function defined when callers
// pass `period_start == period_end`.
v_start + net_contributions
} else {
let total = total_days as f64;
let weighted_sum: f64 = in_period
.iter()
.map(|(date, cf)| {
let t_i = (*date - period_start).num_days() as f64;
let w_i = (total - t_i) / total;
w_i * cf
})
.sum();
v_start + weighted_sum
};
// A non-positive denominator means we have no invested base to annualize
// against (e.g. depleted then refilled with a single late flow). Return
// the raw V_end - V_start - CF as the numerator and flag is_partial so
// the UI can show "Performance non significative" — but only when V_start
// is also 0 / negative; if V_start > 0 we keep the standard math.
if denominator <= 0.0 {
return AccountReturn {
value_start: Some(v_start),
value_end: Some(v_end),
net_contributions,
return_pct: None,
annualized_pct: None,
is_partial: true,
has_no_transfers_warning,
};
}
let numerator = v_end - v_start - net_contributions;
let return_pct = numerator / denominator;
// Annualization only makes sense for strictly positive periods.
let annualized_pct = if total_days > 0 {
let exponent = 365.0 / total_days as f64;
Some((1.0 + return_pct).powf(exponent) - 1.0)
} else {
None
};
AccountReturn {
value_start: Some(v_start),
value_end: Some(v_end),
net_contributions,
return_pct: Some(return_pct),
annualized_pct,
is_partial: false,
has_no_transfers_warning,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Small helper that turns a `YYYY-MM-DD` string literal into a
/// `NaiveDate` — keeps the test bodies readable.
fn d(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("test date parses")
}
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn nominal_two_flows_at_one_quarter_and_three_quarters() {
// 100-day period (2026-01-01 → 2026-04-11). V_start = 1000, V_end =
// 1100. CF1 = +50 at day 25, CF2 = +30 at day 75.
let start = d("2026-01-01");
let end = d("2026-04-11"); // 100 days later
let flows = vec![(d("2026-01-26"), 50.0), (d("2026-03-17"), 30.0)];
let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end);
// Sanity / shape
assert_eq!(r.value_start, Some(1000.0));
assert_eq!(r.value_end, Some(1100.0));
assert_eq!(r.net_contributions, 80.0);
assert!(!r.is_partial);
assert!(!r.has_no_transfers_warning);
// Hand calc:
// T = 100, t1 = 25, t2 = 75
// W1 = 75/100 = 0.75, W2 = 25/100 = 0.25
// numerator = 1100 - 1000 - 80 = 20
// denominator = 1000 + 0.75*50 + 0.25*30 = 1045
// R = 20 / 1045 ≈ 0.01913876
let r_pct = r.return_pct.expect("nominal case has a return");
assert!(
approx(r_pct, 20.0 / 1045.0, 1e-9),
"return_pct = {} (expected ≈ {})",
r_pct,
20.0 / 1045.0
);
// Annualization: (1 + R)^(365/100) - 1
let expected_ann = (1.0_f64 + 20.0 / 1045.0).powf(365.0 / 100.0) - 1.0;
let ann = r.annualized_pct.expect("nominal case is annualized");
assert!(approx(ann, expected_ann, 1e-9), "annualized = {}", ann);
}
#[test]
fn no_prior_snapshot_marks_partial() {
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-02-01"), 200.0)];
let r = modified_dietz(None, Some(1500.0), &flows, start, end);
assert_eq!(r.value_start, None);
assert_eq!(r.value_end, Some(1500.0));
assert!(r.is_partial, "missing V_start must flag is_partial");
assert_eq!(r.return_pct, None);
assert_eq!(r.annualized_pct, None);
assert!(!r.has_no_transfers_warning);
// Still surface the contributions sum for the UI breakdown card.
assert_eq!(r.net_contributions, 200.0);
}
#[test]
fn no_end_snapshot_marks_partial() {
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-02-15"), -100.0)];
let r = modified_dietz(Some(2000.0), None, &flows, start, end);
assert_eq!(r.value_start, Some(2000.0));
assert_eq!(r.value_end, None);
assert!(r.is_partial);
assert_eq!(r.return_pct, None);
assert_eq!(r.annualized_pct, None);
assert_eq!(r.net_contributions, -100.0);
}
#[test]
fn account_created_mid_period_with_first_flow() {
// V_start = 0, single +500 flow at day 30 of a 90-day period, V_end
// = 510. The flow's weight is W = (90-30)/90 = 2/3.
let start = d("2026-01-01");
let end = d("2026-04-01"); // 90 days
let flows = vec![(d("2026-01-31"), 500.0)];
let r = modified_dietz(Some(0.0), Some(510.0), &flows, start, end);
// numerator = 510 - 0 - 500 = 10
// W = (90-30)/90 ≈ 0.6666667
// denominator = 0 + 0.6666667 * 500 ≈ 333.3333
// R ≈ 10 / 333.3333 = 0.03
let expected = 10.0 / ((90.0 - 30.0) / 90.0 * 500.0);
let r_pct = r.return_pct.expect("account-created case computes");
assert!(
approx(r_pct, expected, 1e-9),
"return_pct = {} (expected ≈ {})",
r_pct,
expected
);
assert!(!r.is_partial);
assert!(!r.has_no_transfers_warning);
}
#[test]
fn depleted_then_refilled_does_not_panic() {
// Pathological: V_start = 100, then -100 flow on day 1 (account
// emptied), then +200 flow on day 60 of a 90-day period, V_end =
// 210. Modified Dietz handles this without panicking; the value
// may look extreme but the function must stay defined.
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-01-02"), -100.0), (d("2026-03-02"), 200.0)];
let r = modified_dietz(Some(100.0), Some(210.0), &flows, start, end);
// Whatever the math says, the call must complete cleanly. We don't
// assert a precise return — the goal is "no panic, finite output if
// the denominator is positive, else partial flag".
if let Some(rp) = r.return_pct {
assert!(rp.is_finite(), "return must be a finite f64");
}
// Net flows = -100 + 200 = 100
assert_eq!(r.net_contributions, 100.0);
// Not flagged "no transfers" since we have two flows.
assert!(!r.has_no_transfers_warning);
}
#[test]
fn no_transfers_collapses_to_simple_return() {
// No cash flows → R should equal (V_end - V_start) / V_start exactly.
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows: Vec<(NaiveDate, f64)> = vec![];
let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end);
assert!(r.has_no_transfers_warning);
assert_eq!(r.net_contributions, 0.0);
let r_pct = r.return_pct.expect("simple-return case has a value");
let simple = (1100.0 - 1000.0) / 1000.0; // = 0.1
assert!(approx(r_pct, simple, 1e-12), "simple return mismatch: {}", r_pct);
}
#[test]
fn annualization_on_90_day_period_matches_compound_formula() {
// Direct check of the annualization branch with a clean R.
let start = d("2026-01-01");
let end = d("2026-04-01"); // 90 days
let flows: Vec<(NaiveDate, f64)> = vec![];
// V_start = 1000, V_end = 1050 → R = 0.05
let r = modified_dietz(Some(1000.0), Some(1050.0), &flows, start, end);
let expected_ann = (1.0_f64 + 0.05).powf(365.0 / 90.0) - 1.0;
let ann = r.annualized_pct.expect("90-day period annualizes");
assert!(
approx(ann, expected_ann, 1e-12),
"annualized = {} (expected {})",
ann,
expected_ann
);
}
}

View file

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

View file

@ -1,22 +1,32 @@
// BalanceAccountsTable — one-row-per-active-account table on /balance. // BalanceAccountsTable — one-row-per-active-account table on /balance.
// //
// Issue #141 (Bilan #3). Columns: // Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ%
// - Account name + category label // + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via
// - Latest snapshot value (or "—" when no snapshot exists yet) // the Modified Dietz `compute_account_return` Tauri command:
// - Δ% over the active period (latest value vs the period-anchor value;
// null when no anchor exists, rendered as "—").
// - Actions menu (Detail no-op for now, Archive via service).
// //
// Future return-metric columns (3M / 1A / since-creation / unadjusted) // - 3M (last 90 days)
// land in Issue #142. They have a TODO marker below. // - 1A (last 365 days)
// - Depuis création (from earliest snapshot date to today)
// - Non-ajusté (simple `(V_end - V_start) / V_start`, no contribution
// weighting — shown side-by-side as a sanity check / explanation)
//
// Returns load lazily on mount via `Promise.all` over (account × horizon),
// keyed by `account_id`. Each cell renders "—" while loading and shows the
// `is_partial` / `has_no_transfers_warning` badges via tooltip when set.
//
// Issue #142 also adds a "Lier transferts" item in the per-row actions menu
// that opens `LinkTransfersModal` (the modal handles its own state; this
// component just bubbles up the request via `onLinkTransfers`).
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Archive, MoreVertical } from "lucide-react"; import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react";
import type { import type {
AccountLatestSnapshot, AccountLatestSnapshot,
AccountPeriodAnchor, AccountPeriodAnchor,
} from "../../services/balance.service"; } from "../../services/balance.service";
import { computeAccountReturn } from "../../services/balance.service";
import type { AccountReturn } from "../../shared/types";
const cadFormatter = (locale: string) => const cadFormatter = (locale: string) =>
new Intl.NumberFormat(locale, { new Intl.NumberFormat(locale, {
@ -25,16 +35,55 @@ const cadFormatter = (locale: string) =>
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
/** Horizon definition: how many days back from today to start the period. */
type HorizonKey = "3M" | "1A" | "since";
interface HorizonRange {
key: HorizonKey;
/** ISO date for `period_start`. */
from: string;
/** ISO date for `period_end` (always today, computed in the local civil day). */
to: string;
}
function localISO(d: Date): string {
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
function isoDaysAgo(days: number, today: Date): string {
const d = new Date(today);
d.setDate(d.getDate() - days);
return localISO(d);
}
interface BalanceAccountsTableProps { interface BalanceAccountsTableProps {
accounts: AccountLatestSnapshot[]; accounts: AccountLatestSnapshot[];
periodAnchor: AccountPeriodAnchor[]; periodAnchor: AccountPeriodAnchor[];
onArchiveAccount?: (account: AccountLatestSnapshot) => void; onArchiveAccount?: (account: AccountLatestSnapshot) => void;
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
/**
* Earliest snapshot date across the whole profile, used to anchor the
* "depuis création" horizon. Falls back to "1A" range if not provided
* (avoids triggering computation against the unix epoch).
*/
sinceCreationDate?: string | null;
} }
/**
* Per-account, per-horizon return shape used by the local cache state.
* Indexed `[accountId][horizonKey]`.
*/
type ReturnsByAccount = Record<number, Partial<Record<HorizonKey, AccountReturn>>>;
export default function BalanceAccountsTable({ export default function BalanceAccountsTable({
accounts, accounts,
periodAnchor, periodAnchor,
onArchiveAccount, onArchiveAccount,
onLinkTransfers,
sinceCreationDate,
}: BalanceAccountsTableProps) { }: BalanceAccountsTableProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA"); const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
@ -48,6 +97,65 @@ export default function BalanceAccountsTable({
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null); const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
// Returns cache. Cleared whenever the account list changes (new accounts,
// archive, etc.). Loaded lazily after mount.
const [returns, setReturns] = useState<ReturnsByAccount>({});
const [returnsLoading, setReturnsLoading] = useState(false);
// Horizon definitions — recomputed once per mount via today's local civil
// day. We don't memoize against `accounts` because the dates don't depend
// on the row list.
const horizons = useMemo<HorizonRange[]>(() => {
const today = new Date();
const todayISO = localISO(today);
const sinceFrom = sinceCreationDate ?? isoDaysAgo(365, today);
return [
{ key: "3M", from: isoDaysAgo(90, today), to: todayISO },
{ key: "1A", from: isoDaysAgo(365, today), to: todayISO },
{ key: "since", from: sinceFrom, to: todayISO },
];
}, [sinceCreationDate]);
useEffect(() => {
let cancelled = false;
async function loadReturns() {
if (accounts.length === 0) {
setReturns({});
return;
}
setReturnsLoading(true);
const next: ReturnsByAccount = {};
// Run sequentially per account to avoid SQLite contention; per-horizon
// we can parallelize because they hit the same table set.
await Promise.all(
accounts.map(async (acc) => {
next[acc.account_id] = {};
const tasks = horizons.map(async (h) => {
try {
const r = await computeAccountReturn(
acc.account_id,
h.from,
h.to
);
next[acc.account_id]![h.key] = r;
} catch {
// Per-cell failure: leave the slot undefined → renders "—".
}
});
await Promise.all(tasks);
})
);
if (!cancelled) {
setReturns(next);
setReturnsLoading(false);
}
}
void loadReturns();
return () => {
cancelled = true;
};
}, [accounts, horizons]);
if (accounts.length === 0) { if (accounts.length === 0) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
@ -56,8 +164,73 @@ export default function BalanceAccountsTable({
); );
} }
/** Format a return percentage with sign + colour-aware classname. */
function renderReturnCell(r: AccountReturn | undefined) {
if (!r) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.return_pct === null) {
return (
<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 ( 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"> <table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30"> <thead className="bg-[var(--muted)]/30">
<tr> <tr>
@ -73,7 +246,18 @@ export default function BalanceAccountsTable({
<th className="text-right px-4 py-3 font-medium"> <th className="text-right px-4 py-3 font-medium">
{t("balance.overview.periodDelta")} {t("balance.overview.periodDelta")}
</th> </th>
{/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */} <th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
{t("balance.accountsTable.return3m")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
{t("balance.accountsTable.return1y")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
{t("balance.accountsTable.sinceCreation")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
{t("balance.accountsTable.unadjusted")}
</th>
<th className="text-right px-4 py-3 font-medium w-12"> <th className="text-right px-4 py-3 font-medium w-12">
{t("balance.account.fields.actions")} {t("balance.account.fields.actions")}
</th> </th>
@ -88,6 +272,7 @@ export default function BalanceAccountsTable({
Math.abs(anchor.anchor_value)) * Math.abs(anchor.anchor_value)) *
100 100
: null; : null;
const accReturns = returns[acc.account_id] ?? {};
return ( return (
<tr <tr
key={acc.account_id} key={acc.account_id}
@ -123,6 +308,26 @@ export default function BalanceAccountsTable({
"—" "—"
)} )}
</td> </td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["3M"]
? "…"
: renderReturnCell(accReturns["3M"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderReturnCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["since"]
? "…"
: renderReturnCell(accReturns["since"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderUnadjustedCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right relative"> <td className="px-4 py-3 text-right relative">
<button <button
type="button" type="button"
@ -137,7 +342,7 @@ export default function BalanceAccountsTable({
<MoreVertical size={16} /> <MoreVertical size={16} />
</button> </button>
{openMenuFor === acc.account_id && ( {openMenuFor === acc.account_id && (
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[160px] text-left"> <div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
<button <button
type="button" type="button"
disabled disabled
@ -146,6 +351,19 @@ export default function BalanceAccountsTable({
> >
{t("balance.overview.detailAction")} {t("balance.overview.detailAction")}
</button> </button>
{onLinkTransfers && (
<button
type="button"
onClick={() => {
setOpenMenuFor(null);
onLinkTransfers(acc);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
>
<LinkIcon size={14} />
{t("balance.transfers.linkAction")}
</button>
)}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {

View file

@ -22,12 +22,14 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
ReferenceLine,
} from "recharts"; } from "recharts";
import type { import type {
SnapshotTotalPoint, SnapshotTotalPoint,
SnapshotCategoryBreakdownPoint, SnapshotCategoryBreakdownPoint,
} from "../../services/balance.service"; } from "../../services/balance.service";
import type { BalanceChartMode } from "../../hooks/useBalanceOverview"; import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
// Stable palette for the stacked-by-category areas. Indexed deterministically // Stable palette for the stacked-by-category areas. Indexed deterministically
// by category sort order so the colour assignment stays consistent across // by category sort order so the colour assignment stays consistent across
@ -51,6 +53,13 @@ export interface BalanceEvolutionChartProps {
byCategory: SnapshotCategoryBreakdownPoint[]; byCategory: SnapshotCategoryBreakdownPoint[];
/** Map category_key → translated label so the legend reads naturally. */ /** Map category_key → translated label so the legend reads naturally. */
categoryLabels?: Record<string, string>; categoryLabels?: Record<string, string>;
/**
* Issue #142 every linked transfer in the visible range. Rendered as
* vertical `<ReferenceLine>` markers on the X axis: green for `in`
* (capital added), red for `out` (capital removed). The label tooltip
* shows the underlying transaction date + description.
*/
transferMarkers?: BalanceAccountTransferWithTransaction[];
} }
export default function BalanceEvolutionChart({ export default function BalanceEvolutionChart({
@ -58,6 +67,7 @@ export default function BalanceEvolutionChart({
totals, totals,
byCategory, byCategory,
categoryLabels = {}, categoryLabels = {},
transferMarkers = [],
}: BalanceEvolutionChartProps) { }: BalanceEvolutionChartProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@ -114,6 +124,31 @@ export default function BalanceEvolutionChart({
const isEmpty = const isEmpty =
mode === "line" ? lineData.length === 0 : stackedData.length === 0; mode === "line" ? lineData.length === 0 : stackedData.length === 0;
// Filter transfer markers to dates that are actually rendered on the X
// axis (categorical scale ignores unknown ticks). We don't aggregate or
// dedupe — the user can have several transfers on the same day across
// accounts; ReferenceLine tolerates duplicates fine.
const xAxisDates = useMemo(() => {
const dates = new Set<string>();
if (mode === "line") {
for (const p of lineData) dates.add(p.snapshot_date);
} else {
for (const p of stackedData) dates.add(p.snapshot_date as string);
}
return dates;
}, [mode, lineData, stackedData]);
const renderableMarkers = useMemo(
() =>
transferMarkers
.filter((m) => xAxisDates.has(m.transaction_date))
// Sort so 'in' (green) draws before 'out' (red) for stable z-order.
.sort((a, b) =>
a.direction === b.direction ? 0 : a.direction === "in" ? -1 : 1
),
[transferMarkers, xAxisDates]
);
if (isEmpty) { if (isEmpty) {
return ( return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
@ -168,6 +203,28 @@ export default function BalanceEvolutionChart({
dot={{ r: 3 }} dot={{ r: 3 }}
activeDot={{ r: 5 }} activeDot={{ r: 5 }}
/> />
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
label={{
value: t(
m.direction === "in"
? "balance.evolution.transferIn"
: "balance.evolution.transferOut"
),
position: "insideTopRight",
fontSize: 9,
fill: m.direction === "in" ? "var(--positive)" : "var(--negative)",
}}
/>
))}
</LineChart> </LineChart>
) : ( ) : (
<AreaChart <AreaChart
@ -210,6 +267,18 @@ export default function BalanceEvolutionChart({
name={key} name={key}
/> />
))} ))}
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
/>
))}
</AreaChart> </AreaChart>
)} )}
</ResponsiveContainer> </ResponsiveContainer>

View file

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

View file

@ -1,12 +1,13 @@
import { Fragment, useState, useMemo } from "react"; import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react"; import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react";
import type { import type {
TransactionRow, TransactionRow,
TransactionSort, TransactionSort,
Category, Category,
SplitChild, SplitChild,
} from "../../shared/types"; } from "../../shared/types";
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
import CategoryCombobox from "../shared/CategoryCombobox"; import CategoryCombobox from "../shared/CategoryCombobox";
import SplitAdjustmentModal from "./SplitAdjustmentModal"; import SplitAdjustmentModal from "./SplitAdjustmentModal";
@ -22,6 +23,14 @@ interface TransactionTableProps {
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>; onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>; onDeleteSplit: (parentId: number) => Promise<void>;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void; onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
/**
* Issue #142 when supplied, a small Link2 icon appears next to the
* description for every transaction whose id is a key in the map. The
* icon's tooltip lists the linked accounts. The lookup is intentionally
* done by the parent (one batch SELECT, in-memory `.has()` thereafter)
* to avoid an N+1 hit on the table render.
*/
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>;
} }
function SortIcon({ function SortIcon({
@ -52,6 +61,7 @@ export default function TransactionTable({
onSaveSplit, onSaveSplit,
onDeleteSplit, onDeleteSplit,
onRowContextMenu, onRowContextMenu,
linkedTransfersByTxId,
}: TransactionTableProps) { }: TransactionTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
@ -141,8 +151,31 @@ export default function TransactionTable({
className="hover:bg-[var(--muted)] transition-colors" className="hover:bg-[var(--muted)] transition-colors"
> >
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td> <td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-3 py-2 max-w-xs truncate" title={row.description}> <td className="px-3 py-2 max-w-xs">
{row.description} <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>
<td <td
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${ className={`px-3 py-2 text-right font-mono whitespace-nowrap ${

View file

@ -253,6 +253,10 @@
"Assign categories by clicking the category dropdown on each row", "Assign categories by clicking the category dropdown on each row",
"Auto-categorize uses your keyword rules to categorize transactions in bulk" "Auto-categorize uses your keyword rules to categorize transactions in bulk"
] ]
},
"transferIcon": {
"tooltip": "Linked to a balance account",
"ariaLabel": "Transaction linked to a balance account"
} }
}, },
"categories": { "categories": {
@ -1638,6 +1642,56 @@
"snapshot_priced_unit_price_required": "Unit price is required for priced accounts.", "snapshot_priced_unit_price_required": "Unit price is required for priced accounts.",
"snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.", "snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.",
"snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price." "snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price."
},
"returns": {
"partialTooltip": "Partial return: a snapshot is missing for the selected period.",
"noTransfersWarning": "No transfers tagged — performance may be skewed if contributions weren't tagged."
},
"accountsTable": {
"return3m": "3M",
"return3mTooltip": "Modified Dietz return over the last 90 days.",
"return1y": "1Y",
"return1yTooltip": "Modified Dietz return over the last 365 days.",
"sinceCreation": "Since inception",
"sinceCreationTooltip": "Modified Dietz return since the first snapshot.",
"unadjusted": "Unadjusted",
"unadjustedTooltip": "Simple return (V_end V_start) / V_start, with no contribution weighting."
},
"transfers": {
"linkAction": "Link transfers",
"direction": {
"in": "In",
"out": "Out"
},
"modal": {
"title": "Link transfers to {{account}}",
"subtitle": "Select transactions to attribute to this balance account. The direction is suggested based on the amount sign.",
"from": "From",
"to": "To",
"category": "Category",
"anyCategory": "Any category",
"search": "Search",
"searchPlaceholder": "Keyword in description…",
"loading": "Loading…",
"noTransactions": "No transactions match the filters.",
"direction": "Direction",
"toggleDirection": "Click to flip direction",
"summary": "{{selected}} selected of {{total}} shown",
"linkSelection": "Link {{count}} transaction(s)",
"linking": "Linking…",
"partialFailure": "{{linked}}/{{total}} linked successfully"
},
"errors": {
"transfer_direction_invalid": "Invalid transfer direction (expected in/out).",
"transfer_already_linked": "This transaction is already linked to this account.",
"transfer_not_linked": "This transaction is not linked to this account.",
"transfer_active_profile_unknown": "No active profile — cannot compute return.",
"transaction_linked_to_balance_account": "This transaction is linked to balance account {{account}} — unlink it before deleting."
}
},
"evolution": {
"transferIn": "In",
"transferOut": "Out"
} }
} }
} }

View file

@ -253,6 +253,10 @@
"Assignez une catégorie via le menu déroulant sur chaque ligne", "Assignez une catégorie via le menu déroulant sur chaque ligne",
"L'auto-catégorisation utilise vos règles de mots-clés pour catégoriser en masse" "L'auto-catégorisation utilise vos règles de mots-clés pour catégoriser en masse"
] ]
},
"transferIcon": {
"tooltip": "Liée à un compte de bilan",
"ariaLabel": "Transaction liée à un compte de bilan"
} }
}, },
"categories": { "categories": {
@ -1638,6 +1642,56 @@
"snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.", "snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.",
"snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.", "snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.",
"snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix." "snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix."
},
"returns": {
"partialTooltip": "Rendement partiel : un snapshot manque pour calculer la performance sur cette période.",
"noTransfersWarning": "Aucun transfert lié — la performance peut être faussée si des apports n'ont pas été tagués."
},
"accountsTable": {
"return3m": "3M",
"return3mTooltip": "Rendement Modified Dietz sur les 90 derniers jours.",
"return1y": "1A",
"return1yTooltip": "Rendement Modified Dietz sur les 365 derniers jours.",
"sinceCreation": "Depuis création",
"sinceCreationTooltip": "Rendement Modified Dietz depuis le premier snapshot.",
"unadjusted": "Non ajusté",
"unadjustedTooltip": "Rendement simple (V_fin V_début) / V_début, sans pondération des apports."
},
"transfers": {
"linkAction": "Lier transferts",
"direction": {
"in": "Entrée",
"out": "Sortie"
},
"modal": {
"title": "Lier des transferts à {{account}}",
"subtitle": "Sélectionnez les transactions à attribuer à ce compte de bilan. La direction est proposée d'après le signe du montant.",
"from": "Du",
"to": "Au",
"category": "Catégorie",
"anyCategory": "Toutes les catégories",
"search": "Rechercher",
"searchPlaceholder": "Mot-clé dans la description…",
"loading": "Chargement…",
"noTransactions": "Aucune transaction ne correspond aux filtres.",
"direction": "Sens",
"toggleDirection": "Cliquer pour inverser le sens",
"summary": "{{selected}} sélectionnée(s) sur {{total}} affichée(s)",
"linkSelection": "Lier {{count}} transaction(s)",
"linking": "Liaison…",
"partialFailure": "{{linked}}/{{total}} liées avec succès"
},
"errors": {
"transfer_direction_invalid": "Direction de transfert invalide (in/out attendu).",
"transfer_already_linked": "Cette transaction est déjà liée à ce compte.",
"transfer_not_linked": "Cette transaction n'est pas liée à ce compte.",
"transfer_active_profile_unknown": "Aucun profil actif — impossible de calculer le rendement.",
"transaction_linked_to_balance_account": "Cette transaction est liée au compte de bilan {{account}} — déliez-la avant de supprimer."
}
},
"evolution": {
"transferIn": "Entrée",
"transferOut": "Sortie"
} }
} }
} }

View file

@ -11,7 +11,7 @@
// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves // (Modified Dietz) are deferred to Issue #142 — the accounts table reserves
// columns with a TODO comment. // columns with a TODO comment.
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wallet } from "lucide-react"; import { Wallet } from "lucide-react";
import { import {
@ -19,10 +19,17 @@ import {
type BalancePeriod, type BalancePeriod,
type BalanceChartMode, type BalanceChartMode,
} from "../hooks/useBalanceOverview"; } from "../hooks/useBalanceOverview";
import { archiveBalanceAccount } from "../services/balance.service"; import {
archiveBalanceAccount,
listAccountTransfers,
type AccountLatestSnapshot,
} from "../services/balance.service";
import { getAllCategories } from "../services/transactionService";
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard"; import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
@ -30,6 +37,58 @@ export default function BalancePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, setPeriod, setChartMode, reload } = useBalanceOverview(); const { state, setPeriod, setChartMode, reload } = useBalanceOverview();
// Issue #142 — link-transfers modal state. Categories list is loaded once
// on mount (used by the modal's filter dropdown).
const [linkTarget, setLinkTarget] = useState<AccountLatestSnapshot | null>(
null
);
const [categories, setCategories] = useState<Category[]>([]);
const [transfersByAccount, setTransfersByAccount] = useState<
Map<number, BalanceAccountTransferWithTransaction[]>
>(new Map());
useEffect(() => {
void getAllCategories().then(setCategories).catch(() => setCategories([]));
}, []);
// Refresh per-account transfer lists used by the chart markers. Keyed by
// account_id → [transfers]. Used by `BalanceEvolutionChart` to plot
// ReferenceLine markers (green for in, red for out).
useEffect(() => {
let cancelled = false;
async function run() {
const map = new Map<number, BalanceAccountTransferWithTransaction[]>();
await Promise.all(
state.accountsLatest.map(async (acc) => {
try {
const list = await listAccountTransfers(acc.account_id);
map.set(acc.account_id, list);
} catch {
map.set(acc.account_id, []);
}
})
);
if (!cancelled) setTransfersByAccount(map);
}
void run();
return () => {
cancelled = true;
};
}, [state.accountsLatest]);
const allTransferMarkers = useMemo(() => {
const flat: BalanceAccountTransferWithTransaction[] = [];
for (const list of transfersByAccount.values()) flat.push(...list);
return flat;
}, [transfersByAccount]);
// Earliest snapshot date in the dataset, used to anchor the "depuis
// création" Modified Dietz horizon in the accounts table.
const earliestSnapshotDate = useMemo(() => {
if (state.evolutionTotals.length === 0) return null;
return state.evolutionTotals[0].snapshot_date;
}, [state.evolutionTotals]);
// Build a category_key → translated label map from the accounts payload — // Build a category_key → translated label map from the accounts payload —
// the byCategory series is keyed by `key`, not by id, and the same // the byCategory series is keyed by `key`, not by id, and the same
// taxonomy is already loaded with `accountsLatest` joins. // taxonomy is already loaded with `accountsLatest` joins.
@ -123,6 +182,7 @@ export default function BalancePage() {
totals={state.evolutionTotals} totals={state.evolutionTotals}
byCategory={state.evolutionByCategory} byCategory={state.evolutionByCategory}
categoryLabels={categoryLabels} categoryLabels={categoryLabels}
transferMarkers={allTransferMarkers}
/> />
<div> <div>
@ -132,10 +192,24 @@ export default function BalancePage() {
<BalanceAccountsTable <BalanceAccountsTable
accounts={state.accountsLatest} accounts={state.accountsLatest}
periodAnchor={state.accountsPeriodAnchor} periodAnchor={state.accountsPeriodAnchor}
sinceCreationDate={earliestSnapshotDate}
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)} onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
onLinkTransfers={(acc) => setLinkTarget(acc)}
/> />
</div> </div>
</div> </div>
{linkTarget && (
<LinkTransfersModal
accountId={linkTarget.account_id}
accountName={linkTarget.account_name}
categories={categories}
onClose={() => setLinkTarget(null)}
onLinked={() => {
void reload();
}}
/>
)}
</div> </div>
); );
} }

View file

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

View file

@ -4,7 +4,17 @@ vi.mock("./db", () => ({
getDb: vi.fn(), getDb: vi.fn(),
})); }));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("./profileService", () => ({
loadProfiles: vi.fn(),
}));
import { getDb } from "./db"; import { getDb } from "./db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "./profileService";
import { import {
listBalanceCategories, listBalanceCategories,
createBalanceCategory, createBalanceCategory,
@ -30,6 +40,14 @@ import {
getSnapshotTotalsByCategoryAndDate, getSnapshotTotalsByCategoryAndDate,
getAccountsLatestSnapshot, getAccountsLatestSnapshot,
getAccountsPeriodAnchor, getAccountsPeriodAnchor,
computeAccountReturn,
linkTransfer,
unlinkTransfer,
listAccountTransfers,
listLinkedTransactionIds,
listAllLinkedTransfersForTooltip,
isLinkedTransactionFkError,
suggestTransferDirection,
} from "./balance.service"; } from "./balance.service";
const mockSelect = vi.fn(); const mockSelect = vi.fn();
@ -974,3 +992,215 @@ describe("getAccountsPeriodAnchor", () => {
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/); expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
}); });
}); });
// -----------------------------------------------------------------------------
// Returns + transfers (Issue #142)
// -----------------------------------------------------------------------------
describe("computeAccountReturn", () => {
beforeEach(() => {
vi.mocked(loadProfiles).mockReset();
vi.mocked(invoke).mockReset();
});
it("invokes the Tauri command with the active profile's db_filename", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "p1",
profiles: [
{
id: "p1",
name: "Max",
color: "#fff",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
const fakeReturn = {
value_start: 1000,
value_end: 1100,
net_contributions: 0,
return_pct: 0.1,
annualized_pct: 0.42,
is_partial: false,
has_no_transfers_warning: true,
};
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-01");
expect(out).toEqual(fakeReturn);
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
dbFilename: "max.db",
accountId: 7,
periodStart: "2026-01-01",
periodEnd: "2026-04-01",
});
});
it("rejects malformed period dates before invoking the command", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "p1",
profiles: [
{
id: "p1",
name: "Max",
color: "#fff",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
await expect(
computeAccountReturn(1, "not-a-date", "2026-04-01")
).rejects.toBeInstanceOf(BalanceServiceError);
expect(invoke).not.toHaveBeenCalled();
});
it("throws transfer_active_profile_unknown when no active profile resolves", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "missing",
profiles: [],
});
await expect(
computeAccountReturn(1, "2026-01-01", "2026-04-01")
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
expect(invoke).not.toHaveBeenCalled();
});
});
describe("suggestTransferDirection", () => {
it("maps negative bank amounts to 'in' (money left bank → arrived in account)", () => {
expect(suggestTransferDirection(-100)).toBe("in");
});
it("maps positive bank amounts to 'out' (money came back from account)", () => {
expect(suggestTransferDirection(50)).toBe("out");
});
it("treats zero as 'out' as a deterministic fallback", () => {
expect(suggestTransferDirection(0)).toBe("out");
});
});
describe("linkTransfer", () => {
it("rejects an invalid direction without touching the DB", async () => {
await expect(
// @ts-expect-error testing runtime guard
linkTransfer(1, 2, "sideways")
).rejects.toBeInstanceOf(BalanceServiceError);
expect(mockExecute).not.toHaveBeenCalled();
});
it("guards against duplicate links with a typed error", async () => {
mockSelect.mockResolvedValueOnce([{ id: 5 }]);
await expect(linkTransfer(1, 2, "in")).rejects.toMatchObject({
code: "transfer_already_linked",
});
expect(mockExecute).not.toHaveBeenCalled();
});
it("inserts and returns the new transfer id", async () => {
mockSelect.mockResolvedValueOnce([]);
mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
const id = await linkTransfer(1, 2, "out", " manual ");
expect(id).toBe(99);
const sql = mockExecute.mock.calls[0][0] as string;
expect(sql).toContain("INSERT INTO balance_account_transfers");
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2, "out", "manual"]);
});
it("normalizes empty notes to null", async () => {
mockSelect.mockResolvedValueOnce([]);
mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
await linkTransfer(1, 2, "in", " ");
expect(mockExecute.mock.calls[0][1][3]).toBeNull();
});
});
describe("unlinkTransfer", () => {
it("throws transfer_not_linked when no row was deleted", async () => {
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 0 });
await expect(unlinkTransfer(1, 2)).rejects.toMatchObject({
code: "transfer_not_linked",
});
});
it("succeeds when one row is deleted", async () => {
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 1 });
await expect(unlinkTransfer(1, 2)).resolves.toBeUndefined();
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2]);
});
});
describe("listAccountTransfers", () => {
it("filters by account_id only when no date range is supplied", async () => {
mockSelect.mockResolvedValueOnce([]);
await listAccountTransfers(7);
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("FROM balance_account_transfers bat");
expect(sql).toContain("JOIN transactions t");
expect(sql).toContain("JOIN balance_accounts a");
expect(sql).toContain("WHERE bat.account_id = $1");
expect(sql).not.toContain("t.date >=");
expect(mockSelect.mock.calls[0][1]).toEqual([7]);
});
it("appends inclusive date bounds when supplied", async () => {
mockSelect.mockResolvedValueOnce([]);
await listAccountTransfers(7, { from: "2026-01-01", to: "2026-04-01" });
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("t.date >=");
expect(sql).toContain("t.date <=");
expect(mockSelect.mock.calls[0][1]).toEqual([7, "2026-01-01", "2026-04-01"]);
});
});
describe("listLinkedTransactionIds", () => {
it("returns a Set of transaction ids", async () => {
mockSelect.mockResolvedValueOnce([
{ transaction_id: 5 },
{ transaction_id: 12 },
]);
const ids = await listLinkedTransactionIds();
expect(ids).toBeInstanceOf(Set);
expect(ids.has(5)).toBe(true);
expect(ids.has(12)).toBe(true);
expect(ids.size).toBe(2);
});
});
describe("listAllLinkedTransfersForTooltip", () => {
it("groups multiple links per transaction id", async () => {
mockSelect.mockResolvedValueOnce([
{ transaction_id: 1, account_id: 10, account_name: "TFSA", direction: "in" },
{ transaction_id: 1, account_id: 20, account_name: "RRSP", direction: "out" },
{ transaction_id: 2, account_id: 10, account_name: "TFSA", direction: "in" },
]);
const map = await listAllLinkedTransfersForTooltip();
expect(map.get(1)).toHaveLength(2);
expect(map.get(2)).toHaveLength(1);
expect(map.get(1)?.[0].account_name).toBe("TFSA");
});
});
describe("isLinkedTransactionFkError", () => {
it("matches the canonical SQLite FK error text", () => {
expect(
isLinkedTransactionFkError(new Error("FOREIGN KEY constraint failed"))
).toBe(true);
});
it("matches the wrapped tauri-plugin-sql variant", () => {
expect(
isLinkedTransactionFkError(
new Error("code: 787, message: FOREIGN KEY constraint failed")
)
).toBe(true);
});
it("does not match unrelated errors", () => {
expect(isLinkedTransactionFkError(new Error("something else"))).toBe(false);
expect(isLinkedTransactionFkError(undefined)).toBe(false);
});
});

View file

@ -9,14 +9,19 @@
// filesystem / OAuth / license / profile work and the future Modified Dietz // filesystem / OAuth / license / profile work and the future Modified Dietz
// + price-fetch work in Issue #142. // + price-fetch work in Issue #142.
import { invoke } from "@tauri-apps/api/core";
import { getDb } from "./db"; import { getDb } from "./db";
import { loadProfiles } from "./profileService";
import type { import type {
AccountReturn,
BalanceAccount, BalanceAccount,
BalanceAccountTransferWithTransaction,
BalanceAccountWithCategory, BalanceAccountWithCategory,
BalanceCategory, BalanceCategory,
BalanceCategoryKind, BalanceCategoryKind,
BalanceSnapshot, BalanceSnapshot,
BalanceSnapshotLine, BalanceSnapshotLine,
BalanceTransferDirection,
} from "../shared/types"; } from "../shared/types";
import { BALANCE_CURRENCY_CAD } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types";
@ -40,7 +45,13 @@ export type BalanceErrorCode =
| "snapshot_priced_quantity_required" | "snapshot_priced_quantity_required"
| "snapshot_priced_unit_price_required" | "snapshot_priced_unit_price_required"
| "snapshot_priced_value_mismatch" | "snapshot_priced_value_mismatch"
| "snapshot_simple_must_be_scalar"; | "snapshot_simple_must_be_scalar"
// Issue #142 — transfers + returns
| "transfer_direction_invalid"
| "transfer_already_linked"
| "transfer_not_linked"
| "transfer_active_profile_unknown"
| "transaction_linked_to_balance_account";
export class BalanceServiceError extends Error { export class BalanceServiceError extends Error {
readonly code: BalanceErrorCode; readonly code: BalanceErrorCode;
@ -955,3 +966,251 @@ export async function getAccountsPeriodAnchor(
params params
); );
} }
// -----------------------------------------------------------------------------
// Returns + transfers (Issue #142 / Bilan #4)
// -----------------------------------------------------------------------------
//
// Two distinct surface areas:
//
// (1) `computeAccountReturn` — Modified Dietz return for one account over a
// period. Lives on the Rust side (`compute_account_return` Tauri command)
// because it needs to JOIN snapshots + transfers + transactions and
// apply day-precision weighting in a single short-lived connection. The
// TS shim resolves the active profile's `db_filename` from `loadProfiles`
// and forwards it to the command.
//
// (2) Transfer linking helpers — `linkTransfer`, `unlinkTransfer`,
// `listAccountTransfers`. Plain CRUD on `balance_account_transfers` via
// `getDb()`, same pattern as the rest of this file.
/**
* Compute the Modified Dietz return for `accountId` over the period
* `[periodStart, periodEnd]` (both ISO `YYYY-MM-DD`). Returns the typed
* `AccountReturn` shape see `src/shared/types/index.ts`.
*
* Resolves the active profile's `db_filename` from `loadProfiles()` so the
* caller doesn't have to thread it through every screen. Throws
* `transfer_active_profile_unknown` if no active profile is set (should be
* impossible in normal app flow, but the service guards it anyway).
*/
export async function computeAccountReturn(
accountId: number,
periodStart: string,
periodEnd: string
): Promise<AccountReturn> {
const startNorm = normalizeSnapshotDate(periodStart);
const endNorm = normalizeSnapshotDate(periodEnd);
const config = await loadProfiles();
const profile = config.profiles.find(
(p) => p.id === config.active_profile_id
);
if (!profile) {
throw new BalanceServiceError(
"transfer_active_profile_unknown",
"No active profile is set"
);
}
return invoke<AccountReturn>("compute_account_return", {
dbFilename: profile.db_filename,
accountId,
periodStart: startNorm,
periodEnd: endNorm,
});
}
function normalizeDirection(
direction: BalanceTransferDirection
): BalanceTransferDirection {
if (direction !== "in" && direction !== "out") {
throw new BalanceServiceError(
"transfer_direction_invalid",
`Invalid transfer direction: ${direction}`
);
}
return direction;
}
/**
* Suggested direction for an unlinked transaction based on its signed amount.
* Pure helper so the `LinkTransfersModal` UI can pre-fill the direction
* column without round-tripping. Convention: in this codebase, expense
* transactions are stored with negative amounts (money leaving the bank).
* From the *balance account's* perspective:
* - negative bank amount = money left the bank arrived at the balance
* account = `in`
* - positive bank amount = money entered the bank = the balance account
* gave it back = `out`
*/
export function suggestTransferDirection(
transactionAmount: number
): BalanceTransferDirection {
return transactionAmount < 0 ? "in" : "out";
}
/**
* Link a transaction to a balance account with the given direction.
* Throws `transfer_already_linked` if the (transaction, account) pair is
* already in the table (UNIQUE constraint).
*/
export async function linkTransfer(
accountId: number,
transactionId: number,
direction: BalanceTransferDirection,
notes?: string | null
): Promise<number> {
const dir = normalizeDirection(direction);
const trimmedNotes = notes ? notes.trim() || null : null;
const db = await getDb();
// Guard duplicate link with a SELECT — keeps the error typed instead of a
// raw "UNIQUE constraint failed" string.
const existing = await db.select<{ id: number }[]>(
`SELECT id FROM balance_account_transfers
WHERE account_id = $1 AND transaction_id = $2`,
[accountId, transactionId]
);
if (existing.length > 0) {
throw new BalanceServiceError(
"transfer_already_linked",
`Transaction ${transactionId} is already linked to account ${accountId}`
);
}
const result = await db.execute(
`INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes)
VALUES ($1, $2, $3, $4)`,
[accountId, transactionId, dir, trimmedNotes]
);
return result.lastInsertId as number;
}
/**
* Unlink a transaction from an account. Throws `transfer_not_linked` if the
* pair isn't in the table — keeps callers from silently no-op'ing on a stale
* UI state.
*/
export async function unlinkTransfer(
accountId: number,
transactionId: number
): Promise<void> {
const db = await getDb();
const result = await db.execute(
`DELETE FROM balance_account_transfers
WHERE account_id = $1 AND transaction_id = $2`,
[accountId, transactionId]
);
if (result.rowsAffected === 0) {
throw new BalanceServiceError(
"transfer_not_linked",
`No transfer linked transaction ${transactionId} to account ${accountId}`
);
}
}
/**
* List every linked transfer for `accountId`, joined with the transaction
* table for date/description/amount. Optional `dateRange` (ISO YYYY-MM-DD,
* inclusive both sides) filters by `transactions.date`.
*/
export async function listAccountTransfers(
accountId: number,
dateRange?: { from?: string; to?: string }
): Promise<BalanceAccountTransferWithTransaction[]> {
const params: unknown[] = [accountId];
const conditions: string[] = ["bat.account_id = $1"];
if (dateRange?.from) {
conditions.push(`t.date >= $${params.length + 1}`);
params.push(normalizeSnapshotDate(dateRange.from));
}
if (dateRange?.to) {
conditions.push(`t.date <= $${params.length + 1}`);
params.push(normalizeSnapshotDate(dateRange.to));
}
const where = `WHERE ${conditions.join(" AND ")}`;
const db = await getDb();
return db.select<BalanceAccountTransferWithTransaction[]>(
`SELECT bat.id AS id,
bat.account_id AS account_id,
bat.transaction_id AS transaction_id,
bat.direction AS direction,
bat.notes AS notes,
bat.created_at AS created_at,
t.date AS transaction_date,
t.description AS transaction_description,
t.amount AS transaction_amount,
a.name AS account_name
FROM balance_account_transfers bat
JOIN transactions t ON t.id = bat.transaction_id
JOIN balance_accounts a ON a.id = bat.account_id
${where}
ORDER BY t.date DESC, bat.id DESC`,
params
);
}
/**
* Returns the set of `transaction_id`s currently linked to ANY balance
* account. Used by the transactions table to render the transfer icon
* without an N+1 query the caller receives the full set once per render
* and does an in-memory `.has(id)` lookup. Cheap on real-world scales
* (typically < 1000 linked transfers per profile).
*/
export async function listLinkedTransactionIds(): Promise<Set<number>> {
const db = await getDb();
const rows = await db.select<{ transaction_id: number }[]>(
`SELECT DISTINCT transaction_id FROM balance_account_transfers`
);
return new Set(rows.map((r) => r.transaction_id));
}
/**
* Returns transfer info keyed by `transaction_id` for tooltip rendering in
* the transactions table. Each transaction maps to an array because a
* single transaction *could* be linked to several accounts in principle
* (the UNIQUE is on the pair, not on transaction alone).
*/
export interface LinkedTransferTooltipRow {
transaction_id: number;
account_id: number;
account_name: string;
direction: BalanceTransferDirection;
}
export async function listAllLinkedTransfersForTooltip(): Promise<
Map<number, LinkedTransferTooltipRow[]>
> {
const db = await getDb();
const rows = await db.select<LinkedTransferTooltipRow[]>(
`SELECT bat.transaction_id AS transaction_id,
bat.account_id AS account_id,
a.name AS account_name,
bat.direction AS direction
FROM balance_account_transfers bat
JOIN balance_accounts a ON a.id = bat.account_id
ORDER BY bat.transaction_id`
);
const map = new Map<number, LinkedTransferTooltipRow[]>();
for (const r of rows) {
const list = map.get(r.transaction_id) ?? [];
list.push(r);
map.set(r.transaction_id, list);
}
return map;
}
/**
* Detect whether the SQL error returned by `tauri-plugin-sql` is a FK
* RESTRICT violation from `balance_account_transfers.transaction_id`. The
* plugin surfaces the SQLite error message verbatim, so we match on the
* string. Used by `transactionService.deleteTransaction` to surface a
* clean i18n error instead of leaking the raw SQL.
*/
export function isLinkedTransactionFkError(error: unknown): boolean {
const msg = error instanceof Error ? error.message : String(error ?? "");
// SQLite FK error messages look like:
// "FOREIGN KEY constraint failed"
// or
// "code: 787, message: FOREIGN KEY constraint failed"
// Both contain the canonical "FOREIGN KEY constraint failed" substring.
return /FOREIGN KEY constraint failed/i.test(msg);
}

View file

@ -1,4 +1,6 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { isLinkedTransactionFkError } from "./balance.service";
import { TransactionLinkedToBalanceError } from "./transactionService";
import type { ImportedFile, ImportedFileWithSource } from "../shared/types"; import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
export async function getFilesBySourceId( export async function getFilesBySourceId(
@ -94,10 +96,39 @@ export async function deleteImportWithTransactions(
); );
const sourceId = files.length > 0 ? files[0].source_id : null; const sourceId = files.length > 0 ? files[0].source_id : null;
const result = await db.execute( // Pre-flight: if any transaction in this file is linked to a balance
"DELETE FROM transactions WHERE file_id = $1", // 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] [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]); await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]);
// Clean up orphaned source if no files remain // Clean up orphaned source if no files remain
@ -116,7 +147,32 @@ export async function deleteImportWithTransactions(
export async function deleteAllImportsWithTransactions(): Promise<number> { export async function deleteAllImportsWithTransactions(): Promise<number> {
const db = await getDb(); const db = await getDb();
const result = await db.execute("DELETE FROM transactions"); // Same pre-flight as the per-file path: if ANY transaction is linked to
// a balance account, the bulk wipe would explode on FK RESTRICT — surface
// a typed error so the UI can prompt the user to unlink first.
const linked = await db.select<
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
>(
`SELECT bat.transaction_id AS transaction_id,
bat.account_id AS account_id,
a.name AS account_name,
bat.direction AS direction
FROM balance_account_transfers bat
JOIN balance_accounts a ON a.id = bat.account_id
LIMIT 50`
);
if (linked.length > 0) {
throw new TransactionLinkedToBalanceError(null, linked);
}
let result;
try {
result = await db.execute("DELETE FROM transactions");
} catch (err) {
if (isLinkedTransactionFkError(err)) {
throw new TransactionLinkedToBalanceError(null, []);
}
throw err;
}
await db.execute("DELETE FROM imported_files"); await db.execute("DELETE FROM imported_files");
await db.execute("DELETE FROM import_sources"); await db.execute("DELETE FROM import_sources");
return result.rowsAffected; return result.rowsAffected;

View file

@ -1,5 +1,9 @@
import { getDb } from "./db"; import { getDb } from "./db";
import { categorizeBatch } from "./categorizationService"; import { categorizeBatch } from "./categorizationService";
import {
isLinkedTransactionFkError,
type LinkedTransferTooltipRow,
} from "./balance.service";
import type { import type {
Transaction, Transaction,
TransactionRow, TransactionRow,
@ -11,6 +15,85 @@ import type {
SplitChild, SplitChild,
} from "../shared/types"; } from "../shared/types";
/**
* Thrown when a deletion path is blocked by `balance_account_transfers.transaction_id`
* FK RESTRICT. Carries the offending `transaction_id`(s) so the UI can format a
* precise message ("Cette transaction est liée au compte de bilan X déliez-la
* avant de supprimer") and can offer a deep link to the linked account.
*
* `linkedAccounts` is best-effort: when known (single-row delete) the array
* lists every account currently linking the transaction. For bulk deletes
* the array may be empty the UI just shows the generic message in that
* case.
*/
export class TransactionLinkedToBalanceError extends Error {
readonly code = "transaction_linked_to_balance_account" as const;
readonly transactionId: number | null;
readonly linkedAccounts: LinkedTransferTooltipRow[];
constructor(
transactionId: number | null,
linkedAccounts: LinkedTransferTooltipRow[],
message?: string
) {
super(
message ??
"Transaction is linked to one or more balance accounts; unlink before deleting"
);
this.name = "TransactionLinkedToBalanceError";
this.transactionId = transactionId;
this.linkedAccounts = linkedAccounts;
}
}
/**
* Delete one transaction by id. Throws `TransactionLinkedToBalanceError` if
* the transaction has any row in `balance_account_transfers` (FK RESTRICT)
* UI surfaces "Cette transaction est liée au compte de bilan X déliez-la
* avant de supprimer" with a link to the offending account.
*
* Pre-checks the link table so the error carries account names; falls back
* to the FK-error pattern matcher if the constraint fires for any other
* reason.
*/
export async function deleteTransaction(transactionId: number): Promise<void> {
const db = await getDb();
// Pre-check: if any transfer references this transaction, surface a clean
// typed error WITHOUT touching the row. Cheaper than catching the FK
// exception and provides the account names for the UI message.
const linked = await db.select<
Array<{ account_id: number; account_name: string; direction: "in" | "out" }>
>(
`SELECT bat.account_id AS account_id,
a.name AS account_name,
bat.direction AS direction
FROM balance_account_transfers bat
JOIN balance_accounts a ON a.id = bat.account_id
WHERE bat.transaction_id = $1`,
[transactionId]
);
if (linked.length > 0) {
throw new TransactionLinkedToBalanceError(
transactionId,
linked.map((l) => ({
transaction_id: transactionId,
account_id: l.account_id,
account_name: l.account_name,
direction: l.direction,
}))
);
}
try {
await db.execute("DELETE FROM transactions WHERE id = $1", [transactionId]);
} catch (err) {
// Defensive: a race could have linked the transaction between the
// SELECT and the DELETE. Surface the typed error in that case too.
if (isLinkedTransactionFkError(err)) {
throw new TransactionLinkedToBalanceError(transactionId, []);
}
throw err;
}
}
export async function insertBatch( export async function insertBatch(
transactions: Array<{ transactions: Array<{
date: string; date: string;

View file

@ -630,3 +630,57 @@ export interface BalanceSnapshotLine {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
// Account transfers — added Issue #142 (Bilan #4). Links a transaction to a
// balance account so the Modified Dietz return calculator can separate
// contributions from gains. Direction follows the account's perspective:
// 'in' = capital added (deposit / buy)
// 'out' = capital removed (withdrawal / sell)
// `transaction_id` ON DELETE RESTRICT — preserves reproducibility of past
// returns, the UI must force the user to unlink before deleting the
// underlying transaction.
export type BalanceTransferDirection = "in" | "out";
export interface BalanceAccountTransfer {
id: number;
account_id: number;
transaction_id: number;
direction: BalanceTransferDirection;
notes: string | null;
created_at: string;
}
/** Joined view used by LinkTransfersModal + transaction icon lookup. */
export interface BalanceAccountTransferWithTransaction
extends BalanceAccountTransfer {
transaction_date: string;
transaction_description: string;
transaction_amount: number;
account_name: string;
}
/**
* Modified Dietz return for one account over a period.
* Mirrors the Rust struct in `src-tauri/src/commands/return_calculator.rs`.
*
* - `value_start` / `value_end`: latest snapshot value each endpoint, or
* null when no snapshot exists.
* - `net_contributions`: signed sum of cash flows in the period.
* - `return_pct`: Modified Dietz return (0.05 = +5%); null if either
* endpoint is missing or denominator is non-positive.
* - `annualized_pct`: `(1 + R)^(365/T) - 1`; null for zero-length periods
* or whenever `return_pct` is null.
* - `is_partial`: true when one endpoint snapshot is missing.
* - `has_no_transfers_warning`: true when no transfers were tagged in the
* period return collapses to simple `(V_end - V_start) / V_start` and
* the UI surfaces a warning so the user can verify nothing was forgotten.
*/
export interface AccountReturn {
value_start: number | null;
value_end: number | null;
net_contributions: number;
return_pct: number | null;
annualized_pct: number | null;
is_partial: boolean;
has_no_transfers_warning: boolean;
}