Compare commits
1 commit
97680417ee
...
49dec51062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49dec51062 |
45 changed files with 20 additions and 10123 deletions
|
|
@ -2,17 +2,8 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
### Ajouté
|
|
||||||
- **Bilan — documentation et ADRs** (`docs/`) : finalise le milestone Bilan avec la passe documentaire. `docs/architecture.md` répertorie désormais les 5 nouvelles tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`), les 7 nouveaux index, les invariants CHECK et FK (CAD seulement, invariants de type, `RESTRICT` sur `transaction_id` pour la reproductibilité Modified Dietz), le découpage 4 sections de `balance.service.ts` (CRUD / snapshots+lignes / rendements+transferts / prix), les 3 hooks scoped par page (`useBalanceAccounts`, `useSnapshotEditor`, `useBalanceOverview`), la commande Tauri `compute_account_return` (avec mention de la future commande `fetch_price` Phase 5), et les 3 nouvelles routes `/balance*`. Trois nouveaux ADRs accompagnent : **0008 — Modified Dietz** (justifie le choix vs ROI / TWR / IRR avec référence à `return_calculator.rs`) ; **0009 — Proxy price-fetching via maximus-api** (architecture documentée maintenant, implémentation BLOQUÉE en attendant la Phase 2 de maximus-api — couvre les considérations privacy comme le strip de headers, l'absence de corrélation `(symbole, licence)` dans les logs et le User-Agent fixe `simpl-resultat`, l'abstraction adapter Yahoo + CoinGecko, la stratégie d'auth Bearer, le rate-limiting client + serveur et le double gating premium UI + serveur) ; **0010 — FK RESTRICT sur `balance_account_transfers.transaction_id`** (justifie l'arbitrage intégrité vs friction pour la reproductibilité Modified Dietz). Le guide utilisateur gagne une nouvelle section *Bilan* qui détaille la saisie de snapshot (simple + coté), la liaison de transferts, la lecture des rendements multi-horizons (3M / 1A / depuis création avec colonne non-ajustée côte à côte), avec la mention « à venir Phase 5 » pour le price-fetching premium. Clés i18n `docs.balance.*` (FR + EN) ajoutées pour que le guide in-app reflète la nouvelle section (#145)
|
|
||||||
- **Bilan — suite de tests d'intégration cross-cutting** (infrastructure de tests) : clôt la feature *Bilan* avec une couche de tests d'intégration qui exerce toute la surface TypeScript en un seul flux de bout en bout (compte → catégorie cotée → snapshot coté → transfert lié → rendement) et des assertions dédiées sur le verrou de devise (CAD seulement au MVP, refusé à la fois côté service et côté CHECK SQL), la sécurité de tolérance pour le type coté (un mauvais enregistrement ne doit PAS supprimer les lignes existantes), le câblage de `computeAccountReturn` (résolution du profil actif, transmission des dates ISO, conservation telle quelle d'une réponse de période partielle). Trois nouveaux tests Rust d'intégration appliquent la migration v9 par-dessus un schéma v1 seedé contenant déjà des transactions pour vérifier (1) aucune perte ni mutation de données, (2) le round-trip lier / délier sur de vraies `transaction_id`, (3) la chaîne FK RESTRICT (suppression d'une transaction liée bloquée, autorisée après détachement), (4) la cohabitation indépendante des espaces d'identifiants `categories.id` (v1) et `balance_categories.id` (v9). Un test de non-régression au niveau source sur `TransactionTable.tsx` verrouille le contrat de l'icône de transfert inlinée : prop optionnelle, court-circuit en chaînage optionnel, clés i18n, aria-label, layout partagé de la cellule description — pour que la page reste rendue à l'identique en l'absence de transferts liés. (#144)
|
|
||||||
- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin − V_début − ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T − t_i) / T`, et annualisation `(1 + R)^(365/T) − 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin − V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
|
|
||||||
- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<Area stackId>` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
|
|
||||||
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur − quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
|
||||||
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
|
|
||||||
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
|
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
- **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être déployé en production (live à `https://api.lacompagniemaximus.com`). Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants — mais `/licenses/activate` répond désormais, donc l'activation par machine (issue #53) sera débloquée dès la sortie de cette version. La clé privée correspondante vit uniquement sur le serveur (#49)
|
- **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être scaffolded. Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants. La clé privée correspondante vit uniquement sur le serveur (#49)
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
- **Rapport Zoom catégorie** (`/reports/category`) : la liste déroulante du combobox des catégories affiche désormais la liste complète dans un ordre hiérarchique DFS correct — chaque racine est émise avant ses descendants, et les frères et sœurs sont triés par `sort_order` puis nom affiché. Auparavant la liste était triée globalement par `sort_order` (via un `ORDER BY sort_order, name` SQL), ce qui entrelaçait des parents et enfants de sous-arbres différents partageant le même `sort_order`, d'où l'indentation incohérente et l'impression d'arbre cassé. La recherche filtrée (insensible aux accents) conserve le même comportement (#126)
|
- **Rapport Zoom catégorie** (`/reports/category`) : la liste déroulante du combobox des catégories affiche désormais la liste complète dans un ordre hiérarchique DFS correct — chaque racine est émise avant ses descendants, et les frères et sœurs sont triés par `sort_order` puis nom affiché. Auparavant la liste était triée globalement par `sort_order` (via un `ORDER BY sort_order, name` SQL), ce qui entrelaçait des parents et enfants de sous-arbres différents partageant le même `sort_order`, d'où l'indentation incohérente et l'impression d'arbre cassé. La recherche filtrée (insensible aux accents) conserve le même comportement (#126)
|
||||||
|
|
|
||||||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -2,17 +2,8 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Balance sheet — documentation and ADRs** (`docs/`): closes the Bilan milestone with the documentation pass. `docs/architecture.md` now lists the 5 new tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`), the 7 new indexes, the SQL CHECK and FK invariants (CAD-only, kind invariants, `RESTRICT` on `transaction_id` for Modified Dietz reproducibility), the `balance.service.ts` 4-section layout (CRUD / snapshots+lines / returns+transfers / prices), the 3 page-scoped hooks (`useBalanceAccounts`, `useSnapshotEditor`, `useBalanceOverview`), the `compute_account_return` Tauri command (with the `fetch_price` future-Phase-5 mention), and the 3 new `/balance*` routes. Three new ADRs land alongside: **0008 — Modified Dietz** (justifies the choice vs. ROI / TWR / IRR with reference to `return_calculator.rs`); **0009 — Proxy price-fetching via maximus-api** (architecture documented now, implementation stays BLOCKED by maximus-api Phase 2 — covers privacy considerations like header stripping, no `(symbol, license)` log correlation and the fixed `simpl-resultat` UA, the Yahoo + CoinGecko provider abstraction, the Bearer auth strategy, the client + server rate limiting and the dual-side premium gating); **0010 — FK RESTRICT on `balance_account_transfers.transaction_id`** (justifies the integrity over friction trade-off for Modified Dietz reproducibility). The user guide gains a new *Balance sheet* section walking through snapshot entry (simple + priced), transfer linking, multi-horizon return reading (3M / 1Y / since inception with the side-by-side unadjusted column), with the price-fetching premium flagged "coming in Phase 5". `docs.balance.*` i18n keys (FR + EN) ship so the in-app guide reflects the new section (#145)
|
|
||||||
- **Balance sheet — cross-cutting integration test suite** (test infrastructure): closes out the *Bilan* feature with a layer of integration tests that exercise the whole TypeScript surface in a single happy-path flow (account → priced category → priced snapshot → linked transfer → return) plus dedicated assertions for currency lock (CAD-only at the MVP, rejected at both the service layer and SQL CHECK), priced-kind tolerance safety (a bad save must NOT clear pre-existing lines), `computeAccountReturn` wiring (active-profile resolution, ISO date forwarding, partial-period payload pass-through). Three new Rust integration tests apply migration v9 on top of a seeded v1 schema with pre-existing transactions to verify (1) no row loss / data mutation, (2) link / unlink transfer round-trip on real transaction ids, (3) the FK RESTRICT chain (linked transaction deletion blocked, unblocked after unlink), (4) the v1 `categories.id` and v9 `balance_categories.id` namespaces coexist independently. A non-regression source-level test on `TransactionTable.tsx` locks down the inlined transfer icon contract: optional prop, optional-chaining short-circuit, i18n keys, aria-label, shared description-cell layout — so the page renders identically when no transfers are linked. (#144)
|
|
||||||
- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end − V_start − ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T − t_i) / T`, plus `(1 + R)^(365/T) − 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end − V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
|
|
||||||
- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<Area stackId>` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
|
|
||||||
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value − quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
|
||||||
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
|
|
||||||
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **License Ed25519 public key** rotated to match the freshly deployed `maximus-api` license server (now live at `https://api.lacompagniemaximus.com`). No production licenses had been issued against the previous key, so this change is invisible to existing users — but `/licenses/activate` now answers, so machine activation (Issue #53) is unblocked once this release ships. The matching private key lives only on the server (#49)
|
- **License Ed25519 public key** rotated to match the freshly scaffolded `maximus-api` license server. No production licenses had been issued against the previous key, so this change is invisible to existing users. The matching private key now lives only on the server (#49)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Category zoom report** (`/reports/category`): the category combobox dropdown now renders the full list in proper hierarchical DFS order — each root is emitted before its descendants, with siblings sorted by `sort_order` then display name. Previously the list was ordered by `sort_order` globally (from a SQL `ORDER BY sort_order, name`), which interleaved parents and children from different sub-trees that shared the same `sort_order`, producing scrambled indentation and a mis-leading tree. Filtering (accent-insensitive search) still behaves identically (#126)
|
- **Category zoom report** (`/reports/category`): the category combobox dropdown now renders the full list in proper hierarchical DFS order — each root is emitted before its descendants, with siblings sorted by `sort_order` then display name. Previously the list was ordered by `sort_order` globally (from a SQL `ORDER BY sort_order, name`), which interleaved parents and children from different sub-trees that shared the same `sort_order`, producing scrambled indentation and a mis-leading tree. Filtering (accent-insensitive search) still behaves identically (#126)
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
# ADR 0008 — Modified Dietz pour le calcul du rendement par compte
|
|
||||||
|
|
||||||
- Status: Accepted
|
|
||||||
- Date: 2026-04-25
|
|
||||||
- Milestone: `overnight-2026-04-26-bilan` (Issues #138 → #145)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
La feature Bilan introduit une vue patrimoniale (snapshots datés) avec calcul du rendement par compte. Le rendement réel d'un compte d'investissement n'est PAS `(V_fin − V_début) / V_début` : cette formule confond les **gains réels** avec les **apports/retraits**.
|
|
||||||
|
|
||||||
> Exemple : compte CELI à 10 000 $, on dépose 5 000 $, le compte vaut 16 000 $ à la fin. La formule naïve donne 60 % (16/10), mais la moitié du gain est juste l'apport. Le vrai rendement est 6 % : `(16 000 − 5 000 − 10 000) / 10 000`.
|
|
||||||
|
|
||||||
C'est exactement la raison pour laquelle l'utilisateur tague des transferts (table `balance_account_transfers`) : pour les exclure du calcul.
|
|
||||||
|
|
||||||
Quatre formules candidates ont été comparées dans le spike (`~/claude-code/.spikes/bilan/code/rendement.md`) :
|
|
||||||
|
|
||||||
| Méthode | Pondère le timing des flux ? | Nécessite des valeurs intermédiaires ? | Standard d'industrie ? |
|
|
||||||
|---------|---|---|---|
|
|
||||||
| ROI ajusté simple | ❌ | ❌ | ❌ |
|
|
||||||
| **Modified Dietz** | ✅ (approximation linéaire) | ❌ | ✅ (GIPS-compliant en première approximation) |
|
|
||||||
| Time-Weighted Return (TWR) | ✅ (exact) | ✅ (à chaque flux) | ✅ |
|
|
||||||
| Money-Weighted Return / IRR | ✅ (exact, itératif) | ❌ | ✅ |
|
|
||||||
|
|
||||||
Contraintes du contexte Simpl'Résultat :
|
|
||||||
- Les snapshots sont saisis librement (mensuels, trimestriels, ad-hoc) — il n'y a **pas** de valeur du compte aux dates de flux.
|
|
||||||
- Pas de solveur numérique embarqué côté client (pas de Newton-Raphson en Rust pour l'IRR).
|
|
||||||
- L'utilisateur doit pouvoir comprendre le résultat sans formation financière.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Adopter Modified Dietz** comme méthode unique de calcul du rendement par compte au MVP, implémentée côté Rust dans le module privé `src-tauri/src/commands/return_calculator.rs`.
|
|
||||||
|
|
||||||
```
|
|
||||||
R = (V_fin − V_début − C_net) / (V_début + Σ(C_i × W_i))
|
|
||||||
```
|
|
||||||
|
|
||||||
où :
|
|
||||||
- `C_i` = chaque flux (signé : + apport, − retrait)
|
|
||||||
- `W_i = (T − t_i) / T` = poids temporel (1 si début de période, 0 si fin)
|
|
||||||
- `T` = durée totale de la période en jours, `t_i` = position du flux
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
- **Logique pure** : `commands/return_calculator.rs` (module privé, pas exposé comme commande). `pub(crate) fn modified_dietz(...) -> AccountReturn`.
|
|
||||||
- **Commande Tauri** : `commands/balance_commands.rs::compute_account_return(account_id, period_start, period_end, db_filename)` ouvre une connexion `rusqlite` courte sur la DB du profil actif, lit le snapshot ≤ start, le snapshot ≥ end et les cash flows liés, puis délègue le calcul.
|
|
||||||
- **Dépendance Cargo** : `chrono = "0.4"` ajoutée pour l'arithmétique de dates (poids temporels en jours).
|
|
||||||
- **Tests TDD co-localisés** : `#[cfg(test)] mod tests` dans le même fichier — 7 cas (nominal, pas de snapshot début, partial-end, compte créé en cours, compte vidé, aucun transfert, annualisation).
|
|
||||||
|
|
||||||
### Output
|
|
||||||
|
|
||||||
```rust
|
|
||||||
struct AccountReturn {
|
|
||||||
value_start: Option<f64>,
|
|
||||||
value_end: f64,
|
|
||||||
net_contributions: f64,
|
|
||||||
return_pct: Option<f64>, // None si dénominateur ≈ 0
|
|
||||||
annualized_pct: Option<f64>, // (1 + R)^(365/days) - 1, si days > 30
|
|
||||||
is_partial: bool, // true si snapshot manquant après fin
|
|
||||||
has_no_transfers_warning: bool, // true si aucun transfert lié
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Affichage côté UI (`BalanceAccountsTable`)
|
|
||||||
|
|
||||||
- 3 colonnes Modified Dietz : 3M / 1A / depuis création
|
|
||||||
- 1 colonne **rendement non-ajusté** (`(V_fin − V_début) / V_début`) côte-à-côte — pédagogique : montre l'effet des apports vs gains réels
|
|
||||||
- Warnings visibles (`is_partial`, `has_no_transfers_warning`) avec tooltip i18n
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positive
|
|
||||||
|
|
||||||
- **Pas besoin de valeurs intermédiaires** : le calcul ne nécessite que les snapshots existants + les transferts taggés. C'est exactement ce que l'utilisateur saisit déjà.
|
|
||||||
- **Standard d'industrie** : Modified Dietz est GIPS-compliant en première approximation. Le résultat est défendable.
|
|
||||||
- **Pédagogique** : afficher le rendement non-ajusté à côté du Modified Dietz éduque l'utilisateur sur la différence entre "valeur du compte" et "vraie performance".
|
|
||||||
- **Implémentation simple** : ~50 lignes de logique pure en Rust + 7 tests. Pas de solveur numérique.
|
|
||||||
- **Reproductibilité** : combinée avec la FK `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id` (voir [ADR 0010](0010-fk-restrict-balance-transfers.md)), une période déjà calculée ne peut pas changer rétroactivement.
|
|
||||||
|
|
||||||
### Negative / trade-offs
|
|
||||||
|
|
||||||
- **Approximation** : Modified Dietz suppose une distribution linéaire des flux dans le temps. Si plusieurs flux concentrés tombent juste avant un mouvement de marché significatif, l'erreur s'accumule. Acceptable pour un usage personnel ; un investisseur professionnel utiliserait TWR exact.
|
|
||||||
- **Cas dégénéré "compte vidé puis rechargé"** : le dénominateur `V_début + Σ(C_i × W_i)` peut tendre vers zéro et faire exploser le ratio. Mitigé par un warning UI "Performance non significative" basé sur `has_no_transfers_warning` ou un seuil sur le dénominateur.
|
|
||||||
- **Pas de TWR au MVP** : si l'utilisateur veut la vraie performance gestionnaire (indépendante du timing des flux), il devra attendre une v2 qui demandera de saisir des valeurs intermédiaires aux dates de flux.
|
|
||||||
- **Pas de Money-Weighted Return / IRR** : formule plus précise mais nécessite Newton-Raphson. Coût/bénéfice défavorable au MVP.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
- **ROI ajusté simple** (`(V_fin − V_début − C_net) / V_début`). Rejeté : ignore *quand* l'apport est arrivé. Un dépôt de 10 000 $ le 1er janvier vs le 31 décembre donne le même résultat — incorrect.
|
|
||||||
- **TWR (Time-Weighted Return)**. Rejeté pour le MVP : nécessite des valeurs du compte aux dates de flux, qu'on ne stocke pas. Possible v2 si l'utilisateur accepte de saisir des valeurs intermédiaires.
|
|
||||||
- **IRR (Money-Weighted Return)**. Rejeté : nécessite un solveur Newton-Raphson, complexité disproportionnée pour un usage personnel.
|
|
||||||
- **Calcul côté TypeScript (sans commande Rust)**. Rejeté : l'arithmétique de dates en JavaScript (`Date.UTC(...) / 86400000`) est correcte mais le pattern projet (logique financière côté Rust avec tests `cargo`) est plus robuste. Cohérent avec `aes-gcm`, `argon2`, etc.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md), [`spec-plan-bilan.md`](../../spec-plan-bilan.md)
|
|
||||||
- Spike : `~/claude-code/.spikes/bilan/code/rendement.md` (comparaison ROI / Modified Dietz / TWR / IRR)
|
|
||||||
- Implémentation : `src-tauri/src/commands/return_calculator.rs`, `src-tauri/src/commands/balance_commands.rs`
|
|
||||||
- Tests TDD : `#[cfg(test)] mod tests` dans `return_calculator.rs` (7 cas)
|
|
||||||
- ADR liée : [0010 — FK RESTRICT sur `balance_account_transfers.transaction_id`](0010-fk-restrict-balance-transfers.md)
|
|
||||||
- GIPS standards (Global Investment Performance Standards) — Modified Dietz est listé comme méthode acceptable d'approximation pour des périodes < 1 an.
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
# ADR 0009 — Price-fetching premium via proxy maximus-api
|
|
||||||
|
|
||||||
- Status: Accepted (architecture documentée — implémentation reportée à l'Issue #143, BLOCKED par maximus-api Phase 2)
|
|
||||||
- Date: 2026-04-25
|
|
||||||
- Milestone: `overnight-2026-04-26-bilan` (architecture spec)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
La feature Bilan supporte des comptes "priced" (actions, crypto) où chaque ligne de snapshot stocke `(quantity, unit_price, value)`. La saisie manuelle de `unit_price` reste toujours possible mais devient pénible dès qu'on a plusieurs titres ou qu'on rétro-saisit un historique.
|
|
||||||
|
|
||||||
L'objectif est de proposer un bouton "récupérer le prix au [date]" qui interroge un fournisseur de données (Yahoo Finance, CoinGecko, etc.) sans **trahir le principe privacy-first NON NÉGOCIABLE** du projet :
|
|
||||||
|
|
||||||
> Zéro donnée envoyée vers un serveur tiers. Tout le traitement CSV et toutes les données financières restent en local. Aucune télémétrie, aucun analytics cloud.
|
|
||||||
|
|
||||||
Or interroger Yahoo ou CoinGecko, c'est par définition envoyer une requête sortante depuis l'IP de l'utilisateur. Quelles informations fuiteraient ?
|
|
||||||
|
|
||||||
- **L'IP de l'utilisateur** : géolocalisation grossière, profilage de session
|
|
||||||
- **L'User-Agent par défaut** de `reqwest` : `reqwest/0.12 ...`, identifie le client comme une app Tauri (silhouette technique reconnaissable)
|
|
||||||
- **Le symbole + date** : "AAPL au 2026-03-15" n'est pas identifiant en soi mais corrélé à l'IP, le provider peut reconstruire le portefeuille
|
|
||||||
- **Headers résiduels** : `Accept-Language` peut révéler la locale système
|
|
||||||
|
|
||||||
Trois architectures candidates :
|
|
||||||
|
|
||||||
| Option | Privacy | Complexité serveur | Coût d'API |
|
|
||||||
|--------|---------|--------------------|------------|
|
|
||||||
| Appel direct client → provider | ❌ IP exposée, fingerprint headers | aucune | par user (rate limits triggered fast) |
|
|
||||||
| Appel direct + Tor / VPN intégré | ⚠ partiel, latence dégradée | aucune | par user |
|
|
||||||
| **Proxy via maximus-api auto-hébergé** | ✅ IP cachée, headers strippés, cache mutualisé | Endpoint `/v1/prices` à maintenir | mutualisé (cache mutualisé entre users premium) |
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Implémenter le price-fetching comme fonctionnalité premium-only servie par `maximus-api` agissant comme proxy**, avec consentement explicite et hygiène de headers stricte des deux côtés du fil.
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
[App Tauri]
|
|
||||||
│ GET /v1/prices?symbol=AAPL&date=2026-03-15
|
|
||||||
│ Headers: Authorization: Bearer <activation_token>
|
|
||||||
│ Accept: application/json
|
|
||||||
│ User-Agent: simpl-resultat
|
|
||||||
▼
|
|
||||||
[maximus-api] ← VPS Max (Coolify)
|
|
||||||
│ 1. Strip TOUS headers entrants identifiants
|
|
||||||
│ 2. Validation tier premium (403 si non-premium)
|
|
||||||
│ 3. Cache SQLite (symbol, date) → price (TTL infini sur dates passées)
|
|
||||||
│ 4. Cache miss → adapter (Yahoo / CoinGecko)
|
|
||||||
▼
|
|
||||||
[Provider tiers] ← voit l'IP du VPS, pas du client
|
|
||||||
```
|
|
||||||
|
|
||||||
### Choix de providers : abstraction adapter
|
|
||||||
|
|
||||||
Côté maximus-api, un module `price-fetcher` expose une interface unique et délègue à des adapters :
|
|
||||||
|
|
||||||
| Provider | Stocks | Crypto | Coût | Adapter |
|
|
||||||
|----------|--------|--------|------|---------|
|
|
||||||
| **Yahoo Finance** (unofficial) | ✅ | ⚠ | gratuit | `YahooAdapter` (HTTP direct) |
|
|
||||||
| **CoinGecko** | ❌ | ✅ excellent | gratuit (free tier 30 req/min) | `CoinGeckoAdapter` |
|
|
||||||
| Alpha Vantage (fallback) | ✅ | ⚠ | freemium | optionnel si Yahoo casse |
|
|
||||||
|
|
||||||
**Stocks → Yahoo** ; **Crypto → CoinGecko**. L'abstraction permet de swap si un provider casse, sans changer le contrat client.
|
|
||||||
|
|
||||||
### Stratégie d'authentification
|
|
||||||
|
|
||||||
- **`Authorization: Bearer <activation_token>` uniquement.** Le token est lu côté client depuis `activation_path` (le fichier déjà utilisé par `license_commands.rs` pour persister le token d'activation). **Jamais stocké dans `user_preferences`** (la table SQL de l'app n'a pas vocation à versionner les credentials).
|
|
||||||
- **Jamais en query string.** Un token-in-URL leakerait dans :
|
|
||||||
- Les logs Traefik / nginx du VPS (URL complète loguée par défaut)
|
|
||||||
- Le header `Referer` si maximus-api redirige
|
|
||||||
- Les écrans de partage (le header `Authorization` est masqué par les outils de capture, pas l'URL)
|
|
||||||
|
|
||||||
### Hygiène des headers — privacy en profondeur
|
|
||||||
|
|
||||||
**Côté client (Rust / `reqwest`)** :
|
|
||||||
- `reqwest::Client::builder().user_agent("simpl-resultat").build()` — UA fixe, pas le default `reqwest/0.12 ...`
|
|
||||||
- Headers envoyés UNIQUEMENT : `Authorization: Bearer <token>` + `Accept: application/json`
|
|
||||||
- **Pas** de `Accept-Language` (révèle la locale)
|
|
||||||
- **Pas** d'autres headers identifiants
|
|
||||||
|
|
||||||
**Côté serveur (maximus-api)** :
|
|
||||||
- Strip TOUS les headers entrants avant de proxyer vers le provider tiers (`X-Forwarded-For`, `User-Agent` client, `Accept-Language`, etc.)
|
|
||||||
- **Ne JAMAIS logger `(symbol, license_id)` ensemble.** Soit séparer les logs (un journal pour la facturation/quota par licence sans symbole, un journal pour les hits cache/provider sans license), soit hasher le `license_id` côté serveur avec un sel rotatif court avant log
|
|
||||||
- Validation premium **AVANT** cache et provider — un client non-premium reçoit 403 sans qu'aucun appel sortant ne soit déclenché
|
|
||||||
|
|
||||||
### Rate limiting
|
|
||||||
|
|
||||||
**Côté client** :
|
|
||||||
- Max 1 fetch / 2 secondes (timer simple)
|
|
||||||
- Dedup in-flight par `(symbol, date)` (deux clics rapides = 1 seule requête réseau)
|
|
||||||
- Backoff exponentiel sur 5xx / network : 2s, 4s, 8s — max 3 retries
|
|
||||||
- Plafond hard : 100 fetches par session snapshot (anti-loop)
|
|
||||||
|
|
||||||
**Côté serveur** :
|
|
||||||
- Quota par licence (proposition initiale : 1000 req/jour, le cache absorbe l'essentiel)
|
|
||||||
- Le cache `(symbol, date)` est immuable pour les dates passées (TTL infini), 5 min pour `today` (le marché peut bouger)
|
|
||||||
|
|
||||||
### Premium gating — défense en profondeur
|
|
||||||
|
|
||||||
- **UI client** : si `entitlements.check_entitlement("price-fetching")` retourne `false`, le bouton ↻ affiche un tooltip "Disponible avec abonnement" et est désactivé. Pas de tentative de fetch.
|
|
||||||
- **Server-side** : `maximus-api /v1/prices` valide le tier premium AVANT cache/provider. Un client modifié qui bypass la UI reçoit 403.
|
|
||||||
|
|
||||||
La double vérification est délibérée : le client est compromettable (l'app Tauri est ouverte au reverse-engineering), seul le serveur peut faire foi.
|
|
||||||
|
|
||||||
### Consentement explicite (per-profile)
|
|
||||||
|
|
||||||
- Stockage : `user_preferences.price_fetching_consent = {consented_at: <ISO>, version: 1}`
|
|
||||||
- **NE PAS seeder la clé.** Absence = jamais demandé. Le default doit être "non-décidé", pas "false".
|
|
||||||
- Premier clic sur le bouton ↻ → modal de consentement → écriture de la clé après acceptation
|
|
||||||
- **Permanence** : pas de re-consent automatique. Révocation explicite via toggle Settings (supprime la clé)
|
|
||||||
- Stockage **per-profile** (table `user_preferences` est par-profil), pas global au système
|
|
||||||
|
|
||||||
### Mode offline / fallback
|
|
||||||
|
|
||||||
L'app **ne doit jamais bloquer la saisie d'un snapshot** parce que le price-fetching a échoué. La saisie manuelle de `unit_price` reste TOUJOURS disponible :
|
|
||||||
|
|
||||||
| Erreur serveur | Comportement |
|
|
||||||
|----------------|--------------|
|
|
||||||
| 401 license expirée | Toast "Renouvelez votre abonnement" + champ manuel dispo |
|
|
||||||
| 403 non-premium | Toast "Disponible avec abonnement Premium" + champ manuel dispo |
|
|
||||||
| 404 symbole | Toast "Symbole introuvable — vérifiez l'orthographe" + champ manuel |
|
|
||||||
| 429 rate limit | Toast "Limite atteinte — réessayez plus tard" + champ manuel |
|
|
||||||
| Network error / 5xx | Toast "Service temporairement indisponible" + champ manuel |
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positive
|
|
||||||
|
|
||||||
- **L'IP de l'utilisateur n'est JAMAIS exposée à Yahoo / CoinGecko.** Le provider voit l'IP du VPS de Max — privacy-first préservée.
|
|
||||||
- **Aucun symbole ne révèle de données personnelles.** "AAPL" ou "BTC" ne sont pas identifiants en soi ; corrélés à une license_id ils le redeviennent, c'est pourquoi le serveur ne logue jamais les deux ensemble.
|
|
||||||
- **Cache mutualisé.** Si 500 utilisateurs premium demandent AAPL au 2026-03-15, c'est UN seul appel sortant côté maximus-api. Économise les rate limits ET réduit la surface d'exposition.
|
|
||||||
- **Mode offline préservé.** L'app continue de fonctionner sans price-fetching — la saisie manuelle reste le chemin de secours.
|
|
||||||
- **Justification commerciale.** Le price-fetching premium aligne le coût d'API tiers sur la révenue récurrente, sans dégrader l'expérience free-tier (qui reste 100 % local).
|
|
||||||
- **Adapter pattern.** Si Yahoo casse (API non officielle), swap pour Alpha Vantage côté serveur sans changer le contrat client.
|
|
||||||
|
|
||||||
### Negative / trade-offs
|
|
||||||
|
|
||||||
- **Dépendance opérationnelle au VPS.** Si maximus-api est down, le price-fetching ne fonctionne pas — atténué par le fallback manuel toujours dispo.
|
|
||||||
- **Surface serveur à maintenir.** Endpoint `/v1/prices` + cache + adapters + auth + rate limiting + observabilité (sans corrélation log).
|
|
||||||
- **Charge financière sur Max.** Les tier free n'ont pas accès, donc les coûts d'API tiers sont absorbés par les abonnements premium ; le cache aide significativement.
|
|
||||||
- **Implémentation BLOQUÉE.** L'Issue #143 ne peut shipper tant que `maximus-api` Phase 2 n'expose pas `/v1/prices` (dépendance externe : issues maximus-api `#49` license server core et `#136` Stripe webhooks).
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
- **Appel direct client → provider.** Rejeté : viole le principe privacy-first (IP exposée + fingerprint headers).
|
|
||||||
- **Tor / I2P intégré.** Rejeté : latence prohibitive (5-10 secondes par fetch), maintenance d'un client Tor embarqué dans Tauri, et certains providers bloquent les exits Tor.
|
|
||||||
- **VPN tiers (Mullvad, etc.) configuré par l'utilisateur.** Rejeté : ne supprime pas le fingerprint headers, et "exiger l'utilisateur à configurer un VPN" est une régression UX inacceptable.
|
|
||||||
- **Cache local sans serveur (chaque user a son propre cache).** Rejeté : pas de mutualisation, chaque user paie son propre rate limit, et le client doit toujours faire l'appel sortant initial (donc IP exposée).
|
|
||||||
- **Saisie manuelle uniquement, pas de price-fetching du tout.** C'est le mode free-tier — fonctionnel mais friction élevée pour les utilisateurs avec un portefeuille actions/crypto significatif. Le proxy premium est le compromis qui justifie l'abonnement sans dégrader le free-tier.
|
|
||||||
- **Endpoint `/v1/symbols/search` côté maximus-api** pour autocomplete. Reporté à v2 : l'autocomplete double la surface d'API et n'est pas critique. La saisie texte simple suffit au MVP.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md), [`spec-plan-bilan.md`](../../spec-plan-bilan.md) (Issue #5 — Phase 5)
|
|
||||||
- Spike : `~/claude-code/.spikes/bilan/code/price-fetching.md` (architecture, choix providers, consent flow)
|
|
||||||
- Issue client (BLOCKED) : maximus/simpl-resultat #143
|
|
||||||
- Issues maximus-api (externes, prerequisites) : `maximus-api#49` (license server core), `maximus-api#136` (Stripe webhooks)
|
|
||||||
- Pattern auth : `src-tauri/src/commands/license_commands.rs` (`activation_path` + `activate_machine` — le token Bearer existe déjà)
|
|
||||||
- Privacy frame : ce que `maximus-api` voit jamais ensemble = `(IP, license_id, symbol)`. Le proxy garantit que (IP) est cachée du provider et que (license_id, symbol) ne se retrouvent pas dans le même log.
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
# ADR 0010 — `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id`
|
|
||||||
|
|
||||||
- Status: Accepted
|
|
||||||
- Date: 2026-04-25
|
|
||||||
- Milestone: `overnight-2026-04-26-bilan` (Issues #138 → #145)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
La table `balance_account_transfers` lie une `transaction` existante à un `balance_account` avec une direction (`'in'` = capital ajouté au compte, `'out'` = capital retiré). Cette table est l'input du calcul Modified Dietz (cf. [ADR 0008](0008-modified-dietz-pour-rendement.md)) qui sépare les **apports** des **gains réels** pour calculer la performance d'un compte d'investissement.
|
|
||||||
|
|
||||||
La question structurante : que se passe-t-il si l'utilisateur supprime une transaction qui est liée à un transfert de bilan ?
|
|
||||||
|
|
||||||
Trois politiques de FK sont possibles côté SQL :
|
|
||||||
|
|
||||||
| Politique | Comportement | Intégrité historique | Friction utilisateur |
|
|
||||||
|-----------|--------------|----------------------|----------------------|
|
|
||||||
| `ON DELETE CASCADE` | Suppression de la transaction supprime aussi le transfert | ❌ Le rendement Modified Dietz d'une période passée change rétroactivement | ✅ Aucune friction : tout disparaît silencieusement |
|
|
||||||
| `ON DELETE SET NULL` | Le transfert reste mais perd son `transaction_id` | ⚠ Le transfert devient "orphelin" : direction connue mais montant introuvable (les montants vivent dans `transactions.amount`) | ⚠ État partiellement valide |
|
|
||||||
| **`ON DELETE RESTRICT`** | La suppression est bloquée par SQLite tant que des transferts pointent vers la transaction | ✅ Préservée : un rendement déjà calculé reste reproductible | ⚠ L'utilisateur doit délier explicitement avant suppression |
|
|
||||||
|
|
||||||
Contraintes du contexte :
|
|
||||||
- Modified Dietz produit un rendement **R** sur une période **[t1, t2]** à partir de `(V_début, V_fin, [(date, montant)])`. Si une `transaction` liée disparaît silencieusement (CASCADE), la fonction reste pure mais ses inputs changent — `R` calculé hier ≠ `R` calculé aujourd'hui sur la même période. C'est exactement l'antithèse de la reproductibilité financière.
|
|
||||||
- Le calcul est déclenché à la demande (chargement de `BalanceAccountsTable`), il n'y a pas de cache server-side. Donc l'historique de "ce que le user a vu hier" n'existe pas : si les inputs bougent, le résultat affiché change sans que l'utilisateur sache pourquoi.
|
|
||||||
- L'usage attendu de la suppression de transactions est rare et lié à des erreurs d'import (doublons, mauvaise source). Bloquer ce cas avec un message clair est acceptable.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Adopter `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id`** :
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE balance_account_transfers (
|
|
||||||
...
|
|
||||||
transaction_id INTEGER NOT NULL,
|
|
||||||
...
|
|
||||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### UX correspondante
|
|
||||||
|
|
||||||
La couche service `transactionService.ts` détecte l'erreur SQLite `FOREIGN KEY constraint failed` et la transforme en `TransactionLinkedToBalanceError` typée, qui porte la liste des comptes liés. La UI affiche alors :
|
|
||||||
|
|
||||||
> **Cette transaction est liée au compte de bilan _<nom du compte>_.**
|
|
||||||
> Pour la supprimer, déliez-la d'abord : ouvrez le compte → Lier transferts → décochez cette transaction.
|
|
||||||
|
|
||||||
Avec un lien direct vers la `LinkTransfersModal` du compte concerné. L'utilisateur ne peut pas se retrouver bloqué : le chemin de déliaison est toujours dispo, à un clic du message d'erreur.
|
|
||||||
|
|
||||||
Pour les chemins bulk (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`), une pré-vérification SELECT (`LIMIT 50`) liste les premiers transferts liés AVANT de tenter la suppression — l'utilisateur voit un message agrégé "X transactions de cet import sont liées à des comptes de bilan" plutôt qu'un raw FK error toast.
|
|
||||||
|
|
||||||
### Direction CASCADE conservée pour `account_id`
|
|
||||||
|
|
||||||
À noter : la même table a une autre FK, `account_id`, configurée en `ON DELETE CASCADE`. Si l'utilisateur supprime un compte de bilan, ses transferts disparaissent — c'est cohérent puisque les rendements de ce compte n'ont plus lieu d'être.
|
|
||||||
|
|
||||||
L'asymétrie est délibérée :
|
|
||||||
- `account_id` ON DELETE CASCADE : le compte de bilan est l'objet "principal" du domaine Bilan, sa suppression nettoie ses dépendances internes
|
|
||||||
- `transaction_id` ON DELETE RESTRICT : la transaction est externe au domaine Bilan, sa suppression ne doit pas casser silencieusement les calculs
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positive
|
|
||||||
|
|
||||||
- **Reproductibilité Modified Dietz garantie.** Un rendement calculé sur une période passée ne peut pas changer à cause d'une suppression invisible côté `transactions`.
|
|
||||||
- **Audit trail préservé.** L'utilisateur qui consulte un compte de bilan voit toujours les mêmes flux pour les mêmes périodes, peu importe quand il consulte.
|
|
||||||
- **Erreur visible et actionnable.** L'utilisateur reçoit un message concret avec un chemin clair pour résoudre, plutôt qu'une suppression silencieuse qui invaliderait l'historique financier.
|
|
||||||
- **Aligné avec la convention SQL existante du projet.** D'autres FK utilisent déjà `RESTRICT` quand l'intégrité est critique (cf. `balance_accounts.balance_category_id`, `balance_snapshot_lines.account_id`).
|
|
||||||
|
|
||||||
### Negative / trade-offs
|
|
||||||
|
|
||||||
- **Friction utilisateur** : forcer l'unlink explicite avant suppression ajoute 2 clics (ouvrir le compte → ouvrir LinkTransfersModal → décocher → revenir → supprimer). Acceptable car le cas est rare et le coût d'un rendement faux est élevé.
|
|
||||||
- **Couplage UI ↔ erreur SQL** : `transactionService.ts` doit détecter le format d'erreur SQLite (`FOREIGN KEY constraint failed`) et le mapper sur `TransactionLinkedToBalanceError`. Si tauri-plugin-sql change le format du message d'erreur, le mapping casse silencieusement (mitigé par les tests d'intégration co-localisés dans `transactionService.test.ts`).
|
|
||||||
- **Pré-vérification bulk a un coût** : un `SELECT ... LIMIT 50` sur `balance_account_transfers` à chaque suppression d'import. Négligeable en pratique (la table reste petite), mais à surveiller si un utilisateur a des dizaines de milliers de transferts.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
- **`ON DELETE CASCADE`.** Rejeté : trahit la promesse de reproductibilité du calcul Modified Dietz. Un rendement vu hier peut changer sans signal vers l'utilisateur.
|
|
||||||
- **`ON DELETE SET NULL` + transferts orphelins.** Rejeté : laisse la base dans un état "valide mais incohérent". Le transfert sait sa direction mais a perdu son montant (qui vit dans `transactions.amount`). Le code Modified Dietz devrait alors filtrer les orphelins, et l'utilisateur ne saurait plus pourquoi son rendement a changé. Pire que CASCADE, qui au moins est explicite.
|
|
||||||
- **Pas de FK du tout, juste un INTEGER orphelin possible.** Rejeté : retire toute garantie d'intégrité référentielle, et les calculs de rendement deviendraient une chasse aux pointeurs cassés.
|
|
||||||
- **Soft-delete des transactions (`deleted_at` au lieu de DELETE)** pour préserver les données liées tout en cachant la transaction de l'UI. Rejeté pour l'instant : les transactions n'ont pas de soft-delete dans le schéma actuel et l'introduire ouvrirait un chantier transversal (toutes les requêtes de transactions devraient filtrer `WHERE deleted_at IS NULL`). À reconsidérer si plusieurs domaines en font la demande.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Implémentation : `src-tauri/src/database/balance_schema.sql` (FK definition), `src/services/transactionService.ts` (`TransactionLinkedToBalanceError` mapping)
|
|
||||||
- Tests : `src/services/transactionService.test.ts` (mapping FK error → typed error), `src/__integration__/balance-flow.test.ts` (lien + tentative de suppression bloquée)
|
|
||||||
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md) — décision "FK `balance_account_transfers.transaction_id` : `ON DELETE RESTRICT` + UI force unlink avec message clair"
|
|
||||||
- ADR liée : [0008 — Modified Dietz pour le calcul du rendement](0008-modified-dietz-pour-rendement.md)
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Architecture technique — Simpl'Résultat
|
# Architecture technique — Simpl'Résultat
|
||||||
|
|
||||||
> Document mis à jour le 2026-04-25 — Version 0.8.x (Bilan)
|
> Document mis à jour le 2026-04-13 — Version 0.7.3
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
|
|
||||||
|
|
@ -28,7 +28,6 @@ simpl-resultat/
|
||||||
├── src/ # Frontend React/TypeScript
|
├── src/ # Frontend React/TypeScript
|
||||||
│ ├── components/ # 58 composants organisés par domaine
|
│ ├── components/ # 58 composants organisés par domaine
|
||||||
│ │ ├── adjustments/ # 3 composants
|
│ │ ├── adjustments/ # 3 composants
|
||||||
│ │ ├── balance/ # 7 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
|
|
||||||
│ │ ├── budget/ # 5 composants
|
│ │ ├── budget/ # 5 composants
|
||||||
│ │ ├── categories/ # 5 composants
|
│ │ ├── categories/ # 5 composants
|
||||||
│ │ ├── dashboard/ # 2 composants
|
│ │ ├── dashboard/ # 2 composants
|
||||||
|
|
@ -73,7 +72,7 @@ simpl-resultat/
|
||||||
|
|
||||||
## Base de données
|
## Base de données
|
||||||
|
|
||||||
### Tables (18)
|
### Tables (13)
|
||||||
|
|
||||||
| Table | Description |
|
| Table | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
|
|
@ -90,36 +89,10 @@ simpl-resultat/
|
||||||
| `budget_template_entries` | Catégories et montants dans les modèles |
|
| `budget_template_entries` | Catégories et montants dans les modèles |
|
||||||
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
||||||
| `user_preferences` | Préférences applicatives (clé-valeur) |
|
| `user_preferences` | Préférences applicatives (clé-valeur) |
|
||||||
| `balance_categories` | Taxonomie des types d'actifs (cash, TFSA, RRSP, fund, stock, crypto, other) — `kind ∈ {simple, priced}`, 7 seedées (`is_seed = 1`) |
|
|
||||||
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete |
|
|
||||||
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
|
|
||||||
| `balance_snapshot_lines` | Une ligne par `(snapshot, compte)`. Stockage denormalisé : pour `simple` `value` seul, pour `priced` `quantity + unit_price + value`. CHECK kind invariants côté SQL |
|
|
||||||
| `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains |
|
|
||||||
|
|
||||||
### Index (16)
|
### Index (9)
|
||||||
|
|
||||||
Index existants (9) : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
|
Index sur : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
|
||||||
|
|
||||||
Index Bilan (7, ajoutés en migration v9) :
|
|
||||||
- `idx_balance_accounts_category` (FK lookup catégorie → comptes)
|
|
||||||
- `idx_balance_accounts_active` partiel `WHERE is_active = 1` (filtre liste active)
|
|
||||||
- `idx_balance_snapshot_lines_snapshot` (chargement d'un snapshot)
|
|
||||||
- `idx_balance_snapshot_lines_account` (historique par compte)
|
|
||||||
- `idx_balance_account_transfers_account` (cash flows Modified Dietz par compte)
|
|
||||||
- `idx_balance_account_transfers_transaction` (lookup icône d'attribution dans `TransactionTable`)
|
|
||||||
- `idx_balance_snapshots_date` (sélecteur de période + agrégation chronologique)
|
|
||||||
|
|
||||||
### Invariants Bilan (CHECK + FK)
|
|
||||||
|
|
||||||
- `balance_categories.kind` ∈ `('simple','priced')`
|
|
||||||
- `balance_accounts.currency = 'CAD'` (verrou MVP — v2 lèvera ce CHECK avec table de taux)
|
|
||||||
- `balance_snapshot_lines` : `(quantity, unit_price)` doivent être tous deux NULL (kind simple) OU tous deux NOT NULL (kind priced)
|
|
||||||
- `balance_account_transfers.direction` ∈ `('in','out')` ; UNIQUE `(transaction_id, account_id)` (une transaction ne peut pas être liée deux fois au même compte)
|
|
||||||
- FK `balance_accounts.balance_category_id` → `balance_categories(id)` `ON DELETE RESTRICT` (empêche suppression de catégorie avec comptes liés)
|
|
||||||
- FK `balance_snapshot_lines.snapshot_id` → `balance_snapshots(id)` `ON DELETE CASCADE` (supprimer un snapshot supprime ses lignes)
|
|
||||||
- FK `balance_snapshot_lines.account_id` → `balance_accounts(id)` `ON DELETE RESTRICT` (préserve l'historique)
|
|
||||||
- FK `balance_account_transfers.account_id` → `balance_accounts(id)` `ON DELETE CASCADE`
|
|
||||||
- FK `balance_account_transfers.transaction_id` → `transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md)
|
|
||||||
|
|
||||||
## Système de migrations
|
## Système de migrations
|
||||||
|
|
||||||
|
|
@ -134,19 +107,17 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
|
||||||
| 5 | v5 | Création de `import_config_templates` |
|
| 5 | v5 | Création de `import_config_templates` |
|
||||||
| 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) |
|
| 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) |
|
||||||
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
|
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
|
||||||
| 8 | v8 | Migration de catégories (cf. release 0.8.x) |
|
|
||||||
| 9 | v9 | Schéma Bilan : 5 tables + 7 index + seed des 7 catégories standard (cash, TFSA, RRSP, fund, stock, crypto, other) |
|
|
||||||
|
|
||||||
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
|
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
|
||||||
|
|
||||||
## Services TypeScript (18)
|
## Services TypeScript (17)
|
||||||
|
|
||||||
| Service | Responsabilité |
|
| Service | Responsabilité |
|
||||||
|---------|---------------|
|
|---------|---------------|
|
||||||
| `db.ts` | Wrapper de connexion (tauri-plugin-sql) |
|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql) |
|
||||||
| `profileService.ts` | Gestion des profils |
|
| `profileService.ts` | Gestion des profils |
|
||||||
| `categoryService.ts` | CRUD catégories hiérarchiques |
|
| `categoryService.ts` | CRUD catégories hiérarchiques |
|
||||||
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) |
|
| `transactionService.ts` | CRUD et filtrage des transactions |
|
||||||
| `importSourceService.ts` | Configuration des sources d'import |
|
| `importSourceService.ts` | Configuration des sources d'import |
|
||||||
| `importedFileService.ts` | Suivi des fichiers importés |
|
| `importedFileService.ts` | Suivi des fichiers importés |
|
||||||
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
|
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
|
||||||
|
|
@ -160,20 +131,8 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
||||||
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
||||||
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
|
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
|
||||||
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
|
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
|
||||||
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
|
|
||||||
|
|
||||||
### Service Bilan — `balance.service.ts`
|
## Hooks (14)
|
||||||
|
|
||||||
Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes :
|
|
||||||
|
|
||||||
1. **CRUD catégories + comptes** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount`, `archiveBalanceAccount`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour permettre à la UI d'afficher des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, etc.).
|
|
||||||
2. **Snapshots + lines** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne — choix simple pour < 20 comptes/snapshot), `deleteSnapshot`, helper `validateLineKindInvariants` exporté pour les tests (kind invariants TS en complément du CHECK SQL ; tolérance `PRICED_VALUE_TOLERANCE = 0.01` pour le match `value ≈ quantity × unit_price`).
|
|
||||||
3. **Returns + transfers** — `linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`).
|
|
||||||
4. **Prices** — *(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy.
|
|
||||||
|
|
||||||
Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`).
|
|
||||||
|
|
||||||
## Hooks (17+)
|
|
||||||
|
|
||||||
Chaque hook encapsule la logique d'état via `useReducer` :
|
Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
|
|
||||||
|
|
@ -193,16 +152,13 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
|
||||||
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
|
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
|
||||||
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
|
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
|
||||||
| `useBalanceAccounts` | Bilan — état de la page `/balance/accounts` : CRUD comptes ET catégories (un seul hook pour les deux onglets, aligné sur la convention "1 hook par page") |
|
|
||||||
| `useSnapshotEditor` | Bilan — cycle de vie d'un snapshot unique (`/balance/snapshot`) : valeurs simple (string) + valeurs priced (`{quantity, unit_price}` strings), prefill depuis snapshot précédent, save (rewrite-all), delete avec double-confirmation par re-saisie de la date |
|
|
||||||
| `useBalanceOverview` | Bilan — page `/balance` : sélecteur de période (`3M / 6M / 1A / 3A / Tout`), série temporelle agrégée, mode chart (`line` / `stacked`), tableau des comptes avec valeurs courantes et Δ% sur la période. Les rendements multi-horizons sont chargés *lazily* dans `BalanceAccountsTable` (un appel `compute_account_return` par cellule) |
|
|
||||||
| `useDataExport` | Export de données |
|
| `useDataExport` | Export de données |
|
||||||
| `useTheme` | Thème clair/sombre |
|
| `useTheme` | Thème clair/sombre |
|
||||||
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
|
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
|
||||||
| `useLicense` | État de la licence et entitlements |
|
| `useLicense` | État de la licence et entitlements |
|
||||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||||
|
|
||||||
## Commandes Tauri (36)
|
## Commandes Tauri (35)
|
||||||
|
|
||||||
### `fs_commands.rs` — Système de fichiers (6)
|
### `fs_commands.rs` — Système de fichiers (6)
|
||||||
|
|
||||||
|
|
@ -274,14 +230,6 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
|
||||||
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code
|
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code
|
||||||
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live
|
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live
|
||||||
|
|
||||||
### `balance_commands.rs` — Bilan (1)
|
|
||||||
|
|
||||||
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif, lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
|
|
||||||
|
|
||||||
Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation).
|
|
||||||
|
|
||||||
**À venir Phase 5** (Issue #143, BLOCKED par maximus-api Phase 2) : commande `fetch_price(symbol, date)` pour le price-fetching premium via proxy maximus-api. L'architecture est documentée dans l'ADR 0009 ; la livraison est différée jusqu'à ce que le serveur de licences (`maximus-api`) expose l'endpoint `GET /v1/prices`.
|
|
||||||
|
|
||||||
## Plugins Tauri
|
## Plugins Tauri
|
||||||
|
|
||||||
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
|
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
|
||||||
|
|
@ -343,9 +291,6 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
|
||||||
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
|
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
|
||||||
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
|
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
|
||||||
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
|
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
|
||||||
| `/balance` | `BalancePage` | Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté |
|
|
||||||
| `/balance/snapshot` | `SnapshotEditPage` | Saisie / édition d'un snapshot daté. Mode `?date=today` (création) ou `?date=YYYY-MM-DD` (édition, date immutable). Lignes groupées par catégorie : `simple` = champ valeur, `priced` = `quantity` × `unit_price` (`value` calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date |
|
|
||||||
| `/balance/accounts` | `AccountsPage` | CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (`is_seed = 1`) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT) |
|
|
||||||
| `/settings` | `SettingsPage` | Paramètres |
|
| `/settings` | `SettingsPage` | Paramètres |
|
||||||
| `/docs` | `DocsPage` | Documentation in-app |
|
| `/docs` | `DocsPage` | Documentation in-app |
|
||||||
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
|
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
|
||||||
|
|
|
||||||
|
|
@ -355,74 +355,7 @@ L'application est atomique : soit toutes les transactions cochées sont recatég
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Bilan
|
## 10. Paramètres
|
||||||
|
|
||||||
Le **Bilan** est une vue patrimoniale : vous saisissez périodiquement un *snapshot* daté de l'ensemble de vos comptes (encaisse, REER, CELI, fonds, actions, crypto, autres), vous suivez leur évolution dans le temps, et vous calculez le **vrai rendement** de chaque compte d'investissement en liant les transferts (apports / retraits) aux comptes correspondants.
|
|
||||||
|
|
||||||
Trois pages composent le module Bilan :
|
|
||||||
- `/balance` — vue d'ensemble (graphique + tableau des comptes)
|
|
||||||
- `/balance/snapshot` — saisie / édition d'un snapshot daté
|
|
||||||
- `/balance/accounts` — CRUD des comptes et catégories
|
|
||||||
|
|
||||||
L'entrée **Bilan** dans la barre latérale (icône portefeuille) donne accès à `/balance` ; les deux autres pages s'ouvrent depuis là.
|
|
||||||
|
|
||||||
### Fonctionnalités
|
|
||||||
|
|
||||||
- 7 catégories standard pré-installées : Encaisse, CELI, REER, Fonds, Actions, Crypto, Autres — renommables, non-supprimables
|
|
||||||
- Création de catégories personnalisées (ex. FERR, RPDB) avec choix `simple` (montant direct) ou `priced` (quantité × prix unitaire)
|
|
||||||
- Comptes par catégorie : nom, symbole optionnel, devise (CAD au MVP), notes
|
|
||||||
- Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer
|
|
||||||
- Saisie groupée par catégorie ; pour les catégories `priced`, le `value` est calculé automatiquement (`quantity × unit_price`)
|
|
||||||
- Bouton **Pré-remplir depuis le snapshot précédent** : copie les valeurs simples + les quantités priced (vous remplissez juste les nouveaux prix)
|
|
||||||
- Liaison de transactions existantes à un compte de bilan (modal avec filtres par période / catégorie / recherche, sens auto-proposé selon le signe)
|
|
||||||
- Icône d'attribution dans la page Transactions pour les transactions liées à un transfert
|
|
||||||
- Graphique d'évolution du bilan (mode courbe simple ou aire empilée par catégorie) avec marqueurs verticaux pour les transferts taggés (vert = in, rouge = out)
|
|
||||||
- Tableau des comptes avec **3 colonnes de rendement Modified Dietz** (3 mois / 1 an / depuis création) + colonne rendement non-ajusté côte-à-côte
|
|
||||||
- Avertissement si le dernier snapshot remonte à plus de 60 jours
|
|
||||||
- Soft-delete des comptes (`Archiver`) : masqués des nouveaux snapshots, conservés dans l'historique
|
|
||||||
- Suppression d'un snapshot avec double-confirmation (re-saisie de la date)
|
|
||||||
- Privacy-first : tout est local, aucun appel sortant au MVP
|
|
||||||
|
|
||||||
### Comment faire
|
|
||||||
|
|
||||||
1. Allez dans `/balance/accounts` → onglet Catégories pour créer si besoin une catégorie supplémentaire (ex. "FERR" en `simple`, ou "Stocks Wealthsimple" en `priced`)
|
|
||||||
2. Allez dans l'onglet Comptes pour créer chaque compte (ex. "TFSA Tangerine" rattaché à CELI, "BTC Ledger" rattaché à Crypto avec symbole `BTC`)
|
|
||||||
3. Cliquez **+ Nouveau snapshot** depuis `/balance` pour ouvrir `/balance/snapshot` à la date du jour
|
|
||||||
4. Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée
|
|
||||||
5. Enregistrez. Le graphique sur `/balance` s'actualise immédiatement
|
|
||||||
6. Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions du compte → **Lier transferts** → cochez les transactions qui correspondent à des apports / retraits (un dépôt CELI, un achat d'actions, etc.). Le sens (in/out) est proposé automatiquement selon le signe de la transaction
|
|
||||||
7. Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création. Le rendement non-ajusté à droite vous permet de comparer "valeur du compte" et "vraie performance"
|
|
||||||
8. Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition (la date est immutable)
|
|
||||||
9. Pour supprimer un snapshot, cliquez **Supprimer** dans son éditeur et re-saisissez la date pour confirmer
|
|
||||||
|
|
||||||
### Lecture des rendements multi-horizons
|
|
||||||
|
|
||||||
- **3 mois** : performance courte, sensible aux mouvements récents
|
|
||||||
- **1 an** : horizon de référence pour la plupart des décisions d'allocation
|
|
||||||
- **Depuis création** : performance totale du compte depuis le premier snapshot
|
|
||||||
- **Non-ajusté (côte-à-côte)** : `(V_fin − V_début) / V_début` brut, sans soustraction des apports — utile pour voir la croissance totale (gains + apports). La différence entre les deux colonnes vous montre la part qui vient des apports plutôt que de la performance
|
|
||||||
|
|
||||||
Avertissements affichés :
|
|
||||||
- *Période partielle* — un snapshot manque au début ou à la fin de la période
|
|
||||||
- *Aucun transfert lié* — le rendement est calculé sans apports identifiés (équivaut au non-ajusté)
|
|
||||||
- *Performance non significative* — le compte a été vidé puis rechargé, le calcul Modified Dietz produit un résultat instable
|
|
||||||
|
|
||||||
### Que faire si je supprime une transaction liée ?
|
|
||||||
|
|
||||||
C'est intentionnellement bloqué : si vous tentez de supprimer une transaction qui est liée à un compte de bilan, vous voyez le message **"Cette transaction est liée au compte de bilan _<nom>_"** avec un lien direct vers le compte. Ouvrez le compte → Lier transferts → décochez la transaction → revenez la supprimer. Cette friction préserve la reproductibilité de vos rendements passés (un rendement calculé hier ne peut pas changer aujourd'hui à cause d'une suppression silencieuse).
|
|
||||||
|
|
||||||
### Astuces
|
|
||||||
|
|
||||||
- Saisissez vos snapshots à un rythme régulier (mensuel ou trimestriel) — la qualité des rendements dépend directement de la régularité
|
|
||||||
- Utilisez le bouton **Pré-remplir** : ça copie tout, vous mettez juste à jour ce qui a changé
|
|
||||||
- Le mode **graphique empilé par catégorie** raconte une histoire différente du mode ligne : il montre la composition de votre patrimoine, pas seulement son total
|
|
||||||
- Les marqueurs verticaux du graphique (transferts taggés) aident à lire les sauts de valeur — un saut suivi d'un marqueur vert n'est pas une "performance", c'est juste un dépôt
|
|
||||||
- L'avertissement "bilan pas à jour" apparaît si votre dernier snapshot remonte à plus de 60 jours — c'est le signe qu'il est temps d'en saisir un nouveau
|
|
||||||
- (À venir Phase 5) **Récupération automatique des prix** pour les comptes Actions / Crypto via un proxy privé (premium-only). Le service interroge un serveur Maximus dédié qui anonymise votre requête (votre IP n'est jamais exposée à Yahoo / CoinGecko). La saisie manuelle reste toujours disponible.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Paramètres
|
|
||||||
|
|
||||||
Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import.
|
Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import.
|
||||||
|
|
||||||
|
|
|
||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -4428,7 +4428,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,6 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
//! Tauri commands for the Bilan (balance sheet) feature — Issue #142.
|
|
||||||
//!
|
|
||||||
//! At Issue #142 the only command exposed is `compute_account_return`. The
|
|
||||||
//! Modified Dietz formula needs to read snapshot endpoints + linked transfer
|
|
||||||
//! amounts in a single Rust pass (the math itself lives in
|
|
||||||
//! `return_calculator.rs`); doing it server-side avoids 3 round-trips from
|
|
||||||
//! the renderer and keeps the calculation reproducible.
|
|
||||||
//!
|
|
||||||
//! Future commands (`fetch_price`, etc.) ship in Issue #143 / Bilan #5.
|
|
||||||
//!
|
|
||||||
//! Database access pattern:
|
|
||||||
//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
|
|
||||||
//! matching the existing `repair_migrations` helper in `profile_commands.rs`.
|
|
||||||
//! - The frontend passes `db_filename` (the active profile DB), exactly
|
|
||||||
//! like it does for `repair_migrations` and `delete_profile_db`. Keeps
|
|
||||||
//! the active-profile resolution where it already lives (in TS) and
|
|
||||||
//! avoids re-reading `profiles.json` on every call.
|
|
||||||
//! - Reads are short-lived: connection opens, runs ≤ 3 SQL statements,
|
|
||||||
//! drops at end of function. No connection pooling needed (commands run
|
|
||||||
//! on the Tauri async runtime, one at a time per invocation).
|
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use tauri::Manager;
|
|
||||||
|
|
||||||
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
|
|
||||||
|
|
||||||
/// Compute the Modified Dietz return for one account over the period
|
|
||||||
/// `[period_start, period_end]`. Reads:
|
|
||||||
/// - `value_start`: latest snapshot line for the account whose
|
|
||||||
/// `snapshot_date <= period_start` (None if no prior snapshot).
|
|
||||||
/// - `value_end`: latest snapshot line for the account whose
|
|
||||||
/// `snapshot_date <= period_end` (None if no snapshot in range).
|
|
||||||
/// - cash flows: every linked transfer in `[period_start, period_end]`,
|
|
||||||
/// sign applied per direction (`in` → `+`, `out` → `−`).
|
|
||||||
///
|
|
||||||
/// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn`
|
|
||||||
/// (Serialize) ready to ship across the Tauri boundary.
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn compute_account_return(
|
|
||||||
app: tauri::AppHandle,
|
|
||||||
db_filename: String,
|
|
||||||
account_id: i64,
|
|
||||||
period_start: String,
|
|
||||||
period_end: String,
|
|
||||||
) -> Result<AccountReturn, String> {
|
|
||||||
let start_date = parse_iso_date(&period_start, "period_start")?;
|
|
||||||
let end_date = parse_iso_date(&period_end, "period_end")?;
|
|
||||||
|
|
||||||
let app_dir = app
|
|
||||||
.path()
|
|
||||||
.app_data_dir()
|
|
||||||
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
|
|
||||||
let db_path = app_dir.join(&db_filename);
|
|
||||||
if !db_path.exists() {
|
|
||||||
return Err(format!(
|
|
||||||
"Profile database not found: {}",
|
|
||||||
db_path.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let conn = Connection::open(&db_path)
|
|
||||||
.map_err(|e| format!("Cannot open database: {}", e))?;
|
|
||||||
|
|
||||||
let value_start = read_value_at_or_before(&conn, account_id, &period_start)?;
|
|
||||||
let value_end = read_value_at_or_before(&conn, account_id, &period_end)?;
|
|
||||||
let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?;
|
|
||||||
|
|
||||||
Ok(modified_dietz(
|
|
||||||
value_start,
|
|
||||||
value_end,
|
|
||||||
&cash_flows,
|
|
||||||
start_date,
|
|
||||||
end_date,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn parse_iso_date(input: &str, field: &str) -> Result<NaiveDate, String> {
|
|
||||||
NaiveDate::parse_from_str(input, "%Y-%m-%d")
|
|
||||||
.map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the value of the snapshot line for `account_id` at the most recent
|
|
||||||
/// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no
|
|
||||||
/// such snapshot exists for this account.
|
|
||||||
fn read_value_at_or_before(
|
|
||||||
conn: &Connection,
|
|
||||||
account_id: i64,
|
|
||||||
as_of_date: &str,
|
|
||||||
) -> Result<Option<f64>, String> {
|
|
||||||
// Single-row query: pick the latest snapshot date for this account that
|
|
||||||
// is on or before `as_of_date`, then return that line's value. Indexed
|
|
||||||
// on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT l.value
|
|
||||||
FROM balance_snapshot_lines l
|
|
||||||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
|
||||||
WHERE l.account_id = ?1
|
|
||||||
AND s.snapshot_date <= ?2
|
|
||||||
ORDER BY s.snapshot_date DESC
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("prepare value query: {}", e))?;
|
|
||||||
|
|
||||||
let mut rows = stmt
|
|
||||||
.query(rusqlite::params![account_id, as_of_date])
|
|
||||||
.map_err(|e| format!("execute value query: {}", e))?;
|
|
||||||
|
|
||||||
match rows.next().map_err(|e| format!("read value row: {}", e))? {
|
|
||||||
Some(row) => Ok(Some(
|
|
||||||
row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?,
|
|
||||||
)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads every linked transfer for `account_id` whose underlying
|
|
||||||
/// transaction's `transaction_date` falls inside `[period_start, period_end]`.
|
|
||||||
/// Returns `(NaiveDate, signed_amount)` — sign applied per `direction`
|
|
||||||
/// (`in` → `+`, `out` → `−`). Amounts come from the linked transaction.
|
|
||||||
fn read_cash_flows(
|
|
||||||
conn: &Connection,
|
|
||||||
account_id: i64,
|
|
||||||
period_start: &str,
|
|
||||||
period_end: &str,
|
|
||||||
) -> Result<Vec<(NaiveDate, f64)>, String> {
|
|
||||||
// NOTE: the transactions table column is `date` (not `transaction_date`).
|
|
||||||
// See `src-tauri/src/database/schema.sql:67`.
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT t.date,
|
|
||||||
ABS(t.amount) AS abs_amount,
|
|
||||||
bat.direction
|
|
||||||
FROM balance_account_transfers bat
|
|
||||||
JOIN transactions t ON t.id = bat.transaction_id
|
|
||||||
WHERE bat.account_id = ?1
|
|
||||||
AND t.date BETWEEN ?2 AND ?3
|
|
||||||
ORDER BY t.date",
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("prepare flows query: {}", e))?;
|
|
||||||
|
|
||||||
let rows = stmt
|
|
||||||
.query_map(
|
|
||||||
rusqlite::params![account_id, period_start, period_end],
|
|
||||||
|row| {
|
|
||||||
// `transactions.date` may come back as String (TEXT) — keep
|
|
||||||
// the decoder generic enough.
|
|
||||||
let date_str: String = row.get(0)?;
|
|
||||||
let amount: f64 = row.get(1)?;
|
|
||||||
let direction: String = row.get(2)?;
|
|
||||||
Ok((date_str, amount, direction))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("execute flows query: {}", e))?;
|
|
||||||
|
|
||||||
let mut flows: Vec<(NaiveDate, f64)> = Vec::new();
|
|
||||||
for row_result in rows {
|
|
||||||
let (date_str, amount, direction) =
|
|
||||||
row_result.map_err(|e| format!("decode flow row: {}", e))?;
|
|
||||||
// `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column —
|
|
||||||
// see consolidated_schema.sql). Defensive trim of any trailing
|
|
||||||
// time component just in case.
|
|
||||||
let iso = date_str.split('T').next().unwrap_or(&date_str).to_string();
|
|
||||||
let date = parse_iso_date(&iso, "transaction_date")?;
|
|
||||||
let signed = match direction.as_str() {
|
|
||||||
"in" => amount,
|
|
||||||
"out" => -amount,
|
|
||||||
other => {
|
|
||||||
return Err(format!(
|
|
||||||
"Invalid transfer direction stored in DB: {}",
|
|
||||||
other
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
flows.push((date, signed));
|
|
||||||
}
|
|
||||||
Ok(flows)
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
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::*;
|
||||||
|
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
//! 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
-- Balance sheet schema (Bilan) — Migration v9
|
|
||||||
-- Created: 2026-04-25
|
|
||||||
-- Issue: #138 (Bilan #1a — Schema migration + balance.service skeleton + AccountsPage)
|
|
||||||
--
|
|
||||||
-- Adds 5 tables, 7 indexes, and seeds 7 standard categories (5 simple + 2 priced).
|
|
||||||
-- Conventions aligned with consolidated_schema.sql:
|
|
||||||
-- - INTEGER PRIMARY KEY AUTOINCREMENT
|
|
||||||
-- - REAL for monetary amounts (matches transactions.amount)
|
|
||||||
-- - snake_case
|
|
||||||
-- - FK with explicit ON DELETE policies
|
|
||||||
-- - is_* INTEGER NOT NULL DEFAULT for booleans
|
|
||||||
-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps
|
|
||||||
--
|
|
||||||
-- MVP constraints (decisions-log + spec-decisions-bilan.md):
|
|
||||||
-- - balance_accounts.currency hardcoded to 'CAD' via CHECK — v2 will lift this
|
|
||||||
-- - balance_account_transfers.transaction_id ON DELETE RESTRICT (preserves
|
|
||||||
-- reproducibility of Modified Dietz returns calculated on past periods)
|
|
||||||
-- - balance_snapshot_lines kind invariants: (quantity, unit_price) both NULL
|
|
||||||
-- (simple kind) OR both NOT NULL (priced kind)
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- balance_categories — taxonomy of asset types
|
|
||||||
-- =========================================================================
|
|
||||||
-- Seeded with 7 standard categories (is_seed = 1). Users can add custom
|
|
||||||
-- categories with their own kind ('simple' or 'priced'). Seeded categories
|
|
||||||
-- can be renamed but never deleted.
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_categories (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
key TEXT NOT NULL UNIQUE, -- 'cash', 'tfsa', 'rrsp', 'fund', 'stock', 'crypto', 'other'
|
|
||||||
i18n_key TEXT NOT NULL, -- 'balance.category.cash', etc.
|
|
||||||
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
is_seed INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- balance_accounts — user's specific holdings
|
|
||||||
-- =========================================================================
|
|
||||||
-- A "TFSA at Wealthsimple", a "BTC in Ledger", etc.
|
|
||||||
-- For priced categories, `symbol` identifies the security/coin.
|
|
||||||
-- For simple categories, `symbol` is NULL.
|
|
||||||
-- MVP: currency hardcoded to 'CAD' — v2 lifts the CHECK and adds a rate table.
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_accounts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
balance_category_id INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
symbol TEXT,
|
|
||||||
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
|
|
||||||
notes TEXT,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
archived_at DATETIME,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- balance_snapshots — point-in-time captures
|
|
||||||
-- =========================================================================
|
|
||||||
-- One snapshot per `snapshot_date` (UNIQUE). Editing a snapshot = updating
|
|
||||||
-- its lines, not creating a duplicate.
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
snapshot_date DATE NOT NULL UNIQUE,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- balance_snapshot_lines — one row per (snapshot, account)
|
|
||||||
-- =========================================================================
|
|
||||||
-- Storage shape:
|
|
||||||
-- - simple kind: value is set, quantity/unit_price are NULL
|
|
||||||
-- - priced kind: quantity + unit_price are set, value = quantity * unit_price
|
|
||||||
-- Stored denormalized (value always set, even for priced rows) so reports
|
|
||||||
-- are reproducible without re-fetching prices and the user can override a
|
|
||||||
-- fetched price.
|
|
||||||
-- The CHECK enforces kind invariants at SQL level for direct-write safety.
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
snapshot_id INTEGER NOT NULL,
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
quantity REAL,
|
|
||||||
unit_price REAL,
|
|
||||||
value REAL NOT NULL,
|
|
||||||
price_source TEXT, -- 'manual' | 'maximus-api' | NULL for simple
|
|
||||||
price_fetched_at DATETIME,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
|
|
||||||
UNIQUE(snapshot_id, account_id),
|
|
||||||
CHECK (
|
|
||||||
(quantity IS NULL AND unit_price IS NULL)
|
|
||||||
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- balance_account_transfers — links transactions to accounts (capital flows)
|
|
||||||
-- =========================================================================
|
|
||||||
-- Used by the Modified Dietz return calculator (Issue #142 / Bilan #4) to
|
|
||||||
-- separate contributions from gains. Direction follows the account's
|
|
||||||
-- perspective: 'in' = capital added (deposit/buy), 'out' = capital removed
|
|
||||||
-- (withdrawal/sell). The amount is taken from the linked transaction (no
|
|
||||||
-- duplication).
|
|
||||||
--
|
|
||||||
-- transaction_id ON DELETE RESTRICT: preserves reproducibility of past
|
|
||||||
-- Modified Dietz returns. The UI must force unlink before allowing the
|
|
||||||
-- transaction to be deleted.
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
transaction_id INTEGER NOT NULL,
|
|
||||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
|
|
||||||
UNIQUE(transaction_id, account_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- Indexes (7 total)
|
|
||||||
-- =========================================================================
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
|
||||||
|
|
||||||
|
|
||||||
-- =========================================================================
|
|
||||||
-- Seed (7 standard categories — idempotent via INSERT OR IGNORE on `key`)
|
|
||||||
-- =========================================================================
|
|
||||||
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
|
|
||||||
('cash', 'balance.category.cash', 'simple', 10, 1),
|
|
||||||
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
|
|
||||||
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
|
|
||||||
('fund', 'balance.category.fund', 'simple', 40, 1),
|
|
||||||
('other', 'balance.category.other', 'simple', 50, 1),
|
|
||||||
('stock', 'balance.category.stock', 'priced', 60, 1),
|
|
||||||
('crypto', 'balance.category.crypto', 'priced', 70, 1);
|
|
||||||
|
|
@ -181,95 +181,6 @@ CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, mon
|
||||||
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
|
|
||||||
-- ============================================================================
|
|
||||||
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
|
|
||||||
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
|
|
||||||
-- New profiles created from this consolidated schema get the balance feature
|
|
||||||
-- preinstalled without needing to replay v9 separately.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_categories (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
key TEXT NOT NULL UNIQUE,
|
|
||||||
i18n_key TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
is_seed INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_accounts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
balance_category_id INTEGER NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
symbol TEXT,
|
|
||||||
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
|
|
||||||
notes TEXT,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
archived_at DATETIME,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
snapshot_date DATE NOT NULL UNIQUE,
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
snapshot_id INTEGER NOT NULL,
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
quantity REAL,
|
|
||||||
unit_price REAL,
|
|
||||||
value REAL NOT NULL,
|
|
||||||
price_source TEXT,
|
|
||||||
price_fetched_at DATETIME,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
|
|
||||||
UNIQUE(snapshot_id, account_id),
|
|
||||||
CHECK (
|
|
||||||
(quantity IS NULL AND unit_price IS NULL)
|
|
||||||
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
account_id INTEGER NOT NULL,
|
|
||||||
transaction_id INTEGER NOT NULL,
|
|
||||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
|
||||||
notes TEXT,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
|
|
||||||
UNIQUE(transaction_id, account_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
|
|
||||||
('cash', 'balance.category.cash', 'simple', 10, 1),
|
|
||||||
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
|
|
||||||
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
|
|
||||||
('fund', 'balance.category.fund', 'simple', 40, 1),
|
|
||||||
('other', 'balance.category.other', 'simple', 50, 1),
|
|
||||||
('stock', 'balance.category.stock', 'priced', 60, 1),
|
|
||||||
('crypto', 'balance.category.crypto', 'priced', 70, 1);
|
|
||||||
|
|
||||||
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
pub const SCHEMA: &str = include_str!("schema.sql");
|
pub const SCHEMA: &str = include_str!("schema.sql");
|
||||||
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
||||||
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
||||||
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");
|
|
||||||
|
|
|
||||||
|
|
@ -95,19 +95,6 @@ pub fn run() {
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
},
|
},
|
||||||
// Migration v9 — Bilan (balance sheet) schema:
|
|
||||||
// 5 tables (balance_categories, balance_accounts, balance_snapshots,
|
|
||||||
// balance_snapshot_lines, balance_account_transfers) + 7 indexes +
|
|
||||||
// 7 seeded categories (5 simple + 2 priced).
|
|
||||||
// CHECK(currency = 'CAD') is hardcoded for the MVP (will be lifted in v2
|
|
||||||
// with a multi-currency rate table). FK transaction_id ON DELETE
|
|
||||||
// RESTRICT preserves reproducibility of Modified Dietz returns.
|
|
||||||
Migration {
|
|
||||||
version: 9,
|
|
||||||
description: "create balance schema",
|
|
||||||
sql: database::BALANCE_SCHEMA,
|
|
||||||
kind: MigrationKind::Up,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -216,7 +203,6 @@ 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");
|
||||||
|
|
@ -254,791 +240,3 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Tests for migration v9 — balance schema
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// These tests apply `database::BALANCE_SCHEMA` (the SQL embedded in the v9
|
|
||||||
// migration) on a fresh in-memory SQLite database and assert that:
|
|
||||||
// - the schema applies cleanly (all 5 tables + 7 indexes created)
|
|
||||||
// - the 7 seed categories are present (5 simple + 2 priced) with is_seed = 1
|
|
||||||
// - CHECK constraints reject invalid kind / direction / currency / kind invariants
|
|
||||||
// - UNIQUE constraints enforce snapshot_date / (snapshot_id,account_id) /
|
|
||||||
// (transaction_id,account_id) / category key
|
|
||||||
// - FK ON DELETE policies behave as expected (CASCADE on snapshot, RESTRICT
|
|
||||||
// on transaction_id and on category with linked accounts)
|
|
||||||
//
|
|
||||||
// rusqlite (0.32, bundled) is already a runtime dependency — no extra dev-dep
|
|
||||||
// required. The migration v9 SQL is the source of truth; v1-v8 are not
|
|
||||||
// required here because v9 is additive and only references the existing
|
|
||||||
// `transactions` table for the FK on balance_account_transfers — we mirror
|
|
||||||
// that with a minimal `transactions` table for the integration scenarios.
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
|
|
||||||
/// Apply the v9 schema on a fresh in-memory DB. Includes a minimal
|
|
||||||
/// `transactions` table because balance_account_transfers references it.
|
|
||||||
fn fresh_db() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().expect("open in-memory db");
|
|
||||||
// FKs must be enabled per-connection in SQLite.
|
|
||||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
|
||||||
.expect("enable FKs");
|
|
||||||
// Minimal transactions table mirroring the relevant columns the FK
|
|
||||||
// references (id is the only column we need at the SQL level).
|
|
||||||
conn.execute_batch(
|
|
||||||
"CREATE TABLE transactions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
date DATE NOT NULL,
|
|
||||||
description TEXT NOT NULL,
|
|
||||||
amount REAL NOT NULL
|
|
||||||
);",
|
|
||||||
)
|
|
||||||
.expect("create stub transactions table");
|
|
||||||
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
|
||||||
.expect("apply BALANCE_SCHEMA");
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_applies_cleanly() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
// 5 expected tables
|
|
||||||
let tables: Vec<String> = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'balance_%' ORDER BY name",
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.query_map([], |row| row.get::<_, String>(0))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
tables,
|
|
||||||
vec![
|
|
||||||
"balance_account_transfers",
|
|
||||||
"balance_accounts",
|
|
||||||
"balance_categories",
|
|
||||||
"balance_snapshot_lines",
|
|
||||||
"balance_snapshots",
|
|
||||||
]
|
|
||||||
);
|
|
||||||
// 7 expected indexes
|
|
||||||
let index_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name LIKE 'idx_balance_%'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(index_count, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_seeds_7_categories() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 7);
|
|
||||||
|
|
||||||
let simple_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'simple' AND is_seed = 1",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(simple_count, 5);
|
|
||||||
|
|
||||||
let priced_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM balance_categories WHERE kind = 'priced' AND is_seed = 1",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(priced_count, 2);
|
|
||||||
|
|
||||||
// Seeded keys are stable
|
|
||||||
let stock_kind: String = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT kind FROM balance_categories WHERE key = 'stock'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(stock_kind, "priced");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_rejects_non_cad_currency() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
// 'cash' category exists from seed; try to insert a non-CAD account.
|
|
||||||
let result = conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name, currency)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'USD account', 'USD')",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"CHECK(currency='CAD') should reject 'USD'"
|
|
||||||
);
|
|
||||||
let err_msg = result.unwrap_err().to_string();
|
|
||||||
assert!(err_msg.to_lowercase().contains("check"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_accepts_default_cad_currency() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
let inserted = conn
|
|
||||||
.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.expect("CAD default insert should succeed");
|
|
||||||
assert_eq!(inserted, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_rejects_invalid_kind() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
let result = conn.execute(
|
|
||||||
"INSERT INTO balance_categories (key, i18n_key, kind) VALUES ('bogus', 'x.bogus', 'unknown')",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
assert!(result.is_err(), "kind CHECK should reject 'unknown'");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_unique_snapshot_date() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let result = conn.execute(
|
|
||||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
assert!(result.is_err(), "UNIQUE(snapshot_date) should reject dup");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_kind_invariants_check() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
// Setup: a snapshot + an account
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let snap_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
let acct_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// OK: simple kind (quantity + unit_price both NULL)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
|
||||||
VALUES (?1, ?2, 1234.56)",
|
|
||||||
rusqlite::params![snap_id, acct_id],
|
|
||||||
)
|
|
||||||
.expect("simple kind row (qty/price both NULL) should be accepted");
|
|
||||||
|
|
||||||
// OK: priced kind (both set) — needs second account on a priced category
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name, symbol)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='stock'), 'AAPL', 'AAPL')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let acct2_id: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM balance_accounts WHERE name='AAPL'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, unit_price, value)
|
|
||||||
VALUES (?1, ?2, 10.0, 200.0, 2000.0)",
|
|
||||||
rusqlite::params![snap_id, acct2_id],
|
|
||||||
)
|
|
||||||
.expect("priced kind row (both set) should be accepted");
|
|
||||||
|
|
||||||
// KO: only quantity set, unit_price NULL → CHECK violation
|
|
||||||
let bad = conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, quantity, value)
|
|
||||||
VALUES (?1, ?2, 10.0, 0.0)",
|
|
||||||
rusqlite::params![snap_id, acct_id],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
bad.is_err(),
|
|
||||||
"kind invariants CHECK should reject (qty set, price NULL)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// KO: only unit_price set, quantity NULL → CHECK violation
|
|
||||||
let bad2 = conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, unit_price, value)
|
|
||||||
VALUES (?1, ?2, 200.0, 0.0)",
|
|
||||||
rusqlite::params![snap_id, acct_id],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
bad2.is_err(),
|
|
||||||
"kind invariants CHECK should reject (price set, qty NULL)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_unique_snapshot_account_pair() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let snap_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
let acct_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
|
||||||
VALUES (?1, ?2, 100.0)",
|
|
||||||
rusqlite::params![snap_id, acct_id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let dup = conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
|
||||||
VALUES (?1, ?2, 200.0)",
|
|
||||||
rusqlite::params![snap_id, acct_id],
|
|
||||||
);
|
|
||||||
assert!(dup.is_err(), "UNIQUE(snapshot_id, account_id) should reject dup");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_fk_cascade_on_snapshot_delete() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-04-25')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let snap_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_snapshots LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
let acct_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
|
||||||
VALUES (?1, ?2, 100.0)",
|
|
||||||
rusqlite::params![snap_id, acct_id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM balance_snapshots WHERE id = ?1",
|
|
||||||
rusqlite::params![snap_id],
|
|
||||||
)
|
|
||||||
.expect("delete snapshot should cascade");
|
|
||||||
let remaining: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM balance_snapshot_lines", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(remaining, 0, "snapshot delete should cascade lines");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_fk_restrict_on_transaction_delete() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let acct_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
let tx_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
|
||||||
VALUES (?1, ?2, 'in')",
|
|
||||||
rusqlite::params![acct_id, tx_id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Attempting to delete the linked transaction must be rejected (RESTRICT)
|
|
||||||
let result = conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id]);
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"FK RESTRICT should block deleting linked transaction"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Once the transfer is removed, deletion is allowed again
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM balance_account_transfers WHERE transaction_id = ?1",
|
|
||||||
rusqlite::params![tx_id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute("DELETE FROM transactions WHERE id = ?1", rusqlite::params![tx_id])
|
|
||||||
.expect("after unlink, transaction can be deleted");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_fk_restrict_on_category_with_accounts() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
// Try to delete the seeded 'cash' category while an account references it
|
|
||||||
let result = conn.execute(
|
|
||||||
"DELETE FROM balance_categories WHERE key = 'cash'",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"FK RESTRICT should block deleting category with linked accounts"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_unique_transaction_account_transfer() {
|
|
||||||
let conn = fresh_db();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Encaisse')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO transactions (date, description, amount) VALUES ('2026-04-25', 'Deposit', 1000.0)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let acct_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
let tx_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM transactions LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
|
||||||
VALUES (?1, ?2, 'in')",
|
|
||||||
rusqlite::params![acct_id, tx_id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let dup = conn.execute(
|
|
||||||
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
|
||||||
VALUES (?1, ?2, 'out')",
|
|
||||||
rusqlite::params![acct_id, tx_id],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
dup.is_err(),
|
|
||||||
"UNIQUE(transaction_id, account_id) should reject dup"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_seed_idempotent_on_replay() {
|
|
||||||
// The migration uses `INSERT OR IGNORE` keyed by `key`, so applying
|
|
||||||
// the schema twice on the same DB must not duplicate seeded rows.
|
|
||||||
let conn = fresh_db();
|
|
||||||
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
|
||||||
.expect("apply BALANCE_SCHEMA twice");
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 7, "seed must remain idempotent on replay");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Issue #144 (Bilan #6) — integration tests on a seeded DB
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// The previous tests apply BALANCE_SCHEMA on an empty DB. These tests
|
|
||||||
// simulate the realistic upgrade path: a profile DB with imported
|
|
||||||
// transactions already there gets the v9 migration applied on top, and
|
|
||||||
// we verify:
|
|
||||||
// - existing transactions are not affected by the migration (no row
|
|
||||||
// loss, no schema collision),
|
|
||||||
// - link / unlink transfer round-trips on real (non-stub) transaction
|
|
||||||
// ids,
|
|
||||||
// - the FK RESTRICT correctly chains: try to delete a linked
|
|
||||||
// transaction → blocked, unlink → delete succeeds.
|
|
||||||
|
|
||||||
/// Seed a DB with the *full app schema* (transactions + categories +
|
|
||||||
/// keywords + suppliers + adjustments + ...) then apply BALANCE_SCHEMA on
|
|
||||||
/// top — mirroring what migration v9 does on an existing user profile.
|
|
||||||
/// Returns the connection ready for assertions.
|
|
||||||
fn seeded_db_with_balance_schema() -> Connection {
|
|
||||||
let conn = Connection::open_in_memory().expect("open in-memory db");
|
|
||||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
|
||||||
.expect("enable FKs");
|
|
||||||
// Apply the full app schema (v1) — we only need the transactions
|
|
||||||
// table for the v9 FK, but applying the whole schema verifies that
|
|
||||||
// nothing in v9 collides with the existing tables.
|
|
||||||
conn.execute_batch(crate::database::SCHEMA)
|
|
||||||
.expect("apply v1 SCHEMA");
|
|
||||||
// Pre-seed a few transactions to mimic an existing profile (the user
|
|
||||||
// already had data when we shipped v9).
|
|
||||||
conn.execute_batch(
|
|
||||||
"INSERT INTO transactions (date, description, amount) VALUES
|
|
||||||
('2026-01-15', 'Salary deposit', 3500.0),
|
|
||||||
('2026-02-01', 'Wealthsimple contribution', -400.0),
|
|
||||||
('2026-03-15', 'Grocery store', -125.50),
|
|
||||||
('2026-04-01', 'Wealthsimple contribution', -400.0);",
|
|
||||||
)
|
|
||||||
.expect("seed transactions");
|
|
||||||
// Now apply v9 on top — same way the runtime would.
|
|
||||||
conn.execute_batch(crate::database::BALANCE_SCHEMA)
|
|
||||||
.expect("apply v9 BALANCE_SCHEMA on seeded DB");
|
|
||||||
conn
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_v9_preserves_existing_transactions_on_seeded_db() {
|
|
||||||
let conn = seeded_db_with_balance_schema();
|
|
||||||
// Existing transactions must be untouched by the migration.
|
|
||||||
let count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM transactions", [], |row| row.get(0))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(count, 4, "existing transactions must survive the migration");
|
|
||||||
|
|
||||||
// Spot-check one row's content (no silent data mutation).
|
|
||||||
let amount: f64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT amount FROM transactions WHERE description = 'Salary deposit'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
(amount - 3500.0).abs() < f64::EPSILON,
|
|
||||||
"salary amount must be preserved verbatim"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The seeded categories from BALANCE_SCHEMA must coexist with the
|
|
||||||
// pre-existing categories table from v1 (different name, no clash).
|
|
||||||
let bal_cat_count: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM balance_categories WHERE is_seed = 1",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(bal_cat_count, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn integration_link_unlink_transfer_roundtrip_on_seeded_db() {
|
|
||||||
let conn = seeded_db_with_balance_schema();
|
|
||||||
|
|
||||||
// Create a balance account on the seeded 'cash' category.
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let account_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Pick the Feb contribution (-$400) — a typical "in" transfer for the
|
|
||||||
// Wealthsimple account from the bank perspective.
|
|
||||||
let tx_id: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM transactions WHERE date = '2026-02-01'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// 1. Link
|
|
||||||
let inserted = conn
|
|
||||||
.execute(
|
|
||||||
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes)
|
|
||||||
VALUES (?1, ?2, 'in', 'monthly contribution')",
|
|
||||||
rusqlite::params![account_id, tx_id],
|
|
||||||
)
|
|
||||||
.expect("link succeeds with real transaction id");
|
|
||||||
assert_eq!(inserted, 1);
|
|
||||||
|
|
||||||
// 2. Verify the row is queryable through the joined view used by
|
|
||||||
// `listAccountTransfers` in TS.
|
|
||||||
let (joined_amount, direction): (f64, String) = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT t.amount, bat.direction
|
|
||||||
FROM balance_account_transfers bat
|
|
||||||
JOIN transactions t ON t.id = bat.transaction_id
|
|
||||||
WHERE bat.account_id = ?1",
|
|
||||||
rusqlite::params![account_id],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
||||||
)
|
|
||||||
.expect("joined view must read");
|
|
||||||
assert!((joined_amount - (-400.0)).abs() < f64::EPSILON);
|
|
||||||
assert_eq!(direction, "in");
|
|
||||||
|
|
||||||
// 3. Try to delete the linked transaction — must be blocked (RESTRICT).
|
|
||||||
let blocked = conn.execute(
|
|
||||||
"DELETE FROM transactions WHERE id = ?1",
|
|
||||||
rusqlite::params![tx_id],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
blocked.is_err(),
|
|
||||||
"linked transaction deletion must be blocked by FK RESTRICT"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Unlink
|
|
||||||
let unlinked = conn
|
|
||||||
.execute(
|
|
||||||
"DELETE FROM balance_account_transfers
|
|
||||||
WHERE account_id = ?1 AND transaction_id = ?2",
|
|
||||||
rusqlite::params![account_id, tx_id],
|
|
||||||
)
|
|
||||||
.expect("unlink succeeds");
|
|
||||||
assert_eq!(unlinked, 1);
|
|
||||||
|
|
||||||
// 5. After unlink, deleting the transaction must succeed.
|
|
||||||
let allowed = conn
|
|
||||||
.execute(
|
|
||||||
"DELETE FROM transactions WHERE id = ?1",
|
|
||||||
rusqlite::params![tx_id],
|
|
||||||
)
|
|
||||||
.expect("after unlink, transaction can be deleted");
|
|
||||||
assert_eq!(allowed, 1);
|
|
||||||
|
|
||||||
// 6. Sanity: no orphan transfer rows survived.
|
|
||||||
let remaining_links: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM balance_account_transfers WHERE transaction_id = ?1",
|
|
||||||
rusqlite::params![tx_id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(remaining_links, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn integration_modified_dietz_inputs_read_back_correctly_on_seeded_db() {
|
|
||||||
// Reads back the snapshot endpoints + cash flows the way
|
|
||||||
// `compute_account_return` does, on a DB that has both v1 transactions
|
|
||||||
// and v9 balance tables. Asserts the SQL queries used by
|
|
||||||
// `balance_commands.rs::read_value_at_or_before` and `read_cash_flows`
|
|
||||||
// return the expected shapes.
|
|
||||||
let conn = seeded_db_with_balance_schema();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_accounts (balance_category_id, name)
|
|
||||||
VALUES ((SELECT id FROM balance_categories WHERE key='cash'), 'Wealthsimple cash')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let account_id: i64 = conn
|
|
||||||
.query_row("SELECT id FROM balance_accounts LIMIT 1", [], |r| r.get(0))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Two snapshot endpoints (V_start, V_end) and one mid-period contribution.
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshots (snapshot_date) VALUES
|
|
||||||
('2026-01-01'),
|
|
||||||
('2026-04-01')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let s_start: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM balance_snapshots WHERE snapshot_date='2026-01-01'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let s_end: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM balance_snapshots WHERE snapshot_date='2026-04-01'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
|
||||||
VALUES (?1, ?2, 1000.0)",
|
|
||||||
rusqlite::params![s_start, account_id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_snapshot_lines (snapshot_id, account_id, value)
|
|
||||||
VALUES (?1, ?2, 1500.0)",
|
|
||||||
rusqlite::params![s_end, account_id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Link the Feb 1 contribution as an `in` transfer.
|
|
||||||
let tx_id: i64 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT id FROM transactions WHERE date='2026-02-01'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_account_transfers (account_id, transaction_id, direction)
|
|
||||||
VALUES (?1, ?2, 'in')",
|
|
||||||
rusqlite::params![account_id, tx_id],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Mirror `read_value_at_or_before` for V_start — exact SQL used in
|
|
||||||
// `balance_commands.rs`.
|
|
||||||
let v_start: Option<f64> = conn
|
|
||||||
.query_row(
|
|
||||||
"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",
|
|
||||||
rusqlite::params![account_id, "2026-01-01"],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
assert_eq!(v_start, Some(1000.0));
|
|
||||||
|
|
||||||
// V_end at 2026-04-01 — picks up the second snapshot.
|
|
||||||
let v_end: Option<f64> = conn
|
|
||||||
.query_row(
|
|
||||||
"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",
|
|
||||||
rusqlite::params![account_id, "2026-04-01"],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
assert_eq!(v_end, Some(1500.0));
|
|
||||||
|
|
||||||
// Cash flows in [2026-01-01, 2026-04-01] — exactly one (-400 abs amount → +400 in).
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT t.date, ABS(t.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",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let flows: Vec<(String, f64, String)> = stmt
|
|
||||||
.query_map(
|
|
||||||
rusqlite::params![account_id, "2026-01-01", "2026-04-01"],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(flows.len(), 1);
|
|
||||||
assert_eq!(flows[0].0, "2026-02-01");
|
|
||||||
assert!((flows[0].1 - 400.0).abs() < f64::EPSILON);
|
|
||||||
assert_eq!(flows[0].2, "in");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn integration_v9_preserves_v1_categories_and_keywords() {
|
|
||||||
// Defensive: v9 introduces `balance_categories` while v1 already has
|
|
||||||
// `categories`. Make sure neither is mistaken for the other and that
|
|
||||||
// the v1 seeds (when present) survive the migration cleanly.
|
|
||||||
let conn = seeded_db_with_balance_schema();
|
|
||||||
|
|
||||||
// Insert a v1 category + keyword (mimicking v1 seed data already present).
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO categories (id, name, type, color, sort_order)
|
|
||||||
VALUES (50, 'Épicerie', 'expense', '#10b981', 50)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO keywords (keyword, category_id, priority, is_active)
|
|
||||||
VALUES ('IGA', 50, 100, 1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Now insert a v9 category with the SAME numeric id (should be allowed
|
|
||||||
// — different table, different namespace).
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO balance_categories (id, key, i18n_key, kind, sort_order)
|
|
||||||
VALUES (50, 'mortgage', 'balance.category.mortgage', 'simple', 100)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.expect(
|
|
||||||
"balance_categories.id namespace must be independent from categories.id",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The v1 row is untouched.
|
|
||||||
let v1_name: String = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT name FROM categories WHERE id = 50",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(v1_name, "Épicerie");
|
|
||||||
|
|
||||||
// The v9 row is queryable on its own table.
|
|
||||||
let v9_key: String = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT key FROM balance_categories WHERE id = 50",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(v9_key, "mortgage");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,6 @@ import ReportsComparePage from "./pages/ReportsComparePage";
|
||||||
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import AccountsPage from "./pages/AccountsPage";
|
|
||||||
import BalancePage from "./pages/BalancePage";
|
|
||||||
import SnapshotEditPage from "./pages/SnapshotEditPage";
|
|
||||||
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
||||||
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
|
|
@ -117,9 +114,6 @@ export default function App() {
|
||||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/balance" element={<BalancePage />} />
|
|
||||||
<Route path="/balance/accounts" element={<AccountsPage />} />
|
|
||||||
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/settings/categories/standard"
|
path="/settings/categories/standard"
|
||||||
element={<CategoriesStandardGuidePage />}
|
element={<CategoriesStandardGuidePage />}
|
||||||
|
|
|
||||||
|
|
@ -1,574 +0,0 @@
|
||||||
/**
|
|
||||||
* Integration tests for the Bilan (balance sheet) feature — Issue #144.
|
|
||||||
*
|
|
||||||
* Cross-cutting tests that exercise the *whole* TS surface in one go:
|
|
||||||
*
|
|
||||||
* account → priced category → priced snapshot → linked transfer → return
|
|
||||||
*
|
|
||||||
* Like `category-migration.test.ts` we cannot spin up real `tauri-plugin-sql`
|
|
||||||
* (the bridge only lives inside the Tauri WebView). Instead we drive every
|
|
||||||
* service against an in-memory FakeDb that:
|
|
||||||
* - records every executed SQL,
|
|
||||||
* - returns hand-tuned `select` results to mimic the real schema,
|
|
||||||
* - simulates `lastInsertId` / `rowsAffected` for INSERT/DELETE.
|
|
||||||
*
|
|
||||||
* The Tauri `invoke` is mocked — `computeAccountReturn` lives on the Rust
|
|
||||||
* side (`compute_account_return`), so we assert the request payload and
|
|
||||||
* have the mock return a stable `AccountReturn` shape. The Rust math itself
|
|
||||||
* is covered by `return_calculator.rs`'s `#[cfg(test)] mod tests`.
|
|
||||||
*
|
|
||||||
* Scope (from spec-plan-bilan.md, Issue #144):
|
|
||||||
* 1. End-to-end happy path
|
|
||||||
* 2. Currency-lock (CHECK `currency = 'CAD'`) at the service level
|
|
||||||
* 3. Migration v9 on a seeded DB — covered in Rust (lib.rs `mod tests`)
|
|
||||||
* 4. TransactionsPage non-regression for the inlined transfer icon
|
|
||||||
* 5. Coverage best-effort (deferred — see decisions-log.md)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("../services/db", () => ({
|
|
||||||
getDb: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
|
||||||
invoke: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../services/profileService", () => ({
|
|
||||||
loadProfiles: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { getDb } from "../services/db";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { loadProfiles } from "../services/profileService";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createBalanceCategory,
|
|
||||||
createBalanceAccount,
|
|
||||||
listBalanceAccounts,
|
|
||||||
createSnapshot,
|
|
||||||
upsertSnapshotLines,
|
|
||||||
listLinesBySnapshot,
|
|
||||||
linkTransfer,
|
|
||||||
unlinkTransfer,
|
|
||||||
listAccountTransfers,
|
|
||||||
computeAccountReturn,
|
|
||||||
BalanceServiceError,
|
|
||||||
PRICED_VALUE_TOLERANCE,
|
|
||||||
} from "../services/balance.service";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FakeDb harness: scripted select results, recorded execute calls.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface FakeDb {
|
|
||||||
calls: Array<{ sql: string; params?: unknown[] }>;
|
|
||||||
selectQueue: Array<unknown[]>;
|
|
||||||
executeQueue: Array<{ lastInsertId?: number; rowsAffected?: number }>;
|
|
||||||
select: ReturnType<typeof vi.fn>;
|
|
||||||
execute: ReturnType<typeof vi.fn>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFakeDb(): FakeDb {
|
|
||||||
const db: FakeDb = {
|
|
||||||
calls: [],
|
|
||||||
selectQueue: [],
|
|
||||||
executeQueue: [],
|
|
||||||
select: vi.fn(),
|
|
||||||
execute: vi.fn(),
|
|
||||||
};
|
|
||||||
db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
|
|
||||||
db.calls.push({ sql, params });
|
|
||||||
if (db.selectQueue.length === 0) {
|
|
||||||
throw new Error(`Unscripted SELECT (no queued result): ${sql}`);
|
|
||||||
}
|
|
||||||
return db.selectQueue.shift();
|
|
||||||
});
|
|
||||||
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
|
|
||||||
db.calls.push({ sql, params });
|
|
||||||
if (db.executeQueue.length === 0) {
|
|
||||||
// Default: 1 affected row, monotonically increasing lastInsertId
|
|
||||||
return { rowsAffected: 1, lastInsertId: db.calls.length };
|
|
||||||
}
|
|
||||||
return db.executeQueue.shift();
|
|
||||||
});
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fake: FakeDb;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fake = makeFakeDb();
|
|
||||||
vi.mocked(getDb).mockResolvedValue(
|
|
||||||
{ select: fake.select, execute: fake.execute } as never
|
|
||||||
);
|
|
||||||
vi.mocked(invoke).mockReset();
|
|
||||||
vi.mocked(loadProfiles).mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper: queue a sequence of SELECT results in FIFO order.
|
|
||||||
function queueSelects(...rows: unknown[][]) {
|
|
||||||
for (const r of rows) fake.selectQueue.push(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: queue a sequence of EXECUTE results in FIFO order.
|
|
||||||
function queueExecutes(
|
|
||||||
...results: Array<{ lastInsertId?: number; rowsAffected?: number }>
|
|
||||||
) {
|
|
||||||
for (const r of results) fake.executeQueue.push(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 1. End-to-end happy path
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Walks the full Bilan flow as if the user just installed the app:
|
|
||||||
// 1. Create a custom priced category ("etf-prov")
|
|
||||||
// 2. Create an account on that category with a stock symbol
|
|
||||||
// 3. Reload the joined accounts list and confirm the account is there
|
|
||||||
// 4. Create a snapshot dated today
|
|
||||||
// 5. Save a priced line for the new account (qty * price = value)
|
|
||||||
// 6. Read the lines back and confirm what was persisted
|
|
||||||
// 7. Link a transaction to the account as a +CAD deposit
|
|
||||||
// 8. Compute the account's return → mock returns a stable shape, we
|
|
||||||
// assert the wiring uses the active profile's db_filename and forwards
|
|
||||||
// every parameter as ISO YYYY-MM-DD.
|
|
||||||
//
|
|
||||||
// Each step is asserted at the service-call level (params + queued SQL),
|
|
||||||
// then we run cross-step sanity checks.
|
|
||||||
|
|
||||||
describe("integration — Bilan end-to-end happy path", () => {
|
|
||||||
it("walks account → priced category → snapshot → transfer → return cleanly", async () => {
|
|
||||||
// ---- 1. Create a custom priced category ----
|
|
||||||
queueExecutes({ lastInsertId: 100 });
|
|
||||||
const categoryId = await createBalanceCategory({
|
|
||||||
key: "etf-prov",
|
|
||||||
i18n_key: "balance.category.etf_prov",
|
|
||||||
kind: "priced",
|
|
||||||
sort_order: 80,
|
|
||||||
});
|
|
||||||
expect(categoryId).toBe(100);
|
|
||||||
|
|
||||||
// ---- 2. Create the account on that category ----
|
|
||||||
// Service first SELECTs the category to validate it exists, then
|
|
||||||
// INSERTs the account.
|
|
||||||
queueSelects([
|
|
||||||
{
|
|
||||||
id: 100,
|
|
||||||
key: "etf-prov",
|
|
||||||
i18n_key: "balance.category.etf_prov",
|
|
||||||
kind: "priced",
|
|
||||||
sort_order: 80,
|
|
||||||
is_active: 1,
|
|
||||||
is_seed: 0,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
queueExecutes({ lastInsertId: 7 });
|
|
||||||
const accountId = await createBalanceAccount({
|
|
||||||
balance_category_id: categoryId,
|
|
||||||
name: "VFV (Wealthsimple)",
|
|
||||||
symbol: "VFV.TO",
|
|
||||||
});
|
|
||||||
expect(accountId).toBe(7);
|
|
||||||
|
|
||||||
// ---- 3. listBalanceAccounts: account joined with category ----
|
|
||||||
queueSelects([
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
balance_category_id: 100,
|
|
||||||
name: "VFV (Wealthsimple)",
|
|
||||||
symbol: "VFV.TO",
|
|
||||||
currency: "CAD",
|
|
||||||
notes: null,
|
|
||||||
is_active: 1,
|
|
||||||
archived_at: null,
|
|
||||||
created_at: "",
|
|
||||||
updated_at: "",
|
|
||||||
category_key: "etf-prov",
|
|
||||||
category_i18n_key: "balance.category.etf_prov",
|
|
||||||
category_kind: "priced",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const accounts = await listBalanceAccounts();
|
|
||||||
expect(accounts).toHaveLength(1);
|
|
||||||
expect(accounts[0].category_kind).toBe("priced");
|
|
||||||
expect(accounts[0].symbol).toBe("VFV.TO");
|
|
||||||
|
|
||||||
// ---- 4. Create a snapshot dated 2026-04-25 ----
|
|
||||||
// createSnapshot first SELECTs by date (must be empty) then INSERTs.
|
|
||||||
queueSelects([]); // no existing snapshot
|
|
||||||
queueExecutes({ lastInsertId: 50 });
|
|
||||||
const snapshotId = await createSnapshot({ snapshot_date: "2026-04-25" });
|
|
||||||
expect(snapshotId).toBe(50);
|
|
||||||
|
|
||||||
// ---- 5. Save a priced line: 10 shares × $200 = $2000 ----
|
|
||||||
// upsertSnapshotLines: SELECT snapshot, then DELETE existing lines, then
|
|
||||||
// one INSERT per line, then UPDATE updated_at.
|
|
||||||
queueSelects([
|
|
||||||
{
|
|
||||||
id: 50,
|
|
||||||
snapshot_date: "2026-04-25",
|
|
||||||
notes: null,
|
|
||||||
created_at: "",
|
|
||||||
updated_at: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
queueExecutes(
|
|
||||||
{ rowsAffected: 0 }, // delete (no prior lines)
|
|
||||||
{ lastInsertId: 200 }, // insert priced line
|
|
||||||
{ rowsAffected: 1 } // bump updated_at
|
|
||||||
);
|
|
||||||
await upsertSnapshotLines(50, [
|
|
||||||
{
|
|
||||||
account_id: 7,
|
|
||||||
account_kind: "priced",
|
|
||||||
quantity: 10,
|
|
||||||
unit_price: 200,
|
|
||||||
value: 2000,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// The 2nd execute call should be the INSERT with the priced placeholders.
|
|
||||||
const insertCall = fake.calls.find(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_snapshot_lines")
|
|
||||||
);
|
|
||||||
expect(insertCall).toBeDefined();
|
|
||||||
expect(insertCall!.params).toEqual([50, 7, 10, 200, 2000]);
|
|
||||||
|
|
||||||
// ---- 6. Read the lines back ----
|
|
||||||
queueSelects([
|
|
||||||
{
|
|
||||||
id: 200,
|
|
||||||
snapshot_id: 50,
|
|
||||||
account_id: 7,
|
|
||||||
quantity: 10,
|
|
||||||
unit_price: 200,
|
|
||||||
value: 2000,
|
|
||||||
price_source: "manual",
|
|
||||||
price_fetched_at: null,
|
|
||||||
created_at: "",
|
|
||||||
updated_at: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const lines = await listLinesBySnapshot(50);
|
|
||||||
expect(lines).toHaveLength(1);
|
|
||||||
expect(lines[0].value).toBe(2000);
|
|
||||||
expect(lines[0].quantity).toBe(10);
|
|
||||||
expect(lines[0].unit_price).toBe(200);
|
|
||||||
|
|
||||||
// ---- 7. Link a transaction (id=42) as a +CAD deposit (in) ----
|
|
||||||
// linkTransfer: SELECT existing duplicate (none), then INSERT.
|
|
||||||
queueSelects([]); // no existing duplicate
|
|
||||||
queueExecutes({ lastInsertId: 9 });
|
|
||||||
const transferId = await linkTransfer(7, 42, "in", "monthly contribution");
|
|
||||||
expect(transferId).toBe(9);
|
|
||||||
const linkCall = fake.calls.find(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_account_transfers")
|
|
||||||
);
|
|
||||||
expect(linkCall).toBeDefined();
|
|
||||||
expect(linkCall!.params).toEqual([7, 42, "in", "monthly contribution"]);
|
|
||||||
|
|
||||||
// ---- 8. Compute the account return ----
|
|
||||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
|
||||||
active_profile_id: "max",
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
id: "max",
|
|
||||||
name: "Max",
|
|
||||||
color: "#3b82f6",
|
|
||||||
pin_hash: null,
|
|
||||||
db_filename: "max.db",
|
|
||||||
created_at: "0",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const fakeReturn = {
|
|
||||||
value_start: 1500,
|
|
||||||
value_end: 2000,
|
|
||||||
net_contributions: 400,
|
|
||||||
return_pct: 0.0667, // (2000 - 1500 - 400) / (1500 + W*400) ≈ 6.67%
|
|
||||||
annualized_pct: 0.28,
|
|
||||||
is_partial: false,
|
|
||||||
has_no_transfers_warning: false,
|
|
||||||
};
|
|
||||||
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
|
|
||||||
|
|
||||||
const ret = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
|
|
||||||
expect(ret).toEqual(fakeReturn);
|
|
||||||
|
|
||||||
// Wiring check: profile resolution + ISO date forwarding.
|
|
||||||
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
|
|
||||||
dbFilename: "max.db",
|
|
||||||
accountId: 7,
|
|
||||||
periodStart: "2026-01-01",
|
|
||||||
periodEnd: "2026-04-25",
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Cross-step sanity: every coherent value matches expectations.
|
|
||||||
// The end snapshot value (2000) matches what we saved.
|
|
||||||
expect(ret.value_end).toBe(2000);
|
|
||||||
// The reported return is a finite, non-zero number on a non-trivial period.
|
|
||||||
expect(ret.return_pct).not.toBeNull();
|
|
||||||
expect(Number.isFinite(ret.return_pct!)).toBe(true);
|
|
||||||
// Net contributions match the 1 linked transfer (+400 in).
|
|
||||||
expect(ret.net_contributions).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports unlink as the inverse of link", async () => {
|
|
||||||
queueExecutes({ rowsAffected: 1 });
|
|
||||||
await expect(unlinkTransfer(7, 42)).resolves.toBeUndefined();
|
|
||||||
const unlinkCall = fake.calls.find(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("DELETE FROM balance_account_transfers")
|
|
||||||
);
|
|
||||||
expect(unlinkCall!.params).toEqual([7, 42]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("listAccountTransfers reads back what link wrote (joined view)", async () => {
|
|
||||||
queueSelects([
|
|
||||||
{
|
|
||||||
id: 9,
|
|
||||||
account_id: 7,
|
|
||||||
transaction_id: 42,
|
|
||||||
direction: "in",
|
|
||||||
notes: "monthly contribution",
|
|
||||||
created_at: "2026-04-25 10:00:00",
|
|
||||||
transaction_date: "2026-04-15",
|
|
||||||
transaction_description: "Wealthsimple contrib",
|
|
||||||
transaction_amount: -400,
|
|
||||||
account_name: "VFV (Wealthsimple)",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const links = await listAccountTransfers(7);
|
|
||||||
expect(links).toHaveLength(1);
|
|
||||||
expect(links[0].direction).toBe("in");
|
|
||||||
expect(links[0].account_name).toBe("VFV (Wealthsimple)");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 2. Currency lock — CAD only at the MVP
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// The MVP locks accounts to CAD: the SQL CHECK is `currency = 'CAD'` and the
|
|
||||||
// service rejects any other value with a typed `currency_unsupported` before
|
|
||||||
// the SQL even fires. Asserts:
|
|
||||||
// - USD is rejected with the typed code,
|
|
||||||
// - the rejection happens BEFORE any SELECT/EXECUTE on the DB,
|
|
||||||
// - the default (no `currency` field) flows through and lands as 'CAD',
|
|
||||||
// - the SQL CHECK side is covered in Rust (lib.rs `migration_v9_*` tests).
|
|
||||||
|
|
||||||
describe("integration — currency lock (CAD only)", () => {
|
|
||||||
it("rejects USD at the service level with a typed error", async () => {
|
|
||||||
await expect(
|
|
||||||
createBalanceAccount({
|
|
||||||
balance_category_id: 1,
|
|
||||||
name: "USD account",
|
|
||||||
currency: "USD",
|
|
||||||
})
|
|
||||||
).rejects.toBeInstanceOf(BalanceServiceError);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createBalanceAccount({
|
|
||||||
balance_category_id: 1,
|
|
||||||
name: "USD account",
|
|
||||||
currency: "USD",
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
expect((e as BalanceServiceError).code).toBe("currency_unsupported");
|
|
||||||
}
|
|
||||||
// CRITICAL: the rejection must happen up-front — no DB hit.
|
|
||||||
expect(fake.calls.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts the default and persists 'CAD' explicitly", async () => {
|
|
||||||
queueSelects([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
key: "cash",
|
|
||||||
i18n_key: "balance.category.cash",
|
|
||||||
kind: "simple",
|
|
||||||
sort_order: 10,
|
|
||||||
is_active: 1,
|
|
||||||
is_seed: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
queueExecutes({ lastInsertId: 5 });
|
|
||||||
await createBalanceAccount({
|
|
||||||
balance_category_id: 1,
|
|
||||||
name: "Encaisse",
|
|
||||||
});
|
|
||||||
const insertCall = fake.calls.find(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("INSERT INTO balance_accounts")
|
|
||||||
);
|
|
||||||
expect(insertCall).toBeDefined();
|
|
||||||
// [category_id, name, symbol, currency, notes]
|
|
||||||
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {
|
|
||||||
for (const ccy of ["EUR", "GBP", "JPY", "AUD"]) {
|
|
||||||
await expect(
|
|
||||||
createBalanceAccount({
|
|
||||||
balance_category_id: 1,
|
|
||||||
name: `Mystery ${ccy}`,
|
|
||||||
currency: ccy,
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ code: "currency_unsupported" });
|
|
||||||
}
|
|
||||||
expect(fake.calls.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 3. Priced-kind invariant — coherence of the qty × price = value chain
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Tied to the priced-kind path, but at the integration layer: a snapshot
|
|
||||||
// saved with a drifting (qty * price ≠ value) line must be rejected before
|
|
||||||
// any DB mutation, so the SQL CHECK never has the chance to fire and we
|
|
||||||
// don't accidentally clear pre-existing lines.
|
|
||||||
|
|
||||||
describe("integration — priced invariant rejects out-of-tolerance saves", () => {
|
|
||||||
it("does not run DELETE when one line is bad", async () => {
|
|
||||||
queueSelects([
|
|
||||||
{
|
|
||||||
id: 50,
|
|
||||||
snapshot_date: "2026-04-25",
|
|
||||||
notes: null,
|
|
||||||
created_at: "",
|
|
||||||
updated_at: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await expect(
|
|
||||||
upsertSnapshotLines(50, [
|
|
||||||
{ account_id: 1, value: 1000 },
|
|
||||||
{
|
|
||||||
account_id: 7,
|
|
||||||
account_kind: "priced",
|
|
||||||
quantity: 10,
|
|
||||||
unit_price: 25,
|
|
||||||
// expected ≈ 250, way beyond ε
|
|
||||||
value: 999,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
|
|
||||||
|
|
||||||
// Critical safety: the DELETE must not have fired — otherwise the user
|
|
||||||
// would lose all existing lines on a partial save.
|
|
||||||
const deletes = fake.calls.filter(
|
|
||||||
(c) =>
|
|
||||||
typeof c.sql === "string" &&
|
|
||||||
c.sql.includes("DELETE FROM balance_snapshot_lines")
|
|
||||||
);
|
|
||||||
expect(deletes).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a drift just within the tolerance", async () => {
|
|
||||||
queueSelects([
|
|
||||||
{
|
|
||||||
id: 50,
|
|
||||||
snapshot_date: "2026-04-25",
|
|
||||||
notes: null,
|
|
||||||
created_at: "",
|
|
||||||
updated_at: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
queueExecutes(
|
|
||||||
{ rowsAffected: 0 },
|
|
||||||
{ lastInsertId: 1 },
|
|
||||||
{ rowsAffected: 1 }
|
|
||||||
);
|
|
||||||
// 12.34 * 1.07 = 13.2038... — drift well within ε = 0.01
|
|
||||||
const drift = PRICED_VALUE_TOLERANCE * 0.5;
|
|
||||||
await expect(
|
|
||||||
upsertSnapshotLines(50, [
|
|
||||||
{
|
|
||||||
account_id: 7,
|
|
||||||
account_kind: "priced",
|
|
||||||
quantity: 10,
|
|
||||||
unit_price: 10,
|
|
||||||
value: 100 + drift,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 4. Returns: malformed period dates rejected before the Tauri invoke
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("integration — computeAccountReturn validates dates client-side", () => {
|
|
||||||
it("rejects non-ISO dates without invoking the Rust command", async () => {
|
|
||||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
|
||||||
active_profile_id: "max",
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
id: "max",
|
|
||||||
name: "Max",
|
|
||||||
color: "#000",
|
|
||||||
pin_hash: null,
|
|
||||||
db_filename: "max.db",
|
|
||||||
created_at: "0",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
computeAccountReturn(7, "01/01/2026", "2026-04-25")
|
|
||||||
).rejects.toBeInstanceOf(BalanceServiceError);
|
|
||||||
// The Tauri side must NOT have been hit — fail-fast on bad dates.
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects when the active profile cannot be resolved", async () => {
|
|
||||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
|
||||||
active_profile_id: "ghost",
|
|
||||||
profiles: [],
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
computeAccountReturn(7, "2026-01-01", "2026-04-25")
|
|
||||||
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
|
|
||||||
expect(invoke).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forwards a partial-period AccountReturn shape unchanged", async () => {
|
|
||||||
// When `is_partial = true` (no V_start), the Rust side returns a payload
|
|
||||||
// with explicit nulls. The TS shim must not coerce them away.
|
|
||||||
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
|
||||||
active_profile_id: "max",
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
id: "max",
|
|
||||||
name: "Max",
|
|
||||||
color: "#000",
|
|
||||||
pin_hash: null,
|
|
||||||
db_filename: "max.db",
|
|
||||||
created_at: "0",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const partial = {
|
|
||||||
value_start: null,
|
|
||||||
value_end: 1500,
|
|
||||||
net_contributions: 200,
|
|
||||||
return_pct: null,
|
|
||||||
annualized_pct: null,
|
|
||||||
is_partial: true,
|
|
||||||
has_no_transfers_warning: false,
|
|
||||||
};
|
|
||||||
vi.mocked(invoke).mockResolvedValueOnce(partial);
|
|
||||||
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
|
|
||||||
expect(out).toEqual(partial);
|
|
||||||
expect(out.is_partial).toBe(true);
|
|
||||||
expect(out.value_start).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
/**
|
|
||||||
* Non-regression check for the inlined transfer icon in TransactionTable
|
|
||||||
* (Issue #142 → #144 follow-up).
|
|
||||||
*
|
|
||||||
* The spec promises that — without any linked transfers — the transactions
|
|
||||||
* table renders exactly as it did before #142 inlined the `<Link2>` icon.
|
|
||||||
* The icon is gated by a single conditional in the JSX:
|
|
||||||
*
|
|
||||||
* {linkedTransfersByTxId?.has(row.id) && (...)}
|
|
||||||
*
|
|
||||||
* If `linkedTransfersByTxId` is undefined OR the map has no entry for `row.id`,
|
|
||||||
* the icon block is short-circuited and the row layout is unchanged.
|
|
||||||
*
|
|
||||||
* Why this approach: this project does not bundle `@testing-library/react`
|
|
||||||
* (see `package.json`), and adding it just for one non-regression check is
|
|
||||||
* out of scope here. Existing component tests (`CategoryCombobox.test.ts`,
|
|
||||||
* `ViewModeToggle.test.ts`, `TrendsChartTypeToggle.test.ts`) likewise extract
|
|
||||||
* pure helpers and assert on them rather than mounting JSX. So we go one
|
|
||||||
* level lower: assert the source-level shape of `TransactionTable.tsx`.
|
|
||||||
*
|
|
||||||
* The assertions are structural on the source file:
|
|
||||||
* 1. The conditional block exists and is gated by `linkedTransfersByTxId?.has`.
|
|
||||||
* 2. The block consumes `Link2` from `lucide-react`.
|
|
||||||
* 3. The prop is OPTIONAL on the component's interface — passing nothing
|
|
||||||
* must remain a valid call (zero-impact path).
|
|
||||||
* 4. The tooltip text comes from the i18n key family `transactions.transferIcon.*`
|
|
||||||
* (so a future rename catches our attention here).
|
|
||||||
* 5. The icon uses `aria-label` for accessibility (Issue #142 acceptance criterion).
|
|
||||||
* 6. The condition uses optional-chaining (so passing `undefined` short-circuits
|
|
||||||
* cleanly without throwing).
|
|
||||||
*
|
|
||||||
* If the icon is ever pulled out into its own component, the tests should be
|
|
||||||
* rewritten to import and exercise that component directly instead. Until
|
|
||||||
* then, this is a tight static contract that catches accidental regressions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { resolve } from "path";
|
|
||||||
|
|
||||||
const TABLE_SRC = readFileSync(
|
|
||||||
resolve(
|
|
||||||
import.meta.dirname,
|
|
||||||
"..",
|
|
||||||
"components",
|
|
||||||
"transactions",
|
|
||||||
"TransactionTable.tsx"
|
|
||||||
),
|
|
||||||
"utf-8"
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("non-regression: TransactionTable transfer icon (#142)", () => {
|
|
||||||
it("guards the icon block behind `linkedTransfersByTxId?.has(row.id)`", () => {
|
|
||||||
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?\.has\(row\.id\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses optional chaining so the icon is opt-in (undefined short-circuits)", () => {
|
|
||||||
// Optional chaining is the safe-render guarantee: if the parent never
|
|
||||||
// passes the prop, `?.has` returns undefined → the && short-circuits to
|
|
||||||
// false, the JSX block is skipped, and the row layout is unchanged.
|
|
||||||
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?\./);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("imports `Link2` from lucide-react for the icon glyph", () => {
|
|
||||||
expect(TABLE_SRC).toMatch(/from\s+["']lucide-react["']/);
|
|
||||||
expect(TABLE_SRC).toMatch(/\bLink2\b/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("declares `linkedTransfersByTxId` as an OPTIONAL prop", () => {
|
|
||||||
// The "?" after the name on the interface is the contract that omitting
|
|
||||||
// the prop is allowed. Without it the entire transactions page would
|
|
||||||
// need to thread the lookup through, breaking pre-#142 callers.
|
|
||||||
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?:/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses `transactions.transferIcon.*` i18n keys for the tooltip and aria-label", () => {
|
|
||||||
// Both the tooltip body and the aria label go through i18n — neither
|
|
||||||
// is a hardcoded English/French string.
|
|
||||||
expect(TABLE_SRC).toMatch(/transactions\.transferIcon\.tooltip/);
|
|
||||||
expect(TABLE_SRC).toMatch(/transactions\.transferIcon\.ariaLabel/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches an `aria-label` for screen readers (a11y)", () => {
|
|
||||||
expect(TABLE_SRC).toMatch(/aria-label=/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the description column structure shared with non-linked rows", () => {
|
|
||||||
// The icon lives inside the description cell, in a flex container
|
|
||||||
// alongside the original `<span class="truncate" title=...>` that
|
|
||||||
// existed pre-#142. If someone moved the description span into a
|
|
||||||
// wrapper that the icon required, this assertion would fail.
|
|
||||||
expect(TABLE_SRC).toMatch(
|
|
||||||
/<span\s+className="truncate"\s+title=\{row\.description\}/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
// AccountForm — account or category variant.
|
|
||||||
//
|
|
||||||
// Mode = 'account' (Issue #138 / Bilan #1a): create / edit a balance_account
|
|
||||||
// row bound to an existing category.
|
|
||||||
// Mode = 'category' (Issue #140 / Bilan #2): create a balance_category row
|
|
||||||
// with a kind selector (`simple | priced`).
|
|
||||||
//
|
|
||||||
// Both variants live in the same component because they share the surrounding
|
|
||||||
// wiring (form layout, save / cancel buttons, validation feedback) and only
|
|
||||||
// the input fields differ.
|
|
||||||
|
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type {
|
|
||||||
BalanceAccount,
|
|
||||||
BalanceCategory,
|
|
||||||
BalanceCategoryKind,
|
|
||||||
} from "../../shared/types";
|
|
||||||
import type {
|
|
||||||
CreateBalanceAccountInput,
|
|
||||||
CreateBalanceCategoryInput,
|
|
||||||
UpdateBalanceAccountInput,
|
|
||||||
} from "../../services/balance.service";
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Account variant types
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface AccountFormValues {
|
|
||||||
balance_category_id: number;
|
|
||||||
name: string;
|
|
||||||
symbol: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AccountVariantProps {
|
|
||||||
mode: "account";
|
|
||||||
/** When provided, the form is in edit mode; otherwise creation. */
|
|
||||||
initialAccount?: BalanceAccount | null;
|
|
||||||
categories: BalanceCategory[];
|
|
||||||
isSaving: boolean;
|
|
||||||
onSubmit: (
|
|
||||||
values: CreateBalanceAccountInput | UpdateBalanceAccountInput
|
|
||||||
) => Promise<void> | void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Category variant types (Issue #140)
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface CategoryFormValues {
|
|
||||||
key: string;
|
|
||||||
i18n_key: string;
|
|
||||||
kind: BalanceCategoryKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CategoryVariantProps {
|
|
||||||
mode: "category";
|
|
||||||
isSaving: boolean;
|
|
||||||
onSubmit: (values: CreateBalanceCategoryInput) => Promise<void> | void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = AccountVariantProps | CategoryVariantProps;
|
|
||||||
|
|
||||||
function defaultAccountValues(
|
|
||||||
initial: BalanceAccount | null | undefined,
|
|
||||||
categories: BalanceCategory[]
|
|
||||||
): AccountFormValues {
|
|
||||||
if (initial) {
|
|
||||||
return {
|
|
||||||
balance_category_id: initial.balance_category_id,
|
|
||||||
name: initial.name,
|
|
||||||
symbol: initial.symbol ?? "",
|
|
||||||
notes: initial.notes ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// First active category as a sane default
|
|
||||||
const first = categories.find((c) => c.is_active) ?? categories[0];
|
|
||||||
return {
|
|
||||||
balance_category_id: first?.id ?? 0,
|
|
||||||
name: "",
|
|
||||||
symbol: "",
|
|
||||||
notes: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AccountForm(props: Props) {
|
|
||||||
if (props.mode === "category") {
|
|
||||||
return <CategoryVariant {...props} />;
|
|
||||||
}
|
|
||||||
return <AccountVariant {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Account variant
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function AccountVariant({
|
|
||||||
initialAccount,
|
|
||||||
categories,
|
|
||||||
isSaving,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
}: AccountVariantProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [values, setValues] = useState<AccountFormValues>(() =>
|
|
||||||
defaultAccountValues(initialAccount, categories)
|
|
||||||
);
|
|
||||||
const [touched, setTouched] = useState(false);
|
|
||||||
|
|
||||||
// Reset form when target account changes (edit different row).
|
|
||||||
useEffect(() => {
|
|
||||||
setValues(defaultAccountValues(initialAccount, categories));
|
|
||||||
setTouched(false);
|
|
||||||
}, [initialAccount, categories]);
|
|
||||||
|
|
||||||
const isEditing = !!initialAccount;
|
|
||||||
const selectedCategory = categories.find(
|
|
||||||
(c) => c.id === values.balance_category_id
|
|
||||||
);
|
|
||||||
const isPriced = selectedCategory?.kind === "priced";
|
|
||||||
const trimmedName = values.name.trim();
|
|
||||||
const trimmedSymbol = values.symbol.trim();
|
|
||||||
const nameInvalid = touched && trimmedName.length === 0;
|
|
||||||
// Priced categories require a symbol — surfaced as a validation error.
|
|
||||||
const symbolMissingForPriced = touched && isPriced && trimmedSymbol.length === 0;
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setTouched(true);
|
|
||||||
if (!trimmedName) return;
|
|
||||||
if (isPriced && !trimmedSymbol) return;
|
|
||||||
|
|
||||||
const payload: CreateBalanceAccountInput = {
|
|
||||||
balance_category_id: values.balance_category_id,
|
|
||||||
name: trimmedName,
|
|
||||||
symbol: trimmedSymbol || null,
|
|
||||||
notes: values.notes.trim() || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditing) {
|
|
||||||
const updatePayload: UpdateBalanceAccountInput = {
|
|
||||||
balance_category_id: payload.balance_category_id,
|
|
||||||
name: payload.name,
|
|
||||||
symbol: payload.symbol,
|
|
||||||
notes: payload.notes,
|
|
||||||
};
|
|
||||||
await onSubmit(updatePayload);
|
|
||||||
} else {
|
|
||||||
await onSubmit(payload);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCategoryLabel = (cat: BalanceCategory) =>
|
|
||||||
t(cat.i18n_key, { defaultValue: cat.key });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1" htmlFor="account-category">
|
|
||||||
{t("balance.account.form.category")}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="account-category"
|
|
||||||
value={values.balance_category_id}
|
|
||||||
onChange={(e) =>
|
|
||||||
setValues({
|
|
||||||
...values,
|
|
||||||
balance_category_id: Number(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
|
||||||
>
|
|
||||||
{categories.length === 0 ? (
|
|
||||||
<option value={0}>{t("balance.account.form.noCategory")}</option>
|
|
||||||
) : (
|
|
||||||
categories.map((cat) => (
|
|
||||||
<option key={cat.id} value={cat.id}>
|
|
||||||
{renderCategoryLabel(cat)}
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1" htmlFor="account-name">
|
|
||||||
{t("balance.account.form.name")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="account-name"
|
|
||||||
type="text"
|
|
||||||
value={values.name}
|
|
||||||
onChange={(e) => setValues({ ...values, name: e.target.value })}
|
|
||||||
onBlur={() => setTouched(true)}
|
|
||||||
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
|
|
||||||
nameInvalid
|
|
||||||
? "border-[var(--negative)]"
|
|
||||||
: "border-[var(--border)]"
|
|
||||||
}`}
|
|
||||||
autoFocus
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{nameInvalid && (
|
|
||||||
<p className="mt-1 text-xs text-[var(--negative)]">
|
|
||||||
{t("balance.account.form.nameRequired")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1" htmlFor="account-symbol">
|
|
||||||
{t("balance.account.form.symbol")}
|
|
||||||
{isPriced && (
|
|
||||||
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
|
|
||||||
({t("balance.account.form.symbolPricedHint")})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="account-symbol"
|
|
||||||
type="text"
|
|
||||||
value={values.symbol}
|
|
||||||
onChange={(e) => setValues({ ...values, symbol: e.target.value })}
|
|
||||||
onBlur={() => setTouched(true)}
|
|
||||||
placeholder={
|
|
||||||
isPriced
|
|
||||||
? t("balance.account.form.symbolPlaceholderPriced")
|
|
||||||
: t("balance.account.form.symbolPlaceholderSimple")
|
|
||||||
}
|
|
||||||
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
|
|
||||||
symbolMissingForPriced
|
|
||||||
? "border-[var(--negative)]"
|
|
||||||
: "border-[var(--border)]"
|
|
||||||
}`}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{symbolMissingForPriced && (
|
|
||||||
<p className="mt-1 text-xs text-[var(--negative)]">
|
|
||||||
{t("balance.account.form.symbolRequiredForPriced")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1" htmlFor="account-notes">
|
|
||||||
{t("balance.account.form.notes")}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="account-notes"
|
|
||||||
value={values.notes}
|
|
||||||
onChange={(e) => setValues({ ...values, notes: e.target.value })}
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.account.form.currencyMvpNotice")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
isSaving ||
|
|
||||||
!trimmedName ||
|
|
||||||
categories.length === 0 ||
|
|
||||||
(isPriced && !trimmedSymbol)
|
|
||||||
}
|
|
||||||
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isEditing
|
|
||||||
? t("balance.account.form.save")
|
|
||||||
: t("balance.account.form.create")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Category variant (Issue #140)
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function CategoryVariant({
|
|
||||||
isSaving,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
}: CategoryVariantProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [values, setValues] = useState<CategoryFormValues>({
|
|
||||||
key: "",
|
|
||||||
i18n_key: "",
|
|
||||||
kind: "simple",
|
|
||||||
});
|
|
||||||
const [touched, setTouched] = useState(false);
|
|
||||||
|
|
||||||
const trimmedKey = values.key.trim();
|
|
||||||
const trimmedLabel = values.i18n_key.trim();
|
|
||||||
const keyInvalid = touched && trimmedKey.length === 0;
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setTouched(true);
|
|
||||||
if (!trimmedKey) return;
|
|
||||||
// Fall back to the key if no human label was supplied.
|
|
||||||
const i18nKey = trimmedLabel || trimmedKey;
|
|
||||||
await onSubmit({
|
|
||||||
key: trimmedKey,
|
|
||||||
i18n_key: i18nKey,
|
|
||||||
kind: values.kind,
|
|
||||||
sort_order: 100, // user-created categories sort after seeded ones
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-medium mb-1"
|
|
||||||
htmlFor="category-key"
|
|
||||||
>
|
|
||||||
{t("balance.category.form.key")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="category-key"
|
|
||||||
type="text"
|
|
||||||
value={values.key}
|
|
||||||
onChange={(e) =>
|
|
||||||
setValues({ ...values, key: e.target.value })
|
|
||||||
}
|
|
||||||
onBlur={() => setTouched(true)}
|
|
||||||
placeholder={t("balance.category.form.keyPlaceholder")}
|
|
||||||
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
|
|
||||||
keyInvalid
|
|
||||||
? "border-[var(--negative)]"
|
|
||||||
: "border-[var(--border)]"
|
|
||||||
}`}
|
|
||||||
autoComplete="off"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{keyInvalid && (
|
|
||||||
<p className="mt-1 text-xs text-[var(--negative)]">
|
|
||||||
{t("balance.account.form.nameRequired")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-medium mb-1"
|
|
||||||
htmlFor="category-label"
|
|
||||||
>
|
|
||||||
{t("balance.category.form.label")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="category-label"
|
|
||||||
type="text"
|
|
||||||
value={values.i18n_key}
|
|
||||||
onChange={(e) =>
|
|
||||||
setValues({ ...values, i18n_key: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={t("balance.category.form.labelPlaceholder")}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-medium mb-1"
|
|
||||||
htmlFor="category-kind"
|
|
||||||
>
|
|
||||||
{t("balance.category.form.kindLabel")}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="category-kind"
|
|
||||||
value={values.kind}
|
|
||||||
onChange={(e) =>
|
|
||||||
setValues({
|
|
||||||
...values,
|
|
||||||
kind: e.target.value as BalanceCategoryKind,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
|
||||||
>
|
|
||||||
<option value="simple">{t("balance.category.kind.simple")}</option>
|
|
||||||
<option value="priced">{t("balance.category.kind.priced")}</option>
|
|
||||||
</select>
|
|
||||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
|
||||||
{values.kind === "priced"
|
|
||||||
? t("balance.category.form.kindHintPriced")
|
|
||||||
: t("balance.category.form.kindHintSimple")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSaving || !trimmedKey}
|
|
||||||
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("balance.category.form.create")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
// BalanceAccountsTable — one-row-per-active-account table on /balance.
|
|
||||||
//
|
|
||||||
// Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ%
|
|
||||||
// + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via
|
|
||||||
// the Modified Dietz `compute_account_return` Tauri command:
|
|
||||||
//
|
|
||||||
// - 3M (last 90 days)
|
|
||||||
// - 1A (last 365 days)
|
|
||||||
// - Depuis création (from earliest snapshot date to today)
|
|
||||||
// - Non-ajusté (simple `(V_end - V_start) / V_start`, no contribution
|
|
||||||
// weighting — shown side-by-side as a sanity check / explanation)
|
|
||||||
//
|
|
||||||
// Returns load lazily on mount via `Promise.all` over (account × horizon),
|
|
||||||
// keyed by `account_id`. Each cell renders "—" while loading and shows the
|
|
||||||
// `is_partial` / `has_no_transfers_warning` badges via tooltip when set.
|
|
||||||
//
|
|
||||||
// Issue #142 also adds a "Lier transferts" item in the per-row actions menu
|
|
||||||
// that opens `LinkTransfersModal` (the modal handles its own state; this
|
|
||||||
// component just bubbles up the request via `onLinkTransfers`).
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react";
|
|
||||||
import type {
|
|
||||||
AccountLatestSnapshot,
|
|
||||||
AccountPeriodAnchor,
|
|
||||||
} from "../../services/balance.service";
|
|
||||||
import { computeAccountReturn } from "../../services/balance.service";
|
|
||||||
import type { AccountReturn } from "../../shared/types";
|
|
||||||
|
|
||||||
const cadFormatter = (locale: string) =>
|
|
||||||
new Intl.NumberFormat(locale, {
|
|
||||||
style: "currency",
|
|
||||||
currency: "CAD",
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Horizon definition: how many days back from today to start the period. */
|
|
||||||
type HorizonKey = "3M" | "1A" | "since";
|
|
||||||
|
|
||||||
interface HorizonRange {
|
|
||||||
key: HorizonKey;
|
|
||||||
/** ISO date for `period_start`. */
|
|
||||||
from: string;
|
|
||||||
/** ISO date for `period_end` (always today, computed in the local civil day). */
|
|
||||||
to: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function localISO(d: Date): string {
|
|
||||||
const yy = d.getFullYear();
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${yy}-${mm}-${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isoDaysAgo(days: number, today: Date): string {
|
|
||||||
const d = new Date(today);
|
|
||||||
d.setDate(d.getDate() - days);
|
|
||||||
return localISO(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BalanceAccountsTableProps {
|
|
||||||
accounts: AccountLatestSnapshot[];
|
|
||||||
periodAnchor: AccountPeriodAnchor[];
|
|
||||||
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
|
||||||
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
|
|
||||||
/**
|
|
||||||
* Earliest snapshot date across the whole profile, used to anchor the
|
|
||||||
* "depuis création" horizon. Falls back to "1A" range if not provided
|
|
||||||
* (avoids triggering computation against the unix epoch).
|
|
||||||
*/
|
|
||||||
sinceCreationDate?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-account, per-horizon return — shape used by the local cache state.
|
|
||||||
* Indexed `[accountId][horizonKey]`.
|
|
||||||
*/
|
|
||||||
type ReturnsByAccount = Record<number, Partial<Record<HorizonKey, AccountReturn>>>;
|
|
||||||
|
|
||||||
export default function BalanceAccountsTable({
|
|
||||||
accounts,
|
|
||||||
periodAnchor,
|
|
||||||
onArchiveAccount,
|
|
||||||
onLinkTransfers,
|
|
||||||
sinceCreationDate,
|
|
||||||
}: BalanceAccountsTableProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
|
||||||
|
|
||||||
/** account_id → period anchor (start-of-period value). */
|
|
||||||
const anchorMap = useMemo(() => {
|
|
||||||
const m = new Map<number, AccountPeriodAnchor>();
|
|
||||||
for (const a of periodAnchor) m.set(a.account_id, a);
|
|
||||||
return m;
|
|
||||||
}, [periodAnchor]);
|
|
||||||
|
|
||||||
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Returns cache. Cleared whenever the account list changes (new accounts,
|
|
||||||
// archive, etc.). Loaded lazily after mount.
|
|
||||||
const [returns, setReturns] = useState<ReturnsByAccount>({});
|
|
||||||
const [returnsLoading, setReturnsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Horizon definitions — recomputed once per mount via today's local civil
|
|
||||||
// day. We don't memoize against `accounts` because the dates don't depend
|
|
||||||
// on the row list.
|
|
||||||
const horizons = useMemo<HorizonRange[]>(() => {
|
|
||||||
const today = new Date();
|
|
||||||
const todayISO = localISO(today);
|
|
||||||
const sinceFrom = sinceCreationDate ?? isoDaysAgo(365, today);
|
|
||||||
return [
|
|
||||||
{ key: "3M", from: isoDaysAgo(90, today), to: todayISO },
|
|
||||||
{ key: "1A", from: isoDaysAgo(365, today), to: todayISO },
|
|
||||||
{ key: "since", from: sinceFrom, to: todayISO },
|
|
||||||
];
|
|
||||||
}, [sinceCreationDate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
async function loadReturns() {
|
|
||||||
if (accounts.length === 0) {
|
|
||||||
setReturns({});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setReturnsLoading(true);
|
|
||||||
const next: ReturnsByAccount = {};
|
|
||||||
// Run sequentially per account to avoid SQLite contention; per-horizon
|
|
||||||
// we can parallelize because they hit the same table set.
|
|
||||||
await Promise.all(
|
|
||||||
accounts.map(async (acc) => {
|
|
||||||
next[acc.account_id] = {};
|
|
||||||
const tasks = horizons.map(async (h) => {
|
|
||||||
try {
|
|
||||||
const r = await computeAccountReturn(
|
|
||||||
acc.account_id,
|
|
||||||
h.from,
|
|
||||||
h.to
|
|
||||||
);
|
|
||||||
next[acc.account_id]![h.key] = r;
|
|
||||||
} catch {
|
|
||||||
// Per-cell failure: leave the slot undefined → renders "—".
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await Promise.all(tasks);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (!cancelled) {
|
|
||||||
setReturns(next);
|
|
||||||
setReturnsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void loadReturns();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [accounts, horizons]);
|
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
|
|
||||||
{t("balance.overview.noAccounts")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Format a return percentage with sign + colour-aware classname. */
|
|
||||||
function renderReturnCell(r: AccountReturn | undefined) {
|
|
||||||
if (!r) {
|
|
||||||
return <span className="text-[var(--muted-foreground)]">—</span>;
|
|
||||||
}
|
|
||||||
if (r.return_pct === null) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="text-[var(--muted-foreground)] inline-flex items-center gap-1"
|
|
||||||
title={t("balance.returns.partialTooltip")}
|
|
||||||
>
|
|
||||||
<AlertTriangle size={12} />
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const pct = r.return_pct * 100;
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
pct >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{pct >= 0 ? "+" : ""}
|
|
||||||
{pct.toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
{r.has_no_transfers_warning && (
|
|
||||||
<AlertTriangle
|
|
||||||
size={12}
|
|
||||||
className="text-amber-500"
|
|
||||||
aria-label={t("balance.returns.noTransfersWarning")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unadjusted (simple) return = `(value_end - value_start) / value_start`
|
|
||||||
* — same numbers Modified Dietz already returns when no flows exist, but
|
|
||||||
* this column shows the simple version for ALL accounts as a side-by-side
|
|
||||||
* sanity check. Computed from the same `AccountReturn` payload (uses the
|
|
||||||
* `value_start` / `value_end` fields filled by the Rust side).
|
|
||||||
*/
|
|
||||||
function renderUnadjustedCell(r: AccountReturn | undefined) {
|
|
||||||
if (!r || r.value_start === null || r.value_end === null) {
|
|
||||||
return <span className="text-[var(--muted-foreground)]">—</span>;
|
|
||||||
}
|
|
||||||
if (r.value_start === 0) {
|
|
||||||
return <span className="text-[var(--muted-foreground)]">—</span>;
|
|
||||||
}
|
|
||||||
const simple = ((r.value_end - r.value_start) / r.value_start) * 100;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
simple >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{simple >= 0 ? "+" : ""}
|
|
||||||
{simple.toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-[var(--muted)]/30">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left px-4 py-3 font-medium">
|
|
||||||
{t("balance.account.fields.name")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-3 font-medium">
|
|
||||||
{t("balance.account.fields.category")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium">
|
|
||||||
{t("balance.overview.latestValue")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium">
|
|
||||||
{t("balance.overview.periodDelta")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
|
|
||||||
{t("balance.accountsTable.return3m")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
|
|
||||||
{t("balance.accountsTable.return1y")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
|
|
||||||
{t("balance.accountsTable.sinceCreation")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
|
|
||||||
{t("balance.accountsTable.unadjusted")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-3 font-medium w-12">
|
|
||||||
{t("balance.account.fields.actions")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{accounts.map((acc) => {
|
|
||||||
const anchor = anchorMap.get(acc.account_id);
|
|
||||||
const deltaPct =
|
|
||||||
acc.latest_value !== null && anchor && anchor.anchor_value !== 0
|
|
||||||
? ((acc.latest_value - anchor.anchor_value) /
|
|
||||||
Math.abs(anchor.anchor_value)) *
|
|
||||||
100
|
|
||||||
: null;
|
|
||||||
const accReturns = returns[acc.account_id] ?? {};
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={acc.account_id}
|
|
||||||
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 font-medium">
|
|
||||||
{acc.account_name}
|
|
||||||
{acc.symbol ? (
|
|
||||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
|
||||||
({acc.symbol})
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-[var(--muted-foreground)]">
|
|
||||||
{t(acc.category_i18n_key, { defaultValue: acc.category_key })}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
|
||||||
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
|
||||||
{deltaPct !== null ? (
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
deltaPct >= 0
|
|
||||||
? "text-[var(--positive)]"
|
|
||||||
: "text-[var(--negative)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{deltaPct >= 0 ? "+" : ""}
|
|
||||||
{deltaPct.toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
|
||||||
{returnsLoading && !accReturns["3M"]
|
|
||||||
? "…"
|
|
||||||
: renderReturnCell(accReturns["3M"])}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
|
||||||
{returnsLoading && !accReturns["1A"]
|
|
||||||
? "…"
|
|
||||||
: renderReturnCell(accReturns["1A"])}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
|
||||||
{returnsLoading && !accReturns["since"]
|
|
||||||
? "…"
|
|
||||||
: renderReturnCell(accReturns["since"])}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right tabular-nums">
|
|
||||||
{returnsLoading && !accReturns["1A"]
|
|
||||||
? "…"
|
|
||||||
: renderUnadjustedCell(accReturns["1A"])}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setOpenMenuFor(
|
|
||||||
openMenuFor === acc.account_id ? null : acc.account_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="p-1 rounded hover:bg-[var(--muted)]/40"
|
|
||||||
aria-label={t("balance.account.fields.actions")}
|
|
||||||
>
|
|
||||||
<MoreVertical size={16} />
|
|
||||||
</button>
|
|
||||||
{openMenuFor === acc.account_id && (
|
|
||||||
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
|
|
||||||
title={t("balance.overview.detailComingSoon")}
|
|
||||||
>
|
|
||||||
{t("balance.overview.detailAction")}
|
|
||||||
</button>
|
|
||||||
{onLinkTransfers && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setOpenMenuFor(null);
|
|
||||||
onLinkTransfers(acc);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
|
|
||||||
>
|
|
||||||
<LinkIcon size={14} />
|
|
||||||
{t("balance.transfers.linkAction")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setOpenMenuFor(null);
|
|
||||||
onArchiveAccount?.(acc);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
|
|
||||||
>
|
|
||||||
<Archive size={14} />
|
|
||||||
{t("balance.account.actions.archive")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
// BalanceEvolutionChart — line / stacked-area chart of net worth over time.
|
|
||||||
//
|
|
||||||
// Issue #141 (Bilan #3). Reuses the established Recharts patterns from the
|
|
||||||
// reports/* charts (see decisions-log #141 — native SVG was reconsidered;
|
|
||||||
// Recharts is the single chart pattern in this codebase). Two modes:
|
|
||||||
// - 'line' : a single LineChart of `SUM(value)` per snapshot date.
|
|
||||||
// - 'stacked' : an AreaChart with one Area per category (stackId='all').
|
|
||||||
//
|
|
||||||
// Tooltip shows per-category breakdown in stacked mode and just the total in
|
|
||||||
// line mode.
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
ReferenceLine,
|
|
||||||
} from "recharts";
|
|
||||||
import type {
|
|
||||||
SnapshotTotalPoint,
|
|
||||||
SnapshotCategoryBreakdownPoint,
|
|
||||||
} from "../../services/balance.service";
|
|
||||||
import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
|
|
||||||
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
|
|
||||||
|
|
||||||
// Stable palette for the stacked-by-category areas. Indexed deterministically
|
|
||||||
// by category sort order so the colour assignment stays consistent across
|
|
||||||
// renders and period changes. Reused from the reports CategoryBarChart palette.
|
|
||||||
const CATEGORY_PALETTE = [
|
|
||||||
"#3b82f6", // blue
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#ef4444", // red
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#84cc16", // lime
|
|
||||||
"#f97316", // orange
|
|
||||||
"#6366f1", // indigo
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface BalanceEvolutionChartProps {
|
|
||||||
mode: BalanceChartMode;
|
|
||||||
totals: SnapshotTotalPoint[];
|
|
||||||
byCategory: SnapshotCategoryBreakdownPoint[];
|
|
||||||
/** Map category_key → translated label so the legend reads naturally. */
|
|
||||||
categoryLabels?: Record<string, string>;
|
|
||||||
/**
|
|
||||||
* Issue #142 — every linked transfer in the visible range. Rendered as
|
|
||||||
* vertical `<ReferenceLine>` markers on the X axis: green for `in`
|
|
||||||
* (capital added), red for `out` (capital removed). The label tooltip
|
|
||||||
* shows the underlying transaction date + description.
|
|
||||||
*/
|
|
||||||
transferMarkers?: BalanceAccountTransferWithTransaction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BalanceEvolutionChart({
|
|
||||||
mode,
|
|
||||||
totals,
|
|
||||||
byCategory,
|
|
||||||
categoryLabels = {},
|
|
||||||
transferMarkers = [],
|
|
||||||
}: BalanceEvolutionChartProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
|
|
||||||
const cadFormatter = useMemo(
|
|
||||||
() =>
|
|
||||||
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "CAD",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}),
|
|
||||||
[i18n.language]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
|
||||||
const formatDate = (iso: string) =>
|
|
||||||
new Date(iso).toLocaleDateString(dateLocale, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Line-mode dataset ---
|
|
||||||
const lineData = useMemo(
|
|
||||||
() =>
|
|
||||||
totals.map((p) => ({
|
|
||||||
snapshot_date: p.snapshot_date,
|
|
||||||
total: p.total,
|
|
||||||
})),
|
|
||||||
[totals]
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Stacked-area dataset ---
|
|
||||||
// We transpose the per-snapshot bucket into one row per snapshot_date with
|
|
||||||
// one column per category_key. Categories absent at a snapshot date are
|
|
||||||
// emitted as 0 so Recharts renders a continuous stack.
|
|
||||||
const { stackedData, categoryKeys } = useMemo(() => {
|
|
||||||
const keys = new Set<string>();
|
|
||||||
for (const point of byCategory) {
|
|
||||||
for (const k of Object.keys(point.byCategory)) keys.add(k);
|
|
||||||
}
|
|
||||||
const orderedKeys = Array.from(keys).sort();
|
|
||||||
const data = byCategory.map((point) => {
|
|
||||||
const row: Record<string, string | number> = {
|
|
||||||
snapshot_date: point.snapshot_date,
|
|
||||||
};
|
|
||||||
for (const k of orderedKeys) {
|
|
||||||
row[k] = point.byCategory[k] ?? 0;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
return { stackedData: data, categoryKeys: orderedKeys };
|
|
||||||
}, [byCategory]);
|
|
||||||
|
|
||||||
const isEmpty =
|
|
||||||
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
|
|
||||||
|
|
||||||
// Filter transfer markers to dates that are actually rendered on the X
|
|
||||||
// axis (categorical scale ignores unknown ticks). We don't aggregate or
|
|
||||||
// dedupe — the user can have several transfers on the same day across
|
|
||||||
// accounts; ReferenceLine tolerates duplicates fine.
|
|
||||||
const xAxisDates = useMemo(() => {
|
|
||||||
const dates = new Set<string>();
|
|
||||||
if (mode === "line") {
|
|
||||||
for (const p of lineData) dates.add(p.snapshot_date);
|
|
||||||
} else {
|
|
||||||
for (const p of stackedData) dates.add(p.snapshot_date as string);
|
|
||||||
}
|
|
||||||
return dates;
|
|
||||||
}, [mode, lineData, stackedData]);
|
|
||||||
|
|
||||||
const renderableMarkers = useMemo(
|
|
||||||
() =>
|
|
||||||
transferMarkers
|
|
||||||
.filter((m) => xAxisDates.has(m.transaction_date))
|
|
||||||
// Sort so 'in' (green) draws before 'out' (red) for stable z-order.
|
|
||||||
.sort((a, b) =>
|
|
||||||
a.direction === b.direction ? 0 : a.direction === "in" ? -1 : 1
|
|
||||||
),
|
|
||||||
[transferMarkers, xAxisDates]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isEmpty) {
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
|
|
||||||
<p className="text-center text-[var(--muted-foreground)] italic py-12">
|
|
||||||
{t("balance.chart.empty")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tooltipContentStyle = {
|
|
||||||
backgroundColor: "var(--card)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "0.5rem",
|
|
||||||
color: "var(--foreground)",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
|
||||||
<ResponsiveContainer width="100%" height={360}>
|
|
||||||
{mode === "line" ? (
|
|
||||||
<LineChart
|
|
||||||
data={lineData}
|
|
||||||
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="snapshot_date"
|
|
||||||
stroke="var(--muted-foreground)"
|
|
||||||
fontSize={11}
|
|
||||||
tickFormatter={(s: string) => formatDate(s)}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="var(--muted-foreground)"
|
|
||||||
fontSize={11}
|
|
||||||
tickFormatter={(v: number) => cadFormatter.format(v)}
|
|
||||||
width={88}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number | undefined) =>
|
|
||||||
cadFormatter.format(value ?? 0)
|
|
||||||
}
|
|
||||||
labelFormatter={(label) => formatDate(String(label))}
|
|
||||||
contentStyle={tooltipContentStyle}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="total"
|
|
||||||
name={t("balance.chart.totalSeriesLabel")}
|
|
||||||
stroke="var(--primary)"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 3 }}
|
|
||||||
activeDot={{ r: 5 }}
|
|
||||||
/>
|
|
||||||
{renderableMarkers.map((m) => (
|
|
||||||
<ReferenceLine
|
|
||||||
key={`tm-${m.id}`}
|
|
||||||
x={m.transaction_date}
|
|
||||||
stroke={
|
|
||||||
m.direction === "in" ? "var(--positive)" : "var(--negative)"
|
|
||||||
}
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
strokeWidth={1}
|
|
||||||
ifOverflow="extendDomain"
|
|
||||||
label={{
|
|
||||||
value: t(
|
|
||||||
m.direction === "in"
|
|
||||||
? "balance.evolution.transferIn"
|
|
||||||
: "balance.evolution.transferOut"
|
|
||||||
),
|
|
||||||
position: "insideTopRight",
|
|
||||||
fontSize: 9,
|
|
||||||
fill: m.direction === "in" ? "var(--positive)" : "var(--negative)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
) : (
|
|
||||||
<AreaChart
|
|
||||||
data={stackedData}
|
|
||||||
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="snapshot_date"
|
|
||||||
stroke="var(--muted-foreground)"
|
|
||||||
fontSize={11}
|
|
||||||
tickFormatter={(s: string) => formatDate(s)}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
stroke="var(--muted-foreground)"
|
|
||||||
fontSize={11}
|
|
||||||
tickFormatter={(v: number) => cadFormatter.format(v)}
|
|
||||||
width={88}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number | undefined, name) => [
|
|
||||||
cadFormatter.format(value ?? 0),
|
|
||||||
categoryLabels[String(name)] ?? String(name),
|
|
||||||
]}
|
|
||||||
labelFormatter={(label) => formatDate(String(label))}
|
|
||||||
contentStyle={tooltipContentStyle}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
formatter={(value) => categoryLabels[String(value)] ?? String(value)}
|
|
||||||
/>
|
|
||||||
{categoryKeys.map((key, idx) => (
|
|
||||||
<Area
|
|
||||||
key={key}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={key}
|
|
||||||
stackId="all"
|
|
||||||
stroke={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
|
|
||||||
fill={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
|
|
||||||
fillOpacity={0.5}
|
|
||||||
name={key}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{renderableMarkers.map((m) => (
|
|
||||||
<ReferenceLine
|
|
||||||
key={`tm-${m.id}`}
|
|
||||||
x={m.transaction_date}
|
|
||||||
stroke={
|
|
||||||
m.direction === "in" ? "var(--positive)" : "var(--negative)"
|
|
||||||
}
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
strokeWidth={1}
|
|
||||||
ifOverflow="extendDomain"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
)}
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
// BalanceOverviewCard — top summary tile of /balance.
|
|
||||||
//
|
|
||||||
// Issue #141 (Bilan #3). Displays:
|
|
||||||
// - The latest aggregate snapshot total (sum across all accounts on the
|
|
||||||
// most recent snapshot date).
|
|
||||||
// - Δ% versus the previous chronological snapshot (null when only one
|
|
||||||
// snapshot exists; rendered as "—").
|
|
||||||
// - A staleness warning when the latest snapshot is older than 60 days.
|
|
||||||
// - "+ Nouveau snapshot" CTA → `/balance/snapshot`.
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import type { SnapshotTotalPoint } from "../../services/balance.service";
|
|
||||||
|
|
||||||
const STALENESS_DAYS = 60;
|
|
||||||
const cadFormatter = (value: number) =>
|
|
||||||
new Intl.NumberFormat("en-CA", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "CAD",
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(value);
|
|
||||||
|
|
||||||
interface BalanceOverviewCardProps {
|
|
||||||
/** The full evolution series for the active period (latest at the end). */
|
|
||||||
totals: SnapshotTotalPoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
|
||||||
if (totals.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const last = totals[totals.length - 1];
|
|
||||||
const prev = totals.length >= 2 ? totals[totals.length - 2] : null;
|
|
||||||
const deltaPct =
|
|
||||||
prev && prev.total !== 0
|
|
||||||
? ((last.total - prev.total) / Math.abs(prev.total)) * 100
|
|
||||||
: null;
|
|
||||||
const ageMs = Date.now() - new Date(last.snapshot_date).getTime();
|
|
||||||
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
|
|
||||||
return {
|
|
||||||
latest: last,
|
|
||||||
deltaPct,
|
|
||||||
isStale: ageDays > STALENESS_DAYS,
|
|
||||||
ageDays,
|
|
||||||
};
|
|
||||||
}, [totals]);
|
|
||||||
|
|
||||||
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
|
||||||
const formatDate = (iso: string) =>
|
|
||||||
new Date(iso).toLocaleDateString(dateLocale, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.overview.latestTotal")}
|
|
||||||
</p>
|
|
||||||
{summary ? (
|
|
||||||
<>
|
|
||||||
<p className="text-3xl font-bold mt-1">
|
|
||||||
{cadFormatter(summary.latest.total)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
|
||||||
{t("balance.overview.asOf", {
|
|
||||||
date: formatDate(summary.latest.snapshot_date),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mt-2">
|
|
||||||
{t("balance.overview.noSnapshots")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch sm:items-end gap-2">
|
|
||||||
{summary && summary.deltaPct !== null && (
|
|
||||||
<div
|
|
||||||
className={`inline-flex items-center gap-1 text-sm font-medium ${
|
|
||||||
summary.deltaPct >= 0
|
|
||||||
? "text-[var(--positive)]"
|
|
||||||
: "text-[var(--negative)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{summary.deltaPct >= 0 ? (
|
|
||||||
<TrendingUp size={16} />
|
|
||||||
) : (
|
|
||||||
<TrendingDown size={16} />
|
|
||||||
)}
|
|
||||||
{summary.deltaPct >= 0 ? "+" : ""}
|
|
||||||
{summary.deltaPct.toFixed(2)}%
|
|
||||||
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
|
|
||||||
{t("balance.overview.vsPrevious")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/balance/snapshot"
|
|
||||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
{t("balance.overview.newSnapshot")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{summary?.isStale && (
|
|
||||||
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/30 text-sm">
|
|
||||||
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
|
||||||
<span>
|
|
||||||
{t("balance.overview.staleWarning", { days: summary.ageDays })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,410 +0,0 @@
|
||||||
// LinkTransfersModal — multi-select transactions and link them to a balance
|
|
||||||
// account in one shot. Issue #142 / Bilan #4.
|
|
||||||
//
|
|
||||||
// Filters available:
|
|
||||||
// - Period (from / to ISO dates) — default: last 90 days.
|
|
||||||
// - Category dropdown.
|
|
||||||
// - Free-text search on description.
|
|
||||||
//
|
|
||||||
// Each row shows: date, description, amount, suggested direction
|
|
||||||
// (auto-proposed via `suggestTransferDirection` from the signed amount,
|
|
||||||
// can be flipped per row), and a checkbox.
|
|
||||||
//
|
|
||||||
// On submit, calls `linkTransfer` for every selected row in sequence and
|
|
||||||
// reports any failures (most likely `transfer_already_linked` if the user
|
|
||||||
// double-clicked or another tab linked them already).
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { X, Loader2, AlertCircle } from "lucide-react";
|
|
||||||
import { getTransactionPage } from "../../services/transactionService";
|
|
||||||
import {
|
|
||||||
linkTransfer,
|
|
||||||
suggestTransferDirection,
|
|
||||||
BalanceServiceError,
|
|
||||||
} from "../../services/balance.service";
|
|
||||||
import type {
|
|
||||||
Category,
|
|
||||||
TransactionRow,
|
|
||||||
BalanceTransferDirection,
|
|
||||||
} from "../../shared/types";
|
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 100;
|
|
||||||
|
|
||||||
function isoDaysAgo(days: number): string {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - days);
|
|
||||||
return localISO(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
function localISO(d: Date): string {
|
|
||||||
const yy = d.getFullYear();
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${yy}-${mm}-${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LinkTransfersModalProps {
|
|
||||||
/** Account that the selected transfers will be attached to. */
|
|
||||||
accountId: number;
|
|
||||||
accountName: string;
|
|
||||||
/** Full category list for the filter dropdown. */
|
|
||||||
categories: Category[];
|
|
||||||
/** Optional pre-fill date bounds (defaults to last 90 days). */
|
|
||||||
initialFrom?: string;
|
|
||||||
initialTo?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
/** Fired after at least one transfer was linked (parent typically reloads). */
|
|
||||||
onLinked?: (linkedCount: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LinkTransfersModal({
|
|
||||||
accountId,
|
|
||||||
accountName,
|
|
||||||
categories,
|
|
||||||
initialFrom,
|
|
||||||
initialTo,
|
|
||||||
onClose,
|
|
||||||
onLinked,
|
|
||||||
}: LinkTransfersModalProps) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
|
|
||||||
const [from, setFrom] = useState(initialFrom ?? isoDaysAgo(90));
|
|
||||||
const [to, setTo] = useState(initialTo ?? localISO(new Date()));
|
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const [rows, setRows] = useState<TransactionRow[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Selection state: id → direction. Presence in the map = selected.
|
|
||||||
const [selection, setSelection] = useState<
|
|
||||||
Map<number, BalanceTransferDirection>
|
|
||||||
>(new Map());
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fmt = useMemo(
|
|
||||||
() =>
|
|
||||||
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "CAD",
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}),
|
|
||||||
[i18n.language]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-fetch whenever the filters change. Debounced via React's render cycle
|
|
||||||
// — typing in the search box re-runs the SQL but at < 500 rows that's fine.
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
async function run() {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await getTransactionPage(
|
|
||||||
{
|
|
||||||
search: search.trim(),
|
|
||||||
categoryId,
|
|
||||||
sourceId: null,
|
|
||||||
dateFrom: from || null,
|
|
||||||
dateTo: to || null,
|
|
||||||
uncategorizedOnly: false,
|
|
||||||
},
|
|
||||||
{ column: "date", direction: "desc" },
|
|
||||||
1,
|
|
||||||
DEFAULT_PAGE_SIZE
|
|
||||||
);
|
|
||||||
if (!cancelled) {
|
|
||||||
setRows(result.rows);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void run();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [from, to, categoryId, search]);
|
|
||||||
|
|
||||||
function toggleRow(row: TransactionRow) {
|
|
||||||
setSelection((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
if (next.has(row.id)) {
|
|
||||||
next.delete(row.id);
|
|
||||||
} else {
|
|
||||||
next.set(row.id, suggestTransferDirection(row.amount));
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function flipDirection(rowId: number) {
|
|
||||||
setSelection((prev) => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
const current = next.get(rowId);
|
|
||||||
if (current === undefined) return prev;
|
|
||||||
next.set(rowId, current === "in" ? "out" : "in");
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (selection.size === 0) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
setSubmitError(null);
|
|
||||||
let linked = 0;
|
|
||||||
const failures: string[] = [];
|
|
||||||
for (const [transactionId, direction] of selection.entries()) {
|
|
||||||
try {
|
|
||||||
await linkTransfer(accountId, transactionId, direction);
|
|
||||||
linked += 1;
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof BalanceServiceError) {
|
|
||||||
failures.push(`${transactionId}: ${t(`balance.transfers.errors.${e.code}`, { defaultValue: e.message })}`);
|
|
||||||
} else {
|
|
||||||
failures.push(`${transactionId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSubmitting(false);
|
|
||||||
if (failures.length > 0) {
|
|
||||||
setSubmitError(
|
|
||||||
`${t("balance.transfers.modal.partialFailure", { linked, total: selection.size })} — ${failures.join("; ")}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (linked > 0) {
|
|
||||||
onLinked?.(linked);
|
|
||||||
if (failures.length === 0) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allFiltered = rows.length;
|
|
||||||
const selectedCount = selection.size;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{t("balance.transfers.modal.title", { account: accountName })}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
|
|
||||||
{t("balance.transfers.modal.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 rounded hover:bg-[var(--muted)]/40"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-5 py-3 border-b border-[var(--border)] grid grid-cols-1 md:grid-cols-4 gap-3">
|
|
||||||
<label className="text-xs">
|
|
||||||
<span className="block text-[var(--muted-foreground)] mb-1">
|
|
||||||
{t("balance.transfers.modal.from")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={from}
|
|
||||||
onChange={(e) => setFrom(e.target.value)}
|
|
||||||
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-xs">
|
|
||||||
<span className="block text-[var(--muted-foreground)] mb-1">
|
|
||||||
{t("balance.transfers.modal.to")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={to}
|
|
||||||
onChange={(e) => setTo(e.target.value)}
|
|
||||||
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="text-xs">
|
|
||||||
<span className="block text-[var(--muted-foreground)] mb-1">
|
|
||||||
{t("balance.transfers.modal.category")}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={categoryId === null ? "" : String(categoryId)}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
|
|
||||||
}
|
|
||||||
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
|
||||||
>
|
|
||||||
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
|
|
||||||
{categories.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="text-xs">
|
|
||||||
<span className="block text-[var(--muted-foreground)] mb-1">
|
|
||||||
{t("balance.transfers.modal.search")}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder={t("balance.transfers.modal.searchPlaceholder")}
|
|
||||||
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-8 text-center text-[var(--muted-foreground)] flex items-center justify-center gap-2">
|
|
||||||
<Loader2 className="animate-spin" size={16} />
|
|
||||||
{t("balance.transfers.modal.loading")}
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="p-8 text-center text-[var(--negative)] flex items-center justify-center gap-2">
|
|
||||||
<AlertCircle size={16} />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<div className="p-8 text-center text-[var(--muted-foreground)] italic">
|
|
||||||
{t("balance.transfers.modal.noTransactions")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-[var(--muted)]/30 sticky top-0">
|
|
||||||
<tr>
|
|
||||||
<th className="w-10 px-3 py-2"></th>
|
|
||||||
<th className="text-left px-3 py-2 font-medium">
|
|
||||||
{t("transactions.date")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-3 py-2 font-medium">
|
|
||||||
{t("transactions.description")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-3 py-2 font-medium">
|
|
||||||
{t("transactions.amount")}
|
|
||||||
</th>
|
|
||||||
<th className="text-center px-3 py-2 font-medium">
|
|
||||||
{t("balance.transfers.modal.direction")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((row) => {
|
|
||||||
const isSelected = selection.has(row.id);
|
|
||||||
const direction = selection.get(row.id) ?? suggestTransferDirection(row.amount);
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => toggleRow(row)}
|
|
||||||
aria-label={`select-${row.id}`}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
|
||||||
<td className="px-3 py-2 max-w-md truncate" title={row.description}>
|
|
||||||
{row.description}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className={`px-3 py-2 text-right font-mono ${row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}
|
|
||||||
>
|
|
||||||
{fmt.format(row.amount)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center">
|
|
||||||
{isSelected ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => flipDirection(row.id)}
|
|
||||||
className={`px-2 py-0.5 text-xs rounded font-medium ${
|
|
||||||
direction === "in"
|
|
||||||
? "bg-[var(--positive)]/15 text-[var(--positive)]"
|
|
||||||
: "bg-[var(--negative)]/15 text-[var(--negative)]"
|
|
||||||
}`}
|
|
||||||
title={t("balance.transfers.modal.toggleDirection")}
|
|
||||||
>
|
|
||||||
{t(`balance.transfers.direction.${direction}`)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t(`balance.transfers.direction.${direction}`)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{submitError && (
|
|
||||||
<div className="px-5 py-2 border-t border-[var(--border)] text-xs text-[var(--negative)]">
|
|
||||||
{submitError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-between">
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.transfers.modal.summary", {
|
|
||||||
selected: selectedCount,
|
|
||||||
total: allFiltered,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] hover:bg-[var(--muted)]/30"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={submitting || selectedCount === 0}
|
|
||||||
className="px-3 py-1.5 text-sm rounded bg-[var(--primary)] text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Loader2 className="animate-spin" size={14} />
|
|
||||||
{t("balance.transfers.modal.linking")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t("balance.transfers.modal.linkSelection", { count: selectedCount })
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
// SnapshotEditor — groups the active accounts by balance category and
|
|
||||||
// renders one `SnapshotLineRow` per account.
|
|
||||||
//
|
|
||||||
// Both `simple` and `priced` variants are dispatched by `account.category_kind`
|
|
||||||
// inside `SnapshotLineRow`. The editor itself only carries the values down
|
|
||||||
// and the change handlers up.
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type {
|
|
||||||
BalanceAccountWithCategory,
|
|
||||||
BalanceCategory,
|
|
||||||
} from "../../shared/types";
|
|
||||||
import type { PricedEntry } from "../../hooks/useSnapshotEditor";
|
|
||||||
import SnapshotLineRow from "./SnapshotLineRow";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
accounts: BalanceAccountWithCategory[];
|
|
||||||
categories: BalanceCategory[];
|
|
||||||
/** account_id → string-typed value (simple kind). */
|
|
||||||
values: Record<number, string>;
|
|
||||||
/** account_id → {quantity, unit_price} strings (priced kind). */
|
|
||||||
pricedValues: Record<number, PricedEntry>;
|
|
||||||
onValueChange: (accountId: number, next: string) => void;
|
|
||||||
onQuantityChange: (accountId: number, next: string) => void;
|
|
||||||
onUnitPriceChange: (accountId: number, next: string) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SnapshotEditor({
|
|
||||||
accounts,
|
|
||||||
categories,
|
|
||||||
values,
|
|
||||||
pricedValues,
|
|
||||||
onValueChange,
|
|
||||||
onQuantityChange,
|
|
||||||
onUnitPriceChange,
|
|
||||||
disabled,
|
|
||||||
}: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Group accounts by their category, preserving the categories' sort_order
|
|
||||||
// first then the account name within each group.
|
|
||||||
const groups = useMemo(() => {
|
|
||||||
const byCategory = new Map<number, BalanceAccountWithCategory[]>();
|
|
||||||
for (const acc of accounts) {
|
|
||||||
const list = byCategory.get(acc.balance_category_id) ?? [];
|
|
||||||
list.push(acc);
|
|
||||||
byCategory.set(acc.balance_category_id, list);
|
|
||||||
}
|
|
||||||
const sortedCategories = [...categories].sort(
|
|
||||||
(a, b) => a.sort_order - b.sort_order || a.key.localeCompare(b.key)
|
|
||||||
);
|
|
||||||
return sortedCategories
|
|
||||||
.map((cat) => ({
|
|
||||||
category: cat,
|
|
||||||
accounts: (byCategory.get(cat.id) ?? []).sort((a, b) =>
|
|
||||||
a.name.localeCompare(b.name)
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.filter((group) => group.accounts.length > 0);
|
|
||||||
}, [accounts, categories]);
|
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.snapshot.editor.empty")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{groups.map(({ category, accounts: catAccounts }) => (
|
|
||||||
<div
|
|
||||||
key={category.id}
|
|
||||||
className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
|
|
||||||
<h3 className="text-sm font-semibold">
|
|
||||||
{t(category.i18n_key, { defaultValue: category.key })}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="px-4">
|
|
||||||
{catAccounts.map((acc) => {
|
|
||||||
const priced = pricedValues[acc.id];
|
|
||||||
return (
|
|
||||||
<SnapshotLineRow
|
|
||||||
key={acc.id}
|
|
||||||
account={acc}
|
|
||||||
value={values[acc.id] ?? ""}
|
|
||||||
quantityValue={priced?.quantity ?? ""}
|
|
||||||
unitPriceValue={priced?.unit_price ?? ""}
|
|
||||||
onChange={(next) => onValueChange(acc.id, next)}
|
|
||||||
onQuantityChange={(next) => onQuantityChange(acc.id, next)}
|
|
||||||
onUnitPriceChange={(next) => onUnitPriceChange(acc.id, next)}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
// SnapshotLineRow — single account line inside the snapshot editor.
|
|
||||||
//
|
|
||||||
// Two variants are dispatched by `account.category_kind`:
|
|
||||||
//
|
|
||||||
// - `simple` (Issue #146): a single value input keyed by `account_id`.
|
|
||||||
// - `priced` (Issue #140): three inputs — `quantity`, `unit_price` (both
|
|
||||||
// required), and a read-only `value` field that
|
|
||||||
// renders `quantity * unit_price` live as the
|
|
||||||
// user types. An attribution tag `[Manuel]`
|
|
||||||
// appears next to the row; the `[via Maximus]`
|
|
||||||
// tag will land with Issue #143 (price-fetching).
|
|
||||||
//
|
|
||||||
// We keep this component dumb on purpose: it receives strings from the
|
|
||||||
// parent (the editor stores raw strings to preserve partial input) and
|
|
||||||
// emits new strings on every change. Numeric validation happens at save
|
|
||||||
// time in `useSnapshotEditor.save` against the service's
|
|
||||||
// `validateLineKindInvariants` helper.
|
|
||||||
|
|
||||||
import { ChangeEvent, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { BalanceAccountWithCategory } from "../../shared/types";
|
|
||||||
|
|
||||||
interface BaseProps {
|
|
||||||
account: BalanceAccountWithCategory;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SimpleProps extends BaseProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (next: string) => void;
|
|
||||||
/** Optional priced handlers for callers that wire both at once. */
|
|
||||||
quantityValue?: string;
|
|
||||||
unitPriceValue?: string;
|
|
||||||
onQuantityChange?: (next: string) => void;
|
|
||||||
onUnitPriceChange?: (next: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = SimpleProps;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a string like "12.34" or "12,34" into a finite number, or null
|
|
||||||
* if invalid / empty. Used by the priced variant to compute the live
|
|
||||||
* `value` preview.
|
|
||||||
*/
|
|
||||||
function parseDecimal(raw: string): number | null {
|
|
||||||
if (!raw) return null;
|
|
||||||
const trimmed = String(raw).trim().replace(",", ".");
|
|
||||||
if (!trimmed) return null;
|
|
||||||
const n = Number(trimmed);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SnapshotLineRow({
|
|
||||||
account,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
disabled,
|
|
||||||
quantityValue,
|
|
||||||
unitPriceValue,
|
|
||||||
onQuantityChange,
|
|
||||||
onUnitPriceChange,
|
|
||||||
}: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isPriced = account.category_kind === "priced";
|
|
||||||
|
|
||||||
// Compute the live value preview for priced rows. Returns null when
|
|
||||||
// either input cannot yet be parsed (so we display a placeholder).
|
|
||||||
const computedPricedValue = useMemo(() => {
|
|
||||||
if (!isPriced) return null;
|
|
||||||
const qty = parseDecimal(quantityValue ?? "");
|
|
||||||
const price = parseDecimal(unitPriceValue ?? "");
|
|
||||||
if (qty === null || price === null) return null;
|
|
||||||
return qty * price;
|
|
||||||
}, [isPriced, quantityValue, unitPriceValue]);
|
|
||||||
|
|
||||||
if (isPriced) {
|
|
||||||
const handleQty = (e: ChangeEvent<HTMLInputElement>) =>
|
|
||||||
onQuantityChange?.(e.target.value);
|
|
||||||
const handlePrice = (e: ChangeEvent<HTMLInputElement>) =>
|
|
||||||
onUnitPriceChange?.(e.target.value);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium truncate">{account.name}</span>
|
|
||||||
<span
|
|
||||||
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)]"
|
|
||||||
title={t("balance.snapshot.priced.attributionManualHint")}
|
|
||||||
>
|
|
||||||
{t("balance.snapshot.priced.attributionManual")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{account.symbol && (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{account.symbol}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={quantityValue ?? ""}
|
|
||||||
onChange={handleQty}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
|
|
||||||
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
|
||||||
aria-label={t("balance.snapshot.priced.quantityLabel", {
|
|
||||||
account: account.name,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
|
||||||
{t("balance.snapshot.priced.quantity")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={unitPriceValue ?? ""}
|
|
||||||
onChange={handlePrice}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
|
|
||||||
className="w-28 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
|
||||||
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
|
|
||||||
account: account.name,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
|
||||||
{t("balance.snapshot.priced.unitPrice")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
|
||||||
=
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={
|
|
||||||
computedPricedValue === null
|
|
||||||
? ""
|
|
||||||
: computedPricedValue.toFixed(2)
|
|
||||||
}
|
|
||||||
readOnly
|
|
||||||
disabled
|
|
||||||
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
|
||||||
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] focus:outline-none cursor-not-allowed"
|
|
||||||
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
|
||||||
account: account.name,
|
|
||||||
})}
|
|
||||||
aria-readonly="true"
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
|
||||||
{t("balance.snapshot.priced.computedValue")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
|
||||||
{account.currency}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple variant — unchanged from #146.
|
|
||||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium truncate">{account.name}</div>
|
|
||||||
{account.symbol && (
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{account.symbol}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="decimal"
|
|
||||||
value={value}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={t("balance.snapshot.line.valuePlaceholder")}
|
|
||||||
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
|
||||||
aria-label={t("balance.snapshot.line.valueLabel", {
|
|
||||||
account: account.name,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
|
||||||
{account.currency}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Wallet,
|
|
||||||
Settings,
|
Settings,
|
||||||
Languages,
|
Languages,
|
||||||
Moon,
|
Moon,
|
||||||
|
|
@ -26,7 +25,6 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Wallet,
|
|
||||||
Settings,
|
Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
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, Link2 } from "lucide-react";
|
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } 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";
|
||||||
|
|
||||||
|
|
@ -23,14 +22,6 @@ 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({
|
||||||
|
|
@ -61,7 +52,6 @@ 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);
|
||||||
|
|
@ -151,31 +141,8 @@ 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">
|
<td className="px-3 py-2 max-w-xs truncate" title={row.description}>
|
||||||
<div className="flex items-center gap-1.5">
|
{row.description}
|
||||||
<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 ${
|
||||||
|
|
|
||||||
|
|
@ -1,276 +0,0 @@
|
||||||
// useBalanceAccounts — scoped useReducer hook backing AccountsPage.
|
|
||||||
//
|
|
||||||
// Domain coverage (per spec-plan-bilan.md v2): the AccountsPage CRUD over
|
|
||||||
// `balance_accounts` AND `balance_categories`. Snapshots, lines, transfers,
|
|
||||||
// and returns are out of scope here — they belong to `useSnapshotEditor`
|
|
||||||
// (Issue #146 / Bilan #1b) and `useBalanceOverview` (Issue #141 / Bilan #3).
|
|
||||||
|
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
|
||||||
import type {
|
|
||||||
BalanceAccountWithCategory,
|
|
||||||
BalanceCategory,
|
|
||||||
BalanceCategoryKind,
|
|
||||||
} from "../shared/types";
|
|
||||||
import {
|
|
||||||
listBalanceAccounts,
|
|
||||||
listBalanceCategories,
|
|
||||||
createBalanceAccount,
|
|
||||||
updateBalanceAccount,
|
|
||||||
archiveBalanceAccount,
|
|
||||||
unarchiveBalanceAccount,
|
|
||||||
createBalanceCategory,
|
|
||||||
updateBalanceCategory,
|
|
||||||
deleteBalanceCategory,
|
|
||||||
BalanceServiceError,
|
|
||||||
type CreateBalanceAccountInput,
|
|
||||||
type CreateBalanceCategoryInput,
|
|
||||||
type UpdateBalanceAccountInput,
|
|
||||||
type UpdateBalanceCategoryInput,
|
|
||||||
} from "../services/balance.service";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
accounts: BalanceAccountWithCategory[];
|
|
||||||
categories: BalanceCategory[];
|
|
||||||
includeArchived: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
error: string | null;
|
|
||||||
/** Stable error code for UIs that want to localize via i18n (e.g. seed protection). */
|
|
||||||
errorCode: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
|
||||||
| { type: "SET_SAVING"; payload: boolean }
|
|
||||||
| { type: "SET_ERROR"; payload: { message: string | null; code: string | null } }
|
|
||||||
| {
|
|
||||||
type: "SET_DATA";
|
|
||||||
payload: {
|
|
||||||
accounts: BalanceAccountWithCategory[];
|
|
||||||
categories: BalanceCategory[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| { type: "SET_INCLUDE_ARCHIVED"; payload: boolean };
|
|
||||||
|
|
||||||
function initialState(): State {
|
|
||||||
return {
|
|
||||||
accounts: [],
|
|
||||||
categories: [],
|
|
||||||
includeArchived: false,
|
|
||||||
isLoading: false,
|
|
||||||
isSaving: false,
|
|
||||||
error: null,
|
|
||||||
errorCode: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
|
||||||
switch (action.type) {
|
|
||||||
case "SET_LOADING":
|
|
||||||
return { ...state, isLoading: action.payload };
|
|
||||||
case "SET_SAVING":
|
|
||||||
return { ...state, isSaving: action.payload };
|
|
||||||
case "SET_ERROR":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
error: action.payload.message,
|
|
||||||
errorCode: action.payload.code,
|
|
||||||
isLoading: false,
|
|
||||||
isSaving: false,
|
|
||||||
};
|
|
||||||
case "SET_DATA":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
accounts: action.payload.accounts,
|
|
||||||
categories: action.payload.categories,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
errorCode: null,
|
|
||||||
};
|
|
||||||
case "SET_INCLUDE_ARCHIVED":
|
|
||||||
return { ...state, includeArchived: action.payload };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeError(e: unknown): { message: string; code: string | null } {
|
|
||||||
if (e instanceof BalanceServiceError) {
|
|
||||||
return { message: e.message, code: e.code };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
message: e instanceof Error ? e.message : String(e),
|
|
||||||
code: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBalanceAccounts() {
|
|
||||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
|
||||||
const fetchIdRef = useRef(0);
|
|
||||||
|
|
||||||
const refreshData = useCallback(async (includeArchived: boolean) => {
|
|
||||||
const fetchId = ++fetchIdRef.current;
|
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
|
||||||
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
|
||||||
try {
|
|
||||||
const [accounts, categories] = await Promise.all([
|
|
||||||
listBalanceAccounts({ includeArchived }),
|
|
||||||
listBalanceCategories(),
|
|
||||||
]);
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
|
||||||
dispatch({ type: "SET_DATA", payload: { accounts, categories } });
|
|
||||||
} catch (e) {
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshData(state.includeArchived);
|
|
||||||
}, [state.includeArchived, refreshData]);
|
|
||||||
|
|
||||||
const setIncludeArchived = useCallback((next: boolean) => {
|
|
||||||
dispatch({ type: "SET_INCLUDE_ARCHIVED", payload: next });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Account mutations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const addAccount = useCallback(
|
|
||||||
async (input: CreateBalanceAccountInput) => {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
try {
|
|
||||||
await createBalanceAccount(input);
|
|
||||||
await refreshData(state.includeArchived);
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state.includeArchived, refreshData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editAccount = useCallback(
|
|
||||||
async (id: number, input: UpdateBalanceAccountInput) => {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
try {
|
|
||||||
await updateBalanceAccount(id, input);
|
|
||||||
await refreshData(state.includeArchived);
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state.includeArchived, refreshData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const archiveAccount = useCallback(
|
|
||||||
async (id: number) => {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
try {
|
|
||||||
await archiveBalanceAccount(id);
|
|
||||||
await refreshData(state.includeArchived);
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state.includeArchived, refreshData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const unarchiveAccount = useCallback(
|
|
||||||
async (id: number) => {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
try {
|
|
||||||
await unarchiveBalanceAccount(id);
|
|
||||||
await refreshData(state.includeArchived);
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state.includeArchived, refreshData]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Category mutations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Issue #138 keeps the AccountsPage Categories tab to user-created
|
|
||||||
* `simple` kind only. The priced creation UI lands in #140 — until then,
|
|
||||||
* callers should pass kind = 'simple'.
|
|
||||||
*/
|
|
||||||
const addCategory = useCallback(
|
|
||||||
async (input: CreateBalanceCategoryInput) => {
|
|
||||||
const kind: BalanceCategoryKind = input.kind ?? "simple";
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
try {
|
|
||||||
await createBalanceCategory({ ...input, kind });
|
|
||||||
await refreshData(state.includeArchived);
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state.includeArchived, refreshData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editCategory = useCallback(
|
|
||||||
async (id: number, input: UpdateBalanceCategoryInput) => {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
try {
|
|
||||||
await updateBalanceCategory(id, input);
|
|
||||||
await refreshData(state.includeArchived);
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state.includeArchived, refreshData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeCategory = useCallback(
|
|
||||||
async (id: number) => {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
try {
|
|
||||||
await deleteBalanceCategory(id);
|
|
||||||
await refreshData(state.includeArchived);
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state.includeArchived, refreshData]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
setIncludeArchived,
|
|
||||||
refresh: () => refreshData(state.includeArchived),
|
|
||||||
// Account ops
|
|
||||||
addAccount,
|
|
||||||
editAccount,
|
|
||||||
archiveAccount,
|
|
||||||
unarchiveAccount,
|
|
||||||
// Category ops
|
|
||||||
addCategory,
|
|
||||||
editCategory,
|
|
||||||
removeCategory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { computeBalanceDateRange } from "./useBalanceOverview";
|
|
||||||
|
|
||||||
const FIXED_TODAY = new Date(2026, 3, 25); // local 2026-04-25
|
|
||||||
|
|
||||||
describe("computeBalanceDateRange", () => {
|
|
||||||
it("returns an empty range for 'all'", () => {
|
|
||||||
expect(computeBalanceDateRange("all", FIXED_TODAY)).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subtracts 90 days for 3M and emits a from-only range", () => {
|
|
||||||
const r = computeBalanceDateRange("3M", FIXED_TODAY);
|
|
||||||
expect(r.to).toBeUndefined();
|
|
||||||
expect(r.from).toBe("2026-01-25");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subtracts 180 days for 6M", () => {
|
|
||||||
const r = computeBalanceDateRange("6M", FIXED_TODAY);
|
|
||||||
expect(r.from).toBe("2025-10-27");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subtracts 365 days for 1A", () => {
|
|
||||||
const r = computeBalanceDateRange("1A", FIXED_TODAY);
|
|
||||||
expect(r.from).toBe("2025-04-25");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subtracts 1095 days for 3A", () => {
|
|
||||||
const r = computeBalanceDateRange("3A", FIXED_TODAY);
|
|
||||||
expect(r.from).toBe("2023-04-26");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits ISO-8601 zero-padded month/day", () => {
|
|
||||||
// 2026-01-05 → 3M → 2025-10-07; both fields zero-padded.
|
|
||||||
const today = new Date(2026, 0, 5);
|
|
||||||
const r = computeBalanceDateRange("3M", today);
|
|
||||||
expect(r.from).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
||||||
expect(r.from).toBe("2025-10-07");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
// useBalanceOverview — scoped useReducer hook backing BalancePage.
|
|
||||||
//
|
|
||||||
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141):
|
|
||||||
// - Time-series for the evolution chart (totals + per-category breakdown)
|
|
||||||
// - Per-account latest snapshot value + period-anchor value (for Δ%)
|
|
||||||
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
|
||||||
// - Chart mode toggle (line / stacked-area)
|
|
||||||
//
|
|
||||||
// Returns are intentionally OUT of scope here — they ship in Issue #142
|
|
||||||
// (Modified Dietz). The accounts table reserves columns for the return
|
|
||||||
// metrics with TODO comments.
|
|
||||||
|
|
||||||
import { useReducer, useEffect, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
getSnapshotTotalsByDate,
|
|
||||||
getSnapshotTotalsByCategoryAndDate,
|
|
||||||
getAccountsLatestSnapshot,
|
|
||||||
getAccountsPeriodAnchor,
|
|
||||||
type SnapshotTotalPoint,
|
|
||||||
type SnapshotCategoryBreakdownPoint,
|
|
||||||
type AccountLatestSnapshot,
|
|
||||||
type AccountPeriodAnchor,
|
|
||||||
type SnapshotDateRange,
|
|
||||||
} from "../services/balance.service";
|
|
||||||
|
|
||||||
export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all";
|
|
||||||
export type BalanceChartMode = "line" | "stacked";
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
period: BalancePeriod;
|
|
||||||
chartMode: BalanceChartMode;
|
|
||||||
evolutionTotals: SnapshotTotalPoint[];
|
|
||||||
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
|
||||||
accountsLatest: AccountLatestSnapshot[];
|
|
||||||
accountsPeriodAnchor: AccountPeriodAnchor[];
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| { type: "SET_PERIOD"; payload: BalancePeriod }
|
|
||||||
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
|
|
||||||
| { type: "LOAD_START" }
|
|
||||||
| {
|
|
||||||
type: "LOAD_SUCCESS";
|
|
||||||
payload: {
|
|
||||||
evolutionTotals: SnapshotTotalPoint[];
|
|
||||||
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
|
||||||
accountsLatest: AccountLatestSnapshot[];
|
|
||||||
accountsPeriodAnchor: AccountPeriodAnchor[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| { type: "LOAD_ERROR"; payload: string };
|
|
||||||
|
|
||||||
function initialState(): State {
|
|
||||||
return {
|
|
||||||
period: "1A",
|
|
||||||
chartMode: "line",
|
|
||||||
evolutionTotals: [],
|
|
||||||
evolutionByCategory: [],
|
|
||||||
accountsLatest: [],
|
|
||||||
accountsPeriodAnchor: [],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
|
||||||
switch (action.type) {
|
|
||||||
case "SET_PERIOD":
|
|
||||||
return { ...state, period: action.payload };
|
|
||||||
case "SET_CHART_MODE":
|
|
||||||
return { ...state, chartMode: action.payload };
|
|
||||||
case "LOAD_START":
|
|
||||||
return { ...state, isLoading: true, error: null };
|
|
||||||
case "LOAD_SUCCESS":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
...action.payload,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
case "LOAD_ERROR":
|
|
||||||
return { ...state, isLoading: false, error: action.payload };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure helper: turn a `BalancePeriod` into a `SnapshotDateRange` anchored on
|
|
||||||
* the supplied `today` (defaults to now). Exported so the unit tests can
|
|
||||||
* exercise the date math without mocking time.
|
|
||||||
*
|
|
||||||
* Period anchor decision (decisions-log #141): we anchor on `today`, not on
|
|
||||||
* the latest snapshot. Aggregators read snapshot rows so the answer is
|
|
||||||
* identical either way, but anchoring on today keeps the chart's right edge
|
|
||||||
* stable as the user enters new snapshots — intuitive UX.
|
|
||||||
*/
|
|
||||||
export function computeBalanceDateRange(
|
|
||||||
period: BalancePeriod,
|
|
||||||
today: Date = new Date()
|
|
||||||
): SnapshotDateRange {
|
|
||||||
if (period === "all") return {};
|
|
||||||
const days =
|
|
||||||
period === "3M" ? 90 : period === "6M" ? 180 : period === "1A" ? 365 : 1095;
|
|
||||||
const from = new Date(today);
|
|
||||||
from.setDate(from.getDate() - days);
|
|
||||||
// Local-civil `YYYY-MM-DD` (matches normalizeSnapshotDate's expectations).
|
|
||||||
const yyyy = from.getFullYear();
|
|
||||||
const mm = String(from.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(from.getDate()).padStart(2, "0");
|
|
||||||
return { from: `${yyyy}-${mm}-${dd}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseBalanceOverviewResult {
|
|
||||||
state: State;
|
|
||||||
setPeriod: (period: BalancePeriod) => void;
|
|
||||||
setChartMode: (mode: BalanceChartMode) => void;
|
|
||||||
reload: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBalanceOverview(): UseBalanceOverviewResult {
|
|
||||||
const [state, dispatch] = useReducer(reducer, undefined, initialState);
|
|
||||||
|
|
||||||
const load = useCallback(async (period: BalancePeriod) => {
|
|
||||||
dispatch({ type: "LOAD_START" });
|
|
||||||
try {
|
|
||||||
const range = computeBalanceDateRange(period);
|
|
||||||
// Parallel fetches — no inter-dependency between the four queries.
|
|
||||||
const [totals, byCategory, latest, anchors] = await Promise.all([
|
|
||||||
getSnapshotTotalsByDate(range),
|
|
||||||
getSnapshotTotalsByCategoryAndDate(range),
|
|
||||||
getAccountsLatestSnapshot(),
|
|
||||||
getAccountsPeriodAnchor(range),
|
|
||||||
]);
|
|
||||||
dispatch({
|
|
||||||
type: "LOAD_SUCCESS",
|
|
||||||
payload: {
|
|
||||||
evolutionTotals: totals,
|
|
||||||
evolutionByCategory: byCategory,
|
|
||||||
accountsLatest: latest,
|
|
||||||
accountsPeriodAnchor: anchors,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
dispatch({ type: "LOAD_ERROR", payload: message });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reload whenever the period changes (and on mount).
|
|
||||||
useEffect(() => {
|
|
||||||
void load(state.period);
|
|
||||||
}, [state.period, load]);
|
|
||||||
|
|
||||||
const setPeriod = useCallback((period: BalancePeriod) => {
|
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setChartMode = useCallback((mode: BalanceChartMode) => {
|
|
||||||
dispatch({ type: "SET_CHART_MODE", payload: mode });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reload = useCallback(() => load(state.period), [load, state.period]);
|
|
||||||
|
|
||||||
return { state, setPeriod, setChartMode, reload };
|
|
||||||
}
|
|
||||||
|
|
@ -1,546 +0,0 @@
|
||||||
// useSnapshotEditor — scoped useReducer hook backing SnapshotEditPage.
|
|
||||||
//
|
|
||||||
// Lifecycle of a single snapshot (Issue #146 / Bilan #1b — simple kind only):
|
|
||||||
// 1. mount in 'new' mode (no `?date=` query param) → user picks a date,
|
|
||||||
// types values, hits Save → service.createSnapshot + upsertLines;
|
|
||||||
// 2. mount in 'edit' mode (`?date=YYYY-MM-DD`) → load snapshot + lines,
|
|
||||||
// user edits values, hits Save → upsertLines on the existing snapshot;
|
|
||||||
// 3. delete → service.deleteSnapshot (the page wraps this in a
|
|
||||||
// double-confirm modal that requires retyping the snapshot date).
|
|
||||||
//
|
|
||||||
// Priced-kind UI lands in #140 (Bilan #2). Until then values are scalar
|
|
||||||
// numbers keyed by account_id and quantity/unit_price are forced to NULL by
|
|
||||||
// `upsertSnapshotLines` (the SQL CHECK guards the invariant too).
|
|
||||||
|
|
||||||
import {
|
|
||||||
useReducer,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
import type {
|
|
||||||
BalanceAccountWithCategory,
|
|
||||||
BalanceCategory,
|
|
||||||
BalanceSnapshot,
|
|
||||||
BalanceSnapshotLine,
|
|
||||||
} from "../shared/types";
|
|
||||||
import {
|
|
||||||
listBalanceAccounts,
|
|
||||||
listBalanceCategories,
|
|
||||||
getSnapshotByDate,
|
|
||||||
createSnapshot,
|
|
||||||
deleteSnapshot,
|
|
||||||
listLinesBySnapshot,
|
|
||||||
upsertSnapshotLines,
|
|
||||||
getPreviousSnapshot,
|
|
||||||
BalanceServiceError,
|
|
||||||
} from "../services/balance.service";
|
|
||||||
|
|
||||||
export type SnapshotEditorMode = "new" | "edit";
|
|
||||||
|
|
||||||
/** String-typed entry for a priced-kind line being edited. */
|
|
||||||
export interface PricedEntry {
|
|
||||||
quantity: string;
|
|
||||||
unit_price: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
mode: SnapshotEditorMode;
|
|
||||||
/** ISO YYYY-MM-DD; controlled in 'new' mode, frozen in 'edit'. */
|
|
||||||
snapshotDate: string;
|
|
||||||
/** Current snapshot row in 'edit' mode (has the id needed for upsert). */
|
|
||||||
snapshot: BalanceSnapshot | null;
|
|
||||||
/** All active accounts (with category metadata) — drives the line list. */
|
|
||||||
accounts: BalanceAccountWithCategory[];
|
|
||||||
/** Used to group lines by category in the editor view. */
|
|
||||||
categories: BalanceCategory[];
|
|
||||||
/**
|
|
||||||
* Map of account_id → string-typed value (simple kind only). We keep
|
|
||||||
* strings to preserve empty / partial input; conversion to number
|
|
||||||
* happens at save time.
|
|
||||||
*/
|
|
||||||
values: Record<number, string>;
|
|
||||||
/**
|
|
||||||
* Map of account_id → string-typed `{quantity, unit_price}` (priced
|
|
||||||
* kind only). Same partial-input guarantee as `values`.
|
|
||||||
*/
|
|
||||||
pricedValues: Record<number, PricedEntry>;
|
|
||||||
/** Snapshot whose values would prefill if the user clicks "Prefill". */
|
|
||||||
previousSnapshot: BalanceSnapshot | null;
|
|
||||||
/** Lines from `previousSnapshot` (loaded lazily when needed). */
|
|
||||||
previousLines: BalanceSnapshotLine[] | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isSaving: boolean;
|
|
||||||
isDirty: boolean;
|
|
||||||
error: string | null;
|
|
||||||
errorCode: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Action =
|
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
|
||||||
| { type: "SET_SAVING"; payload: boolean }
|
|
||||||
| { type: "SET_ERROR"; payload: { message: string | null; code: string | null } }
|
|
||||||
| {
|
|
||||||
type: "LOADED";
|
|
||||||
payload: {
|
|
||||||
mode: SnapshotEditorMode;
|
|
||||||
snapshotDate: string;
|
|
||||||
snapshot: BalanceSnapshot | null;
|
|
||||||
accounts: BalanceAccountWithCategory[];
|
|
||||||
categories: BalanceCategory[];
|
|
||||||
values: Record<number, string>;
|
|
||||||
pricedValues: Record<number, PricedEntry>;
|
|
||||||
previousSnapshot: BalanceSnapshot | null;
|
|
||||||
previousLines: BalanceSnapshotLine[] | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| { type: "SET_DATE"; payload: string }
|
|
||||||
| { type: "SET_VALUE"; payload: { accountId: number; value: string } }
|
|
||||||
| {
|
|
||||||
type: "SET_PRICED_FIELD";
|
|
||||||
payload: {
|
|
||||||
accountId: number;
|
|
||||||
field: "quantity" | "unit_price";
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "PREFILL";
|
|
||||||
payload: {
|
|
||||||
values: Record<number, string>;
|
|
||||||
pricedValues: Record<number, PricedEntry>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| { type: "RESET" }
|
|
||||||
| { type: "CLEAR_DIRTY" };
|
|
||||||
|
|
||||||
function initialState(initialDate: string): State {
|
|
||||||
return {
|
|
||||||
mode: "new",
|
|
||||||
snapshotDate: initialDate,
|
|
||||||
snapshot: null,
|
|
||||||
accounts: [],
|
|
||||||
categories: [],
|
|
||||||
values: {},
|
|
||||||
pricedValues: {},
|
|
||||||
previousSnapshot: null,
|
|
||||||
previousLines: null,
|
|
||||||
isLoading: false,
|
|
||||||
isSaving: false,
|
|
||||||
isDirty: false,
|
|
||||||
error: null,
|
|
||||||
errorCode: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
|
||||||
switch (action.type) {
|
|
||||||
case "SET_LOADING":
|
|
||||||
return { ...state, isLoading: action.payload };
|
|
||||||
case "SET_SAVING":
|
|
||||||
return { ...state, isSaving: action.payload };
|
|
||||||
case "SET_ERROR":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
error: action.payload.message,
|
|
||||||
errorCode: action.payload.code,
|
|
||||||
isLoading: false,
|
|
||||||
isSaving: false,
|
|
||||||
};
|
|
||||||
case "LOADED":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
mode: action.payload.mode,
|
|
||||||
snapshotDate: action.payload.snapshotDate,
|
|
||||||
snapshot: action.payload.snapshot,
|
|
||||||
accounts: action.payload.accounts,
|
|
||||||
categories: action.payload.categories,
|
|
||||||
values: action.payload.values,
|
|
||||||
pricedValues: action.payload.pricedValues,
|
|
||||||
previousSnapshot: action.payload.previousSnapshot,
|
|
||||||
previousLines: action.payload.previousLines,
|
|
||||||
isLoading: false,
|
|
||||||
isDirty: false,
|
|
||||||
error: null,
|
|
||||||
errorCode: null,
|
|
||||||
};
|
|
||||||
case "SET_DATE":
|
|
||||||
// Only meaningful in 'new' mode — the page guards against this in 'edit'.
|
|
||||||
return { ...state, snapshotDate: action.payload, isDirty: true };
|
|
||||||
case "SET_VALUE":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
values: {
|
|
||||||
...state.values,
|
|
||||||
[action.payload.accountId]: action.payload.value,
|
|
||||||
},
|
|
||||||
isDirty: true,
|
|
||||||
};
|
|
||||||
case "SET_PRICED_FIELD": {
|
|
||||||
const existing =
|
|
||||||
state.pricedValues[action.payload.accountId] ?? {
|
|
||||||
quantity: "",
|
|
||||||
unit_price: "",
|
|
||||||
};
|
|
||||||
const next: PricedEntry =
|
|
||||||
action.payload.field === "quantity"
|
|
||||||
? { ...existing, quantity: action.payload.value }
|
|
||||||
: { ...existing, unit_price: action.payload.value };
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
pricedValues: {
|
|
||||||
...state.pricedValues,
|
|
||||||
[action.payload.accountId]: next,
|
|
||||||
},
|
|
||||||
isDirty: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "PREFILL":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
values: { ...state.values, ...action.payload.values },
|
|
||||||
pricedValues: {
|
|
||||||
...state.pricedValues,
|
|
||||||
...action.payload.pricedValues,
|
|
||||||
},
|
|
||||||
isDirty: true,
|
|
||||||
};
|
|
||||||
case "RESET":
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
// Keep the loaded structure (accounts, categories, snapshot) but wipe
|
|
||||||
// user input back to a clean slate sourced from the saved lines.
|
|
||||||
values: {},
|
|
||||||
pricedValues: {},
|
|
||||||
isDirty: true,
|
|
||||||
};
|
|
||||||
case "CLEAR_DIRTY":
|
|
||||||
return { ...state, isDirty: false };
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeError(e: unknown): { message: string; code: string | null } {
|
|
||||||
if (e instanceof BalanceServiceError) {
|
|
||||||
return { message: e.message, code: e.code };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
message: e instanceof Error ? e.message : String(e),
|
|
||||||
code: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function todayISO(): string {
|
|
||||||
// Avoid timezone drift: use local YYYY-MM-DD, not toISOString() which is UTC.
|
|
||||||
const d = new Date();
|
|
||||||
const yyyy = d.getFullYear();
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${yyyy}-${mm}-${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
/** ISO date from the route query string. `undefined` means 'new' mode. */
|
|
||||||
dateParam?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSnapshotEditor(options: Options = {}) {
|
|
||||||
const { dateParam } = options;
|
|
||||||
const [state, dispatch] = useReducer(
|
|
||||||
reducer,
|
|
||||||
undefined,
|
|
||||||
() => initialState(dateParam ?? todayISO())
|
|
||||||
);
|
|
||||||
const fetchIdRef = useRef(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the editor state from the database. In 'new' mode we still load
|
|
||||||
* accounts + categories + the previous snapshot (so the prefill button
|
|
||||||
* can be enabled); we do NOT pre-create a snapshot row — that happens at
|
|
||||||
* save time so the user can abandon the form without leaving an empty
|
|
||||||
* snapshot behind.
|
|
||||||
*/
|
|
||||||
const loadForDate = useCallback(async (date: string | null | undefined) => {
|
|
||||||
const fetchId = ++fetchIdRef.current;
|
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
|
||||||
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
|
||||||
const targetDate = date && date.length > 0 ? date : todayISO();
|
|
||||||
try {
|
|
||||||
const [accounts, categories] = await Promise.all([
|
|
||||||
listBalanceAccounts(),
|
|
||||||
listBalanceCategories(),
|
|
||||||
]);
|
|
||||||
const existing = await getSnapshotByDate(targetDate);
|
|
||||||
const isEdit = !!existing;
|
|
||||||
let values: Record<number, string> = {};
|
|
||||||
let pricedValues: Record<number, PricedEntry> = {};
|
|
||||||
let previousLines: BalanceSnapshotLine[] | null = null;
|
|
||||||
// Index account kinds for quick line classification.
|
|
||||||
const kindByAccountId = new Map<number, BalanceCategory["kind"]>();
|
|
||||||
for (const acc of accounts) {
|
|
||||||
kindByAccountId.set(acc.id, acc.category_kind);
|
|
||||||
}
|
|
||||||
if (existing) {
|
|
||||||
const lines = await listLinesBySnapshot(existing.id);
|
|
||||||
for (const line of lines) {
|
|
||||||
// The line itself carries quantity / unit_price for priced kinds;
|
|
||||||
// we still cross-check against the account kind to decide which
|
|
||||||
// input map this row belongs to (it dictates what the user sees).
|
|
||||||
const kind = kindByAccountId.get(line.account_id);
|
|
||||||
if (
|
|
||||||
kind === "priced" ||
|
|
||||||
(line.quantity !== null && line.unit_price !== null)
|
|
||||||
) {
|
|
||||||
pricedValues[line.account_id] = {
|
|
||||||
quantity:
|
|
||||||
line.quantity !== null && line.quantity !== undefined
|
|
||||||
? String(line.quantity)
|
|
||||||
: "",
|
|
||||||
unit_price:
|
|
||||||
line.unit_price !== null && line.unit_price !== undefined
|
|
||||||
? String(line.unit_price)
|
|
||||||
: "",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
values[line.account_id] = String(line.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const previous = await getPreviousSnapshot(targetDate);
|
|
||||||
if (previous) {
|
|
||||||
previousLines = await listLinesBySnapshot(previous.id);
|
|
||||||
}
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
|
||||||
dispatch({
|
|
||||||
type: "LOADED",
|
|
||||||
payload: {
|
|
||||||
mode: isEdit ? "edit" : "new",
|
|
||||||
snapshotDate: targetDate,
|
|
||||||
snapshot: existing,
|
|
||||||
accounts,
|
|
||||||
categories,
|
|
||||||
values,
|
|
||||||
pricedValues,
|
|
||||||
previousSnapshot: previous,
|
|
||||||
previousLines,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load on mount + whenever the route's `?date=` changes.
|
|
||||||
useEffect(() => {
|
|
||||||
loadForDate(dateParam);
|
|
||||||
}, [dateParam, loadForDate]);
|
|
||||||
|
|
||||||
const setDate = useCallback((next: string) => {
|
|
||||||
dispatch({ type: "SET_DATE", payload: next });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setLineValue = useCallback((accountId: number, value: string) => {
|
|
||||||
dispatch({
|
|
||||||
type: "SET_VALUE",
|
|
||||||
payload: { accountId, value },
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setLineQuantity = useCallback(
|
|
||||||
(accountId: number, value: string) => {
|
|
||||||
dispatch({
|
|
||||||
type: "SET_PRICED_FIELD",
|
|
||||||
payload: { accountId, field: "quantity", value },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setLineUnitPrice = useCallback(
|
|
||||||
(accountId: number, value: string) => {
|
|
||||||
dispatch({
|
|
||||||
type: "SET_PRICED_FIELD",
|
|
||||||
payload: { accountId, field: "unit_price", value },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
dispatch({ type: "RESET" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the prefill map from the previous snapshot. Per spec-decisions
|
|
||||||
* row "Bouton Pré-remplir":
|
|
||||||
* - simple kind → copy value
|
|
||||||
* - priced kind → copy quantity, leave unit_price blank (the user
|
|
||||||
* must enter or fetch a fresh price each time).
|
|
||||||
*/
|
|
||||||
const prefillFromPrevious = useCallback(() => {
|
|
||||||
const lines = state.previousLines;
|
|
||||||
if (!lines || lines.length === 0) return;
|
|
||||||
const accountKindById = new Map<number, BalanceCategory["kind"]>();
|
|
||||||
for (const acc of state.accounts) {
|
|
||||||
accountKindById.set(acc.id, acc.category_kind);
|
|
||||||
}
|
|
||||||
const nextSimple: Record<number, string> = {};
|
|
||||||
const nextPriced: Record<number, PricedEntry> = {};
|
|
||||||
for (const line of lines) {
|
|
||||||
const kind = accountKindById.get(line.account_id);
|
|
||||||
if (!kind) continue; // archived account — skip
|
|
||||||
if (kind === "simple") {
|
|
||||||
nextSimple[line.account_id] = String(line.value);
|
|
||||||
} else {
|
|
||||||
// Priced: copy quantity, leave unit_price blank — quantities don't
|
|
||||||
// change unless the user buys / sells, prices always change.
|
|
||||||
nextPriced[line.account_id] = {
|
|
||||||
quantity:
|
|
||||||
line.quantity !== null && line.quantity !== undefined
|
|
||||||
? String(line.quantity)
|
|
||||||
: "",
|
|
||||||
unit_price: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dispatch({
|
|
||||||
type: "PREFILL",
|
|
||||||
payload: { values: nextSimple, pricedValues: nextPriced },
|
|
||||||
});
|
|
||||||
}, [state.previousLines, state.accounts]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persist the editor state to the database.
|
|
||||||
* - 'new' mode: create the snapshot row (UNIQUE per date), then upsert
|
|
||||||
* its lines. If creation fails because a snapshot was created at this
|
|
||||||
* same date concurrently (snapshot_date_taken), the page is expected
|
|
||||||
* to redirect to edit mode.
|
|
||||||
* - 'edit' mode: upsert lines on the existing snapshot.
|
|
||||||
*
|
|
||||||
* Only accounts with a non-empty value (after trim) are persisted; empty
|
|
||||||
* fields mean "no entry for this account at this date" — they're cleared
|
|
||||||
* by the rewrite-all strategy in `upsertSnapshotLines`.
|
|
||||||
*/
|
|
||||||
const save = useCallback(async (): Promise<{ snapshotId: number }> => {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
|
||||||
try {
|
|
||||||
let snapshotId: number;
|
|
||||||
if (state.mode === "edit" && state.snapshot) {
|
|
||||||
snapshotId = state.snapshot.id;
|
|
||||||
} else {
|
|
||||||
snapshotId = await createSnapshot({
|
|
||||||
snapshot_date: state.snapshotDate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Index account kinds for line classification at save time.
|
|
||||||
const kindByAccountId = new Map<number, BalanceCategory["kind"]>();
|
|
||||||
for (const acc of state.accounts) {
|
|
||||||
kindByAccountId.set(acc.id, acc.category_kind);
|
|
||||||
}
|
|
||||||
// Simple-kind lines: drop empty fields, accept any finite number.
|
|
||||||
const simpleLines = Object.entries(state.values)
|
|
||||||
.filter(([, v]) => v !== undefined && String(v).trim().length > 0)
|
|
||||||
.map(([accountIdStr, raw]) => {
|
|
||||||
const accountId = Number(accountIdStr);
|
|
||||||
const trimmed = String(raw).trim().replace(",", ".");
|
|
||||||
const num = Number(trimmed);
|
|
||||||
if (!Number.isFinite(num)) {
|
|
||||||
throw new BalanceServiceError(
|
|
||||||
"snapshot_value_invalid",
|
|
||||||
`Invalid value for account ${accountId}: "${raw}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
account_id: accountId,
|
|
||||||
value: num,
|
|
||||||
account_kind: "simple" as const,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// Priced-kind lines: both qty + price required, value computed.
|
|
||||||
const pricedLines = Object.entries(state.pricedValues)
|
|
||||||
.filter(
|
|
||||||
([, entry]) =>
|
|
||||||
entry &&
|
|
||||||
String(entry.quantity ?? "").trim().length > 0 &&
|
|
||||||
String(entry.unit_price ?? "").trim().length > 0
|
|
||||||
)
|
|
||||||
.map(([accountIdStr, entry]) => {
|
|
||||||
const accountId = Number(accountIdStr);
|
|
||||||
const qtyTrim = String(entry.quantity).trim().replace(",", ".");
|
|
||||||
const priceTrim = String(entry.unit_price).trim().replace(",", ".");
|
|
||||||
const qty = Number(qtyTrim);
|
|
||||||
const price = Number(priceTrim);
|
|
||||||
if (!Number.isFinite(qty)) {
|
|
||||||
throw new BalanceServiceError(
|
|
||||||
"snapshot_priced_quantity_required",
|
|
||||||
`Invalid quantity for account ${accountId}: "${entry.quantity}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(price)) {
|
|
||||||
throw new BalanceServiceError(
|
|
||||||
"snapshot_priced_unit_price_required",
|
|
||||||
`Invalid unit_price for account ${accountId}: "${entry.unit_price}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
account_id: accountId,
|
|
||||||
account_kind: "priced" as const,
|
|
||||||
quantity: qty,
|
|
||||||
unit_price: price,
|
|
||||||
// value = qty * price; the service re-validates the relation
|
|
||||||
// within PRICED_VALUE_TOLERANCE before persisting.
|
|
||||||
value: qty * price,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
await upsertSnapshotLines(snapshotId, [...simpleLines, ...pricedLines]);
|
|
||||||
dispatch({ type: "CLEAR_DIRTY" });
|
|
||||||
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
|
|
||||||
await loadForDate(state.snapshotDate);
|
|
||||||
return { snapshotId };
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
state.mode,
|
|
||||||
state.snapshot,
|
|
||||||
state.snapshotDate,
|
|
||||||
state.values,
|
|
||||||
state.pricedValues,
|
|
||||||
state.accounts,
|
|
||||||
loadForDate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const remove = useCallback(async () => {
|
|
||||||
if (!state.snapshot) return;
|
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
|
||||||
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
|
||||||
try {
|
|
||||||
await deleteSnapshot(state.snapshot.id);
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: "SET_ERROR", payload: describeError(e) });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
dispatch({ type: "SET_SAVING", payload: false });
|
|
||||||
}
|
|
||||||
}, [state.snapshot]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
setDate,
|
|
||||||
setLineValue,
|
|
||||||
setLineQuantity,
|
|
||||||
setLineUnitPrice,
|
|
||||||
reset,
|
|
||||||
prefillFromPrevious,
|
|
||||||
save,
|
|
||||||
remove,
|
|
||||||
/** Manual reload (e.g. after navigation between dates). */
|
|
||||||
reload: () => loadForDate(state.snapshotDate),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
"adjustments": "Adjustments",
|
"adjustments": "Adjustments",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"balance": "Balance sheet",
|
|
||||||
"settings": "Settings"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|
@ -253,10 +252,6 @@
|
||||||
"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": {
|
||||||
|
|
@ -901,44 +896,6 @@
|
||||||
"Seasonality, top movers, and budget adherence stay monthly even when the toggle is set to YTD — only the 4 KPI numbers change"
|
"Seasonality, top movers, and budget adherence stay monthly even when the toggle is set to YTD — only the 4 KPI numbers change"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"balance": {
|
|
||||||
"title": "Balance Sheet",
|
|
||||||
"overview": "The Balance Sheet is a net-worth view: you periodically enter a dated snapshot of all your accounts (cash, RRSP, TFSA, funds, stocks, crypto, other), track their evolution over time, and compute the true return of each investment account by linking transfers (deposits/withdrawals) to the matching accounts.",
|
|
||||||
"features": [
|
|
||||||
"7 standard categories pre-installed (Cash, TFSA, RRSP, Fund, Stock, Crypto, Other) — renameable, non-deletable",
|
|
||||||
"Custom category creation with simple (direct amount) or priced (quantity × unit price) kind",
|
|
||||||
"Accounts per category: name, optional symbol, currency (CAD at MVP), notes",
|
|
||||||
"Dated snapshots with a UNIQUE constraint per date — editing means revisiting the same date, never duplicating",
|
|
||||||
"\"Prefill from previous snapshot\" button: copies simple values + priced quantities",
|
|
||||||
"Linking existing transactions to a balance account (modal with filters and auto-suggested direction)",
|
|
||||||
"Attribution icon in the Transactions page for transactions linked to a transfer",
|
|
||||||
"Evolution chart with line or stacked-area-by-category mode + vertical markers for tagged transfers (green = in, red = out)",
|
|
||||||
"Accounts table with 3 Modified Dietz return columns (3M / 1Y / since inception) + side-by-side unadjusted return column",
|
|
||||||
"Warning if the latest snapshot is more than 60 days old",
|
|
||||||
"Soft-delete of accounts (Archive) — hidden from new snapshots, preserved in history",
|
|
||||||
"Snapshot deletion with double-confirmation by retyping the date",
|
|
||||||
"Privacy-first: everything stays local, no outbound calls at MVP"
|
|
||||||
],
|
|
||||||
"steps": [
|
|
||||||
"Go to /balance/accounts → Categories tab to create an extra category if needed (RRIF as simple, or Stocks Wealthsimple as priced)",
|
|
||||||
"Go to the Accounts tab to create each account (TFSA Tangerine under TFSA, BTC Ledger under Crypto with symbol BTC)",
|
|
||||||
"Click \"+ New snapshot\" from /balance to open /balance/snapshot at today's date",
|
|
||||||
"Fill in values per account (grouped by category). For priced accounts, enter quantity and unit price — value is computed",
|
|
||||||
"Save. The chart on /balance refreshes immediately",
|
|
||||||
"To compute the real return of an investment account, open the actions menu → \"Link transfers\" → check the transactions matching deposits/withdrawals — direction (in/out) is auto-proposed",
|
|
||||||
"The accounts table now shows Modified Dietz returns over 3M / 1Y / since inception, side-by-side with the unadjusted return",
|
|
||||||
"To edit an existing snapshot, click its point on the chart or use the date picker — the page opens in edit mode (date is immutable)",
|
|
||||||
"To delete a snapshot, click \"Delete\" in its editor and retype the date to confirm"
|
|
||||||
],
|
|
||||||
"tips": [
|
|
||||||
"Take snapshots on a regular cadence (monthly or quarterly) — return quality depends on regularity",
|
|
||||||
"The unadjusted return on the right shows \"account value\" vs \"true performance\": the difference comes from contributions, not from performance",
|
|
||||||
"Vertical chart markers help you read value jumps: a jump followed by a green marker isn't \"performance\", it's a deposit",
|
|
||||||
"If you try to delete a transaction linked to a balance account, the app asks you to unlink it first — this friction preserves the reproducibility of past returns",
|
|
||||||
"The \"balance out of date\" warning appears if your latest snapshot is more than 60 days old",
|
|
||||||
"(Coming in Phase 5) Automatic price fetching for Stocks/Crypto via a private proxy (premium-only) that anonymizes your request — manual entry remains always available"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"overview": "Configure app preferences, check for updates, access the user guide, and manage your data with export/import tools.",
|
"overview": "Configure app preferences, check for updates, access the user guide, and manage your data with export/import tools.",
|
||||||
|
|
@ -1024,8 +981,7 @@
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
"lightMode": "Light mode",
|
"lightMode": "Light mode",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"underConstruction": "Under construction",
|
"underConstruction": "Under construction"
|
||||||
"back": "Back"
|
|
||||||
},
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"title": "License",
|
"title": "License",
|
||||||
|
|
@ -1493,243 +1449,5 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"balance": {
|
|
||||||
"overview": {
|
|
||||||
"title": "Balance sheet",
|
|
||||||
"latestTotal": "Current net worth",
|
|
||||||
"asOf": "as of {{date}}",
|
|
||||||
"noSnapshots": "No snapshot yet. Create one to start tracking your balance over time.",
|
|
||||||
"vsPrevious": "vs previous",
|
|
||||||
"newSnapshot": "New snapshot",
|
|
||||||
"staleWarning": "The latest snapshot is more than {{days}} days old. Consider updating it to keep your balance accurate.",
|
|
||||||
"latestValue": "Latest value",
|
|
||||||
"periodDelta": "Δ% over period",
|
|
||||||
"noAccounts": "No active accounts. Create a balance account to get started.",
|
|
||||||
"accountsTitle": "Accounts",
|
|
||||||
"detailAction": "Details",
|
|
||||||
"detailComingSoon": "Available in a future release."
|
|
||||||
},
|
|
||||||
"period": {
|
|
||||||
"legend": "Analysis period",
|
|
||||||
"3M": "3 months",
|
|
||||||
"6M": "6 months",
|
|
||||||
"1A": "1 year",
|
|
||||||
"3A": "3 years",
|
|
||||||
"all": "All"
|
|
||||||
},
|
|
||||||
"chart": {
|
|
||||||
"empty": "No snapshot for this period.",
|
|
||||||
"modeLegend": "Chart display mode",
|
|
||||||
"totalSeriesLabel": "Total",
|
|
||||||
"mode": {
|
|
||||||
"line": "Line",
|
|
||||||
"stacked": "Stacked by category"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sidebar": "Balance sheet",
|
|
||||||
"accountsPage": {
|
|
||||||
"title": "Balance accounts",
|
|
||||||
"tabs": {
|
|
||||||
"accounts": "Accounts",
|
|
||||||
"categories": "Categories"
|
|
||||||
},
|
|
||||||
"newAccount": "New account",
|
|
||||||
"includeArchived": "Show archived accounts",
|
|
||||||
"empty": "No accounts yet. Click “New account” to start."
|
|
||||||
},
|
|
||||||
"account": {
|
|
||||||
"fields": {
|
|
||||||
"name": "Name",
|
|
||||||
"category": "Category",
|
|
||||||
"symbol": "Symbol",
|
|
||||||
"currency": "Currency",
|
|
||||||
"status": "Status",
|
|
||||||
"actions": "Actions"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"active": "Active",
|
|
||||||
"archived": "Archived"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"archive": "Archive",
|
|
||||||
"unarchive": "Restore"
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"createTitle": "New account",
|
|
||||||
"editTitle": "Edit account",
|
|
||||||
"category": "Category",
|
|
||||||
"noCategory": "(no category available)",
|
|
||||||
"name": "Account name",
|
|
||||||
"nameRequired": "Name is required.",
|
|
||||||
"symbol": "Symbol",
|
|
||||||
"symbolPricedHint": "required for priced categories",
|
|
||||||
"symbolRequiredForPriced": "A symbol is required for priced categories.",
|
|
||||||
"symbolPlaceholderSimple": "Optional",
|
|
||||||
"symbolPlaceholderPriced": "e.g. AAPL, BTC-USD",
|
|
||||||
"notes": "Notes",
|
|
||||||
"currencyMvpNotice": "At the MVP, all accounts are in CAD. Multi-currency support will land in a later version.",
|
|
||||||
"save": "Save",
|
|
||||||
"create": "Create account"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"intro": "Seeded categories (TFSA, RRSP, Cash, etc.) ship with the app. You can create your own for special cases.",
|
|
||||||
"fields": {
|
|
||||||
"name": "Name",
|
|
||||||
"key": "Key",
|
|
||||||
"kind": "Kind",
|
|
||||||
"origin": "Origin",
|
|
||||||
"actions": "Actions"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"simple": "Direct amount",
|
|
||||||
"priced": "Quantity × price"
|
|
||||||
},
|
|
||||||
"origin": {
|
|
||||||
"seeded": "Standard",
|
|
||||||
"user": "Custom"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"create": "New category",
|
|
||||||
"renamePrompt": "New label for this category",
|
|
||||||
"deleteConfirm": "Delete this category? This cannot be undone.",
|
|
||||||
"deleteSeedHint": "Standard categories cannot be deleted.",
|
|
||||||
"deleteHasAccountsHint": "This category has {{count}} linked account(s) — archive or move them first."
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"createTitle": "New category",
|
|
||||||
"key": "Key",
|
|
||||||
"keyPlaceholder": "e.g. lira, prpp",
|
|
||||||
"label": "Label",
|
|
||||||
"labelPlaceholder": "e.g. LIRA, PRPP",
|
|
||||||
"kindLabel": "Category kind",
|
|
||||||
"kindHintSimple": "Direct value entry (e.g. checking-account balance).",
|
|
||||||
"kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). Linked accounts will require a symbol.",
|
|
||||||
"simpleOnlyNotice": "Priced categories (stocks, crypto) will be available in a future release.",
|
|
||||||
"create": "Create category"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"has_accounts": "Cannot delete this category: {{count}} linked account(s) ({{names}}). Archive or move them first."
|
|
||||||
},
|
|
||||||
"cash": "Cash",
|
|
||||||
"tfsa": "TFSA",
|
|
||||||
"rrsp": "RRSP",
|
|
||||||
"fund": "Mutual fund",
|
|
||||||
"other": "Other",
|
|
||||||
"stock": "Stock",
|
|
||||||
"crypto": "Crypto"
|
|
||||||
},
|
|
||||||
"snapshot": {
|
|
||||||
"page": {
|
|
||||||
"newTitle": "New snapshot",
|
|
||||||
"editTitle": "Edit snapshot",
|
|
||||||
"dateLabel": "Snapshot date",
|
|
||||||
"dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.",
|
|
||||||
"total": "Entered total",
|
|
||||||
"noAccounts": "You need to create at least one balance account first.",
|
|
||||||
"goToAccounts": "Go to accounts",
|
|
||||||
"prefill": "Prefill from previous",
|
|
||||||
"prefillTooltip": "Copy values from the snapshot dated {{date}}",
|
|
||||||
"prefillNoPrevious": "No earlier snapshot available.",
|
|
||||||
"save": "Save",
|
|
||||||
"create": "Create snapshot",
|
|
||||||
"delete": "Delete this snapshot"
|
|
||||||
},
|
|
||||||
"editor": {
|
|
||||||
"empty": "No active accounts. Create an account before entering a snapshot."
|
|
||||||
},
|
|
||||||
"line": {
|
|
||||||
"valuePlaceholder": "0.00",
|
|
||||||
"valueLabel": "Value for {{account}}"
|
|
||||||
},
|
|
||||||
"priced": {
|
|
||||||
"quantity": "Quantity",
|
|
||||||
"quantityLabel": "Quantity for {{account}}",
|
|
||||||
"quantityPlaceholder": "0",
|
|
||||||
"unitPrice": "Unit price",
|
|
||||||
"unitPriceLabel": "Unit price for {{account}}",
|
|
||||||
"unitPricePlaceholder": "0.00",
|
|
||||||
"computedValue": "Value (computed)",
|
|
||||||
"computedValueLabel": "Computed value for {{account}}",
|
|
||||||
"computedValuePlaceholder": "—",
|
|
||||||
"attributionManual": "Manual",
|
|
||||||
"attributionManualHint": "Value entered manually. Automatic price fetching will land in a later release."
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"title": "Delete this snapshot?",
|
|
||||||
"body": "This permanently deletes the snapshot dated {{date}} and all its lines. To confirm, retype the date below.",
|
|
||||||
"confirmLabel": "Retype the date {{date}} to confirm",
|
|
||||||
"confirm": "Delete permanently"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"currency_unsupported": "Only CAD is supported at the MVP.",
|
|
||||||
"category_seed_protected": "Standard categories cannot be deleted.",
|
|
||||||
"category_has_accounts": "Cannot delete a category with linked accounts. Move or archive linked accounts first.",
|
|
||||||
"category_not_found": "Category not found.",
|
|
||||||
"account_not_found": "Account not found.",
|
|
||||||
"name_required": "Name is required.",
|
|
||||||
"kind_invalid": "Invalid category kind.",
|
|
||||||
"snapshot_date_required": "A date in YYYY-MM-DD format is required.",
|
|
||||||
"snapshot_date_taken": "A snapshot already exists at that date — edit it instead of creating a new one.",
|
|
||||||
"snapshot_not_found": "Snapshot not found.",
|
|
||||||
"snapshot_value_invalid": "An entered value is not a valid number.",
|
|
||||||
"snapshot_priced_unsupported": "Priced accounts (stocks/crypto) will be supported in a future release.",
|
|
||||||
"snapshot_priced_quantity_required": "Quantity 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_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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
"adjustments": "Ajustements",
|
"adjustments": "Ajustements",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"reports": "Rapports",
|
"reports": "Rapports",
|
||||||
"balance": "Bilan",
|
|
||||||
"settings": "Paramètres"
|
"settings": "Paramètres"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|
@ -253,10 +252,6 @@
|
||||||
"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": {
|
||||||
|
|
@ -901,44 +896,6 @@
|
||||||
"La saisonnalité, les top mouvements et l'adhésion budgétaire restent mensuels même quand le toggle est sur YTD — seuls les 4 chiffres KPI changent"
|
"La saisonnalité, les top mouvements et l'adhésion budgétaire restent mensuels même quand le toggle est sur YTD — seuls les 4 chiffres KPI changent"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"balance": {
|
|
||||||
"title": "Bilan",
|
|
||||||
"overview": "Le Bilan est une vue patrimoniale : vous saisissez périodiquement un snapshot daté de l'ensemble de vos comptes (encaisse, REER, CELI, fonds, actions, crypto, autres), suivez leur évolution dans le temps et calculez le vrai rendement de chaque compte d'investissement en liant les transferts (apports/retraits) aux comptes correspondants.",
|
|
||||||
"features": [
|
|
||||||
"7 catégories standard pré-installées (Encaisse, CELI, REER, Fonds, Actions, Crypto, Autres) — renommables, non-supprimables",
|
|
||||||
"Création de catégories personnalisées avec choix simple (montant direct) ou priced (quantité × prix unitaire)",
|
|
||||||
"Comptes par catégorie : nom, symbole optionnel, devise (CAD au MVP), notes",
|
|
||||||
"Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer",
|
|
||||||
"Bouton « Pré-remplir depuis le snapshot précédent » : copie les valeurs simples + les quantités priced",
|
|
||||||
"Liaison de transactions existantes à un compte de bilan (modal avec filtres et sens auto-proposé)",
|
|
||||||
"Icône d'attribution dans la page Transactions pour les transactions liées à un transfert",
|
|
||||||
"Graphique d'évolution avec mode courbe ou aire empilée par catégorie + marqueurs verticaux pour les transferts (vert = in, rouge = out)",
|
|
||||||
"Tableau des comptes avec 3 colonnes de rendement Modified Dietz (3M / 1A / depuis création) + colonne rendement non-ajusté côte-à-côte",
|
|
||||||
"Avertissement si le dernier snapshot remonte à plus de 60 jours",
|
|
||||||
"Soft-delete des comptes (Archiver) — masqués des nouveaux snapshots, conservés dans l'historique",
|
|
||||||
"Suppression d'un snapshot avec double-confirmation par re-saisie de la date",
|
|
||||||
"Privacy-first : tout est local, aucun appel sortant au MVP"
|
|
||||||
],
|
|
||||||
"steps": [
|
|
||||||
"Allez dans /balance/accounts → onglet Catégories pour créer si besoin une catégorie supplémentaire (FERR en simple, ou Stocks Wealthsimple en priced)",
|
|
||||||
"Allez dans l'onglet Comptes pour créer chaque compte (TFSA Tangerine rattaché à CELI, BTC Ledger rattaché à Crypto avec symbole BTC)",
|
|
||||||
"Cliquez « + Nouveau snapshot » depuis /balance pour ouvrir /balance/snapshot à la date du jour",
|
|
||||||
"Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée",
|
|
||||||
"Enregistrez. Le graphique sur /balance s'actualise immédiatement",
|
|
||||||
"Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions → « Lier transferts » → cochez les transactions qui correspondent à des apports/retraits — le sens (in/out) est proposé automatiquement",
|
|
||||||
"Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création, avec le rendement non-ajusté côte-à-côte",
|
|
||||||
"Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition (la date est immutable)",
|
|
||||||
"Pour supprimer un snapshot, cliquez « Supprimer » dans son éditeur et re-saisissez la date pour confirmer"
|
|
||||||
],
|
|
||||||
"tips": [
|
|
||||||
"Saisissez vos snapshots à un rythme régulier (mensuel ou trimestriel) — la qualité des rendements dépend de la régularité",
|
|
||||||
"Le rendement non-ajusté à droite vous permet de voir « valeur du compte » vs « vraie performance » : la différence vient des apports, pas de la performance",
|
|
||||||
"Les marqueurs verticaux du graphique aident à lire les sauts de valeur : un saut suivi d'un marqueur vert n'est pas une « performance », c'est un dépôt",
|
|
||||||
"Si vous tentez de supprimer une transaction liée à un compte de bilan, l'app vous demande de la délier d'abord — cette friction préserve la reproductibilité de vos rendements passés",
|
|
||||||
"L'avertissement « bilan pas à jour » apparaît si votre dernier snapshot remonte à plus de 60 jours",
|
|
||||||
"(À venir Phase 5) Récupération automatique des prix pour Actions/Crypto via un proxy privé (premium-only) qui anonymise votre requête — la saisie manuelle reste toujours disponible"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Paramètres",
|
"title": "Paramètres",
|
||||||
"overview": "Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import.",
|
"overview": "Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import.",
|
||||||
|
|
@ -1024,8 +981,7 @@
|
||||||
"darkMode": "Mode sombre",
|
"darkMode": "Mode sombre",
|
||||||
"lightMode": "Mode clair",
|
"lightMode": "Mode clair",
|
||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
"underConstruction": "En construction",
|
"underConstruction": "En construction"
|
||||||
"back": "Retour"
|
|
||||||
},
|
},
|
||||||
"license": {
|
"license": {
|
||||||
"title": "Licence",
|
"title": "Licence",
|
||||||
|
|
@ -1493,243 +1449,5 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"balance": {
|
|
||||||
"overview": {
|
|
||||||
"title": "Bilan",
|
|
||||||
"latestTotal": "Valeur nette actuelle",
|
|
||||||
"asOf": "au {{date}}",
|
|
||||||
"noSnapshots": "Aucun snapshot pour l'instant. Créez-en un pour suivre l'évolution de votre bilan.",
|
|
||||||
"vsPrevious": "vs précédent",
|
|
||||||
"newSnapshot": "Nouveau snapshot",
|
|
||||||
"staleWarning": "Le dernier snapshot date de plus de {{days}} jours. Pensez à le mettre à jour pour suivre fidèlement l'évolution de votre bilan.",
|
|
||||||
"latestValue": "Dernière valeur",
|
|
||||||
"periodDelta": "Δ% sur la période",
|
|
||||||
"noAccounts": "Aucun compte actif. Commencez par créer un compte de bilan.",
|
|
||||||
"accountsTitle": "Comptes",
|
|
||||||
"detailAction": "Détail",
|
|
||||||
"detailComingSoon": "Disponible dans une prochaine version."
|
|
||||||
},
|
|
||||||
"period": {
|
|
||||||
"legend": "Période d'analyse",
|
|
||||||
"3M": "3 mois",
|
|
||||||
"6M": "6 mois",
|
|
||||||
"1A": "1 an",
|
|
||||||
"3A": "3 ans",
|
|
||||||
"all": "Tout"
|
|
||||||
},
|
|
||||||
"chart": {
|
|
||||||
"empty": "Aucun snapshot pour cette période.",
|
|
||||||
"modeLegend": "Mode d'affichage du graphique",
|
|
||||||
"totalSeriesLabel": "Total",
|
|
||||||
"mode": {
|
|
||||||
"line": "Ligne",
|
|
||||||
"stacked": "Empilé par catégorie"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sidebar": "Bilan",
|
|
||||||
"accountsPage": {
|
|
||||||
"title": "Comptes du bilan",
|
|
||||||
"tabs": {
|
|
||||||
"accounts": "Comptes",
|
|
||||||
"categories": "Catégories"
|
|
||||||
},
|
|
||||||
"newAccount": "Nouveau compte",
|
|
||||||
"includeArchived": "Afficher les comptes archivés",
|
|
||||||
"empty": "Aucun compte pour l'instant. Cliquez sur « Nouveau compte » pour commencer."
|
|
||||||
},
|
|
||||||
"account": {
|
|
||||||
"fields": {
|
|
||||||
"name": "Nom",
|
|
||||||
"category": "Catégorie",
|
|
||||||
"symbol": "Symbole",
|
|
||||||
"currency": "Devise",
|
|
||||||
"status": "Statut",
|
|
||||||
"actions": "Actions"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"active": "Actif",
|
|
||||||
"archived": "Archivé"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"archive": "Archiver",
|
|
||||||
"unarchive": "Restaurer"
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"createTitle": "Nouveau compte",
|
|
||||||
"editTitle": "Modifier le compte",
|
|
||||||
"category": "Catégorie",
|
|
||||||
"noCategory": "(aucune catégorie disponible)",
|
|
||||||
"name": "Nom du compte",
|
|
||||||
"nameRequired": "Le nom est obligatoire.",
|
|
||||||
"symbol": "Symbole",
|
|
||||||
"symbolPricedHint": "obligatoire pour cette catégorie cotée",
|
|
||||||
"symbolRequiredForPriced": "Un symbole est obligatoire pour les catégories cotées.",
|
|
||||||
"symbolPlaceholderSimple": "Optionnel",
|
|
||||||
"symbolPlaceholderPriced": "ex. AAPL, BTC-USD",
|
|
||||||
"notes": "Notes",
|
|
||||||
"currencyMvpNotice": "Au MVP, tous les comptes sont en CAD. Le support multi-devises arrivera dans une version ultérieure.",
|
|
||||||
"save": "Enregistrer",
|
|
||||||
"create": "Créer le compte"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"intro": "Les catégories seedées (CELI, REER, Encaisse, etc.) sont fournies par l'application. Vous pouvez en créer de nouvelles pour vos cas particuliers.",
|
|
||||||
"fields": {
|
|
||||||
"name": "Nom",
|
|
||||||
"key": "Clé",
|
|
||||||
"kind": "Type",
|
|
||||||
"origin": "Origine",
|
|
||||||
"actions": "Actions"
|
|
||||||
},
|
|
||||||
"kind": {
|
|
||||||
"simple": "Montant direct",
|
|
||||||
"priced": "Quantité × prix"
|
|
||||||
},
|
|
||||||
"origin": {
|
|
||||||
"seeded": "Standard",
|
|
||||||
"user": "Personnalisée"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"create": "Nouvelle catégorie",
|
|
||||||
"renamePrompt": "Nouveau libellé pour cette catégorie",
|
|
||||||
"deleteConfirm": "Supprimer cette catégorie ? Cette action est irréversible.",
|
|
||||||
"deleteSeedHint": "Les catégories standard ne peuvent pas être supprimées.",
|
|
||||||
"deleteHasAccountsHint": "Cette catégorie a {{count}} compte(s) lié(s) — archivez ou déplacez-les d'abord."
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"createTitle": "Nouvelle catégorie",
|
|
||||||
"key": "Clé",
|
|
||||||
"keyPlaceholder": "ex. ferr, rpdb",
|
|
||||||
"label": "Libellé",
|
|
||||||
"labelPlaceholder": "ex. FERR, RPDB",
|
|
||||||
"kindLabel": "Type de catégorie",
|
|
||||||
"kindHintSimple": "Saisie d'un montant direct (ex: solde de compte courant).",
|
|
||||||
"kindHintPriced": "Saisie d'une quantité × prix unitaire (ex: actions, cryptomonnaies). Un symbole sera obligatoire pour les comptes liés.",
|
|
||||||
"simpleOnlyNotice": "Les catégories cotées (actions, crypto) seront disponibles dans une prochaine version.",
|
|
||||||
"create": "Créer la catégorie"
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"has_accounts": "Impossible de supprimer cette catégorie : {{count}} compte(s) lié(s) ({{names}}). Archivez ou déplacez-les d'abord."
|
|
||||||
},
|
|
||||||
"cash": "Encaisse",
|
|
||||||
"tfsa": "CELI",
|
|
||||||
"rrsp": "REER",
|
|
||||||
"fund": "Fonds commun",
|
|
||||||
"other": "Autre",
|
|
||||||
"stock": "Action",
|
|
||||||
"crypto": "Cryptomonnaie"
|
|
||||||
},
|
|
||||||
"snapshot": {
|
|
||||||
"page": {
|
|
||||||
"newTitle": "Nouveau snapshot",
|
|
||||||
"editTitle": "Modifier le snapshot",
|
|
||||||
"dateLabel": "Date du snapshot",
|
|
||||||
"dateImmutable": "La date d'un snapshot existant ne peut pas être modifiée. Pour changer la date, supprimez ce snapshot et créez-en un nouveau.",
|
|
||||||
"total": "Total saisi",
|
|
||||||
"noAccounts": "Vous devez d'abord créer au moins un compte de bilan.",
|
|
||||||
"goToAccounts": "Aller aux comptes",
|
|
||||||
"prefill": "Pré-remplir depuis le précédent",
|
|
||||||
"prefillTooltip": "Copier les valeurs du snapshot du {{date}}",
|
|
||||||
"prefillNoPrevious": "Aucun snapshot antérieur disponible.",
|
|
||||||
"save": "Enregistrer",
|
|
||||||
"create": "Créer le snapshot",
|
|
||||||
"delete": "Supprimer ce snapshot"
|
|
||||||
},
|
|
||||||
"editor": {
|
|
||||||
"empty": "Aucun compte actif. Créez un compte avant de saisir un snapshot."
|
|
||||||
},
|
|
||||||
"line": {
|
|
||||||
"valuePlaceholder": "0,00",
|
|
||||||
"valueLabel": "Valeur pour {{account}}"
|
|
||||||
},
|
|
||||||
"priced": {
|
|
||||||
"quantity": "Quantité",
|
|
||||||
"quantityLabel": "Quantité pour {{account}}",
|
|
||||||
"quantityPlaceholder": "0",
|
|
||||||
"unitPrice": "Prix unitaire",
|
|
||||||
"unitPriceLabel": "Prix unitaire pour {{account}}",
|
|
||||||
"unitPricePlaceholder": "0,00",
|
|
||||||
"computedValue": "Valeur (calculée)",
|
|
||||||
"computedValueLabel": "Valeur calculée pour {{account}}",
|
|
||||||
"computedValuePlaceholder": "—",
|
|
||||||
"attributionManual": "Manuel",
|
|
||||||
"attributionManualHint": "Valeur saisie manuellement. La récupération automatique des prix arrivera dans une prochaine version."
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"title": "Supprimer ce snapshot ?",
|
|
||||||
"body": "Cette action supprime définitivement le snapshot du {{date}} et toutes ses lignes. Pour confirmer, retapez la date ci-dessous.",
|
|
||||||
"confirmLabel": "Retapez la date {{date}} pour confirmer",
|
|
||||||
"confirm": "Supprimer définitivement"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"currency_unsupported": "Seul le CAD est supporté au MVP.",
|
|
||||||
"category_seed_protected": "Les catégories standard ne peuvent pas être supprimées.",
|
|
||||||
"category_has_accounts": "Impossible de supprimer une catégorie avec des comptes liés. Déplacez ou archivez d'abord les comptes liés.",
|
|
||||||
"category_not_found": "Catégorie introuvable.",
|
|
||||||
"account_not_found": "Compte introuvable.",
|
|
||||||
"name_required": "Le nom est obligatoire.",
|
|
||||||
"kind_invalid": "Type de catégorie invalide.",
|
|
||||||
"snapshot_date_required": "Une date au format AAAA-MM-JJ est obligatoire.",
|
|
||||||
"snapshot_date_taken": "Un snapshot existe déjà à cette date — modifiez-le au lieu d'en créer un nouveau.",
|
|
||||||
"snapshot_not_found": "Snapshot introuvable.",
|
|
||||||
"snapshot_value_invalid": "Une valeur saisie n'est pas un nombre valide.",
|
|
||||||
"snapshot_priced_unsupported": "Les comptes cotés (actions/crypto) seront supportés dans une prochaine version.",
|
|
||||||
"snapshot_priced_quantity_required": "La quantité 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_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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,458 +0,0 @@
|
||||||
// AccountsPage — CRUD UI for balance accounts and balance categories.
|
|
||||||
//
|
|
||||||
// Issue #138 (Bilan #1a) ships the route `/balance/accounts` with two tabs:
|
|
||||||
// - Comptes : full CRUD over balance_accounts (create/edit/archive)
|
|
||||||
// - Catégories : list of seeded + user-created categories. Users can add
|
|
||||||
// simple-kind categories (the priced toggle lands in #140),
|
|
||||||
// rename them, and delete the ones they created (the seeded
|
|
||||||
// ones are protected at the service layer).
|
|
||||||
//
|
|
||||||
// The sidebar entry "Bilan" is intentionally NOT added here — per spec-plan
|
|
||||||
// v2 it lands in Issue #141 (Bilan #3) when the `/balance` overview page
|
|
||||||
// becomes navigable. Until then the route is reachable directly via URL.
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ArchiveRestore, Edit2, Plus, Trash2, Wallet } from "lucide-react";
|
|
||||||
import type {
|
|
||||||
BalanceAccountWithCategory,
|
|
||||||
BalanceCategory,
|
|
||||||
} from "../shared/types";
|
|
||||||
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
|
|
||||||
import AccountForm from "../components/balance/AccountForm";
|
|
||||||
import type { CreateBalanceCategoryInput } from "../services/balance.service";
|
|
||||||
|
|
||||||
type Tab = "accounts" | "categories";
|
|
||||||
|
|
||||||
export default function AccountsPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
state,
|
|
||||||
setIncludeArchived,
|
|
||||||
addAccount,
|
|
||||||
editAccount,
|
|
||||||
archiveAccount,
|
|
||||||
unarchiveAccount,
|
|
||||||
addCategory,
|
|
||||||
editCategory,
|
|
||||||
removeCategory,
|
|
||||||
} = useBalanceAccounts();
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("accounts");
|
|
||||||
const [showAccountForm, setShowAccountForm] = useState(false);
|
|
||||||
const [editingAccount, setEditingAccount] =
|
|
||||||
useState<BalanceAccountWithCategory | null>(null);
|
|
||||||
|
|
||||||
const [showCategoryForm, setShowCategoryForm] = useState(false);
|
|
||||||
/** Local error string for category deletion guard (count + names of linked accounts). */
|
|
||||||
const [categoryDeleteError, setCategoryDeleteError] = useState<string | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeCategories = useMemo(
|
|
||||||
() => state.categories.filter((c) => c.is_active),
|
|
||||||
[state.categories]
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Map category id → array of accounts linked to it (active + archived). */
|
|
||||||
const accountsByCategory = useMemo(() => {
|
|
||||||
const m = new Map<number, BalanceAccountWithCategory[]>();
|
|
||||||
for (const acc of state.accounts) {
|
|
||||||
const list = m.get(acc.balance_category_id) ?? [];
|
|
||||||
list.push(acc);
|
|
||||||
m.set(acc.balance_category_id, list);
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [state.accounts]);
|
|
||||||
|
|
||||||
const renderCategoryLabel = (cat: BalanceCategory) =>
|
|
||||||
t(cat.i18n_key, { defaultValue: cat.key });
|
|
||||||
|
|
||||||
const closeAccountForm = () => {
|
|
||||||
setShowAccountForm(false);
|
|
||||||
setEditingAccount(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAccountSubmit = async (
|
|
||||||
payload:
|
|
||||||
| Parameters<typeof addAccount>[0]
|
|
||||||
| Parameters<typeof editAccount>[1]
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
if (editingAccount) {
|
|
||||||
await editAccount(editingAccount.id, payload as Parameters<typeof editAccount>[1]);
|
|
||||||
} else {
|
|
||||||
await addAccount(payload as Parameters<typeof addAccount>[0]);
|
|
||||||
}
|
|
||||||
closeAccountForm();
|
|
||||||
} catch {
|
|
||||||
// Error already surfaced via state.error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCategorySubmit = async (input: CreateBalanceCategoryInput) => {
|
|
||||||
try {
|
|
||||||
await addCategory(input);
|
|
||||||
setShowCategoryForm(false);
|
|
||||||
} catch {
|
|
||||||
// Error already surfaced via state.error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete-guard for categories. The service refuses to delete a seeded
|
|
||||||
* category or one with linked accounts, but we pre-check at the UI to
|
|
||||||
* surface a richer message that lists the linked-account names.
|
|
||||||
*/
|
|
||||||
const handleDeleteCategory = (cat: BalanceCategory) => {
|
|
||||||
setCategoryDeleteError(null);
|
|
||||||
if (cat.is_seed) return;
|
|
||||||
const linked = accountsByCategory.get(cat.id) ?? [];
|
|
||||||
if (linked.length > 0) {
|
|
||||||
const sample = linked.slice(0, 3).map((a) => a.name).join(", ");
|
|
||||||
const more = linked.length > 3 ? ", …" : "";
|
|
||||||
setCategoryDeleteError(
|
|
||||||
t("balance.category.error.has_accounts", {
|
|
||||||
count: linked.length,
|
|
||||||
names: `${sample}${more}`,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.confirm(t("balance.category.actions.deleteConfirm"))) return;
|
|
||||||
removeCategory(cat.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<Wallet size={24} className="text-[var(--primary)]" />
|
|
||||||
<h1 className="text-2xl font-bold">{t("balance.accountsPage.title")}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.error && (
|
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
|
||||||
{state.errorCode
|
|
||||||
? t(`balance.errors.${state.errorCode}`, {
|
|
||||||
defaultValue: state.error,
|
|
||||||
})
|
|
||||||
: state.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex border-b border-[var(--border)] mb-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("accounts")}
|
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
|
||||||
activeTab === "accounts"
|
|
||||||
? "border-[var(--primary)] text-[var(--primary)]"
|
|
||||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("balance.accountsPage.tabs.accounts")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab("categories")}
|
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
|
|
||||||
activeTab === "categories"
|
|
||||||
? "border-[var(--primary)] text-[var(--primary)]"
|
|
||||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("balance.accountsPage.tabs.categories")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "accounts" && (
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={state.includeArchived}
|
|
||||||
onChange={(e) => setIncludeArchived(e.target.checked)}
|
|
||||||
/>
|
|
||||||
{t("balance.accountsPage.includeArchived")}
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingAccount(null);
|
|
||||||
setShowAccountForm(true);
|
|
||||||
}}
|
|
||||||
disabled={activeCategories.length === 0}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
{t("balance.accountsPage.newAccount")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAccountForm ? (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">
|
|
||||||
{editingAccount
|
|
||||||
? t("balance.account.form.editTitle")
|
|
||||||
: t("balance.account.form.createTitle")}
|
|
||||||
</h2>
|
|
||||||
<AccountForm
|
|
||||||
mode="account"
|
|
||||||
initialAccount={editingAccount ?? null}
|
|
||||||
categories={activeCategories}
|
|
||||||
isSaving={state.isSaving}
|
|
||||||
onSubmit={handleAccountSubmit}
|
|
||||||
onCancel={closeAccountForm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{state.accounts.length === 0 ? (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.accountsPage.empty")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-[var(--muted)]">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.account.fields.name")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.account.fields.category")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.account.fields.symbol")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.account.fields.currency")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.account.fields.status")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-2 font-medium">
|
|
||||||
{t("balance.account.fields.actions")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{state.accounts.map((acc) => {
|
|
||||||
const isArchived = !!acc.archived_at;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={acc.id}
|
|
||||||
className="border-t border-[var(--border)]"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className={isArchived ? "opacity-60" : ""}>
|
|
||||||
{acc.name}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{t(acc.category_i18n_key, {
|
|
||||||
defaultValue: acc.category_key,
|
|
||||||
})}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
|
||||||
{acc.symbol ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
|
||||||
{acc.currency}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{isArchived ? (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--muted)] text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.account.status.archived")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--positive)]/10 text-[var(--positive)]">
|
|
||||||
{t("balance.account.status.active")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right">
|
|
||||||
<div className="inline-flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingAccount(acc);
|
|
||||||
setShowAccountForm(true);
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
title={t("common.edit")}
|
|
||||||
>
|
|
||||||
<Edit2 size={14} />
|
|
||||||
</button>
|
|
||||||
{isArchived ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => unarchiveAccount(acc.id)}
|
|
||||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
title={t("balance.account.actions.unarchive")}
|
|
||||||
>
|
|
||||||
<ArchiveRestore size={14} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => archiveAccount(acc.id)}
|
|
||||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)]"
|
|
||||||
title={t("balance.account.actions.archive")}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "categories" && (
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.category.intro")}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCategoryForm((prev) => !prev)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
|
||||||
>
|
|
||||||
<Plus size={16} />
|
|
||||||
{t("balance.category.actions.create")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCategoryForm && (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">
|
|
||||||
{t("balance.category.form.createTitle")}
|
|
||||||
</h2>
|
|
||||||
<AccountForm
|
|
||||||
mode="category"
|
|
||||||
isSaving={state.isSaving}
|
|
||||||
onSubmit={handleCategorySubmit}
|
|
||||||
onCancel={() => setShowCategoryForm(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{categoryDeleteError && (
|
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20 flex items-start justify-between gap-2">
|
|
||||||
<span>{categoryDeleteError}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCategoryDeleteError(null)}
|
|
||||||
className="text-xs underline shrink-0"
|
|
||||||
>
|
|
||||||
{t("common.dismiss", { defaultValue: "OK" })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-[var(--muted)]">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.category.fields.name")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.category.fields.key")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.category.fields.kind")}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-4 py-2 font-medium">
|
|
||||||
{t("balance.category.fields.origin")}
|
|
||||||
</th>
|
|
||||||
<th className="text-right px-4 py-2 font-medium">
|
|
||||||
{t("balance.category.fields.actions")}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{state.categories.map((cat) => (
|
|
||||||
<tr key={cat.id} className="border-t border-[var(--border)]">
|
|
||||||
<td className="px-4 py-2">{renderCategoryLabel(cat)}</td>
|
|
||||||
<td className="px-4 py-2 text-[var(--muted-foreground)]">
|
|
||||||
<code className="text-xs">{cat.key}</code>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--muted)]">
|
|
||||||
{t(`balance.category.kind.${cat.kind}`)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{cat.is_seed ? (
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.category.origin.seeded")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.category.origin.user")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right">
|
|
||||||
<div className="inline-flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const next = window.prompt(
|
|
||||||
t("balance.category.actions.renamePrompt"),
|
|
||||||
renderCategoryLabel(cat)
|
|
||||||
);
|
|
||||||
if (next && next.trim()) {
|
|
||||||
editCategory(cat.id, { i18n_key: next.trim() });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
||||||
title={t("common.edit")}
|
|
||||||
>
|
|
||||||
<Edit2 size={14} />
|
|
||||||
</button>
|
|
||||||
{(() => {
|
|
||||||
const linkedCount =
|
|
||||||
accountsByCategory.get(cat.id)?.length ?? 0;
|
|
||||||
const blocked = cat.is_seed || linkedCount > 0;
|
|
||||||
const titleKey = cat.is_seed
|
|
||||||
? t("balance.category.actions.deleteSeedHint")
|
|
||||||
: linkedCount > 0
|
|
||||||
? t("balance.category.actions.deleteHasAccountsHint", {
|
|
||||||
count: linkedCount,
|
|
||||||
})
|
|
||||||
: t("common.delete");
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDeleteCategory(cat)}
|
|
||||||
disabled={blocked}
|
|
||||||
title={titleKey}
|
|
||||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
// BalancePage — overview of net worth at `/balance`.
|
|
||||||
//
|
|
||||||
// Issue #141 (Bilan #3). Composes:
|
|
||||||
// - BalanceOverviewCard (latest total + Δ% + staleness warning + new-snapshot CTA)
|
|
||||||
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
|
||||||
// - Chart-mode toggle (Line / Stacked-by-category)
|
|
||||||
// - BalanceEvolutionChart
|
|
||||||
// - BalanceAccountsTable (one row per active account with latest value + Δ%)
|
|
||||||
//
|
|
||||||
// All data flows through `useBalanceOverview` (scoped useReducer). Returns
|
|
||||||
// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves
|
|
||||||
// columns with a TODO comment.
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Wallet } from "lucide-react";
|
|
||||||
import {
|
|
||||||
useBalanceOverview,
|
|
||||||
type BalancePeriod,
|
|
||||||
type BalanceChartMode,
|
|
||||||
} from "../hooks/useBalanceOverview";
|
|
||||||
import {
|
|
||||||
archiveBalanceAccount,
|
|
||||||
listAccountTransfers,
|
|
||||||
type AccountLatestSnapshot,
|
|
||||||
} from "../services/balance.service";
|
|
||||||
import { getAllCategories } from "../services/transactionService";
|
|
||||||
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
|
||||||
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
|
||||||
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
|
||||||
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
|
||||||
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
|
||||||
|
|
||||||
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
|
||||||
|
|
||||||
export default function BalancePage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { state, setPeriod, setChartMode, reload } = useBalanceOverview();
|
|
||||||
|
|
||||||
// Issue #142 — link-transfers modal state. Categories list is loaded once
|
|
||||||
// on mount (used by the modal's filter dropdown).
|
|
||||||
const [linkTarget, setLinkTarget] = useState<AccountLatestSnapshot | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
|
||||||
const [transfersByAccount, setTransfersByAccount] = useState<
|
|
||||||
Map<number, BalanceAccountTransferWithTransaction[]>
|
|
||||||
>(new Map());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void getAllCategories().then(setCategories).catch(() => setCategories([]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Refresh per-account transfer lists used by the chart markers. Keyed by
|
|
||||||
// account_id → [transfers]. Used by `BalanceEvolutionChart` to plot
|
|
||||||
// ReferenceLine markers (green for in, red for out).
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
async function run() {
|
|
||||||
const map = new Map<number, BalanceAccountTransferWithTransaction[]>();
|
|
||||||
await Promise.all(
|
|
||||||
state.accountsLatest.map(async (acc) => {
|
|
||||||
try {
|
|
||||||
const list = await listAccountTransfers(acc.account_id);
|
|
||||||
map.set(acc.account_id, list);
|
|
||||||
} catch {
|
|
||||||
map.set(acc.account_id, []);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (!cancelled) setTransfersByAccount(map);
|
|
||||||
}
|
|
||||||
void run();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [state.accountsLatest]);
|
|
||||||
|
|
||||||
const allTransferMarkers = useMemo(() => {
|
|
||||||
const flat: BalanceAccountTransferWithTransaction[] = [];
|
|
||||||
for (const list of transfersByAccount.values()) flat.push(...list);
|
|
||||||
return flat;
|
|
||||||
}, [transfersByAccount]);
|
|
||||||
|
|
||||||
// Earliest snapshot date in the dataset, used to anchor the "depuis
|
|
||||||
// création" Modified Dietz horizon in the accounts table.
|
|
||||||
const earliestSnapshotDate = useMemo(() => {
|
|
||||||
if (state.evolutionTotals.length === 0) return null;
|
|
||||||
return state.evolutionTotals[0].snapshot_date;
|
|
||||||
}, [state.evolutionTotals]);
|
|
||||||
|
|
||||||
// Build a category_key → translated label map from the accounts payload —
|
|
||||||
// the byCategory series is keyed by `key`, not by id, and the same
|
|
||||||
// taxonomy is already loaded with `accountsLatest` joins.
|
|
||||||
const categoryLabels = useMemo(() => {
|
|
||||||
const m: Record<string, string> = {};
|
|
||||||
for (const a of state.accountsLatest) {
|
|
||||||
if (!m[a.category_key]) {
|
|
||||||
m[a.category_key] = t(a.category_i18n_key, {
|
|
||||||
defaultValue: a.category_key,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}, [state.accountsLatest, t]);
|
|
||||||
|
|
||||||
const handleArchiveAccount = async (accountId: number) => {
|
|
||||||
try {
|
|
||||||
await archiveBalanceAccount(accountId);
|
|
||||||
await reload();
|
|
||||||
} catch {
|
|
||||||
// Reload swallows; the row simply stays. UX feedback can be added later.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={state.isLoading ? "opacity-60 pointer-events-none" : ""}>
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<Wallet size={24} className="text-[var(--primary)]" />
|
|
||||||
<h1 className="text-2xl font-bold">{t("balance.overview.title")}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.error && (
|
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
|
||||||
{state.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<BalanceOverviewCard totals={state.evolutionTotals} />
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
||||||
{/* Period selector */}
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
aria-label={t("balance.period.legend")}
|
|
||||||
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
|
||||||
>
|
|
||||||
{PERIOD_OPTIONS.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPeriod(p)}
|
|
||||||
className={`px-3 py-1.5 text-sm font-medium ${
|
|
||||||
state.period === p
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
|
||||||
}`}
|
|
||||||
aria-pressed={state.period === p}
|
|
||||||
>
|
|
||||||
{t(`balance.period.${p}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart mode toggle */}
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
aria-label={t("balance.chart.modeLegend")}
|
|
||||||
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
|
||||||
>
|
|
||||||
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setChartMode(mode)}
|
|
||||||
className={`px-3 py-1.5 text-sm font-medium ${
|
|
||||||
state.chartMode === mode
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
|
||||||
}`}
|
|
||||||
aria-pressed={state.chartMode === mode}
|
|
||||||
>
|
|
||||||
{t(`balance.chart.mode.${mode}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BalanceEvolutionChart
|
|
||||||
mode={state.chartMode}
|
|
||||||
totals={state.evolutionTotals}
|
|
||||||
byCategory={state.evolutionByCategory}
|
|
||||||
categoryLabels={categoryLabels}
|
|
||||||
transferMarkers={allTransferMarkers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold mb-3">
|
|
||||||
{t("balance.overview.accountsTitle")}
|
|
||||||
</h2>
|
|
||||||
<BalanceAccountsTable
|
|
||||||
accounts={state.accountsLatest}
|
|
||||||
periodAnchor={state.accountsPeriodAnchor}
|
|
||||||
sinceCreationDate={earliestSnapshotDate}
|
|
||||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
|
||||||
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{linkTarget && (
|
|
||||||
<LinkTransfersModal
|
|
||||||
accountId={linkTarget.account_id}
|
|
||||||
accountName={linkTarget.account_name}
|
|
||||||
categories={categories}
|
|
||||||
onClose={() => setLinkTarget(null)}
|
|
||||||
onLinked={() => {
|
|
||||||
void reload();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Wallet,
|
|
||||||
Settings,
|
Settings,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
|
|
@ -30,7 +29,6 @@ const SECTIONS = [
|
||||||
{ key: "adjustments", icon: SlidersHorizontal },
|
{ key: "adjustments", icon: SlidersHorizontal },
|
||||||
{ key: "budget", icon: PiggyBank },
|
{ key: "budget", icon: PiggyBank },
|
||||||
{ key: "reports", icon: BarChart3 },
|
{ key: "reports", icon: BarChart3 },
|
||||||
{ key: "balance", icon: Wallet },
|
|
||||||
{ key: "settings", icon: Settings },
|
{ key: "settings", icon: Settings },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,358 +0,0 @@
|
||||||
// SnapshotEditPage — create or edit a balance snapshot at a given date.
|
|
||||||
//
|
|
||||||
// Issue #146 / Bilan #1b ships the route `/balance/snapshot` with two modes
|
|
||||||
// driven by the `?date=` query parameter:
|
|
||||||
// - `?date=` absent → 'new' mode (date picker editable, defaults to today)
|
|
||||||
// - `?date=YYYY-MM-DD` → 'edit' mode if a snapshot exists at that date,
|
|
||||||
// otherwise 'new' mode pre-selected at that date (which mirrors the
|
|
||||||
// "redirect to edit" flow when the user comes from the future
|
|
||||||
// /balance overview's "Edit" link).
|
|
||||||
//
|
|
||||||
// The page itself only orchestrates: all DB work flows through
|
|
||||||
// `useSnapshotEditor`, the editor view through `SnapshotEditor`. Per spec
|
|
||||||
// (decisions row "Bouton Pré-remplir"), priced-kind prefill is a no-op
|
|
||||||
// here (the priced editor lands in #140).
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Trash2,
|
|
||||||
Save,
|
|
||||||
Wallet,
|
|
||||||
RotateCcw,
|
|
||||||
AlertTriangle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useSnapshotEditor } from "../hooks/useSnapshotEditor";
|
|
||||||
import SnapshotEditor from "../components/balance/SnapshotEditor";
|
|
||||||
|
|
||||||
export default function SnapshotEditPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const dateParam = searchParams.get("date");
|
|
||||||
const editor = useSnapshotEditor({ dateParam });
|
|
||||||
const { state } = editor;
|
|
||||||
|
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
||||||
const [deleteConfirmText, setDeleteConfirmText] = useState("");
|
|
||||||
|
|
||||||
// Reset the delete modal whenever the underlying snapshot changes (e.g.
|
|
||||||
// after switching ?date=).
|
|
||||||
useEffect(() => {
|
|
||||||
setShowDeleteModal(false);
|
|
||||||
setDeleteConfirmText("");
|
|
||||||
}, [state.snapshot?.id]);
|
|
||||||
|
|
||||||
const isEditMode = state.mode === "edit";
|
|
||||||
const canPrefill = !!state.previousSnapshot;
|
|
||||||
|
|
||||||
// Aggregate value across simple + priced lines (computed live as the
|
|
||||||
// user types). Priced contribution = quantity × unit_price.
|
|
||||||
const totalValue = useMemo(() => {
|
|
||||||
let total = 0;
|
|
||||||
let hasAny = false;
|
|
||||||
for (const raw of Object.values(state.values)) {
|
|
||||||
if (!raw) continue;
|
|
||||||
const trimmed = String(raw).trim().replace(",", ".");
|
|
||||||
const n = Number(trimmed);
|
|
||||||
if (Number.isFinite(n)) {
|
|
||||||
total += n;
|
|
||||||
hasAny = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const entry of Object.values(state.pricedValues)) {
|
|
||||||
if (!entry) continue;
|
|
||||||
const qty = Number(String(entry.quantity ?? "").trim().replace(",", "."));
|
|
||||||
const price = Number(
|
|
||||||
String(entry.unit_price ?? "").trim().replace(",", ".")
|
|
||||||
);
|
|
||||||
if (Number.isFinite(qty) && Number.isFinite(price)) {
|
|
||||||
total += qty * price;
|
|
||||||
hasAny = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasAny ? total : null;
|
|
||||||
}, [state.values, state.pricedValues]);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await editor.save();
|
|
||||||
// After a successful create, the URL should become `?date=...` so
|
|
||||||
// refreshing keeps the user in edit mode.
|
|
||||||
if (!isEditMode) {
|
|
||||||
setSearchParams(
|
|
||||||
{ date: state.snapshotDate },
|
|
||||||
{ replace: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// The hook surfaced the error via state.errorCode/state.error.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
try {
|
|
||||||
await editor.remove();
|
|
||||||
navigate("/balance/accounts");
|
|
||||||
} catch {
|
|
||||||
// surfaced via state.error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate("/balance/accounts")}
|
|
||||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)]"
|
|
||||||
title={t("common.back")}
|
|
||||||
>
|
|
||||||
<ArrowLeft size={18} />
|
|
||||||
</button>
|
|
||||||
<Wallet size={24} className="text-[var(--primary)]" />
|
|
||||||
<h1 className="text-2xl font-bold">
|
|
||||||
{isEditMode
|
|
||||||
? t("balance.snapshot.page.editTitle")
|
|
||||||
: t("balance.snapshot.page.newTitle")}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.error && (
|
|
||||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
|
||||||
{state.errorCode
|
|
||||||
? t(`balance.errors.${state.errorCode}`, {
|
|
||||||
defaultValue: state.error,
|
|
||||||
})
|
|
||||||
: state.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label
|
|
||||||
className="block text-sm font-medium mb-1"
|
|
||||||
htmlFor="snapshot-date"
|
|
||||||
>
|
|
||||||
{t("balance.snapshot.page.dateLabel")}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="snapshot-date"
|
|
||||||
type="date"
|
|
||||||
value={state.snapshotDate}
|
|
||||||
disabled={isEditMode}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = e.target.value;
|
|
||||||
editor.setDate(next);
|
|
||||||
// Drive the route param so reloads stay coherent and an
|
|
||||||
// existing snapshot at the chosen date flips us into 'edit'.
|
|
||||||
if (next) {
|
|
||||||
setSearchParams({ date: next }, { replace: true });
|
|
||||||
} else {
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60"
|
|
||||||
/>
|
|
||||||
{isEditMode && (
|
|
||||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.snapshot.page.dateImmutable")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{totalValue !== null && (
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xs text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.snapshot.page.total")}
|
|
||||||
</div>
|
|
||||||
<div className="text-2xl font-semibold tabular-nums">
|
|
||||||
{totalValue.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.accounts.length === 0 && !state.isLoading ? (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
|
|
||||||
<p className="mb-3">{t("balance.snapshot.page.noAccounts")}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate("/balance/accounts")}
|
|
||||||
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
|
||||||
>
|
|
||||||
{t("balance.snapshot.page.goToAccounts")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SnapshotEditor
|
|
||||||
accounts={state.accounts}
|
|
||||||
categories={state.categories}
|
|
||||||
values={state.values}
|
|
||||||
pricedValues={state.pricedValues}
|
|
||||||
onValueChange={editor.setLineValue}
|
|
||||||
onQuantityChange={editor.setLineQuantity}
|
|
||||||
onUnitPriceChange={editor.setLineUnitPrice}
|
|
||||||
disabled={state.isSaving}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action bar */}
|
|
||||||
<div className="mt-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={editor.prefillFromPrevious}
|
|
||||||
disabled={!canPrefill || state.isSaving}
|
|
||||||
title={
|
|
||||||
canPrefill
|
|
||||||
? t("balance.snapshot.page.prefillTooltip", {
|
|
||||||
date: state.previousSnapshot?.snapshot_date,
|
|
||||||
})
|
|
||||||
: t("balance.snapshot.page.prefillNoPrevious")
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} />
|
|
||||||
{t("balance.snapshot.page.prefill")}
|
|
||||||
</button>
|
|
||||||
{isEditMode && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowDeleteModal(true)}
|
|
||||||
disabled={state.isSaving}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--negative)]/40 text-sm text-[var(--negative)] hover:bg-[var(--negative)]/10 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
{t("balance.snapshot.page.delete")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate("/balance/accounts")}
|
|
||||||
disabled={state.isSaving}
|
|
||||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={
|
|
||||||
state.isSaving ||
|
|
||||||
state.isLoading ||
|
|
||||||
state.accounts.length === 0 ||
|
|
||||||
!state.snapshotDate
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Save size={14} />
|
|
||||||
{isEditMode
|
|
||||||
? t("balance.snapshot.page.save")
|
|
||||||
: t("balance.snapshot.page.create")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete confirmation modal — double-confirmation requires retyping
|
|
||||||
the snapshot date. */}
|
|
||||||
{showDeleteModal && state.snapshot && (
|
|
||||||
<DeleteConfirmModal
|
|
||||||
snapshotDate={state.snapshot.snapshot_date}
|
|
||||||
confirmText={deleteConfirmText}
|
|
||||||
onConfirmTextChange={setDeleteConfirmText}
|
|
||||||
isSaving={state.isSaving}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowDeleteModal(false);
|
|
||||||
setDeleteConfirmText("");
|
|
||||||
}}
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Internal components
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function DeleteConfirmModal({
|
|
||||||
snapshotDate,
|
|
||||||
confirmText,
|
|
||||||
onConfirmTextChange,
|
|
||||||
isSaving,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
snapshotDate: string;
|
|
||||||
confirmText: string;
|
|
||||||
onConfirmTextChange: (next: string) => void;
|
|
||||||
isSaving: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isMatch = confirmText.trim() === snapshotDate;
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full p-6">
|
|
||||||
<div className="flex items-start gap-3 mb-4">
|
|
||||||
<div className="p-2 rounded-full bg-[var(--negative)]/10 text-[var(--negative)]">
|
|
||||||
<AlertTriangle size={20} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{t("balance.snapshot.delete.title")}
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
|
||||||
{t("balance.snapshot.delete.body", { date: snapshotDate })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
className="block text-sm font-medium mb-1"
|
|
||||||
htmlFor="delete-confirm-input"
|
|
||||||
>
|
|
||||||
{t("balance.snapshot.delete.confirmLabel", { date: snapshotDate })}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="delete-confirm-input"
|
|
||||||
type="text"
|
|
||||||
value={confirmText}
|
|
||||||
onChange={(e) => onConfirmTextChange(e.target.value)}
|
|
||||||
placeholder={snapshotDate}
|
|
||||||
autoComplete="off"
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--negative)]"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={isSaving || !isMatch}
|
|
||||||
className="px-4 py-2 rounded-lg bg-[var(--negative)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("balance.snapshot.delete.confirm")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from "react";
|
import { 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,10 +9,6 @@ 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() {
|
||||||
|
|
@ -22,18 +18,6 @@ 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();
|
||||||
|
|
@ -111,7 +95,6 @@ export default function TransactionsPage() {
|
||||||
onSaveSplit={saveSplit}
|
onSaveSplit={saveSplit}
|
||||||
onDeleteSplit={deleteSplit}
|
onDeleteSplit={deleteSplit}
|
||||||
onRowContextMenu={handleRowContextMenu}
|
onRowContextMenu={handleRowContextMenu}
|
||||||
linkedTransfersByTxId={linkedTransfersByTxId}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionPagination
|
<TransactionPagination
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,4 @@
|
||||||
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(
|
||||||
|
|
@ -96,39 +94,10 @@ export async function deleteImportWithTransactions(
|
||||||
);
|
);
|
||||||
const sourceId = files.length > 0 ? files[0].source_id : null;
|
const sourceId = files.length > 0 ? files[0].source_id : null;
|
||||||
|
|
||||||
// Pre-flight: if any transaction in this file is linked to a balance
|
const result = await db.execute(
|
||||||
// account via `balance_account_transfers`, the FK RESTRICT will fire on
|
"DELETE FROM transactions WHERE file_id = $1",
|
||||||
// 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
|
||||||
|
|
@ -147,32 +116,7 @@ export async function deleteImportWithTransactions(
|
||||||
|
|
||||||
export async function deleteAllImportsWithTransactions(): Promise<number> {
|
export async function deleteAllImportsWithTransactions(): Promise<number> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
// Same pre-flight as the per-file path: if ANY transaction is linked to
|
const result = await db.execute("DELETE FROM transactions");
|
||||||
// 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;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
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,
|
||||||
|
|
@ -15,85 +11,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,6 @@ export const NAV_ITEMS: NavItem[] = [
|
||||||
icon: "BarChart3",
|
icon: "BarChart3",
|
||||||
labelKey: "nav.reports",
|
labelKey: "nav.reports",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "balance",
|
|
||||||
path: "/balance",
|
|
||||||
icon: "Wallet",
|
|
||||||
labelKey: "nav.balance",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "settings",
|
key: "settings",
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
|
|
|
||||||
|
|
@ -555,132 +555,3 @@ export interface TransactionPageResult {
|
||||||
incomeTotal: number;
|
incomeTotal: number;
|
||||||
expenseTotal: number;
|
expenseTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Balance (Bilan) types ---
|
|
||||||
// Backed by migration v9 (see src-tauri/src/database/balance_schema.sql).
|
|
||||||
// MVP scope (Issue #138 / #1a): categories + accounts CRUD only. Snapshots,
|
|
||||||
// snapshot lines and transfers ship in subsequent issues (#1b / #2 / #4).
|
|
||||||
|
|
||||||
export type BalanceCategoryKind = "simple" | "priced";
|
|
||||||
|
|
||||||
export const BALANCE_CURRENCY_CAD = "CAD";
|
|
||||||
|
|
||||||
export interface BalanceCategory {
|
|
||||||
id: number;
|
|
||||||
/** Stable lookup key (e.g. 'cash', 'tfsa', 'stock'). UNIQUE NOT NULL. */
|
|
||||||
key: string;
|
|
||||||
/** Translation key into i18n locales (e.g. 'balance.category.cash'). */
|
|
||||||
i18n_key: string;
|
|
||||||
/** simple = direct value entry; priced = quantity x unit_price. */
|
|
||||||
kind: BalanceCategoryKind;
|
|
||||||
sort_order: number;
|
|
||||||
is_active: boolean;
|
|
||||||
/** True when seeded by Migration v9 — cannot be deleted, can be renamed. */
|
|
||||||
is_seed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BalanceAccount {
|
|
||||||
id: number;
|
|
||||||
balance_category_id: number;
|
|
||||||
name: string;
|
|
||||||
/** Symbol (e.g. 'AAPL', 'BTC-USD'); NULL for simple-kind accounts. */
|
|
||||||
symbol: string | null;
|
|
||||||
/** ISO 4217. MVP: hardcoded 'CAD' (CHECK enforced server-side). */
|
|
||||||
currency: string;
|
|
||||||
notes: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
/** Soft-delete timestamp; archived accounts hide from new snapshots. */
|
|
||||||
archived_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Joined view used by AccountsPage tables. */
|
|
||||||
export interface BalanceAccountWithCategory extends BalanceAccount {
|
|
||||||
category_key: string;
|
|
||||||
category_i18n_key: string;
|
|
||||||
category_kind: BalanceCategoryKind;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshots — added Issue #146 (Bilan #1b) for the SnapshotEditPage.
|
|
||||||
// Lines are kept simple-kind only here (`quantity` / `unit_price` always NULL).
|
|
||||||
// The priced-kind UI lands in #140 / Bilan #2.
|
|
||||||
|
|
||||||
export interface BalanceSnapshot {
|
|
||||||
id: number;
|
|
||||||
/** ISO date (YYYY-MM-DD), UNIQUE across the table. */
|
|
||||||
snapshot_date: string;
|
|
||||||
notes: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BalanceSnapshotLine {
|
|
||||||
id: number;
|
|
||||||
snapshot_id: number;
|
|
||||||
account_id: number;
|
|
||||||
/** Always NULL for simple-kind lines (Issue #146 scope). */
|
|
||||||
quantity: number | null;
|
|
||||||
/** Always NULL for simple-kind lines (Issue #146 scope). */
|
|
||||||
unit_price: number | null;
|
|
||||||
value: number;
|
|
||||||
/** 'manual' for simple-kind, 'maximus-api' for priced (#142). */
|
|
||||||
price_source: string | null;
|
|
||||||
price_fetched_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account transfers — added Issue #142 (Bilan #4). Links a transaction to a
|
|
||||||
// balance account so the Modified Dietz return calculator can separate
|
|
||||||
// contributions from gains. Direction follows the account's perspective:
|
|
||||||
// 'in' = capital added (deposit / buy)
|
|
||||||
// 'out' = capital removed (withdrawal / sell)
|
|
||||||
// `transaction_id` ON DELETE RESTRICT — preserves reproducibility of past
|
|
||||||
// returns, the UI must force the user to unlink before deleting the
|
|
||||||
// underlying transaction.
|
|
||||||
export type BalanceTransferDirection = "in" | "out";
|
|
||||||
|
|
||||||
export interface BalanceAccountTransfer {
|
|
||||||
id: number;
|
|
||||||
account_id: number;
|
|
||||||
transaction_id: number;
|
|
||||||
direction: BalanceTransferDirection;
|
|
||||||
notes: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Joined view used by LinkTransfersModal + transaction icon lookup. */
|
|
||||||
export interface BalanceAccountTransferWithTransaction
|
|
||||||
extends BalanceAccountTransfer {
|
|
||||||
transaction_date: string;
|
|
||||||
transaction_description: string;
|
|
||||||
transaction_amount: number;
|
|
||||||
account_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modified Dietz return for one account over a period.
|
|
||||||
* Mirrors the Rust struct in `src-tauri/src/commands/return_calculator.rs`.
|
|
||||||
*
|
|
||||||
* - `value_start` / `value_end`: latest snapshot value ≤ each endpoint, or
|
|
||||||
* null when no snapshot exists.
|
|
||||||
* - `net_contributions`: signed sum of cash flows in the period.
|
|
||||||
* - `return_pct`: Modified Dietz return (0.05 = +5%); null if either
|
|
||||||
* endpoint is missing or denominator is non-positive.
|
|
||||||
* - `annualized_pct`: `(1 + R)^(365/T) - 1`; null for zero-length periods
|
|
||||||
* or whenever `return_pct` is null.
|
|
||||||
* - `is_partial`: true when one endpoint snapshot is missing.
|
|
||||||
* - `has_no_transfers_warning`: true when no transfers were tagged in the
|
|
||||||
* period — return collapses to simple `(V_end - V_start) / V_start` and
|
|
||||||
* the UI surfaces a warning so the user can verify nothing was forgotten.
|
|
||||||
*/
|
|
||||||
export interface AccountReturn {
|
|
||||||
value_start: number | null;
|
|
||||||
value_end: number | null;
|
|
||||||
net_contributions: number;
|
|
||||||
return_pct: number | null;
|
|
||||||
annualized_pct: number | null;
|
|
||||||
is_partial: boolean;
|
|
||||||
has_no_transfers_warning: boolean;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue