Compare commits
139 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f0833c36 | ||
| ae0f8150ff | |||
| d95d80580c | |||
| c8a6f74a1d | |||
| 1a4cab2e9b | |||
| c4edfb0a35 | |||
| cbaa9cb6d0 | |||
| 205e1ded54 | |||
| 4e4e4bd0d2 | |||
| 604b97fc4d | |||
|
|
d41ccbd618 | ||
|
|
6c82501d6d | ||
|
|
9608fd3618 | ||
|
|
76ddad66c9 | ||
|
|
4846120b0f | ||
|
|
737654579f | ||
|
|
582cf4012d | ||
|
|
90c115e0c0 | ||
|
|
a1c3dafcd0 | ||
|
|
ac125b90ac | ||
| f1cbcb1c27 | |||
|
|
ebc709a277 | ||
| d3b8ad6266 | |||
|
|
0104e9223a | ||
| 6cf0c8850e | |||
|
|
344c27ee6d | ||
| cb58bbb31a | |||
|
|
5861346eb3 | ||
|
|
6d54ffa7a9 | ||
|
|
526cb34fe2 | ||
| a73bf2ebb0 | |||
|
|
459bcf9ca5 | ||
|
|
75ea48d96a | ||
| 3024374e50 | |||
| bc7a0e0231 | |||
|
|
9010c04315 | ||
| d2e65ae1ea | |||
|
|
4095aec453 | ||
| 4e7ba6b460 | |||
|
|
9dd78b77f2 | ||
|
|
a7daabdf70 | ||
|
|
372a785834 | ||
|
|
445822b792 | ||
|
|
8c3a64d172 | ||
|
|
2eeac78b40 | ||
|
|
3b9badb726 | ||
|
|
fbd8be403a | ||
| 87dfd59eda | |||
|
|
0a8b5c7805 | ||
| efea8fb273 | |||
|
|
f02fd95ab1 | ||
| e7e02d636c | |||
|
|
7f5e5a8c71 | ||
| f9b4e4fa40 | |||
|
|
0d50a92b0e | ||
|
|
4cd0ac9a13 | ||
|
|
0cf13de7fe | ||
|
|
a9d1301dd2 | ||
|
|
e342a1f567 | ||
|
|
3260ea8c47 | ||
|
|
8030a4a1c4 | ||
|
|
d147520d6b | ||
|
|
cd0a2b826f | ||
|
|
eac2a516b5 | ||
|
|
50b119121f | ||
|
|
44cc77d8f6 | ||
|
|
bde47dabed | ||
|
|
5836760f3c | ||
| 67c48029a0 | |||
|
|
e0844f0f34 | ||
| f16f340c22 | |||
|
|
af36b51cf7 | ||
| 3342fd9bb7 | |||
|
|
3963f552ae | ||
| 877aff8d6d | |||
|
|
a6097afcf3 | ||
| d140ed938a | |||
|
|
da4eef2bdd | ||
|
|
88c3c04dea | ||
| 97f91f87aa | |||
|
|
55c610c1f2 | ||
| edd1a5cbe4 | |||
| 01cfbdba8b | |||
| 3b2384af25 | |||
| 0511d2ef06 | |||
|
|
80c28d43ac | ||
|
|
8fa34d786d | ||
|
|
043e9bf622 | ||
|
|
c90badae39 | ||
|
|
99814b9a0d | ||
|
|
b1dc76b487 | ||
|
|
920f81fce5 | ||
|
|
531624bcb4 | ||
|
|
98f68f7a1f | ||
|
|
ab7e0a3362 | ||
|
|
ddb0cb257b | ||
| c14de9a6f8 | |||
|
|
97680417ee | ||
| 9c79b73871 | |||
| 51a6cec8f1 | |||
| 8df1aed258 | |||
| 47ecf886d2 | |||
| 6341aeb74c | |||
| a344eab2bb | |||
| b6387f4b31 | |||
|
|
ce15c903e4 | ||
|
|
bef330affb | ||
|
|
098e15bb5c | ||
|
|
4d5a0e2e3b | ||
|
|
5274e51907 | ||
|
|
5a54d37de5 | ||
|
|
50fe0ab1ac | ||
|
|
9adfb85d84 | ||
|
|
ca275821bc | ||
|
|
faa09614a3 | ||
|
|
0e996a5aa1 | ||
|
|
a45e5c3cd0 | ||
|
|
dafdd4ce17 | ||
|
|
23ff8466c0 | ||
|
|
0381dd48bb | ||
|
|
c9cdb5a891 | ||
|
|
1e261ae2ea | ||
|
|
83ac484a22 | ||
|
|
ffefa90fd0 | ||
|
|
202b008bc9 | ||
|
|
396310aa74 | ||
|
|
80c0a97841 | ||
|
|
5bc7fe80b1 | ||
|
|
6288a3fe23 | ||
|
|
db5bffbdcf | ||
|
|
8f5cc71707 | ||
|
|
fdc6cc6c38 | ||
|
|
afc338b564 | ||
|
|
4c71eaca2d | ||
|
|
fccc8e4fa2 | ||
|
|
58d3c86336 | ||
|
|
a6787adef0 | ||
| 1f506fb171 | |||
|
|
871768593d |
|
|
@ -1,17 +1,24 @@
|
||||||
name: PR Check
|
name: PR Check
|
||||||
|
|
||||||
# Validates Rust + frontend on every branch push and PR.
|
# Validates Rust + frontend on every PR opened against main.
|
||||||
# Goal: catch compile errors, type errors, and failing tests BEFORE merge,
|
# Goal: catch compile errors, type errors, and failing tests BEFORE merge,
|
||||||
# instead of waiting for the release tag (which is when release.yml runs).
|
# instead of waiting for the release tag (which is when release.yml runs).
|
||||||
|
#
|
||||||
|
# Trigger is `pull_request` only — the previous `push` trigger duplicated
|
||||||
|
# every run when a branch was pushed and immediately opened as a PR (#171).
|
||||||
|
# Trade-off: branches pushed without an open PR don't get CI feedback. Open
|
||||||
|
# a draft PR if you want feedback before requesting review.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches-ignore:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
|
|
|
||||||
11
.github/workflows/check.yml
vendored
|
|
@ -2,15 +2,20 @@ name: PR Check
|
||||||
|
|
||||||
# Mirror of .forgejo/workflows/check.yml using GitHub-native runners.
|
# Mirror of .forgejo/workflows/check.yml using GitHub-native runners.
|
||||||
# Forgejo is the primary host; this file keeps the GitHub mirror functional.
|
# Forgejo is the primary host; this file keeps the GitHub mirror functional.
|
||||||
|
#
|
||||||
|
# Trigger is `pull_request` only — kept in sync with the Forgejo workflow
|
||||||
|
# after #171 dropped the redundant `push` trigger that duplicated every run.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches-ignore:
|
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
rust:
|
rust:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
12
.gitignore
vendored
|
|
@ -53,7 +53,19 @@ public/CHANGELOG.fr.md
|
||||||
# Tauri generated
|
# Tauri generated
|
||||||
src-tauri/gen/
|
src-tauri/gen/
|
||||||
|
|
||||||
|
# Tauri icon CLI also generates iOS/Android assets — desktop targets only (nsis, deb, rpm)
|
||||||
|
src-tauri/icons/android/
|
||||||
|
src-tauri/icons/ios/
|
||||||
|
|
||||||
# Claude Code local state
|
# Claude Code local state
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/scheduled_tasks.lock
|
.claude/scheduled_tasks.lock
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
|
decisions-log.md
|
||||||
|
|
||||||
|
# Autopilot scratch + daily reports
|
||||||
|
reports/
|
||||||
|
|
||||||
|
# Spec scratch (committed only when promoted to docs/archive/)
|
||||||
|
spec-decisions-*.md
|
||||||
|
spec-plan-*.md
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,70 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- Bilan : **saisie de snapshot par titre**. Un compte *détaillé* se saisit désormais titre par titre — chaque position a sa propre ligne avec un sélecteur de titre (autocomplétion sur vos titres existants, avec création inline d'un nouveau symbole), quantité, cours (avec la récupération automatique de prix optionnelle), coût d'acquisition, et un gain latent calculé en direct. La valeur du compte est la somme affichée de ses positions. Les comptes simples sont inchangés. Le sélecteur de titre accepte n'importe quel symbole normalisé (MAJUSCULES/sans espaces) — pas de validation en direct du symbole, puisque la récupération du prix est une étape séparée et au mieux ; vous choisissez la classe d'actif (Action / Crypto) à la création d'un nouveau symbole (#214).
|
||||||
|
- Bilan : **détailler un compte agrégé en titres** (assistant). Un compte *simple* peut être basculé en *détaillé* via l'action « Détailler en titres » dans le tableau des comptes. La bascule est à sens unique : elle passe le compte en détaillé et fixe la date du jour comme date de bascule (pivot, `detailed_since`) — l'historique agrégé passé reste figé en lecture seule, et les titres se saisissent au prochain snapshot normal (l'assistant ne capture pas de portefeuille initial). Une fois un titre saisi, le compte ne peut plus revenir en saisie simple (verrouillé à la fois dans l'interface et dans le service) (#215).
|
||||||
|
- Bilan : **drill-down par titre et gain latent dans le tableau des comptes et l'aperçu**. Un compte détaillé peut être déplié pour afficher ses titres (valeur + gain latent %), et le gain latent (valeur − coût d'acquisition) est agrégé par compte, par classe d'actif et par enveloppe fiscale dans le tableau des comptes et la carte d'aperçu du bilan. Les positions sans coût d'acquisition sont signalées « N/A » et exclues du pourcentage (pas de division par zéro) ; le rendement Modified Dietz par compte est inchangé (#216).
|
||||||
|
- Bilan : la date d'un snapshot existant peut maintenant être déplacée. Le champ date devient modifiable en mode édition — changez-la puis enregistrez, et le snapshot (avec toutes ses lignes) est déplacé à la nouvelle date dans une seule transaction atomique. Si un autre snapshot occupe déjà la date cible, le déplacement est refusé avec un message clair et rien n'est modifié (#200).
|
||||||
|
- Bilan : **enveloppe fiscale sur les comptes**. Un compte peut désormais porter une enveloppe fiscale optionnelle (Non-enregistré, CELI, REER, FERR, CELIAPP, REEE — ou aucune), choisie via un menu déroulant dans le formulaire de compte. C'est un axe distinct du type du compte (la classe d'actif) : un compte d'actions logé dans un CELI est enfin exprimable — type *Actions* + enveloppe *CELI*. La migration v12 ajoute la colonne nullable `balance_accounts.vehicle_type` avec un CHECK sur l'enum et backfille les anciens comptes CELI/REER (#202, #203).
|
||||||
|
- Bilan : **axe enveloppe du graphique empilé**. Le graphique d'évolution empilé gagne un sous-choix d'axe — **Par classe d'actif** (défaut, comportement inchangé) ou **Par enveloppe** (regroupe par enveloppe fiscale, avec un bucket « Aucune » pour les comptes sans enveloppe). Lisez votre patrimoine par ce que vous détenez *ou* par l'abri fiscal où il se trouve (#204).
|
||||||
|
- Bilan : **colonnes de rendement repliables**. Les quatre colonnes de rendement Modified Dietz du tableau des comptes (3M / 1A / depuis création / non-ajusté) sont maintenant repliées par défaut derrière un bouton afficher/masquer ; le choix est mémorisé d'une session à l'autre (`user_preferences.balance_show_returns`) pour réduire le bruit visuel chez les utilisateurs qui ne suivent que des valeurs (#204).
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- Bilan : terminologie clarifiée. Les regroupements de comptes (Liquidités, CELI, REER, etc.) sont désormais appelés **types** de façon cohérente dans l'interface du bilan et le guide utilisateur, pour éviter la confusion avec les *catégories* de transactions (un autre module). Le type « Encaisse » devient **Liquidités** et « Fonds commun » devient **Fonds / FNB**. Le terme *snapshot* est conservé mais glosé à son premier usage (#198).
|
||||||
|
- Bilan : **un type est désormais une pure classe d'actif**. Les types standard sont les cinq classes d'actif — Liquidités, Fonds / FNB, Actions, Crypto, Autres. Les enveloppes fiscales qui étaient des types (CELI, REER) ne sont plus des types : elles ont migré vers le nouvel attribut d'enveloppe fiscale, porté par le compte. À la migration, les comptes CELI/REER existants sont reclassés dans la classe d'actif **Autres** tout en conservant leur enveloppe (`tfsa`/`rrsp`) ; les types seedés `tfsa`/`rrsp` sont désactivés et disparaissent des menus de types (migrations v12 + v13). Les nouveaux profils seedent cinq classes d'actif ; les comptes de départ CELI/REER passent sous *Autres* avec leur enveloppe renseignée. Vos montants et votre historique ne changent pas — seul le regroupement change (#202).
|
||||||
|
- Bilan : **note sur les bilans historiques**. L'axe « par classe d'actif » du graphique est recalculé sur le type *actuel* de chaque compte ; un snapshot saisi *avant* cette migration pour un ancien compte CELI/REER apparaît donc désormais sous **Autres** sur cet axe (il ne s'affiche plus sous « CELI »/« REER »). C'est attendu — le nouvel axe **par enveloppe** retrouve bien ces CELI/REER, et aucune valeur ni aucun historique n'est modifié (#204).
|
||||||
|
- Bilan : **renommer un type ne casse plus la traduction** (correction de bug). Renommer un type écrasait auparavant sa clé i18n avec le texte libre, cassant la traduction FR/EN. Le nom personnalisé est désormais stocké à part dans `balance_categories.custom_label`, et la clé de traduction d'origine n'est jamais touchée (et réapparaît si vous videz le nom personnalisé). La migration v12 récupère aussi, en défense, tout type seedé dont la clé de traduction aurait déjà été écrasée par un renommage antérieur (#202, #203).
|
||||||
|
- Bilan : **les comptes cotés existants sont convertis automatiquement en comptes détaillés**. À la migration, chaque compte coté existant (doté d'une classe d'actif et d'un symbole) devient un compte *détaillé* à une seule position — ses lignes historiques quantité/cours sont reflétées en positions par titre, sans perte de valeur ni d'historique, et la ligne agrégée conserve le total. Les comptes cotés sans classe d'actif ou sans symbole restent intacts. La valeur du compte et toutes les agrégations sont inchangées ; vous pouvez désormais ajouter d'autres titres au même compte (migrations v14/v15/v16) (#211).
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- Bilan : le symbole (ticker) est maintenant optionnel pour les comptes d'un type coté. Un compte coté peut être créé ou modifié sans symbole — la valorisation manuelle (quantité × prix unitaire) n'en a jamais eu besoin ; un symbole n'est requis que pour utiliser le bouton de récupération automatique des prix (#199).
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-05-10
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- Bilan : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils, plus un modal d'opt-in unique pour les profils existants à leur première visite de /balance. Cases cochées par défaut ; les comptes existants avec le même nom + catégorie désactivent la ligne correspondante avec un tooltip « Déjà présent ». Confirmation ou ignorance enregistrée dans `user_preferences.balance_starter_proposed` pour que le modal ne réapparaisse jamais. ADR 0012 (Proposed) capture le futur modèle à deux niveaux véhicule × composition (#179).
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- **Récupération de prix activée** — `/v1/prices` de `maximus-api` est en production depuis le 2026-05-05. La fonctionnalité premium de récupération de prix livrée en 0.9.0 (#160) est désormais fonctionnellement disponible de bout en bout (#161).
|
||||||
|
- **Paramètres réorganisés en 3 sous-pages** — la page unique de 12 cartes est éclatée en un hub (`/settings`) qui pointe vers trois sous-pages thématiques : `/settings/users` (comptes, licences, guide d'utilisation), `/settings/data` (catégories, sauvegarde, confidentialité de la récupération de prix) et `/settings/systems` (version, mise à jour, historique des versions, journaux + commentaires). Le guide d'utilisation et l'historique des versions, qui occupaient leurs propres pages, sont maintenant intégrés dans leur sous-page parente ; les anciennes URL `/docs` et `/changelog` redirigent automatiquement pour préserver les marque-pages externes et les liens des notes de version. Le bandeau de sécurité du fallback token-store est maintenant rendu une seule fois en haut du layout des paramètres, visible depuis chaque sous-page principale (#190).
|
||||||
|
- **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut).
|
||||||
|
- Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178).
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- Bilan : correction de l'erreur SQLite « misuse of aggregate function MIN() » au chargement de /balance avec des snapshots existants ; remplacement du pattern aggregate-in-WHERE par une window function ROW_NUMBER() dans getAccountsPeriodAnchor (#175).
|
||||||
|
- Bilan : la sauvegarde d'un snapshot utilise désormais une transaction atomique BEGIN/COMMIT et valide toutes les lignes avant toute écriture en BDD, empêchant les snapshots orphelins lorsque la validation échoue. La migration v11 nettoie les orphelins existants (#176).
|
||||||
|
- Bilan : le sélecteur de date sur `/balance/snapshot` se ferme maintenant après la sélection sur Linux (WebKitGTK) au lieu de rester ouvert jusqu'à ce que l'utilisateur appuie sur Échap. Le contournement appelle `blur()` sur le champ après chaque changement — sans effet sur Windows WebView2 / macOS WKWebView, où le sélecteur se ferme déjà automatiquement (#177).
|
||||||
|
- Mise à jour de la dépendance `postcss` (8.5.6 → 8.5.13) pour corriger l'avis de sécurité de sévérité modérée GHSA-qx2v-qp2m-jg93 (XSS via `</style>` non échappé dans le stringifier CSS). Transitive via vite, build-time uniquement — aucun impact runtime sur le binaire Tauri livré (#180).
|
||||||
|
- Contournement du sélecteur de date WebKitGTK étendu aux 7 autres champs `<input type="date">` natifs répartis sur 4 composants (barre de filtres Transactions, formulaire Ajustements, modal de Liaison de transferts, sélecteur de période). Chaque handler onChange appelle désormais `e.currentTarget.blur()` pour fermer le popup natif sur Linux Tauri WebView — sans effet sur Windows WebView2 / macOS WKWebView. Même approche que #177 (#188).
|
||||||
|
- Bilan : nettoyage post-merge des suggestions issues des reviews des PR #182-#185. Six corrections groupées : (1) `getStarterCollisions` filtre désormais `archived_at IS NULL`, donc recréer un compte starter volontairement archivé n'est plus bloqué ; (2) `proposeStarterAccounts` re-vérifie chaque collision (nom, catégorie) en transaction avant l'INSERT en défense-in-depth (saut silencieux si déjà présent, aucune contrainte UNIQUE ajoutée) ; (3) les nouveaux profils reçoivent désormais `balance_starter_proposed` pré-seedé dans `consolidated_schema.sql` pour que le StarterAccountsModal ne s'ouvre jamais brièvement avec uniquement des collisions à la première visite de /balance ; (4) `/balance` cache maintenant le sélecteur de période, le graphique d'évolution et le tableau des comptes tant que la carte d'onboarding vide est affichée (évite trois messages vides empilés) ; (5) `BalanceOnboardingCard.Step` appelle directement `useTranslation()` au lieu de recevoir `t` en prop ; (6) le bloc de doc de la formule Modified Dietz dans `return_calculator.rs` est maintenant entouré d'une fence `text` pour que `cargo test --doc` n'essaie plus de compiler la pseudo-math comme du Rust (#187).
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-04-29
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Bilan — colonne `asset_type` sur les catégories cotées** (route `/balance/accounts`) : les catégories cotées portent maintenant un `asset_type` explicite (`stock` ou `crypto`) qui pilote le routage de PriceFetchControl vers le bon fournisseur, sans heuristique sur le symbole (ex : ETH = Ethan Allen NYSE *et* Ethereum crypto, deux symboles homonymes). La migration v10 ajoute une colonne nullable et backfille les deux catégories cotées seedées (`stock`, `crypto`) avec leur valeur respective ; les lignes cotées custom existantes restent NULL en attendant un futur écran d'édition pour qu'on les renseigne. Le formulaire de création de catégorie (onglet Catégories) affiche désormais un sélecteur de type d'actif quand `kind = priced` et refuse l'enregistrement tant qu'aucune valeur n'est choisie. L'éditeur de snapshot masque le bouton de récupération de prix sur les lignes cotées dont l'`asset_type` est encore NULL — la saisie manuelle reste l'unique chemin sur ces lignes legacy. (#169)
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
- **Récupération de prix premium pour actions (best-effort) et crypto (exchanges directs)** — vie privée préservée via proxy maximus-api. Toggle dans les Paramètres pour révoquer le consentement (activation serveur en attente — fonctionnalité dormante jusqu'à la mise en ligne de `/v1/prices` de maximus-api). (#160)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
## [0.8.4] - 2026-04-21
|
## [0.8.4] - 2026-04-21
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
64
CHANGELOG.md
|
|
@ -2,6 +2,70 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Balance: **per-security snapshot entry**. A *detailed* account is now entered title by title — each holding has its own row with a security picker (autocomplete over your existing securities, with inline creation of a new ticker), quantity, price (with the optional automatic price fetch), cost basis, and a live unrealized-gain figure. The account's value is the displayed sum of its positions. Simple accounts are unchanged. The security picker accepts any normalized symbol (UPPER/TRIM) — there is no live ticker validation, since the price fetch is a separate, best-effort step; you choose the asset class (Stock / Crypto) when creating a new symbol (#214).
|
||||||
|
- Balance: **detail an aggregated account into securities** (wizard). A *simple* account can be switched to *detailed* via a "Detail into securities" action in the accounts table. The switch is one-way: it sets the account to detailed and stamps today as the pivot date (`detailed_since`) — past aggregated history stays frozen and read-only, and the individual securities are entered at your next normal snapshot (the wizard does not capture an initial portfolio). Once any holding is entered, the account can no longer revert to simple (enforced both in the UI and in the service) (#215).
|
||||||
|
- Balance: **per-security drill-down and unrealized gain in the accounts table and overview**. A detailed account can be expanded to show its securities (value + unrealized gain %), and the unrealized gain (value − cost basis) is aggregated by account, by asset class, and by fiscal envelope in the accounts table and the balance overview card. Positions without a cost basis are flagged "N/A" and excluded from the percentage (no division by zero); the per-account Modified Dietz return is unchanged (#216).
|
||||||
|
- Balance: an existing snapshot's date can now be moved. The date field is editable in edit mode — change it and save, and the snapshot (with all its lines) is moved to the new date inside a single atomic transaction. If another snapshot already occupies the target date, the move is rejected with a clear message and nothing changes (#200).
|
||||||
|
- Balance: **fiscal envelope on accounts**. An account can now carry an optional fiscal envelope (Non-registered, TFSA, RRSP, RRIF, FHSA, RESP — or none), set via a dropdown in the account form. This is a separate axis from the account's type (asset class), so an account holding stocks inside a TFSA is finally expressible — type *Stocks* + envelope *TFSA*. Migration v12 adds the nullable `balance_accounts.vehicle_type` column with a CHECK on the enum and backfills the former TFSA/RRSP accounts (#202, #203).
|
||||||
|
- Balance: **stacked-chart envelope axis**. The stacked evolution chart gains an axis sub-toggle — **By asset class** (default, unchanged behaviour) or **By envelope** (groups by fiscal envelope, with a "None" bucket for accounts without one). Read your net worth by what you hold *or* by where it's sheltered (#204).
|
||||||
|
- Balance: **collapsible return columns**. The accounts table's four Modified Dietz return columns (3M / 1Y / since inception / unadjusted) are now collapsed by default behind a show/hide toggle; the choice is persisted across sessions (`user_preferences.balance_show_returns`) to reduce visual noise for users who only track values (#204).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Balance: clearer terminology. Account groupings (Cash, TFSA, RRSP, etc.) are now consistently called **types** across the balance UI and user guide, to avoid confusion with transaction *categories* (a separate module). The "Cash" type is now labelled **Cash** (FR: Liquidités) and "Mutual fund" becomes **Funds / ETF** (FR: Fonds / FNB). The wording of *snapshot* is unchanged but glossed on first use (#198).
|
||||||
|
- Balance: **a type is now a pure asset class**. The standard types are the five asset classes — Cash, Funds / ETF, Stocks, Crypto, Other. The fiscal envelopes that used to be types (TFSA, RRSP) are no longer types: they moved to the new per-account fiscal-envelope attribute. On migration, existing TFSA/RRSP accounts are reclassed to the **Other** asset class while carrying their envelope (`tfsa`/`rrsp`); the `tfsa`/`rrsp` seed types are deactivated and disappear from the type dropdowns (migrations v12 + v13). New profiles seed five asset classes; the TFSA/RRSP starter accounts now sit under *Other* with their envelope set. Your amounts and history are unchanged — only the grouping changes (#202).
|
||||||
|
- Balance: **historical reclass note**. The "by asset class" chart axis is recomputed on each account's *current* type, so a snapshot taken *before* this migration for a former TFSA/RRSP account now appears under **Other** on that axis (it no longer shows under "TFSA"/"RRSP"). This is expected — the new **by envelope** axis still surfaces those CELI/REER, and no value or history is altered (#204).
|
||||||
|
- Balance: **renaming a type no longer breaks the translation** (bug fix). Renaming a type used to overwrite its i18n key with the free text, clobbering the FR/EN translation. The custom name is now stored separately in `balance_categories.custom_label`, and the original translation key is never touched (and reappears if you clear the custom name). Migration v12 also defensively recovers any seed type whose translation key had already been clobbered by an earlier rename (#202, #203).
|
||||||
|
- Balance: **existing priced accounts auto-converted to detailed**. On migration, every existing priced account (one with an asset class and a symbol) becomes a *detailed* account with a single position — its historical quantity/price lines are mirrored into per-security holdings with no value or history loss, and the aggregated line keeps the total. Priced accounts without an asset class or without a symbol are left untouched. The account's value and all aggregations are unchanged; you can now add further securities to the same account (migrations v14/v15/v16) (#211).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Balance: the symbol (ticker) is now optional for accounts in a priced type. A priced account can be created or edited without a symbol — manual valuation (quantity × unit price) never needed it; a symbol is only required to use the automatic price-fetch button (#199).
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-05-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Bilan: 4 starter accounts (Checking account, TFSA, RRSP, Non-registered account) are seeded for new profiles, and a one-shot opt-in modal proposes them to existing profiles on their first /balance visit. Default-checked checkboxes; existing accounts with the same name + category disable the matching row with a "Already exists" tooltip. Confirming or dismissing both write `user_preferences.balance_starter_proposed` so the modal never re-appears. ADR 0012 (Proposed) captures the future two-level vehicle × composition model (#179).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Price-fetching activated** — `maximus-api` `/v1/prices` is live in production since 2026-05-05. The premium price-fetching feature shipped in 0.9.0 (#160) is now functionally available end-to-end (#161).
|
||||||
|
- **Settings reorganized into 3 sub-pages** — the single 12-card `Settings` page is split into a hub (`/settings`) that links to three thematic sub-pages: `/settings/users` (accounts, licenses, user guide), `/settings/data` (categories, backup, price-fetch privacy) and `/settings/systems` (version, update, version history, logs + feedback). The user guide and changelog, previously full-page routes, are now embedded inside their parent sub-page; the legacy `/docs` and `/changelog` URLs redirect to keep external bookmarks and release-note links working. The token-store fallback security banner is now rendered once at the top of the settings layout, visible from every main settings sub-page (#190).
|
||||||
|
- **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold).
|
||||||
|
- Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bilan: fix SQLite "misuse of aggregate function MIN()" error when loading /balance with existing snapshots; replaced aggregate-in-WHERE pattern with ROW_NUMBER() window function in getAccountsPeriodAnchor (#175).
|
||||||
|
- Bilan: snapshot save now uses atomic BEGIN/COMMIT and validates all lines before any DB write, preventing orphan snapshot rows when validation fails. Migration v11 cleans existing orphans (#176).
|
||||||
|
- Bilan: snapshot date picker on `/balance/snapshot` now closes after a date is selected on Linux (WebKitGTK), instead of staying open until the user pressed Esc. Workaround calls `blur()` on the input after each change — no-op on Windows WebView2 / macOS WKWebView, where the picker already auto-closes (#177).
|
||||||
|
- Updated `postcss` dependency (8.5.6 → 8.5.13) to address moderate severity advisory GHSA-qx2v-qp2m-jg93 (XSS via unescaped `</style>` in CSS stringifier). Transitive via vite, build-time only — no runtime impact on the shipped Tauri binary (#180).
|
||||||
|
- WebKitGTK date picker workaround extended to the remaining 7 native `<input type="date">` fields across 4 components (Transactions filter bar, Adjustments form, Link Transfers modal, Period selector). Each onChange handler now calls `e.currentTarget.blur()` to dismiss the native popup on Linux Tauri WebView — no-op on Windows WebView2 / macOS WKWebView. Same approach as #177 (#188).
|
||||||
|
- Bilan: post-merge cleanup of suggestions raised in the #182-#185 reviews. Six fixes bundled: (1) `getStarterCollisions` now filters `archived_at IS NULL` so re-creating a voluntarily archived starter is no longer blocked; (2) `proposeStarterAccounts` re-checks each (name, category) collision in-transaction before INSERT as defense-in-depth (skips silently on hit, no UNIQUE constraint added); (3) brand-new profiles now get `balance_starter_proposed` pre-seeded in `consolidated_schema.sql` so the StarterAccountsModal never briefly opens with all-collisions on first /balance visit; (4) `/balance` now hides the period selector, evolution chart and accounts table while the empty-state onboarding card is shown (avoids three stacked empty messages); (5) `BalanceOnboardingCard.Step` now calls `useTranslation()` directly instead of receiving `t` as a prop; (6) `return_calculator.rs` Modified Dietz formula doc block is wrapped in a `text` fence so `cargo test --doc` no longer fails to compile pseudo-math as Rust (#187).
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-04-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Balance sheet — `asset_type` column on priced categories** (route `/balance/accounts`): priced balance categories now carry an explicit `asset_type` (`stock` or `crypto`) that drives PriceFetchControl provider routing without relying on symbol heuristics (e.g. ETH = Ethan Allen NYSE *and* Ethereum crypto are no longer ambiguous). Migration v10 adds a nullable column and backfills the two seeded priced categories (`stock`, `crypto`) with their matching values; legacy custom priced rows stay NULL until a future edit-category UI lets the user fill them in. The category creation form (Categories tab) now shows an asset-type selector when `kind = priced` and rejects submission until a value is picked. The snapshot editor hides the price-fetch button on priced rows whose `asset_type` is still NULL — manual entry remains the only path on those legacy rows. (#169)
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
- **Price-fetching premium for stocks (best-effort) and crypto (direct exchanges)** — privacy preserved via maximus-api proxy. Privacy toggle in Settings to revoke consent (server activation pending — feature dormant until maximus-api `/v1/prices` ships). (#160)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
## [0.8.4] - 2026-04-21
|
## [0.8.4] - 2026-04-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# CLAUDE.md — Simpl'Résultat
|
# CLAUDE.md — Simpl'Résultat
|
||||||
|
|
||||||
|
@STATE.md
|
||||||
|
|
||||||
## Contexte du projet
|
## Contexte du projet
|
||||||
|
|
||||||
**Simpl'Résultat** est une application de bureau desktop **privacy-first** pour la gestion des finances personnelles. Elle traite localement les fichiers CSV bancaires sans aucune dépendance cloud. Projet solo entrepreneurial, en développement par Max.
|
**Simpl'Résultat** est une application de bureau desktop **privacy-first** pour la gestion des finances personnelles. Elle traite localement les fichiers CSV bancaires sans aucune dépendance cloud. Projet solo entrepreneurial, en développement par Max.
|
||||||
|
|
@ -49,7 +51,7 @@ src/
|
||||||
│ ├── shared/ # Composants réutilisables
|
│ ├── shared/ # Composants réutilisables
|
||||||
│ └── transactions/ # Transactions
|
│ └── transactions/ # Transactions
|
||||||
├── contexts/ # ProfileContext (état global profil)
|
├── contexts/ # ProfileContext (état global profil)
|
||||||
├── hooks/ # 12 hooks custom (useReducer)
|
├── hooks/ # 13 hooks custom (useReducer)
|
||||||
├── pages/ # 11 pages
|
├── pages/ # 11 pages
|
||||||
├── services/ # 14 services métier
|
├── services/ # 14 services métier
|
||||||
├── shared/ # Types et constantes partagés
|
├── shared/ # Types et constantes partagés
|
||||||
|
|
@ -116,8 +118,8 @@ src-tauri/
|
||||||
|
|
||||||
## Base de données
|
## Base de données
|
||||||
|
|
||||||
- **13 tables** SQLite, **15 index** (voir `docs/architecture.md` pour le détail)
|
- **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index
|
||||||
- **7 migrations inline** dans `lib.rs` (via `tauri_plugin_sql::Migration`)
|
- **16 migrations inline** dans `lib.rs` (v1→v16, via `tauri_plugin_sql::Migration`). Étape 2 (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position). Voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md)
|
||||||
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
||||||
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
||||||
|
|
||||||
|
|
|
||||||
28
STATE.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# STATE — Simpl'Résultat
|
||||||
|
|
||||||
|
> Derniere MAJ : 2026-06-09 (post-merge manuel Etape 2 bilan detail par titre, PRs #219-#227)
|
||||||
|
|
||||||
|
## Position actuelle
|
||||||
|
|
||||||
|
v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-seed-categories-ipc`, `spec-price-fetching`, `overnight-2026-04-26-bilan` et `overnight-2026-04-27-prices` complétées et fermées. Maximus-api 0.3.0 LIVE en prod (`api.lacompagniemaximus.com`) — pubkey Ed25519 alignée, smoke test Phase B (#161) validé. Backlog actif concentré sur `spec-monetisation` (7/12) : activation en ligne (#53), pipeline Stripe desktop (#50, #52) et maximus-api Stripe webhooks (#135, #136).
|
||||||
|
|
||||||
|
Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. Validé localement avant merge (build + 627 tests vitest + cargo check + 97 tests Rust) car `check.yml` ne couvre pas les PRs ciblant des bases non-`main`. Reste **#228** (fix-forward : garde d'abort v16 trop large → scoper aux comptes convertibles `asset_type NOT NULL`) avant fermeture milestone + tag release. 20 tables / 24 index, 16 migrations (v1→v16).
|
||||||
|
|
||||||
|
## Decisions recentes
|
||||||
|
|
||||||
|
- 2026-06-09 : Etape 2 bilan « detail par titre » livree — pile de 9 PRs stackees #219-#227 mergee bottom-up (merge manuel via API Forgejo, pas /fix-issue ; retarget auto base->main entre chaque), milestone overnight-2026-06-05-bilan-detail-titres 9/10, issues #210-#218 fermees. `balance_securities` + `balance_snapshot_holdings`, `account.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion comptes cotes -> detailles 1-position, service securities transactionnel, reducer holdings + dispatch account.kind, UI SecurityPicker + wizard date-pivot, drill-down + gain latent, ADR 0015 Accepted. Validation locale pre-merge (build + 627 vitest + cargo check + 97 Rust) car check.yml ne couvre pas les PRs vers bases non-main. Reste #228 (fix-forward garde v16) avant tag release (ref #210-#218, #219-#227)
|
||||||
|
- 2026-06-01 : Etape 1 bilan « axe vehicule » livree via /sprint (4 issues, PRs #206-#209 mergees, milestone fermee). `vehicle_type` nullable = enveloppe fiscale sur le compte ; categorie = pure classe d'actif (5) ; migrations additives v12/v13 (v1-v11 intactes, checksum SHA-384) ; reclass ex-CELI/REER -> Autres ; `custom_label` (fix bug I renommage) ; toggle graphique classe/enveloppe + rendements repliables persistes. ADR 0014 Accepted, ADR 0012 Rejected. Etape 2 (detail par titre) reportee (ref #202-#205, #206-#209)
|
||||||
|
- 2026-05-31 : Audit bilan (revue CPA + UX) -> `docs/audit-bilan-2026-05.md` ; Etape 0 quick wins mergee (#201) : terminologie categorie->type, symbole optionnel pour comptes cotes, date de snapshot deplacable (ref #198/#199/#200). Racine identifiee : modele plat fusionne vehicule fiscal x classe d'actif ; chantier structurel a specifier, ADR 0012 a superseder
|
||||||
|
- 2026-05-10 : Release v0.9.1 + note changelog maximus-api activation post-0.9.0 (ref #197)
|
||||||
|
- 2026-05-09 : ADR 0013 — stocks provider evaluation, AlphaVantage retenu comme bascule cible (ref #196)
|
||||||
|
- 2026-05-03 : Bilan post-merge cleanup (S1-S5+S7) — `getStarterCollisions` filtre `archived_at IS NULL`, in-txn re-check sur `proposeStarterAccounts`, pre-seed `balance_starter_proposed`, guard empty-state `/balance`, doctest fence `text` Modified Dietz (ref #187)
|
||||||
|
- 2026-05-03 : WebKitGTK date picker workaround etendu aux 7 inputs date restants dans 4 composants (TransactionFilterBar, AdjustmentForm, LinkTransfersModal, PeriodSelector) (ref #188)
|
||||||
|
- 2026-05-03 : postcss 8.5.6 -> 8.5.13, fix GHSA-qx2v-qp2m-jg93 (transitif via vite, build-time only) (ref #180)
|
||||||
|
- 2026-05-02 : Settings eclate en 3 sous-pages `/settings/{users,data,systems}` + redirections legacy `/docs` et `/changelog` (ref #190)
|
||||||
|
- 2026-05-02 : Doc license — placeholder Bearer JWT-like remplace par `<license-token>` (ref #181)
|
||||||
|
|
||||||
|
## Blockers actifs
|
||||||
|
|
||||||
|
- #135 / #136 — maximus-api Stripe webhooks license auto-generate (BLOCKED par maximus-api Phase 2)
|
||||||
|
- #53 — online activation + machine limit enforcement (status:needs-fix)
|
||||||
|
- #50 / #52 — Stripe integration desktop + purchase page (status:ready, design en attente)
|
||||||
100
docs/adr/0008-modified-dietz-pour-rendement.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# 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.
|
||||||
158
docs/adr/0009-proxy-price-fetching-via-maximus-api.md
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
# 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.
|
||||||
85
docs/adr/0010-fk-restrict-balance-transfers.md
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# 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)
|
||||||
89
docs/adr/0011-providers-best-effort-yahoo.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# ADR 0011 — Providers de prix : exchanges directs (crypto) + Yahoo Finance best-effort (stocks)
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-04-26
|
||||||
|
- Successor of: ADR 0009 (architecture proxy) — précise les providers concrets
|
||||||
|
- Milestone: `spec-price-fetching` + `prices-proxy` (maximus-api)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0009 a établi qu'un proxy `maximus-api` mutualisé sert le price-fetching premium pour préserver la privacy (IP cachée, headers strippés). La revue spec du contrat `/v1/prices` (2026-04-26) a soulevé deux risques critiques :
|
||||||
|
|
||||||
|
1. **Yahoo Finance n'a pas d'API publique officielle.** Les endpoints `query1/query2.finance.yahoo.com` sont non documentés, leur ToS interdit l'usage commercial et la redistribution. Un IP-ban du VPS coupe le feature pour 100% des premium en même temps.
|
||||||
|
2. **CoinGecko free tier interdit le proxy commercial.** Seul le plan Demo/Pro payant (~129 $/mo Analyst) le permet contractuellement.
|
||||||
|
|
||||||
|
Quatre options ont été considérées (cf. revue inline `docs/api-contract-prices.md` §0) :
|
||||||
|
|
||||||
|
| Option | Coût/mois | Légalité commercial | Stabilité | Couverture |
|
||||||
|
|--------|-----------|---------------------|-----------|------------|
|
||||||
|
| Polygon.io Starter | 29 $ | ✅ contractuelle | ✅ haute | Stocks NYSE/NASDAQ + crypto |
|
||||||
|
| Tiingo Power + exchanges directs (crypto) | 10 $ | ✅ Tiingo, ✅ exchanges (public market data OSS-légal) | ✅ haute | Stocks + crypto |
|
||||||
|
| **Exchanges directs (crypto) + Yahoo best-effort (stocks)** | **0 $** | ⚠️ Yahoo ToS risqué (data publique mais redistribution interdite) ; ✅ exchanges | ⚠️ Yahoo fragile, exchanges stables | Stocks + crypto |
|
||||||
|
| Polygon Stocks + CoinGecko Pro | 158 $ | ✅ | ✅ | Best-of-both |
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Adopter l'option « tout-OSS / best-effort »** pour le MVP :
|
||||||
|
|
||||||
|
- **Crypto** : interrogation directe des exchanges majeurs via la lib `ccxt` (MIT). Les données de marché publiques (ticker, OHLC) sont gratuites et explicitement autorisées en commercial par les ToS de Kraken, Coinbase, Binance, etc. Implémentation initiale : Kraken d'abord, Coinbase en fallback si Kraken 404.
|
||||||
|
- **Stocks** : interrogation de Yahoo Finance via `query1.finance.yahoo.com/v7/finance/quote` (et v8 chart pour historique) avec un User-Agent navigateur. **Best-effort assumé** : peut échouer ou changer sans préavis.
|
||||||
|
|
||||||
|
Le client paie pour **l'infrastructure d'anonymisation**, pas pour la donnée. Cette distinction est centrale au modèle économique : la valeur premium = privacy (proxy mutualisé) + commodité (auto-fill), pas la donnée elle-même.
|
||||||
|
|
||||||
|
### Garde-fous obligatoires
|
||||||
|
|
||||||
|
1. **Label UX explicite** : sur les catégories de bilan en stocks, le bouton fetch affiche un badge « best-effort » + warning au premier usage. Sur crypto : pas de warning.
|
||||||
|
2. **Circuit breaker côté maximus-api** : seuil `5 erreurs Yahoo / 60 sec → breaker ouvert pour 15 min`. Notification automatique Telegram/email à `maxime2tremblay@protonmail.com`.
|
||||||
|
3. **Quota baissé** : 200 req/jour/licence (vs 2000 initialement). Suffit pour ~50 actifs × snapshot mensuel. Réduit l'incitation à abuser.
|
||||||
|
4. **Saisie manuelle toujours active** : aucun chemin d'erreur ne bloque la saisie d'un snapshot.
|
||||||
|
5. **Headers stripping rigoureux** : tous les headers entrants supprimés avant call sortant. Vers Yahoo : UA browser-like (`Mozilla/5.0 ...`). Vers exchanges : UA `maximus-api/<version>`.
|
||||||
|
6. **Logs séparés** : pas de log conjoint `(license_id, symbol)`. Implémentation via wrapper logger injectable (`src/logger.ts` pino).
|
||||||
|
|
||||||
|
### Plan de migration si Yahoo devient inutilisable
|
||||||
|
|
||||||
|
Triggers de migration vers un provider payant :
|
||||||
|
- Plus de 1 incident IP-ban / mois pendant 2 mois consécutifs, OU
|
||||||
|
- Plus de 30% des requêtes stocks tombent en circuit-breaker `service_degraded` sur 7 jours, OU
|
||||||
|
- Plainte légale formelle de Yahoo / Verizon Media.
|
||||||
|
|
||||||
|
Provider de bascule prioritaire : **Tiingo plan Power** (~10 $/mo, 1000 req/jour, ToS-clean).
|
||||||
|
- Implémentation : ajouter un module `providers/tiingo.ts` parallèle à `providers/yahoo.ts`. Switch via env var `STOCKS_PROVIDER=yahoo|tiingo`.
|
||||||
|
- Délai de bascule : ≤ 30 jours après déclenchement d'un trigger.
|
||||||
|
- Communication : entrée CHANGELOG explicite + email aux licences premium actives.
|
||||||
|
|
||||||
|
Si l'audience grandit (>500 licences premium actives), bascule vers Polygon Starter (~29 $/mo) considérée.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **0 $ de coût récurrent au MVP** — pas de cash burn avant que le produit ait validé son marché.
|
||||||
|
- **Crypto 100% OSS-légal** — voie pérenne, ne nécessitera jamais de migration.
|
||||||
|
- **Justification premium cohérente** — privacy comme valeur, pas la donnée. Aligne avec les principes du projet.
|
||||||
|
- **Plan de bascule pré-engagé** — pas pris au dépourvu si Yahoo devient hostile.
|
||||||
|
|
||||||
|
### Négatives / Risques actés
|
||||||
|
|
||||||
|
- **ToS Yahoo en zone grise** — le proxy commercial de leur data publique n'est pas formellement autorisé. Yahoo a déjà émis des cease-and-desist contre yfinance (lib Python). Risque légal théorique mais peu probable à petite échelle.
|
||||||
|
- **IP-ban probable à un moment ou l'autre** — Yahoo bloque les UA non browser et les patterns de requête trop réguliers. Le circuit breaker absorbe l'événement, mais le feature devient temporairement HS pour tous les premium.
|
||||||
|
- **Pas de garantie de stabilité de schéma** — Yahoo peut renommer un champ JSON sans préavis. Tests d'intégration `nock` ne capturent pas ça (mock = donnée figée).
|
||||||
|
- **Charge ops accrue** — il faudra surveiller le taux d'erreur Yahoo et réagir vite si dégradation.
|
||||||
|
|
||||||
|
### Neutre
|
||||||
|
|
||||||
|
- Première implémentation un peu plus complexe côté serveur (deux providers + circuit breaker), mais le code reste contained dans `src/providers/` et est testable.
|
||||||
|
|
||||||
|
## Suivi
|
||||||
|
|
||||||
|
- ADR à reviewer dans 6 mois (2026-10-26) ou plus tôt si trigger de migration déclenché.
|
||||||
|
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`.
|
||||||
|
- Issue de suivi : créer une issue `ops` dans `maximus-api` pour le monitoring continu une fois deployé.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Yahoo Finance ToS](https://legal.yahoo.com/us/en/yahoo/terms/index.html) — sec. 7-8 sur l'usage commercial
|
||||||
|
- [CoinGecko API ToS](https://www.coingecko.com/en/api/terms) — restrictions free tier
|
||||||
|
- [Kraken API public market data](https://docs.kraken.com/rest/) — explicite : free public tier, commercial OK pour data publique
|
||||||
|
- [CCXT (MIT)](https://github.com/ccxt/ccxt) — abstraction multi-exchange, lib OSS
|
||||||
|
- ADR 0009 — Architecture du proxy
|
||||||
|
- `docs/api-contract-prices.md` — Contrat figé `/v1/prices`
|
||||||
107
docs/adr/0012-balance-two-level-model.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# ADR 0012 — Modèle à deux niveaux pour le Bilan (véhicules × compositions)
|
||||||
|
|
||||||
|
- Status: **Rejected** (jamais accepté ; reste à l'état de proposition)
|
||||||
|
- Date: 2026-05-01 (proposé) · 2026-06-01 (rejeté)
|
||||||
|
- Issue: #179
|
||||||
|
- Rejeté au profit de : [ADR 0014](0014-balance-vehicule-attribut.md)
|
||||||
|
|
||||||
|
> **Rejet (2026-06-01).** L'audit Bilan (`docs/audit-bilan-2026-05.md`) a retenu une **trajectoire additive** plutôt que ce modèle à deux tables surdimensionné : l'ADR 0014 fait du véhicule fiscal un simple **attribut nullable du compte** (`vehicle_type`) et de la catégorie une **pure classe d'actif**, sans réécriture de `/balance` ni migration massive. Le grain visé par 0012 (triplet véhicule × *composition*) restait par ailleurs au niveau **classe d'actif**, pas **titre** — il déplaçait le mur sans débloquer le détail par titre. *Rejected* (et non *Superseded*) : 0012 n'a jamais quitté l'état `Proposed`, donc aucune décision active n'est remplacée. La réflexion sur les groupements croisés (`GROUP BY véhicule` / `GROUP BY classe d'actif`) reste valable et est reprise par 0014.
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Le Bilan modélise actuellement les comptes de manière **plate** : `balance_accounts` est rattaché à exactement une `balance_categories`, qui combine implicitement la **nature fiscale du véhicule** (CELI, REER, non enregistré) et la **classe d'actif** (encaisse, action, fonds, crypto). Les sept catégories seedées par Migration v9 sont des frères/sœurs au même niveau :
|
||||||
|
|
||||||
|
```
|
||||||
|
cash · tfsa · rrsp · fund · other · stock · crypto
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette structure pose une limite expressive : **un véhicule fiscal et une classe d'actif sont deux dimensions orthogonales**, pas une hiérarchie. Un utilisateur qui détient une action d'Apple à l'intérieur d'un CELI n'a aujourd'hui que des choix dégradés :
|
||||||
|
|
||||||
|
- créer un compte `priced` rattaché à la catégorie `stock` → l'avantage fiscal CELI disparaît du modèle ;
|
||||||
|
- créer un compte `simple` rattaché à `tfsa` avec un montant agrégé → la valeur de marché et le rendement réel par titre disparaissent ;
|
||||||
|
- créer une catégorie utilisateur custom (`tfsa_stock`) → l'arbre explose en N×M permutations.
|
||||||
|
|
||||||
|
Cette tension est visible mais reste tolérable au MVP — la plupart des utilisateurs commencent avec des comptes simples (chèque, CELI cotisations, REER cotisations) et n'investissent en titres cotés que plus tard. La question est néanmoins structurante pour la roadmap : un changement de modèle après livraison V1 nécessitera une migration de données massive et une réécriture quasi totale de `/balance`.
|
||||||
|
|
||||||
|
L'ADR 0012 documente la réflexion **avant que le besoin devienne bloquant**, sans engager de code.
|
||||||
|
|
||||||
|
## Proposition — Modèle à deux niveaux
|
||||||
|
|
||||||
|
Remplacer `balance_accounts → balance_categories` par deux tables conceptuelles :
|
||||||
|
|
||||||
|
| Table | Rôle | Exemples |
|
||||||
|
|---|---|---|
|
||||||
|
| `balance_vehicles` | Véhicule fiscal / contenant | Compte chèque, CELI, REER, FERR, RPDB, non enregistré |
|
||||||
|
| `balance_compositions` | Classe d'actif détenue dans le véhicule | Encaisse, action, fonds indiciel, obligation, crypto |
|
||||||
|
|
||||||
|
Une **ligne de snapshot** devient un triplet `(vehicle_id, composition_id, value)` au lieu de l'actuel `(account_id, value)` :
|
||||||
|
|
||||||
|
```
|
||||||
|
balance_snapshot_lines
|
||||||
|
├── vehicle_id (FK balance_vehicles)
|
||||||
|
├── composition_id (FK balance_compositions)
|
||||||
|
├── quantity, unit_price (NULL pour compositions de type 'simple')
|
||||||
|
└── value
|
||||||
|
```
|
||||||
|
|
||||||
|
Bénéfices :
|
||||||
|
- **Expressivité** : un CELI avec 3 actions et un peu d'encaisse devient 4 lignes lisibles, additionnables, filtrables sur l'une OU l'autre dimension.
|
||||||
|
- **Rapports croisés** : "valeur totale en CELI" (somme par véhicule) ET "valeur totale en actions" (somme par composition) sont deux groupements naturels.
|
||||||
|
- **Modified Dietz par véhicule** ou **par composition** : les apports/retraits suivent le véhicule, le rendement suit la composition.
|
||||||
|
|
||||||
|
## Alternatives considérées
|
||||||
|
|
||||||
|
### A. Tagging multi-axes sur le modèle plat actuel
|
||||||
|
|
||||||
|
Garder `balance_accounts` plat, ajouter une table `balance_account_tags` libre. L'utilisateur tague chaque compte avec autant d'axes que voulu (`tfsa`, `stock`, `apple`, `tech`).
|
||||||
|
|
||||||
|
- ✅ Migration triviale (table additive).
|
||||||
|
- ❌ Aucune contrainte sur les combinaisons → la cohérence retombe sur l'utilisateur.
|
||||||
|
- ❌ Les rapports "actions dans CELI" deviennent une intersection de tags, beaucoup plus coûteuse à requêter et à expliquer.
|
||||||
|
- ❌ Risque d'arbres divergents entre profils — pas de vocabulaire partagé.
|
||||||
|
|
||||||
|
### B. Sous-comptes sous comptes
|
||||||
|
|
||||||
|
Introduire `balance_accounts.parent_id` (auto-référence). Un compte `Mon CELI` (catégorie `tfsa`, `simple`) pourrait avoir des enfants `Apple Inc.` (catégorie `stock`, `priced`).
|
||||||
|
|
||||||
|
- ✅ Modèle hiérarchique familier (similaire aux catégories de transactions).
|
||||||
|
- ❌ La somme parent = somme enfants devient un invariant à maintenir → friction de saisie.
|
||||||
|
- ❌ Les snapshots doublent leur taille (ligne parent + lignes enfants) sans gain expressif réel : la nature fiscale du parent et la nature d'actif des enfants restent collées sur un seul axe.
|
||||||
|
- ❌ Profondeur d'arbre incertaine : on retombe sur le multi-axes mal déguisé.
|
||||||
|
|
||||||
|
### C. Statu quo (modèle plat enrichi)
|
||||||
|
|
||||||
|
Garder le modèle actuel et accepter que les utilisateurs avancés créent des catégories user-définies pour les permutations qui les intéressent (`tfsa_stock`, `rrsp_fund`).
|
||||||
|
|
||||||
|
- ✅ Aucun coût de migration.
|
||||||
|
- ✅ Suffisant pour 80% des cas d'usage (utilisateurs avec des comptes simples).
|
||||||
|
- ❌ Friction documentée croissante au fur et à mesure que la base d'utilisateurs détient des portefeuilles diversifiés.
|
||||||
|
- ❌ La taxonomie utilisateur diverge entre profils, rendant tout futur partage ou agrégation cross-profil très coûteux.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
Une adoption du modèle à deux niveaux implique, au minimum :
|
||||||
|
|
||||||
|
- **Migration v12+** : décomposer chaque `balance_accounts` existant en `(vehicle, composition)` selon une heuristique sur `category.kind` + `category.asset_type`. Migration v9 actuelle (7 catégories seedées) sera scindée en deux seeds.
|
||||||
|
- **Réécriture complète des écrans `/balance/accounts` et `/balance/snapshot`** : la grille de saisie passe d'une dimension à deux.
|
||||||
|
- **Adaptation des agrégateurs `balance.service.ts`** : `getSnapshotTotalsByDate` reste valide, mais `getSnapshotTotalsByCategoryAndDate` doit être dédoublé en `getSnapshotTotalsByVehicleAndDate` + `getSnapshotTotalsByCompositionAndDate`.
|
||||||
|
- **Adaptation du calcul Modified Dietz** : la pertinence du rendement par véhicule vs par composition doit être tranchée.
|
||||||
|
- **Adaptation des graphiques** : la pile actuelle (stacked-by-category) doit choisir un axe par défaut + offrir une bascule.
|
||||||
|
|
||||||
|
Cet impact est massif. La proposition n'est viable qu'après stabilisation du modèle plat actuel et collecte de retours utilisateurs réels confirmant le besoin.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Status: Proposed.** L'équipe gèle la décision jusqu'à ce que les conditions de réévaluation soient réunies :
|
||||||
|
|
||||||
|
1. La V1 du Bilan (issues #138 → #179) est livrée et utilisée en production sans régression majeure pendant au moins un cycle de release ;
|
||||||
|
2. Au moins trois retours utilisateurs distincts décrivent le cas d'usage "actions à l'intérieur d'un CELI/REER" comme bloquant ;
|
||||||
|
3. La fonctionnalité de price-fetching (Issue #143, ADR 0009) est livrée — sans elle, le modèle à deux niveaux résoudrait un problème (rendement par titre dans CELI) sans pouvoir l'exploiter.
|
||||||
|
|
||||||
|
À la prochaine évaluation, cet ADR passera à `Accepted` (avec plan de migration v12+) ou `Rejected` (au profit du statu quo + tagging optionnel).
|
||||||
|
|
||||||
|
## Liens
|
||||||
|
|
||||||
|
- [ADR 0008](0008-modified-dietz-pour-rendement.md) — Modified Dietz par compte (modèle plat)
|
||||||
|
- [ADR 0010](0010-fk-restrict-balance-transfers.md) — FK RESTRICT sur transferts (contrainte préservée par les deux modèles)
|
||||||
|
- Issue #179 — Comptes de départ + cet ADR
|
||||||
217
docs/adr/0013-stocks-provider-evaluation.md
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
# ADR 0013 — Évaluation provider stocks : Alpha Vantage retenu comme cible de bascule (override partiel ADR 0011)
|
||||||
|
|
||||||
|
- Status: **Accepted**
|
||||||
|
- Date: 2026-05-07
|
||||||
|
- Successor of: ADR 0011 (override partiel — la pré-désignation **Tiingo Power** comme cible de bascule devient invalide ; **Alpha Vantage Premium** la remplace)
|
||||||
|
- Issue: [maximus-api#41](https://git.lacompagniemaximus.com/maximus/maximus-api/issues/41)
|
||||||
|
- Phase 1 research note: [maximus-api/docs/research/0013-stocks-providers-phase1.md](https://git.lacompagniemaximus.com/maximus/maximus-api/src/branch/issue-41-stocks-providers-research/docs/research/0013-stocks-providers-phase1.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
L'ADR 0011 (2026-04-26) a adopté Yahoo Finance en best-effort pour les stocks, **avec un plan de bascule pré-désigné vers Tiingo Power (~10 $/mo, 1000 req/jour)** déclenché par triggers (1+ IP-ban/mois × 2 mois consécutifs, ou 30 %+ requêtes en service_degraded sur 7 jours, ou plainte légale).
|
||||||
|
|
||||||
|
Le smoke test 2026-05-04 (issue #25) a confirmé que Yahoo bloque l'IP du VPS OVH de manière stable. Un trigger ADR 0011 est de fait actif. Le feature stocks est non-fonctionnel en production.
|
||||||
|
|
||||||
|
**Décision encadrante (le présent ADR ne la modifie pas)** : on reste dans l'esprit MVP de l'ADR 0011 — **0 $ de cash burn tant que le produit n'a pas validé son marché**. La bascule vers un provider payant est repoussée jusqu'à un trigger plus net (1ère licence payée, OU 1ère plainte client active, OU saturation des plaintes "stocks cassé"). Le scope du présent ADR est de **valider empiriquement quel provider sera la cible de bascule** quand un trigger réel se déclenchera, **pas** de déclencher la bascule maintenant.
|
||||||
|
|
||||||
|
Avant de figer mécaniquement Tiingo Power comme dans 0011, l'évaluation a été élargie à 3 providers — Alpha Vantage, Tiingo, Polygon — pour potentiellement override la pré-désignation 0011 si un autre provider domine.
|
||||||
|
|
||||||
|
## Phase 1 — Recherche documentaire (sources publiques uniquement)
|
||||||
|
|
||||||
|
3 sous-agents WebSearch ont produit une synthèse 6-axes par provider. Synthèse complète dans `maximus-api/docs/research/0013-stocks-providers-phase1.md`.
|
||||||
|
|
||||||
|
Findings critiques :
|
||||||
|
|
||||||
|
| Critère | Alpha Vantage | Tiingo | Polygon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **TSX coverage** | ✅ via `.TRT` / `.TRV` | ❓ non confirmé publiquement | ❌ non couvert |
|
||||||
|
| **Plan technique min pour 1500 rpm × 10k/jour** | aucun palier public ≥ 1500 rpm (top 1200 rpm @ ~$249.99/mo) | Power suffit techniquement (~$30/mo, 100k/jour) | Starter suffit techniquement ($29/mo "unlimited") |
|
||||||
|
| **ToS proxy mutualisé pour clients tiers payants** | ⚠️ zone grise, pas de clause explicite, email business pour cas commercial | ❌ Power = "internal consumption only" → Commercial $500+/mo + redistribution license | ❌ Individuals ToS interdit explicitement → Business négocié obligatoire |
|
||||||
|
| **HTTP error model** | ⚠️ 200 OK + champ `Note`/`Information` | ⚠️ 200 OK + body non-JSON sur quota | ✅ HTTP 429 standard |
|
||||||
|
| **Profondeur historique daily** | 20+ ans | 30+ ans US | 5 ans Starter |
|
||||||
|
| **Free tier viable pour dev** | ✅ 25 req/jour, 5 req/min — OK pour valider l'intégration | ⚠️ 1000 req/jour mais ToS interdit le commercial sans Commercial plan | ⚠️ 5 req/min, EOD only, ToS interdit le commercial sans Business |
|
||||||
|
|
||||||
|
**Finding majeur** : la pré-désignation ADR 0011 « Tiingo Power ~10 $/mo » est obsolète sur le prix (réel 2026 ≈ $30/mo) ET invalide sur le ToS (Power = internal-use only). Notre cas d'usage cible (proxy multi-tenant) forcerait Tiingo en plan Commercial ($500+/mo) avec redistribution license négociée. Polygon idem (Business plan, prix non public). Alpha Vantage seul reste en zone grise sans interdiction explicite et offre un free tier exploitable pour valider l'intégration en dev.
|
||||||
|
|
||||||
|
## Phase 2 — Smoke test live Alpha Vantage (2026-05-07, free tier)
|
||||||
|
|
||||||
|
13 calls réels sur `https://www.alphavantage.co/query` avec une clé free tier. Réponses brutes archivées dans `~/.maximus-research-keys/raw-av.json` (à supprimer après validation de cet ADR).
|
||||||
|
|
||||||
|
| # | Test | Résultat | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 01 | `GLOBAL_QUOTE` AAPL | $287.44, day 2026-05-07 | Happy path ✅ |
|
||||||
|
| 02 | `TIME_SERIES_DAILY_ADJUSTED` AAPL | `Information` field — premium endpoint | ⚠️ **Adjusted close = premium $49.99/mo minimum** |
|
||||||
|
| 03 | `GLOBAL_QUOTE SHOP.TRT` | $152.57 CAD | TSX coverage confirmée ✅ |
|
||||||
|
| 04 | `GLOBAL_QUOTE RY.TRT` | $247.64 CAD | TSX big caps OK ✅ |
|
||||||
|
| 05 | **`GLOBAL_QUOTE SHOP.TO`** (Yahoo style) | **$152.57 — alias silencieux de `.TRT`** | 🎉 **Pas de mapping table requis** — drop-in Yahoo |
|
||||||
|
| 06 | `GLOBAL_QUOTE SHOP` (sans suffixe) | $111.74 — listing US différent | Suffixe nécessaire pour désambiguïsation CA vs US |
|
||||||
|
| 07 | `GLOBAL_QUOTE XYZAB123` (inconnu) | `"Global Quote": {}` (objet vide) | ⚠️ **Pas de `Error Message`** — détection = empty object |
|
||||||
|
| 08 | `GLOBAL_QUOTE SPX` | objet racine `{}` vide | ❌ Indices broad-market non couverts |
|
||||||
|
| 09 | `GLOBAL_QUOTE ^GSPC` | objet racine `{}` vide | ❌ Idem |
|
||||||
|
| 10 | `GLOBAL_QUOTE VTSAX` (mutual fund) | $176.23, day 2026-05-06 | Mutual funds OK ✅ |
|
||||||
|
| 11 | `GLOBAL_QUOTE BRK.B` (share class) | $475.08 | Format dot natif ✅ |
|
||||||
|
| 12 | `SYMBOL_SEARCH keywords=shopify` | 5 résultats incluant `SHOP.TRT` Toronto | Convention `.TRT` confirmée par AV |
|
||||||
|
| 13 | `TIME_SERIES_DAILY_ADJUSTED` AAPL outputsize=full | `Information` field — premium endpoint | ⚠️ Premium-gate global sur l'historique ajusté |
|
||||||
|
|
||||||
|
**Headers HTTP** : `Retry-After` absent sur les 13 réponses. Toutes en HTTP 200 (confirme la doc — pas de 4xx propres). Content-Type `application/json` partout.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Override partiel de l'ADR 0011 — uniquement le provider de bascule désigné** :
|
||||||
|
|
||||||
|
- L'ADR 0011 reste en vigueur sur tout le reste : Yahoo best-effort en prod, garde-fous (badge UX, circuit breaker, quota 200 req/jour/licence, saisie manuelle toujours active), triggers de migration inchangés.
|
||||||
|
- **Le provider de bascule désigné passe de Tiingo Power à Alpha Vantage Premium $49.99/mo (75 rpm)** quand un trigger ADR 0011 se déclenchera réellement.
|
||||||
|
- **Aucune bascule immédiate.** Yahoo best-effort reste en prod tant qu'aucun trigger réel (1ère licence payée, plainte client formelle, saturation des incidents) ne justifie le cash burn.
|
||||||
|
|
||||||
|
### Pourquoi Alpha Vantage plutôt que Tiingo (pré-désignation 0011)
|
||||||
|
|
||||||
|
1. **Tiingo Power est invalide pour notre cas** : "internal consumption only" dans le ToS. Notre proxy multi-tenant force Tiingo en plan Commercial ~$500/mo + redistribution license — ~10× plus cher que la pré-désignation 0011 ($10/mo). Le rapport coût/bénéfice supposé par 0011 ne tient plus.
|
||||||
|
2. **Alpha Vantage Premium $49.99/mo** est le palier le moins cher qui (a) couvre le besoin technique avec marge (75 rpm × 1440 = ~108k req/jour, vs cible 10k/jour), (b) inclut `TIME_SERIES_DAILY_ADJUSTED` confirmé empiriquement comme premium-gate, (c) couvre le TSX nativement.
|
||||||
|
3. **Drop-in Yahoo via `.TO` natif** — découverte Phase 2 majeure : AV accepte le suffixe Yahoo `.TO` silencieusement comme alias de `.TRT`. **Aucune mapping table à coder.** Le code `yahooProvider.ts` existant peut être copié quasi-tel-quel en `alphaVantageProvider.ts`. C'est l'argument décisif pour la bascule rapide quand elle sera déclenchée — délai d'implémentation ~quelques heures, pas quelques jours.
|
||||||
|
4. **ToS en zone grise = négociable au moment voulu** : pas d'interdiction explicite (vs Polygon Individuals qui interdit, vs Tiingo Power qui interdit, vs Yahoo qui interdit). Email à `support@alphavantage.co` peut être envoyé au moment du déclenchement, pas avant.
|
||||||
|
|
||||||
|
### Polygon écarté
|
||||||
|
|
||||||
|
Polygon est techniquement supérieur (data quality, "unlimited" rpm sur Starter $29) mais **disqualifié seul par l'absence de couverture TSX**. Une stratégie hybride Polygon US + AV CA serait plus complexe et plus chère pour un bénéfice marginal vs AV seul.
|
||||||
|
|
||||||
|
### État du free tier AV (statu quo dev)
|
||||||
|
|
||||||
|
La clé free tier obtenue pendant l'évaluation reste active pour :
|
||||||
|
- **Dev / smoke test continu** : valider l'intégration en local avant tout déploiement payant.
|
||||||
|
- **Smoke test périodique de la cible de bascule** : 1× par mois, 5-10 calls pour vérifier que la convention `.TO` fonctionne toujours, que les premium-gates n'ont pas changé, que `support@alphavantage.co` n'a pas resserré le free tier.
|
||||||
|
|
||||||
|
La clé reste hors-Coolify (jamais déployée en prod), dans `~/.maximus-research-keys/av.txt` côté machine de dev. **À ne pas confondre avec un déploiement de prod** — le free tier 25 req/jour est 400× insuffisant pour servir 50 licences réelles.
|
||||||
|
|
||||||
|
## Plan de bascule (déclenché par trigger ADR 0011, pas maintenant)
|
||||||
|
|
||||||
|
Quand un trigger réel se déclenchera :
|
||||||
|
|
||||||
|
1. **Décision business** : confirmer que la bascule est justifiée vs amender ADR 0011 (rester sur Yahoo + mitigations alternatives).
|
||||||
|
2. **Email ToS** à `support@alphavantage.co` (draft inclus en annexe ci-dessous) — à envoyer **à ce moment-là**, pas maintenant. Délai de réponse standard 3-5 j ouvrables. Use case précisé : "server-side proxy serving N paying B2B licensees".
|
||||||
|
3. **Sur réponse positive** : signup Premium $49.99/mo (75 rpm). Clé en var Coolify `ALPHAVANTAGE_API_KEY` (secret). Issue follow-up `feat(api): integrer Alpha Vantage comme provider stocks` créée à ce moment-là.
|
||||||
|
4. **Sur réponse négative ou zone grise prolongée** : amender cet ADR pour retomber sur Tiingo Commercial ($500+/mo) — financièrement justifiable seulement si l'audience est suffisamment monétisée pour absorber le coût.
|
||||||
|
5. **Implémentation variante A** (remplacement direct, switch via env var `STOCKS_PROVIDER=alphavantage`). Yahoo retiré du code dans une seconde PR séquencée pour rollback rapide.
|
||||||
|
6. **Smoke test prod** : `?symbol=AAPL` et `?symbol=SHOP.TO` doivent renvoyer 200 + prix non-stale.
|
||||||
|
7. **Bascule + monitoring 7 jours**.
|
||||||
|
8. **Cleanup** : `~/.maximus-research-keys/` supprimé.
|
||||||
|
|
||||||
|
## Garde-fous obligatoires (mirror ADR 0011, applicables à AV au moment de la bascule)
|
||||||
|
|
||||||
|
1. **Label UX inchangé** : badge "best-effort" reste sur les catégories stocks. AV est plus stable que Yahoo mais reste un provider tiers — pas de SLA contractuel pour notre cas d'usage à $49.99/mo.
|
||||||
|
2. **Circuit breaker côté maximus-api** : seuil identique 5 erreurs AV / 60 sec → breaker ouvert 15 min. Notification Telegram/email à `maxime2tremblay@protonmail.com`.
|
||||||
|
3. **Quota côté maximus-api** : 200 req/jour/licence — inchangé. Avec 75 rpm × 1440 min = 108 000 req/jour de capacité, marge ample pour 50 licences.
|
||||||
|
4. **Saisie manuelle toujours active** : aucun chemin d'erreur ne bloque la saisie d'un snapshot.
|
||||||
|
5. **Headers stripping rigoureux** : tous les headers entrants supprimés. Vers AV : UA `maximus-api/<version>` (pas besoin de UA browser-like contrairement à Yahoo). Auth via query string `?apikey=` (limitation AV — pas de header support).
|
||||||
|
6. **Logs** : URL avec `?apikey=` masquée dans les logs Coolify/Traefik via une regex de filtre côté pino logger.
|
||||||
|
|
||||||
|
## Parsing défensif (issu des findings Phase 2 — applicable au moment de l'implémentation)
|
||||||
|
|
||||||
|
Le module `alphaVantageProvider.ts` doit gérer **5 cas distincts** sur HTTP 200 :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. Happy path — body['Global Quote'] populated
|
||||||
|
if (body['Global Quote'] && Object.keys(body['Global Quote']).length > 0) { /* ok */ }
|
||||||
|
// 2. Symbol unknown — body['Global Quote'] = {} empty object
|
||||||
|
else if (body['Global Quote'] && Object.keys(body['Global Quote']).length === 0) { /* symbol_not_found */ }
|
||||||
|
// 3. Premium endpoint blocked — body['Information'] field with subscribe message
|
||||||
|
else if (body['Information']) { /* premium_required or rate_limit */ }
|
||||||
|
// 4. Error — body['Error Message'] field (param malformed)
|
||||||
|
else if (body['Error Message']) { /* invalid_request */ }
|
||||||
|
// 5. Rate limit hit — body['Note'] field (legacy free tier message)
|
||||||
|
else if (body['Note']) { /* rate_limit */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pas de fallback sur HTTP status (toujours 200). Le code de parsing yahoo existant ne couvre pas ces cas — adaptation requise dans la PR follow-up déclenchée par la bascule.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **0 $ de cash burn maintenu** — l'esprit MVP de l'ADR 0011 est préservé. Pas de bascule prématurée à un provider payant.
|
||||||
|
- **Cible de bascule validée empiriquement** — `.TO` natif, TSX confirmé, mutual funds OK, format API simple. Au moment du trigger, la bascule prendra des heures, pas des jours.
|
||||||
|
- **Override propre de la pré-désignation Tiingo** — la décision 0011 ne s'auto-déclenche pas mécaniquement vers un provider mal calibré.
|
||||||
|
- **Email ToS reporté** — pas d'effort gaspillé tant qu'il n'y a pas d'enjeu réel.
|
||||||
|
|
||||||
|
### Négatives / risques actés
|
||||||
|
|
||||||
|
- **Yahoo reste cassé en prod** — feature stocks non-fonctionnel jusqu'au déclenchement du trigger ou résolution Yahoo (improbable). Acceptable tant qu'aucun client payant ne se plaint.
|
||||||
|
- **Adjusted close = premium endpoint** chez AV : confirmé empiriquement. Au moment de la bascule, le tier Premium $49.99 sera nécessaire (pas de chemin gratuit pour `TIME_SERIES_DAILY_ADJUSTED`).
|
||||||
|
- **Indices broad-market non couverts via `GLOBAL_QUOTE`** : SPX, ^GSPC, GSPTSE retournent objet vide. Hors-scope tant que la roadmap ne demande pas d'indices ; sinon ETF proxy (`SPY`, `XIC.TO`).
|
||||||
|
- **HTTP 200 sur toutes les erreurs** : parsing fragile, code défensif obligatoire (5 cas distincts à gérer).
|
||||||
|
- **Pas de `Retry-After` natif** : exponential backoff côté client requis sur détection de `Note`/`Information`.
|
||||||
|
- **Auth `?apikey=` query string uniquement** : leak risk dans les logs Coolify/Traefik. Mitigation = regex de masking pino.
|
||||||
|
- **ToS en zone grise jusqu'à confirmation écrite future** : risque de suspension de clé sans préavis si AV détecte un pattern multi-tenant. Probabilité faible à petite échelle, à monitorer.
|
||||||
|
- **Profondeur smallcaps TSXV non validée** : risque sur ~5-10 % des positions clients (à mitiger en Phase 4 par smoke test sur échantillon représentatif des holdings réels).
|
||||||
|
- **Free tier érodé historiquement** (500 → 100 → 25 req/jour) : signal qu'AV peut resserrer aussi les paid tiers à terme. Risque budget sur 12-24 mois.
|
||||||
|
|
||||||
|
### Neutre
|
||||||
|
|
||||||
|
- Le code reste `src/services/providers/<provider>Provider.ts` parallèle, switch via env var. Pattern déjà en place pour Yahoo, extension triviale.
|
||||||
|
|
||||||
|
## Triggers de bascule (rappel ADR 0011, inchangés)
|
||||||
|
|
||||||
|
- 1+ incidents IP-ban Yahoo / mois pendant 2 mois consécutifs, OU
|
||||||
|
- 30 %+ requêtes stocks tombent en `service_degraded` sur 7 jours, OU
|
||||||
|
- Plainte légale formelle de Yahoo / Verizon Media, OU
|
||||||
|
- (nouveau) 1ère licence payée active OU 1ère plainte client formelle sur le feature stocks.
|
||||||
|
|
||||||
|
Le 4ème trigger est ajouté pour aligner la décision avec la réalité business : tant qu'il n'y a pas de licence payée ou de plainte active, le 0 $ recurring l'emporte sur le service-quality.
|
||||||
|
|
||||||
|
## Suivi
|
||||||
|
|
||||||
|
- ADR à reviewer dans 6 mois (2026-11-07) ou plus tôt si :
|
||||||
|
- Trigger de bascule déclenché (réévaluer Yahoo vs AV en fonction du contexte du moment),
|
||||||
|
- AV resserre le free tier au point que le smoke test mensuel devient impossible,
|
||||||
|
- Yahoo redevient stable (improbable mais possible).
|
||||||
|
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`, `paid_licenses_active_count`.
|
||||||
|
- Smoke test mensuel AV free tier : entrée TODO dans le calendrier ou cron `claude` skill.
|
||||||
|
|
||||||
|
## Annexe — Draft email à `support@alphavantage.co` (à envoyer au moment de la bascule, pas maintenant)
|
||||||
|
|
||||||
|
```
|
||||||
|
Subject: Commercial use authorization — server-side proxy for ~N paying B2B licensees
|
||||||
|
|
||||||
|
Hello Alpha Vantage support team,
|
||||||
|
|
||||||
|
I am evaluating Alpha Vantage Premium for a small B2B SaaS use case and would like
|
||||||
|
to confirm the licensing model in writing before subscribing.
|
||||||
|
|
||||||
|
Use case:
|
||||||
|
- Server-side proxy (single VPS, single API key) on behalf of ~N paying B2B licensees
|
||||||
|
- Delayed/EOD US equities (NYSE/NASDAQ/AMEX) and Canadian equities (TSX via .TRT/.TO)
|
||||||
|
- Mutual funds (limited)
|
||||||
|
- ~Y 000 requests/day total, ~Z req/min peak
|
||||||
|
- No client-side redistribution: only the licensee's own portfolio holdings are
|
||||||
|
fetched, and the response data is consumed by the licensee's own desktop app
|
||||||
|
(no public-facing data feed, no resale to non-licensees)
|
||||||
|
|
||||||
|
Question:
|
||||||
|
1. Is this use case authorized under the standard Premium ToS, or do we need
|
||||||
|
a custom commercial agreement / data onboarding process?
|
||||||
|
2. Which tier would you recommend for the volume above?
|
||||||
|
3. Are there any per-end-user fees or exchange data fees I should be aware of
|
||||||
|
for delayed/EOD US + Canadian equities?
|
||||||
|
|
||||||
|
I would prefer to have written confirmation before subscribing, to comply with
|
||||||
|
my own internal documentation requirements (the ToS confirmation will be filed
|
||||||
|
as part of an internal architecture decision record).
|
||||||
|
|
||||||
|
Thanks for your help,
|
||||||
|
Maxime Tremblay
|
||||||
|
maxime2tremblay@protonmail.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Réponse écrite à archiver dans `simpl-resultat/docs/adr/0013-attachments/alphavantage-tos-confirmation-YYYY-MM-DD.txt` une fois reçue.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Alpha Vantage — API Documentation](https://www.alphavantage.co/documentation/)
|
||||||
|
- [Alpha Vantage — Premium API Key (pricing)](https://www.alphavantage.co/premium/)
|
||||||
|
- [Alpha Vantage — Terms of Service](https://www.alphavantage.co/terms_of_service/)
|
||||||
|
- [Alpha Vantage — Customer Support](https://www.alphavantage.co/support/)
|
||||||
|
- [Macroption — Alpha Vantage Symbols (suffixes)](https://www.macroption.com/alpha-vantage-symbols/)
|
||||||
|
- ADR 0009 — Architecture du proxy
|
||||||
|
- ADR 0011 — Providers best-effort Yahoo (override partiel sur le provider de bascule désigné)
|
||||||
|
- `maximus-api/docs/research/0013-stocks-providers-phase1.md` — Synthèse 3-way Phase 1
|
||||||
|
- `~/.maximus-research-keys/raw-av.json` — Réponses brutes Phase 2 smoke test (à supprimer après merge)
|
||||||
|
- Issue Forgejo `maximus-api#41`
|
||||||
104
docs/adr/0014-balance-vehicule-attribut.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# ADR 0014 — Bilan : le véhicule fiscal est un attribut du compte, la classe d'actif est la catégorie
|
||||||
|
|
||||||
|
- Status: **Accepted**
|
||||||
|
- Date: 2026-06-01
|
||||||
|
- Rejette: ADR 0012 (modèle à deux niveaux véhicule × composition — voir « Alternatives » ci-dessous)
|
||||||
|
- Issues: #202 (couche données), #203 (UI saisie), #204 (UI suivi), #205 (cet ADR + doc)
|
||||||
|
- Audit source: [docs/audit-bilan-2026-05.md](../audit-bilan-2026-05.md)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'audit critique de la page Bilan (`docs/audit-bilan-2026-05.md`, revue CPA + UX, 2026-05-31) a identifié une **racine unique** (finding A) derrière la plupart des frictions du module : le modèle est *plat*. Une seule « catégorie » (`balance_categories`) encodait simultanément **deux axes orthogonaux** :
|
||||||
|
|
||||||
|
- le **véhicule fiscal** / l'enveloppe : CELI, REER, non-enregistré… ;
|
||||||
|
- la **classe d'actif** : liquidités, fonds/FNB, actions, crypto, autres.
|
||||||
|
|
||||||
|
Les 7 catégories seedées par la migration v9 — `cash · tfsa · rrsp · fund · other · stock · crypto` — mélangeaient donc des frères/sœurs de natures incomparables. Conséquences directes mesurées par l'audit :
|
||||||
|
|
||||||
|
- **Cas québécois inexprimables** : suivre des actions *dans* un CELI force un choix dégradé (compte `simple` sous `tfsa` → le titre disparaît ; compte `priced` sous `stock` → l'abri CELI disparaît).
|
||||||
|
- **Empilé « par catégorie » illisible** (finding G) : ni « répartition par classe d'actif », ni « répartition par enveloppe » — un axe bâtard.
|
||||||
|
- **Bug i18n latent** (finding I) : renommer une catégorie via `window.prompt()` écrasait `i18n_key` avec du texte libre, cassant la traduction FR/EN — une promesse-socle du projet.
|
||||||
|
|
||||||
|
L'audit recommandait une **trajectoire additive en trois temps** (Étape 0 quick wins, déjà livrée en #201 ; Étape 1 séparer l'axe véhicule ; Étape 2 détail par titre) explicitement opposée au big-bang de l'ADR 0012. Le présent ADR documente la décision prise pour l'**Étape 1**.
|
||||||
|
|
||||||
|
### Pourquoi additif (et pas le modèle deux-tables de l'ADR 0012)
|
||||||
|
|
||||||
|
L'ADR 0012 proposait deux tables `balance_vehicles` × `balance_compositions` et une ligne de snapshot devenant un triplet `(vehicle_id, composition_id, value)`. C'est une réécriture quasi totale de `/balance` (grille de saisie 2D imposée à tous, dédoublement des agrégateurs, migration de données massive) pour un grain qui reste la **classe d'actif** — pas le **titre individuel** que l'investisseur attend réellement (audit : « l'ADR 0012 résout le mauvais grain »). L'approche retenue ajoute deux colonnes nullables, ne touche aucun compte simple existant, et garde la grille de saisie à une dimension.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Le véhicule fiscal devient un attribut du compte ; la catégorie devient une pure classe d'actif.**
|
||||||
|
|
||||||
|
1. **Nouvelle colonne `balance_accounts.vehicle_type`** — TEXT nullable, enveloppe fiscale, contrainte CHECK sur l'enum réduit courant :
|
||||||
|
|
||||||
|
| Code | FR | EN |
|
||||||
|
|---|---|---|
|
||||||
|
| `unregistered` | Non-enregistré | Non-registered |
|
||||||
|
| `tfsa` | CELI | TFSA |
|
||||||
|
| `rrsp` | REER | RRSP |
|
||||||
|
| `rrif` | FERR | RRIF |
|
||||||
|
| `fhsa` | CELIAPP | FHSA |
|
||||||
|
| `resp` | REEE | RESP |
|
||||||
|
|
||||||
|
⚠️ `vehicle_type` = enveloppe **fiscale**, jamais un véhicule automobile. Nullable : un compte chèque ou un wallet crypto n'a pas d'enveloppe (valeur `NULL`, et non `unregistered` — un compte courant n'est pas un placement non-enregistré).
|
||||||
|
|
||||||
|
2. **Les catégories sont 5 classes d'actif pures** : Liquidités, Fonds/FNB, Actions, Crypto, Autres. Les ex-catégories-véhicules `tfsa`/`rrsp` ne sont plus des catégories — elles deviennent des `vehicle_type`.
|
||||||
|
|
||||||
|
3. **Deux migrations additives** (jamais de modification d'une migration ≤ v11 — checksum SHA-384 sqlx) :
|
||||||
|
- **v12 (additive)** : ajoute `vehicle_type` (+ CHECK) et `balance_categories.custom_label` ; backfille `vehicle_type='tfsa'`/`'rrsp'` pour les comptes des ex-enveloppes ; les comptes `cash` restent `NULL`. Inclut un **backfill défensif du bug I** : toute catégorie seed dont `i18n_key` avait été écrasé par du texte libre récupère ce texte dans `custom_label`, et son `i18n_key` est restauré depuis `key`.
|
||||||
|
- **v13 (reclassement, conditionnelle/idempotente)** : re-rattache les comptes ex-`tfsa`/`rrsp` à la classe « Autres » (`other`), gardé par `EXISTS(other) AND is_seed=1` ; désactive (`is_active=0`) les seeds `tfsa`/`rrsp` devenus des véhicules. v12 stampe `vehicle_type` **avant** que v13 ne déplace `balance_category_id` (ordre garanti par le versioning sqlx).
|
||||||
|
- `consolidated_schema.sql` (profils neufs) et les comptes starter (consolidated + `STARTER_ACCOUNTS` service) sont synchronisés : CELI/REER pointent vers `other` + portent leur `vehicle_type`.
|
||||||
|
|
||||||
|
4. **Renommage de catégorie via `custom_label`** (corrige le bug I) : l'affichage suit la règle `custom_label?.trim() || t(i18n_key, { defaultValue: key })`, factorisée dans un helper `renderCategoryLabel`. Le renommage écrit `custom_label` et **ne touche plus jamais `i18n_key`** — la traduction FR/EN reste intacte.
|
||||||
|
|
||||||
|
5. **Deux axes de lecture** : le graphique empilé gagne un sous-toggle « Par classe d'actif » (défaut, = comportement existant) / « Par enveloppe » (`getSnapshotTotalsByVehicleAndDate`, bucket `COALESCE(vehicle_type,'none')`). Le tableau des comptes replie ses colonnes de rendement par défaut, avec un toggle dont l'état est persisté (`user_preferences.balance_show_returns`).
|
||||||
|
|
||||||
|
### Hors scope (Étape 2 — explicitement exclue de cette décision)
|
||||||
|
|
||||||
|
Le **détail par titre** reste hors scope et n'est *pas* tranché par cet ADR :
|
||||||
|
|
||||||
|
- pas de table `balance_securities` (symbole normalisé, devise, asset_type) ;
|
||||||
|
- pas de holdings/positions par titre sous un compte ;
|
||||||
|
- pas de `book_cost` / PRU (distinction apport vs gain latent) ;
|
||||||
|
- pas de migration du `kind` (`simple`/`priced`) de la catégorie vers le compte, donc pas encore d'assistant « détailler un compte agrégé en titres » sans rupture d'historique ;
|
||||||
|
- pas d'import CSV de cours local, pas de multi-devise (reste CAD), pas d'agrégation du rendement par véhicule.
|
||||||
|
|
||||||
|
L'Étape 2 fera l'objet d'un ADR distinct quand le besoin sera confirmé (gating de l'audit : trajectoire additive, pas de big-bang). L'Étape 1 ne **ferme aucune** de ces portes — elle pose l'axe véhicule sans présumer du grain titre.
|
||||||
|
|
||||||
|
## Alternatives considérées
|
||||||
|
|
||||||
|
- **ADR 0012 — modèle à deux niveaux `balance_vehicles` × `balance_compositions`** (rejeté, voir ci-dessus) : surdimensionné, grille 2D imposée, migration massive, grain « classe d'actif » et non « titre ».
|
||||||
|
- **Tagging multi-axes libre** (alternative A de l'ADR 0012) : aucune contrainte sur les combinaisons, rapports « actions dans CELI » coûteux, vocabulaire divergent entre profils.
|
||||||
|
- **Sous-comptes (`parent_id`)** (alternative B de l'ADR 0012) : invariant somme parent = somme enfants à maintenir, snapshots dédoublés sans gain expressif.
|
||||||
|
- **Statu quo** (modèle plat enrichi de catégories user `tfsa_stock`…) : explosion N×M de la taxonomie, friction documentée croissante.
|
||||||
|
|
||||||
|
L'attribut nullable sur le compte gagne sur les quatre : migration triviale (2 colonnes), zéro impact sur les comptes simples existants, deux groupements naturels (`GROUP BY category` / `GROUP BY vehicle_type`), et aucune porte fermée pour l'Étape 2.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **Cas québécois exprimables** : « combien dans mon CELI ? » se lit par `vehicle_type`, indépendamment de la classe d'actif détenue.
|
||||||
|
- **Empilé assaini** (finding G) : deux axes distincts et explicites, défaut « par classe d'actif » (zéro surprise vs l'existant).
|
||||||
|
- **Bug i18n corrigé** (finding I) : le renommage n'altère plus la traduction ; le backfill défensif v12 répare les profils déjà cassés.
|
||||||
|
- **Migration sûre** : purement additive, idempotente, gardée ; les comptes simples ne bougent jamais ; `snapshot_lines` référencent `account_id` → **historique des valeurs préservé**.
|
||||||
|
- **Tableau dégonflé** (finding F) : rendements repliés par défaut, état persisté — moins de bruit pour le grand public.
|
||||||
|
|
||||||
|
### Négatives / risques actés
|
||||||
|
|
||||||
|
- **Historique re-affiché sous « Autres »** : l'axe « par classe d'actif » est recalculé sur la **catégorie courante** du compte. Un snapshot pré-migration d'un compte ex-CELI/REER apparaît donc désormais sous « Autres » (et non plus « CELI »/« REER »). **Comportement attendu**, documenté au CHANGELOG et au guide. La lecture par enveloppe, elle, retrouve bien le CELI/REER via `vehicle_type`.
|
||||||
|
- **Pas de migration Down** : v12/v13 sont idempotentes et conditionnelles ; toute correction passe par une v14 (jamais d'édition rétroactive).
|
||||||
|
- **Comptes ex-CELI/REER contenant de vraies actions** : restent un montant agrégé en « Autres » + leur `vehicle_type`. Le détail par titre est l'Étape 2 — non débloqué ici.
|
||||||
|
- **`vehicle_type` lu comme automobile** : risque d'ambiguïté de nommage (un agent d'exploration a déjà halluciné « car/truck »). Mitigé par le CHECK explicite, le commentaire de migration et la table d'enum ci-dessus.
|
||||||
|
|
||||||
|
### Neutre
|
||||||
|
|
||||||
|
- L'enum `vehicle_type` couvre le set courant (6 valeurs). Ajouter CRI, RPDB, etc. plus tard = une nouvelle migration qui élargit le CHECK (jamais une édition de v12).
|
||||||
|
|
||||||
|
## Liens
|
||||||
|
|
||||||
|
- [Audit Bilan 2026-05](../audit-bilan-2026-05.md) — racine (finding A), trajectoire additive en trois temps, recommandation sur l'ADR 0012
|
||||||
|
- [ADR 0012](0012-balance-two-level-model.md) — modèle à deux niveaux, **Rejected** au profit du présent ADR
|
||||||
|
- [ADR 0008](0008-modified-dietz-pour-rendement.md) — Modified Dietz par compte (modèle plat préservé : le rendement reste par compte)
|
||||||
|
- [ADR 0010](0010-fk-restrict-balance-transfers.md) — FK RESTRICT sur transferts (contrainte inchangée)
|
||||||
|
- Issues #202 / #203 / #204 / #205 — implémentation Étape 1
|
||||||
122
docs/adr/0015-balance-detail-par-titre.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# ADR 0015 — Bilan : détail par titre (holdings par snapshot)
|
||||||
|
|
||||||
|
- Status: **Accepted**
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Issues: #210 (schéma/migrations v14/v15 + types), #211 (conversion v16), #212 (service securities + save détaillé), #213 (refonte reducer + dispatch `account.kind`), #214 (UI saisie multi-titres), #215 (assistant « détailler »), #216 (drill-down + gain latent), #217 (tests), #218 (cet ADR + doc)
|
||||||
|
- Spec: [`spec-decisions-bilan-detail-titres.md`](../../spec-decisions-bilan-detail-titres.md), [`spec-plan-bilan-detail-titres.md`](../../spec-plan-bilan-detail-titres.md)
|
||||||
|
- Audit source: [docs/audit-bilan-2026-05.md](../audit-bilan-2026-05.md)
|
||||||
|
- Lève le gating « détail par titre reporté » posé par [ADR 0012](0012-balance-two-level-model.md) (Rejected) et le « Hors scope Étape 2 » de [ADR 0014](0014-balance-vehicule-attribut.md)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
L'audit critique de la page Bilan (`docs/audit-bilan-2026-05.md`, revue CPA + UX) a défini une **trajectoire additive en trois temps**. L'Étape 0 (quick wins, #201) et l'Étape 1 (axe véhicule, [ADR 0014](0014-balance-vehicule-attribut.md) Accepted) sont livrées. L'**Étape 2 — détail par titre** — était reportée et explicitement hors scope de l'ADR 0014 (« l'Étape 2 fera l'objet d'un ADR distinct quand le besoin sera confirmé »). Ce besoin est confirmé : l'audit le qualifie d'« unique levier qui débloque le progressif pour les deux personas », et l'Étape 1 laisse une limitation connue — un compte ex-CELI/REER contenant de vraies actions reste un **montant agrégé** sous « Autres », sans détail de portefeuille.
|
||||||
|
|
||||||
|
État technique de départ : le modèle **coté** (`priced`) existe déjà, mais à raison d'**un seul titre par compte** — `balance_accounts.symbol` + `balance_snapshot_lines(quantity, unit_price, value, price_source)`, alimenté par le price-fetching maximus-api (ADR 0009/0011/0013). L'Étape 2 ne réinvente pas le `quantité × cours` : elle le **généralise de 1 titre/compte à N titres/compte**, avec un coût d'acquisition (`book_cost`) par position pour distinguer l'apport du gain latent.
|
||||||
|
|
||||||
|
### Pourquoi un ADR distinct (et non une extension de 0014)
|
||||||
|
|
||||||
|
L'ADR 0014 a posé l'axe véhicule en gardant le **grain « classe d'actif »** ; il a explicitement laissé ouverte la question du grain **« titre »**. Le détail par titre touche un autre plan du modèle (positions sous une ligne de snapshot, gain latent, bascule agrégé→détaillé, immortalité des titres référencés) qui mérite sa propre décision tracée — d'où ce 0015.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
**Le détail par titre est stocké par snapshot, sous la ligne agrégée existante, qui reste la source de vérité unique.**
|
||||||
|
|
||||||
|
### 1. Modèle holdings-par-snapshot (additif)
|
||||||
|
|
||||||
|
Trois migrations additives **post-v13** — `v14`, `v15`, `v16` — sans aucune modification des migrations `v1`–`v13` (checksum SHA-384 sqlx). `consolidated_schema.sql` (nouveaux profils) et les seeds sont synchronisés en parallèle.
|
||||||
|
|
||||||
|
- **`v14`** — deux tables :
|
||||||
|
- `balance_securities` : table normalisée et **partagée** des titres. `symbol TEXT NOT NULL UNIQUE COLLATE NOCASE` (forme canonique upper/trim), `currency TEXT NOT NULL DEFAULT 'CAD'` (préparé multi-devise, sans CHECK restrictif), `asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto'))`, `name?`. Un même titre est partagé entre comptes.
|
||||||
|
- `balance_snapshot_holdings` : le détail par titre d'un compte détaillé, rattaché à **sa ligne de snapshot agrégée** (`snapshot_line_id`, `security_id`, `quantity`, `unit_price`, `value`, `book_cost?`, `price_source?`, `price_fetched_at?`), `UNIQUE (snapshot_line_id, security_id)`. Deux index (`_line`, `_security`).
|
||||||
|
- **`v15`** — `balance_accounts.kind` (`'simple' | 'detailed'`, défaut `'simple'`, CHECK) + `balance_accounts.detailed_since` (DATE nullable). Backfill : un compte sous catégorie `kind='priced'` devient `'detailed'`. `balance_categories.kind` est **conservé** comme **défaut suggéré** pour les nouveaux comptes (additif, non supprimé).
|
||||||
|
- **`v16`** — convertit les comptes cotés **existants** (1 titre via `account.symbol`) en comptes `detailed` à **1 position** : création/liaison d'un `balance_securities` depuis le symbole normalisé, miroir de chaque ligne `priced` en holding, puis neutralisation de `quantity`/`unit_price` sur la ligne agrégée — **uniquement** si un holding a effectivement été créé (garde anti-perte de données pour les comptes priced à `asset_type` NULL ou sans symbole, laissés intacts). Idempotente façon v13.
|
||||||
|
|
||||||
|
### 2. Invariant central — la ligne agrégée reste la source de vérité
|
||||||
|
|
||||||
|
À chaque snapshot, **un compte = une ligne `balance_snapshot_lines`** (`UNIQUE(snapshot_id, account_id)` inchangé) :
|
||||||
|
|
||||||
|
- compte `simple` : la ligne porte `value` (saisie libre), `quantity`/`unit_price` NULL, aucun holding ;
|
||||||
|
- compte `detailed` : la ligne porte la **valeur totale** du compte — `value = SUM(holdings.value)`, chaque holding arrondi au cent puis sommé (**comparaison exacte au cent, pas de tolérance flottante**) — `quantity`/`unit_price` NULL ; le détail par titre vit dans `balance_snapshot_holdings`.
|
||||||
|
|
||||||
|
**C'est l'invariant qui rend l'Étape 2 non-breaking.** Tous les agrégateurs existants (`getSnapshotTotalsByDate` / `…ByCategoryAndDate` / `…ByVehicleAndDate`) et le **rendement Modified Dietz par compte** ([ADR 0008](0008-modified-dietz-pour-rendement.md), `compute_account_return`) continuent de lire **exactement** `balance_snapshot_lines.value` — zéro changement de chemin de lecture, zéro rebuild des lignes existantes. Les golden values des agrégations sont figées avant/après (tests de régression, #217). Le détail par titre est une **couche d'enrichissement** sous la valeur agrégée, jamais un remplacement.
|
||||||
|
|
||||||
|
### 3. `detailed_since` — pivot faisant autorité
|
||||||
|
|
||||||
|
La frontière agrégé/détaillé est portée par `balance_accounts.detailed_since` (DATE), **faisant autorité** plutôt qu'inférée du comptage de holdings : une ligne **à ou après** `detailed_since` **doit** porter des holdings ; **avant**, l'agrégé figé est toléré (historique pré-bascule en lecture seule). Un compte `detailed` peut donc cumuler des snapshots passés agrégés et des snapshots récents détaillés, mais la frontière est **explicite et requêtable**, conforme à la convention d'état du projet (`archived_at`, `is_active`). La validation est une passe séparée `validateDetailedSnapshot(account.kind, line, holdings)` ; le CHECK SQL par ligne reste inchangé ; la ligne agrégée s'écrit via le chemin « simple ».
|
||||||
|
|
||||||
|
### 4. Dispatch sur `account.kind` (et non `category_kind`)
|
||||||
|
|
||||||
|
Tout le dispatch UI/service bascule de `category_kind` vers **`account.kind`** (`BalanceAccountWithCategory.kind` propagé). `category.kind` (`simple`/`priced`) ne survit que comme **défaut suggéré** de l'`AccountForm` à la création. Un compte `detailed` sous une catégorie `simple` s'affiche donc correctement.
|
||||||
|
|
||||||
|
### 5. Gain latent vs Modified Dietz par titre
|
||||||
|
|
||||||
|
Le **gain/perte latent** d'une position = `value − book_cost`, en valeur et en pourcentage (`/ book_cost`), agrégeable par compte / classe d'actif / enveloppe, avec drill-down par titre dans le tableau des comptes (`computeUnrealizedGain`, garde-fou `book_cost = 0` ou NULL → « N/A »). `book_cost` est **saisi directement par position** (pré-rempli depuis le snapshot précédent) et historisé sur `balance_snapshot_holdings` — il n'est **pas** dérivé de transactions.
|
||||||
|
|
||||||
|
Le **rendement Modified Dietz reste au niveau compte** (inchangé). Un Modified Dietz **par titre** est **hors scope** : il exigerait des flux datés par lot et par titre (achats/ventes/RoC) — c'est-à-dire un suivi par lots, que l'app ne tient pas (modèle snapshot, pas journal de transactions). Le gain latent depuis l'acquisition est exact **sans** ces flux et répond directement au besoin (apport vs plus-value latente).
|
||||||
|
|
||||||
|
### 6. Assistant « détailler un compte agrégé » (date pivot)
|
||||||
|
|
||||||
|
Sur un compte `simple`, une action **« Détailler en titres »** (point d'entrée dans `BalanceAccountsTable`) bascule `kind='detailed'` et fixe `detailed_since = aujourd'hui`. L'historique agrégé passé reste **figé en lecture seule** ; les titres se saisissent au **prochain snapshot normal** (l'assistant ne capture pas de positions initiales). Le retour `detailed → simple` est **interdit dès qu'un holding existe** — bloqué côté UI **et** côté service (`updateBalanceAccount`, erreur typée), pour éviter les holdings orphelins.
|
||||||
|
|
||||||
|
### 7. Politique des titres : immortels une fois référencés
|
||||||
|
|
||||||
|
`balance_snapshot_holdings.security_id` → `balance_securities(id)` **`ON DELETE RESTRICT`** (miroir de `balance_account_transfers.transaction_id`, [ADR 0010](0010-fk-restrict-balance-transfers.md)) : un titre référencé par au moins un holding **ne peut pas être supprimé** — l'historique reste reproductible. La suppression de titre est donc **masquée dans l'UI** (immortalité assumée). `balance_snapshot_holdings.snapshot_line_id` → `balance_snapshot_lines(id)` **`ON DELETE CASCADE`** : supprimer une ligne (ou son snapshot) emporte ses holdings.
|
||||||
|
|
||||||
|
### Hors scope (explicitement exclu)
|
||||||
|
|
||||||
|
- **Suivi des lots d'achat / transactions** (PBR/ACB calculé, gain en capital *réalisé*, remboursement de capital, perte superficielle 61 j). `book_cost` est **saisi**, pas dérivé. Un Modified Dietz par titre en relève aussi.
|
||||||
|
- **Conversion multi-devise réelle** (taux datés, devise de référence). Seule la colonne `currency` est préparée ; affichage et agrégation restent **CAD**. L'affichage natif ≠ CAD est dé-scopé (chemin non testable tant qu'aucun titre non-CAD ne peut être créé).
|
||||||
|
- **Import CSV de cours local** (mode privacy/hors-ligne) : déféré, le price-fetching maximus-api couvre déjà l'acquisition des cours.
|
||||||
|
- **Agrégation du rendement par titre × véhicule fiscal** (`GROUP BY vehicle_type, security`).
|
||||||
|
|
||||||
|
## Alternatives considérées
|
||||||
|
|
||||||
|
### A. Modèle transaction-based (valeur dérivée) — **rejeté**
|
||||||
|
|
||||||
|
L'alternative établie dans l'industrie (cf. **[Portfolio Performance PR #779](https://github.com/portfolio-performance/portfolio/pull/779)** — « historical quotes from transactions ») : ne pas snapshoter de valeur, mais la **dériver** d'un journal de transactions (achats/ventes datés) × cours historiques. **Rejeté consciemment.**
|
||||||
|
|
||||||
|
- L'app est **snapshot-based par design** (saisie périodique manuelle d'un relevé daté), pas un journal de transactions. Tous les chemins Bilan lisent déjà des snapshots.
|
||||||
|
- Le **rendement Modified Dietz tourne déjà par compte** ([ADR 0008](0008-modified-dietz-pour-rendement.md)) à partir des snapshots + transferts taggés — sans valeurs intermédiaires. Passer au transaction-based forcerait à dériver la valeur à chaque date, soit une **refonte complète** du modèle de lecture, pour un gain (PBR exact, gain réalisé) qui est précisément le **suivi par lots hors scope**.
|
||||||
|
- Le **`book_cost` total saisi par position** colle à la référence courtier ([Wealthsimple stocke une *book value* totale, pas des lots](https://help.wealthsimple.com/hc/en-ca/articles/4409775037083-What-is-adjusted-cost-base-ACB)) ; le snapshot par titre est l'extension **additive** naturelle (cf. [Portfolio Performance — Securities/Transactions/Quotes séparés](https://help.portfolio-performance.info/en/reference/view/securities/all-securities/)).
|
||||||
|
|
||||||
|
### B. Table de positions courantes (hors snapshot) — rejeté
|
||||||
|
|
||||||
|
Maintenir une table « portefeuille courant » distincte des snapshots. Rejeté : incohérent avec le modèle snapshot (le portefeuille courant = le dernier snapshot), et la valeur agrégée ne vivrait plus sur la ligne → agrégations à dédoubler. Stocker les holdings **par snapshot** garde les agrégations existantes intactes et la migration additive sans rebuild.
|
||||||
|
|
||||||
|
### C. Tagging multi-axes / sous-comptes (`parent_id`) — déjà rejetés en 0012/0014
|
||||||
|
|
||||||
|
Ne donnent pas le grain titre et imposent un invariant somme parent = somme enfants. Voir [ADR 0012](0012-balance-two-level-model.md) (alternatives A/B) et [ADR 0014](0014-balance-vehicule-attribut.md).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **Détail de portefeuille enfin exprimable** : N titres (quantité, cours, valeur, `book_cost`) dans un même compte ; un ex-CELI/REER avec de vraies actions n'est plus un montant agrégé opaque — il se détaille via l'assistant.
|
||||||
|
- **Non-breaking par construction** : la ligne agrégée reste la source de vérité ; agrégations date/classe/enveloppe et Modified Dietz par compte **inchangés** (golden values figées, #217). Migration purement additive, idempotente, atomique (rollback testé), `v1`–`v13` intactes.
|
||||||
|
- **Gain latent exact sans suivi par lots** : `value − book_cost` répond aux findings D/E de l'audit sans le coût d'un journal de transactions.
|
||||||
|
- **Frontière agrégé/détaillé explicite** : `detailed_since` faisant autorité, pas d'état implicite ; les comptes cotés existants sont auto-convertis sans perte d'historique.
|
||||||
|
- **Reproductibilité préservée** : titres immortels une fois référencés (RESTRICT), holdings emportés par CASCADE avec leur ligne/snapshot.
|
||||||
|
|
||||||
|
### Négatives / risques actés
|
||||||
|
|
||||||
|
- **`book_cost` pré-rempli ≠ ajusté automatiquement** : la copie depuis le snapshot précédent est correcte tant qu'aucun apport/retrait n'a eu lieu sur le titre ; un achat/vente doit être ajusté manuellement, sinon gain latent (`book_cost`) et rendement (Dietz, via les transferts) peuvent diverger silencieusement. Documenté au guide.
|
||||||
|
- **Pas de gain en capital réalisé / PBR fiscal** : le `book_cost` est une *book value* estimée, pas un ACB fiscalement opposable (pas de RoC, pas de perte superficielle). Hors scope assumé.
|
||||||
|
- **Titres orphelins non nettoyables** : un titre référencé puis « abandonné » (qty 0) reste indélébile (RESTRICT). Acceptable (mêmes propriétés que `balance_account_transfers`) ; un nettoyage gardé « si aucun holding » pourra être ajouté plus tard.
|
||||||
|
- **`book_cost` NULL sur l'historique converti** : les lignes converties par v16 n'ont pas de coût rétroactif → gain latent « N/A » avant la première saisie. Attendu.
|
||||||
|
- **Pas de migration Down** : v14/v15/v16 idempotentes et conditionnelles ; toute correction passe par une `v17+` (jamais d'édition rétroactive).
|
||||||
|
|
||||||
|
### Neutre
|
||||||
|
|
||||||
|
- La colonne `currency` est prête mais inerte (CAD partout) tant qu'aucun titre non-CAD ne peut être créé — l'UX d'affichage natif s'activera avec le multi-devise.
|
||||||
|
|
||||||
|
## Liens
|
||||||
|
|
||||||
|
- [Audit Bilan 2026-05](../audit-bilan-2026-05.md) — trajectoire additive en trois temps ; Étape 2 = levier central
|
||||||
|
- [ADR 0008](0008-modified-dietz-pour-rendement.md) — Modified Dietz **par compte** (préservé tel quel ; pas de Dietz par titre)
|
||||||
|
- [ADR 0010](0010-fk-restrict-balance-transfers.md) — FK `RESTRICT` (modèle d'immortalité réutilisé pour `security_id`)
|
||||||
|
- [ADR 0012](0012-balance-two-level-model.md) — Rejected ; levait le gating « détail par titre reporté »
|
||||||
|
- [ADR 0014](0014-balance-vehicule-attribut.md) — axe véhicule (Étape 1) ; détail par titre y était explicitement hors scope
|
||||||
|
- Spec : [`spec-decisions-bilan-detail-titres.md`](../../spec-decisions-bilan-detail-titres.md) · [`spec-plan-bilan-detail-titres.md`](../../spec-plan-bilan-detail-titres.md)
|
||||||
|
- [Portfolio Performance PR #779](https://github.com/portfolio-performance/portfolio/pull/779) — modèle transaction-based **rejeté** au profit du snapshot
|
||||||
|
- [Wealthsimple — Adjusted cost base](https://help.wealthsimple.com/hc/en-ca/articles/4409775037083-What-is-adjusted-cost-base-ACB) · [AdjustedCostBase.ca](https://www.adjustedcostbase.ca/blog/how-to-calculate-adjusted-cost-base-acb-and-capital-gains/) — `book_cost` total comme modèle légitime
|
||||||
|
- Issues #210 → #218 — implémentation Étape 2
|
||||||
598
docs/api-contract-prices.md
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
# Contrat API — `GET /v1/prices`
|
||||||
|
|
||||||
|
> **Statut** : Draft v2 (2026-04-26) — décisions de revue intégrées, prêt pour gel après création des issues
|
||||||
|
> **Producteurs** : `maximus-api` (serveur Hono/Node.js sur VPS OVH)
|
||||||
|
> **Consommateurs** : `simpl-resultat` (client desktop Tauri)
|
||||||
|
> **Fichiers de référence (mirror)** :
|
||||||
|
> - `simpl-resultat/docs/api-contract-prices.md` (ce fichier — source de vérité)
|
||||||
|
> - `maximus-api/docs/api-contract-prices.md` (copie identique, à synchroniser à chaque modification)
|
||||||
|
|
||||||
|
Ce document fige la surface d'API entre le client desktop premium et le proxy de récupération de prix de `maximus-api`. Le but : permettre au client et au serveur d'être développés et testés en parallèle, sans dépendance temporelle, contre des mocks conformes.
|
||||||
|
|
||||||
|
## 0. Décisions de revue (2026-04-26)
|
||||||
|
|
||||||
|
Synthèse des décisions issues de la revue multi-expert (cf. issue #143 et ce fichier annoté). Ces décisions sont **gelées** et impactent toutes les sections ci-dessous.
|
||||||
|
|
||||||
|
| Décision | Choix | Section impactée |
|
||||||
|
|----------|-------|------------------|
|
||||||
|
| Providers de prix | **Crypto via exchanges directs (Kraken, Coinbase via CCXT, OSS-légal, gratuit) + Stocks via Yahoo Finance en best-effort assumé** (gratuit, ToS risque acté dans ADR 0011) | §8 |
|
||||||
|
| Coût mensuel | 0 $ initial. Migration vers Tiingo (~10 $/mo) ou Polygon (~29 $/mo) si Yahoo devient inutilisable (cf. ADR 0011) | §8, §11 |
|
||||||
|
| Rate-limit infra | **Postgres token-bucket atomique** (`INSERT ... ON CONFLICT DO UPDATE` + `pg_advisory_xact_lock`). Pas de Redis. | §6.1 |
|
||||||
|
| Versioning + enveloppe | **Migration globale vers `/v1/` + enveloppe nestée `{error: {code, message, retry_after}}`**. `/licenses/*` deviennent aliases deprecated 30 jours pointant sur `/v1/licenses/*`. | §2, §5, §11 |
|
||||||
|
| Quota par licence | **30/min, 200/jour** (revu à la baisse depuis 2000/jour : Yahoo est gratuit mais fragile, et 200 suffit pour ~50 actifs × snapshot mensuel) | §6.1 |
|
||||||
|
| Justification du gating premium | Le client premium paie le **proxy d'anonymisation et l'infrastructure**, pas la donnée (qui est gratuite/best-effort). Cohérent avec privacy-first. | §1, ADR 0011 |
|
||||||
|
| `product` claim binding | Middleware valide `claims.product === 'simpl-resultat'`. | §7.1 |
|
||||||
|
| Lib mocks | Client TS : `vi.fn()` sur `fetch`. Serveur outbound : `nock`. Serveur inbound : `app.request()` natif Hono. | §12 |
|
||||||
|
|
||||||
|
## 1. Objectif fonctionnel
|
||||||
|
|
||||||
|
Permettre au client desktop d'obtenir le prix d'un actif coté (action ou crypto) à une date donnée, sans exposer l'identité ni l'IP de l'utilisateur aux fournisseurs de données. Le proxy `maximus-api` fait écran et mutualise les appels.
|
||||||
|
|
||||||
|
**Le client premium paie pour l'infrastructure d'anonymisation et de proxy, pas pour la donnée elle-même.** La donnée est gratuite (crypto via exchanges publics, stocks via Yahoo en best-effort). Cette distinction est centrale au modèle économique et préserve la cohérence privacy-first.
|
||||||
|
|
||||||
|
**UX explicite — feature stocks en best-effort** : pour les catégories de bilan en stocks (actions cotées), le bouton de fetch affiche un label « best-effort » + warning au premier usage : « Source non garantie, peut être indisponible. La saisie manuelle reste prioritaire et toujours active. » Pas de warning pour crypto (provider stable).
|
||||||
|
|
||||||
|
**Ce qui n'est PAS dans cet endpoint** :
|
||||||
|
- Recherche / autocomplete de symboles (hors scope MVP)
|
||||||
|
- Historique sur intervalle (un seul couple `(symbol, date)` par requête)
|
||||||
|
- Conversion de devise
|
||||||
|
|
||||||
|
## 2. Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/prices?symbol=<symbol>&date=<YYYY-MM-DD>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Méthode** : `GET` (idempotent, cacheable au sens HTTP — mais voir §10 pour les contraintes côté client)
|
||||||
|
- **Base URL prod** : `https://api.lacompagniemaximus.com`
|
||||||
|
- **Base URL dev** : configurable côté client (`MAXIMUS_API_URL` ou équivalent)
|
||||||
|
- **Versioning** : préfixe `/v1/`. Toute modification non rétrocompatible passe par `/v2/`. Au sein de `/v1/`, seuls les ajouts de champs sont autorisés.
|
||||||
|
|
||||||
|
> **🔴 ARCHITECTURE** — Asymétrie de versioning `/v1/prices` vs `/licenses/*`.
|
||||||
|
> Introduire `/v1/` sur prices alors que `/licenses/*` reste non-versionné crée une surface mixte. La politique §11 ne s'applique alors qu'à un endpoint.
|
||||||
|
> **Resolution :** Trancher avant gel : soit renommer en `/v1/licenses/*` (avec alias durant deprecation window), soit retirer le préfixe `/v1` sur prices. À acter dans l'ADR 0009.
|
||||||
|
|
||||||
|
### 2.1 Paramètres de requête
|
||||||
|
|
||||||
|
| Param | Type | Format | Validation | Obligatoire |
|
||||||
|
|-------|------|--------|------------|-------------|
|
||||||
|
| `symbol` | string | alphanum + `.` + `-`, 1-20 chars, case-insensitive (normalisé en MAJUSCULES côté serveur) | regex `^[A-Za-z0-9.\-]{1,20}$` | oui |
|
||||||
|
| `date` | string | ISO 8601 `YYYY-MM-DD` | doit être ≥ `1970-01-01` et ≤ aujourd'hui (UTC) | oui |
|
||||||
|
|
||||||
|
Toute autre query string est ignorée silencieusement (le serveur ne se base que sur ces deux paramètres).
|
||||||
|
|
||||||
|
> **🔴 ARCHITECTURE** — Binding manquant à la claim `product` du JWT.
|
||||||
|
> Tous les endpoints existants requièrent `product` (schéma multi-produit) et le JWT activation porte une claim `product`. `/v1/prices` ne la valide pas — un futur 2e produit pourrait hitter prices avec son propre token.
|
||||||
|
> **Resolution :** Valider `claims.product === 'simpl-resultat'` dans le middleware d'auth (extraction JWT, pas en query). Documenter en §7.1 (étape 5.5).
|
||||||
|
|
||||||
|
## 3. Headers de requête
|
||||||
|
|
||||||
|
### 3.1 Headers requis (client → maximus-api)
|
||||||
|
|
||||||
|
| Header | Valeur | Notes |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| `Authorization` | `Bearer <activation_token>` | Token opaque côté client. Le serveur valide la signature Ed25519 + l'état de la licence en DB. |
|
||||||
|
| `Accept` | `application/json` | |
|
||||||
|
| `User-Agent` | `simpl-resultat` | **FIXE**, sans version, sans OS, sans architecture. |
|
||||||
|
|
||||||
|
> **🟢 SECURITE+TECHNIQUE** — User-Agent fixe empêche la dépréciation d'urgence des clients.
|
||||||
|
> Aucun moyen de refuser un build vulnérable connu (ex. version qui leak l'`activation_token` dans les logs). Pas de gate de version minimale possible.
|
||||||
|
> **Resolution :** Envoyer un header séparé `X-Client-Major: 0.x` (major+minor uniquement, pas patch/OS/arch) — préserve la k-anonymity et active les gates de dépréciation. Documenter le tradeoff de privacy.
|
||||||
|
|
||||||
|
### 3.2 Headers interdits (client → maximus-api)
|
||||||
|
|
||||||
|
Le client **ne doit jamais envoyer** :
|
||||||
|
- `Accept-Language`
|
||||||
|
- `X-Forwarded-For`, `X-Real-IP`, ou tout header `X-Forwarded-*`
|
||||||
|
- Cookies
|
||||||
|
- Aucun header personnalisé identifiant la machine
|
||||||
|
|
||||||
|
Cette règle est testée par un test unitaire côté client (« privacy headers test »). Le serveur tolère leur présence (ne les rejette pas par 400) mais les supprime avant tout traitement.
|
||||||
|
|
||||||
|
### 3.3 Comportement du serveur sur les headers entrants
|
||||||
|
|
||||||
|
Avant tout appel sortant vers Yahoo / CoinGecko, `maximus-api` strippe **tous** les headers entrants à l'exception de ceux qu'il génère lui-même. Garanti par contrat — vérifié par tests d'intégration côté serveur.
|
||||||
|
|
||||||
|
> **🟡 TECHNIQUE** — Headers injectés par l'infra (CF-*, Coolify, Traefik) non couverts par le test §12.2.
|
||||||
|
> Le proxy Traefik / Coolify ajoute des headers (`X-Forwarded-*`, `X-Real-IP`, etc.) entre le client et l'app — si l'app les propage par accident vers Yahoo/CoinGecko, la promesse §3.3 est cassée.
|
||||||
|
> **Resolution :** Utiliser un client HTTP nu (`fetch` natif Node, sans propagation d'headers) pour les appels sortants. Test : asserter que la liste de headers reçue par le mock provider == exactement `['user-agent', 'accept', 'host']`.
|
||||||
|
|
||||||
|
## 4. Réponse de succès (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"symbol": "AAPL",
|
||||||
|
"date": "2026-04-25",
|
||||||
|
"actual_date": "2026-04-24",
|
||||||
|
"price": 173.45,
|
||||||
|
"currency": "USD",
|
||||||
|
"source": "yahoo",
|
||||||
|
"fetched_at": "2026-04-25T14:32:11Z",
|
||||||
|
"cached": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Sémantique des champs
|
||||||
|
|
||||||
|
| Champ | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `symbol` | string | Symbole tel que normalisé par le serveur (MAJUSCULES) |
|
||||||
|
| `date` | string | Date demandée (echo du paramètre) |
|
||||||
|
| `actual_date` | string \| null | Si la date demandée n'a pas de cotation (week-end, jour férié pour les actions), date effective de la cotation retournée. `null` si `actual_date == date`. |
|
||||||
|
| `price` | number | Prix de clôture pour les actions, prix instantané pour les crypto. Précision : 4 décimales pour les actions, 8 pour les crypto. |
|
||||||
|
| `currency` | string | ISO 4217 (3 lettres). Exemples : `USD`, `CAD`, `EUR`. |
|
||||||
|
| `source` | string | `yahoo` ou `coingecko`. Indicatif uniquement — le client ne doit pas faire de logique conditionnelle dessus. |
|
||||||
|
| `fetched_at` | string | ISO 8601 UTC du moment où le prix a été récupéré du provider (différent de `now` si servi depuis cache). |
|
||||||
|
| `cached` | boolean | `true` si servi depuis le cache serveur. Indicatif uniquement — n'affecte pas la fraîcheur garantie (cf. §10). |
|
||||||
|
|
||||||
|
### 4.2 Headers de réponse (succès)
|
||||||
|
|
||||||
|
| Header | Toujours présent | Description |
|
||||||
|
|--------|------------------|-------------|
|
||||||
|
| `Content-Type` | oui | `application/json; charset=utf-8` |
|
||||||
|
| `X-RateLimit-Limit` | oui | Quota total sur la fenêtre courante (entier) |
|
||||||
|
| `X-RateLimit-Remaining` | oui | Quota restant (entier) |
|
||||||
|
| `X-RateLimit-Reset` | oui | Unix timestamp (secondes) du reset de quota |
|
||||||
|
| `Cache-Control` | oui | `private, max-age=0` — le client **ne doit pas** mettre en cache HTTP |
|
||||||
|
|
||||||
|
## 5. Réponses d'erreur
|
||||||
|
|
||||||
|
### 5.1 Format d'enveloppe (toutes les 4xx/5xx)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "premium_required",
|
||||||
|
"message": "Premium license required for price fetching",
|
||||||
|
"retry_after": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Champ | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `error.code` | string | Code stable, lisible-machine. Snake_case. Liste fermée (cf. §5.2). |
|
||||||
|
| `error.message` | string | Message lisible-humain en anglais. **Ne pas afficher tel quel à l'utilisateur** — le client doit traduire en FR/EN via i18n à partir du `code`. |
|
||||||
|
| `error.retry_after` | number \| absent | Présent uniquement sur 429 et 503. Secondes à attendre avant retry. |
|
||||||
|
|
||||||
|
> **🔴 ARCHITECTURE** — Enveloppe d'erreur incohérente avec `/licenses/*`.
|
||||||
|
> Les routes existantes `/licenses/*` retournent un format plat `{ error: "string" }` (parfois avec `details` ou `machines`). Cette spec propose `{ error: { code, message, retry_after } }` — deux shapes dans la même app Hono force les clients à brancher par route.
|
||||||
|
> **Resolution :** Trancher : soit migrer `/licenses/*` vers la nouvelle enveloppe (versionner en `/v1/licenses/*`), soit aligner `/v1/prices` sur `{ error, code, retry_after }` plat. Documenter le choix dans le README maximus-api.
|
||||||
|
|
||||||
|
### 5.2 Codes d'erreur par status HTTP
|
||||||
|
|
||||||
|
| Status | `error.code` | Cause | Retry possible ? |
|
||||||
|
|--------|--------------|-------|------------------|
|
||||||
|
| **400 Bad Request** | `invalid_symbol` | `symbol` ne matche pas la regex | non |
|
||||||
|
| 400 | `invalid_date` | `date` mal formée, future, ou pré-1970 | non |
|
||||||
|
| 400 | `missing_param` | `symbol` ou `date` absent | non |
|
||||||
|
| **401 Unauthorized** | `missing_token` | Header `Authorization` absent | non |
|
||||||
|
| 401 | `invalid_token` | Signature Ed25519 invalide | non |
|
||||||
|
| 401 | `expired_token` | Token expiré | non — re-activation requise |
|
||||||
|
| **403 Forbidden** | `premium_required` | Licence valide mais `edition != 'premium'` | non — abonnement requis |
|
||||||
|
| 403 | `license_revoked` | Licence révoquée | non — contact support |
|
||||||
|
| **404 Not Found** | `symbol_not_found` | Le symbole est inconnu de tous les providers consultés | non |
|
||||||
|
| **429 Too Many Requests** | `rate_limit_exceeded` | Quota dépassé pour cette licence | oui — après `retry_after` secondes |
|
||||||
|
| **502 Bad Gateway** | `provider_unavailable` | Yahoo / CoinGecko a échoué (timeout, 5xx) | oui — backoff exponentiel |
|
||||||
|
| **503 Service Unavailable** | `service_degraded` | Maintenance ou panne interne | oui — après `retry_after` secondes |
|
||||||
|
| **500 Internal Server Error** | `internal_error` | Bug serveur. Loggé côté server. | oui — backoff |
|
||||||
|
|
||||||
|
Le serveur **ne doit jamais** retourner un code HTTP avec un body qui ne respecte pas l'enveloppe `{error: {code, message}}`. Aucune fuite de stack trace, aucun message d'erreur de provider non sanitisé.
|
||||||
|
|
||||||
|
## 6. Rate-limiting
|
||||||
|
|
||||||
|
### 6.1 Côté serveur
|
||||||
|
|
||||||
|
Le quota est appliqué **par licence** (clé = `hash(license_id)`).
|
||||||
|
|
||||||
|
| Tier | Fenêtre | Quota |
|
||||||
|
|------|---------|-------|
|
||||||
|
| premium | 1 minute glissante | 30 requêtes |
|
||||||
|
| premium | 1 jour glissant | 200 requêtes |
|
||||||
|
|
||||||
|
Le quota est partagé entre toutes les machines activées sur la même licence.
|
||||||
|
|
||||||
|
**Implémentation** : table Postgres `rate_limit_buckets(license_id PK, window_start TIMESTAMPTZ, count INT)` avec atomicité via `INSERT ... ON CONFLICT (license_id) DO UPDATE SET count = count + 1, window_start = CASE WHEN now() - window_start > '1 day' THEN now() ELSE window_start END RETURNING count`. Pour la fenêtre minute, idem en table séparée. Pas de Redis (cf. décision §0).
|
||||||
|
|
||||||
|
> **🔴 SECURITE+ARCHITECTURE** — Rate-limit in-memory ne peut pas garantir un quota par-licence.
|
||||||
|
> `maximus-api/src/middleware/rateLimit.ts` keye par IP via `Map` global (5 req/min, hardcoded). Le quota promis (30/min, 2000/jour, partagé entre machines) requiert un store atomique partagé : sur restart Coolify les compteurs se réinitialisent (refill gratuit), et toute scaling horizontale crée une race TOCTOU exploitable.
|
||||||
|
> **Resolution :** Avant que §6 ne quitte le draft : ajouter Redis OU token-bucket Postgres avec row-level lock. Généraliser `rateLimit.ts` en `{ keyFn, windows: [{ms,max}] }` pour réutiliser une même middleware avec deux configs (IP pour licenses, license-id pour prices). Documenter le backend.
|
||||||
|
> *Ref : OWASP API4:2023 — Unrestricted Resource Consumption*
|
||||||
|
|
||||||
|
> **🔴 TECHNIQUE** — Quota 2000/jour × N licences incompatible avec free tier CoinGecko (30/min).
|
||||||
|
> Le free tier CoinGecko Demo plafonne à 30 calls/min ; promettre 2000 req/j × N premium dépasse ce plafond dès quelques utilisateurs simultanés. Le quota promis n'est pas adossé à une capacité provider.
|
||||||
|
> **Resolution :** Soit prévoir CoinGecko Analyst payant (~$129/mois, 500 calls/min) et le mentionner dans la section coûts, soit baisser le quota par licence (ex. 200/j) et le justifier. Test interne que le quota provider est respecté en interne.
|
||||||
|
|
||||||
|
### 6.2 Côté client
|
||||||
|
|
||||||
|
Le client implémente **en plus** un rate-limit local pour éviter de gaspiller le quota serveur :
|
||||||
|
- Max 1 requête sortante toutes les 2 secondes
|
||||||
|
- Déduplication des requêtes en vol identiques (mêmes `symbol` + `date` → une seule requête réseau, plusieurs awaiters)
|
||||||
|
- Plafond hard : 100 requêtes par session de saisie de snapshot (anti-loop)
|
||||||
|
|
||||||
|
Ces limites sont des défenses en profondeur — le contrat ne dépend pas de leur valeur exacte.
|
||||||
|
|
||||||
|
> **🟡 TECHNIQUE** — Test "1 req / 2s" requiert fake timers + emplacement du rate-limiter non figé.
|
||||||
|
> Le test `it("respecte le rate-limit local 1 req / 2s")` est temporel et flaky sans `vi.useFakeTimers()`. Le contrat ne dit pas où implémenter le rate-limiter (hook ? service ?), ce qui laisse une décision d'archi ouverte.
|
||||||
|
> **Resolution :** Préciser §6.2 : « rate-limit implémenté dans `src/services/priceService.ts` (ou dans `balance.service.ts` section prices). Tests vitest avec `vi.useFakeTimers()`. » Ajouter le service au CLAUDE.md avant gel.
|
||||||
|
|
||||||
|
## 7. Authentification et autorisation — détail
|
||||||
|
|
||||||
|
### 7.1 Validation côté serveur (ordre)
|
||||||
|
|
||||||
|
Implémentée comme middleware partagée `src/middleware/licenseAuth.ts` (analogue à `adminAuth.ts`), réutilisable pour `/v1/quota` et autres endpoints premium futurs.
|
||||||
|
|
||||||
|
1. Header `Authorization` présent → sinon 401 `missing_token`
|
||||||
|
2. Format `Bearer <token>` correct → sinon 401 `invalid_token`
|
||||||
|
3. Signature Ed25519 valide (clé publique embarquée côté serveur) → sinon 401 `invalid_token`
|
||||||
|
4. Token non expiré (`exp` claim) → sinon 401 `expired_token`
|
||||||
|
5. **Claim `product` du JWT = `'simpl-resultat'`** → sinon 401 `invalid_token` (un futur 2e produit aura sa propre clé ou son propre claim ; pas de cross-product)
|
||||||
|
6. Licence en DB existe et `is_revoked = false` → sinon 403 `license_revoked`
|
||||||
|
7. Licence `edition = 'premium'` → sinon 403 `premium_required`
|
||||||
|
8. **Aucun appel provider tant que ces 7 étapes n'ont pas réussi.** Cette règle est testée côté serveur.
|
||||||
|
|
||||||
|
La middleware populate `c.set('license', { id, edition, product })` pour les handlers downstream.
|
||||||
|
|
||||||
|
> **🟡 SECURITE** — `activation_token` sans `jti`, durée 2 ans → fenêtre de replay non bornée.
|
||||||
|
> Tokens signés Ed25519 avec `exp` ~2 ans, sans `jti` (vérifié dans `licenseService.ts`). Un token leaké (compromission machine, slip de log, exfil malware) donne un accès premium pendant ~2 ans sans path de révocation hors révocation de la licence entière. `/v1/prices` envoie ce token à chaque appel — multiplie la surface d'exfil.
|
||||||
|
> **Resolution :** (a) ajouter `jti` + revocation list Redis-backed checkée à chaque `/v1/prices`, OU (b) raccourcir le TTL à 7-14 jours avec refresh-token silencieux, OU (c) bind du token au canal TLS via DPoP. Choix à acter en §7.
|
||||||
|
> *Ref : CWE-294 (Authentication Bypass by Capture-Replay), OWASP API2:2023*
|
||||||
|
|
||||||
|
> **🟡 ARCHITECTURE** — Auth + premium check doit être une middleware partagée.
|
||||||
|
> Étapes 1-6 = concern auth/authz autonome. Inliner dans le handler prices = duplication quand `/v1/quota` ou autres endpoints premium arriveront.
|
||||||
|
> **Resolution :** Ajouter `src/middleware/licenseAuth.ts` (analogue à `adminAuth.ts`) qui populate `c.set('license', ...)`. Référencer cette middleware par nom dans §7.1.
|
||||||
|
|
||||||
|
### 7.2 Le champ `edition` côté licence
|
||||||
|
|
||||||
|
Le serveur expose **déjà** `edition` dans la réponse de `POST /v1/licenses/verify` (depuis maximus-api Phase 1, cf. `licenseService.ts:216`). Valeurs possibles : `"base"` | `"premium"`. Aucun travail backend additionnel sur ce champ. Le client lit ce champ pour afficher / cacher conditionnellement le bouton de price-fetching dans l'UI — mais cette vérif n'est qu'**ergonomique**, jamais un substitut au check serveur.
|
||||||
|
|
||||||
|
> **🟡 ARCHITECTURE** — `edition` est déjà exposé par `/licenses/verify` (depuis Phase 1).
|
||||||
|
> La référence à une « issue maximus-api dédiée à ajouter » est obsolète : `licenseService.ts:216` retourne déjà `edition` dans la réponse verify.
|
||||||
|
> **Resolution :** Mettre à jour §7.2 : « `edition` est déjà exposé par `/licenses/verify` depuis maximus-api Phase 1. Aucun travail backend nécessaire pour ce champ. »
|
||||||
|
|
||||||
|
## 8. Comportement de proxying (côté serveur)
|
||||||
|
|
||||||
|
### 8.1 Routage par type de symbole
|
||||||
|
|
||||||
|
`maximus-api` détermine le provider en interne :
|
||||||
|
- **Crypto** : symbole matche le catalogue crypto connu (BTC, ETH, SOL, ADA, DOT, etc. ou suffixe `-USD`/`-USDT`) → exchanges directs via la lib `ccxt`. Tente Kraken d'abord, fallback Coinbase si Kraken 404. Données de marché publiques, ToS-clean, gratuit.
|
||||||
|
- **Stocks** : tout le reste → Yahoo Finance en best-effort assumé (cf. ADR 0011). Endpoint `query1.finance.yahoo.com/v7/finance/quote` ou `v8/finance/chart`. **Best-effort** : peut échouer / bouger sans préavis.
|
||||||
|
|
||||||
|
Si crypto 404 sur Kraken ET Coinbase, retourne `404 symbol_not_found`. Si Yahoo 404 ou indisponible, retourne `404 symbol_not_found` ou `503 service_degraded` (cf. circuit breaker §8.4). Le client doit utiliser la saisie manuelle dans ces cas.
|
||||||
|
|
||||||
|
**Pas de fallback cross-asset** : un symbole inconnu de la liste crypto n'est pas réessayé sur Yahoo (et vice-versa). Le client est explicite.
|
||||||
|
|
||||||
|
> **🔴 SECURITE+TECHNIQUE** — Yahoo Finance n'a pas d'API publique stable et son ToS interdit la redistribution.
|
||||||
|
> Endpoints `query1/query2.finance.yahoo.com` non documentés, sujets à blocage IP/CAPTCHA. ToS interdit l'usage commercial et la redistribution. Proxying tous les premium via une IP VPS = kill-switch unique pour le feature payant + risque légal.
|
||||||
|
> **Resolution :** Soit (a) souscrire à un fournisseur licencié payant (Polygon, Alpha Vantage, Twelve Data, Finnhub) avec droit contractuel de proxy, soit (b) flagger explicitement `source: 'yahoo'` comme best-effort + circuit breaker + fallback provider documenté. Acter dans un ADR avant ship en payant.
|
||||||
|
> *Ref : Yahoo Finance ToS sec. 7-8 (no commercial reuse)*
|
||||||
|
|
||||||
|
> **🔴 SECURITE** — Free tier CoinGecko interdit l'usage commercial / proxying.
|
||||||
|
> CoinGecko Demo gratuit interdit explicitement le commercial use et le proxy/redistribution ; seul le plan Demo/Pro payant avec API key le permet. Feature premium-payant sur free tier = breach ToS + risque de cutoff soudain.
|
||||||
|
> **Resolution :** Souscrire à CoinGecko Demo/Pro (API key en env), documenter le tier contractuel en §8, ajouter le header API-key serveur. Refléter le coût dans le pricing model.
|
||||||
|
> *Ref : CoinGecko ToS — Free Plan restrictions*
|
||||||
|
|
||||||
|
### 8.2 Headers sortants vers le provider
|
||||||
|
|
||||||
|
**Vers les exchanges crypto (Kraken, Coinbase via CCXT)** :
|
||||||
|
- `User-Agent: maximus-api/<version>` (version interne, jamais transmise au client)
|
||||||
|
- `Accept: application/json`
|
||||||
|
|
||||||
|
**Vers Yahoo Finance (stocks, best-effort)** : Yahoo bloque les requêtes sans User-Agent navigateur. Le serveur envoie un UA browser-like (`Mozilla/5.0 (X11; Linux x86_64) ... Chrome/...`) — c'est une exception explicite à la règle « UA fixe maximus-api ». Documenté dans ADR 0011.
|
||||||
|
|
||||||
|
**Garanties communes** : aucun header issu de la requête entrante n'est répercuté vers les providers. Implémentation via un client HTTP nu (`fetch` natif Node sans propagation), validée par le test §12.2.
|
||||||
|
|
||||||
|
### 8.3 IP source du provider
|
||||||
|
|
||||||
|
L'IP source vue par les providers est celle du VPS Maximus, mutualisée pour tous les utilisateurs premium. Cette propriété est garantie par la topologie réseau (pas de NAT transparent, pas de proxy SSL inverse vers les providers).
|
||||||
|
|
||||||
|
### 8.4 Circuit breaker (Yahoo best-effort)
|
||||||
|
|
||||||
|
Yahoo Finance étant un provider best-effort sans API officielle, un circuit breaker est obligatoire :
|
||||||
|
|
||||||
|
- **Compteur d'erreurs** : sur les 60 dernières secondes, si `count(5xx | 403 | timeout) >= 5`, le breaker s'ouvre.
|
||||||
|
- **État ouvert** : pendant 15 minutes, toutes les requêtes stocks retournent immédiatement `503 service_degraded` avec `retry_after: <secondes restantes>`. Aucun appel sortant Yahoo.
|
||||||
|
- **Half-open** : après 15 min, une seule requête tentée. Si succès, breaker fermé ; si échec, ouvert pour 15 min de plus.
|
||||||
|
- **Notification** : à l'ouverture du breaker, log structuré `level=warn` + (optionnel) webhook Telegram / email à `maxime2tremblay@protonmail.com`.
|
||||||
|
|
||||||
|
Crypto via CCXT n'a pas de circuit breaker dédié — les exchanges sont stables, leurs erreurs sont rares et déterministes.
|
||||||
|
|
||||||
|
> **🔴 SECURITE** — Single point of failure : un IP block VPS coupe le feature pour TOUS les premium.
|
||||||
|
> Mutualiser l'IP VPS est la promesse privacy, mais une seule sanction de Yahoo (qui voit du trafic commercial) bloque l'IP — kill-switch global qui touche 100% des utilisateurs payants en même temps.
|
||||||
|
> **Resolution :** Documenter dans un ADR le risque + budget pour un fallback provider rotatif. Ajouter un circuit breaker côté maximus-api : sur 5xx ou 403 répétés du provider, marquer automatiquement le service degraded et notifier (Telegram/email).
|
||||||
|
|
||||||
|
## 9. Garanties de logging et de privacy
|
||||||
|
|
||||||
|
Le serveur garantit par contrat :
|
||||||
|
|
||||||
|
1. **Pas de log conjoint** `(IP utilisateur, symbol)`. Les logs d'accès Traefik conservent les IP, mais le log applicatif des prix utilise `hash(license_id, salt_serveur)` à la place de toute info utilisateur.
|
||||||
|
2. **Pas de log de l'`activation_token` complet**. Seulement le `license_id` extrait du payload après validation de signature.
|
||||||
|
3. **Le cache prix** ne stocke aucune référence utilisateur — clé = `(symbol, date)`, valeur = `(price, currency, source, fetched_at)`.
|
||||||
|
4. **Aucun analytics, aucune télémétrie** sur `/v1/prices`. Seuls les logs minimaux d'observabilité.
|
||||||
|
|
||||||
|
> **🟡 SECURITE** — Logs Traefik (IP) + log applicatif (license_hash, symbol) → corrélation par timestamp casse §9.1.
|
||||||
|
> §9.1 promet « pas de log conjoint (IP, symbol) », mais Traefik enregistre `(IP, ts, path?querystring)` et l'app enregistre `(license_hash, symbol, ts)`. Quiconque a accès aux deux logs (ou un backup co-localisé) corrèle par timestamp et reconstitue `(IP, symbol)`. La garantie privacy est structurellement plus faible qu'annoncée.
|
||||||
|
> **Resolution :** (a) configurer Traefik pour stripper la querystring sur `/v1/prices`, OU (b) ne pas logger `/v1/prices` du tout côté Traefik (rely sur log app + stats privacy-preserving). Mettre à jour §9.1 pour refléter la garantie réelle.
|
||||||
|
> *Ref : CWE-532 (Insertion of Sensitive Info into Log)*
|
||||||
|
|
||||||
|
## 10. Sémantique de cache
|
||||||
|
|
||||||
|
### 10.1 Cache serveur
|
||||||
|
|
||||||
|
| Type de date | TTL |
|
||||||
|
|--------------|-----|
|
||||||
|
| Date passée (< aujourd'hui UTC) | 90 jours (les prix passés sont immuables, mais TTL fini = défense LRU) |
|
||||||
|
| Aujourd'hui (UTC) | 5 minutes |
|
||||||
|
| Réponse 404 `symbol_not_found` | 1 heure (TTL court séparé pour éviter pollution) |
|
||||||
|
|
||||||
|
**Implémentation** : table Drizzle `pricesCache` dans la même DB Postgres que les licenses (cf. décision §0).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/db/schema.ts
|
||||||
|
export const pricesCache = pgTable("prices_cache", {
|
||||||
|
symbol: text("symbol").notNull(),
|
||||||
|
date: text("date").notNull(), // YYYY-MM-DD
|
||||||
|
price: numeric("price", { precision: 20, scale: 8 }), // null si 404
|
||||||
|
currency: text("currency"),
|
||||||
|
source: text("source").notNull(), // 'yahoo' | 'kraken' | 'coinbase'
|
||||||
|
fetchedAt: timestamp("fetched_at", { withTimezone: true }).notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
}, (t) => [primaryKey({ columns: [t.symbol, t.date] })]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Pas de FK vers `licenses` (privacy). Job nightly cleanup `DELETE FROM prices_cache WHERE expires_at < now()`.
|
||||||
|
|
||||||
|
L'invalidation manuelle du cache n'est **pas** exposée par l'API.
|
||||||
|
|
||||||
|
**Défenses anti-pollution** :
|
||||||
|
- Cap LRU implicite via TTL fini (90 jours max sur les passées)
|
||||||
|
- 404s en TTL court (1h) sur table séparée pour éviter qu'un attaquant fill l'espace clé avec des symboles inexistants
|
||||||
|
- Idéalement (optimisation v1.1) : allowlist de symboles connus refresh quotidiennement (catalogue Yahoo + crypto exchanges) — reject les inconnus en 404 avant tout appel provider
|
||||||
|
|
||||||
|
> **🟡 ARCHITECTURE+TECHNIQUE** — Emplacement du cache non spécifié + maximus-api utilise Postgres (pas SQLite).
|
||||||
|
> §10 dit « cache local SQLite » dans l'ADR 0009 mais maximus-api est sur Postgres+Drizzle, pas SQLite. Pas de Redis non plus. Sans précision, soit on stocke en mémoire (perdu au restart Coolify), soit on persiste sans plan.
|
||||||
|
> **Resolution :** Ajouter §10.3 : « Cache implémenté via table Drizzle `prices_cache(symbol, date PK, price, currency, source, fetched_at, expires_at)` dans la même DB Postgres. Pas de FK à licenses (privacy). » Migration Drizzle nouvelle. Test d'intégration `it('persiste à travers un restart')`.
|
||||||
|
|
||||||
|
> **🟡 SECURITE** — Cache illimité avec TTL infini + regex symbole permissive → pollution de l'espace clé.
|
||||||
|
> TTL « infini » sur dates passées + regex `^[A-Za-z0-9.\-]{1,20}$` sans allowlist. Une licence premium compromise (ou plusieurs en parallèle) peut itérer ~36^20 symboles pour saturer le cache OU brûler le quota provider. Plusieurs machines par licence partagent le quota — abus coordonné dans le quota légal possible.
|
||||||
|
> **Resolution :** Cap LRU sur la taille du cache. 404s cachés en TTL court (1h max) avec LRU séparé. Idéalement : allowlist de symboles connus (catalogue Yahoo + CoinGecko refresh quotidien) — reject les inconnus en 404 avant tout appel provider.
|
||||||
|
> *Ref : CWE-770 (Allocation of Resources Without Limits)*
|
||||||
|
|
||||||
|
### 10.2 Cache client
|
||||||
|
|
||||||
|
Le client **ne doit pas** mettre en cache les réponses au-delà de la session courante :
|
||||||
|
- Pas de stockage SQLite des prix retournés
|
||||||
|
- Pas de `localStorage` ni `IndexedDB`
|
||||||
|
- Le `value` calculé (`quantity × unit_price`) **est** stocké dans `balance_snapshot_lines` — c'est une valeur dérivée denormalisée, pas un cache de l'API
|
||||||
|
- En mémoire de la session : OK pour la déduplication in-flight (cf. §6.2)
|
||||||
|
|
||||||
|
Cette contrainte protège contre l'extraction d'historique de consultation en cas de compromission de la machine cliente.
|
||||||
|
|
||||||
|
## 11. Versioning et évolutions
|
||||||
|
|
||||||
|
### 11.1 Changements rétrocompatibles autorisés en `/v1/`
|
||||||
|
|
||||||
|
- Ajout d'un champ optionnel dans la réponse de succès
|
||||||
|
- Ajout d'un nouveau code d'erreur (le client doit avoir un fallback sur les codes inconnus)
|
||||||
|
- Ajout d'un header optionnel
|
||||||
|
- Élargissement du quota
|
||||||
|
|
||||||
|
### 11.2 Changements non rétrocompatibles → `/v2/`
|
||||||
|
|
||||||
|
- Renommage / suppression d'un champ
|
||||||
|
- Changement du format d'enveloppe d'erreur
|
||||||
|
- Changement du format d'auth
|
||||||
|
- Restriction des paramètres acceptés
|
||||||
|
|
||||||
|
### 11.3 Coexistence
|
||||||
|
|
||||||
|
`maximus-api` peut servir simultanément `/v1/` et `/v2/` pendant une période de transition. Le client signale sa version par le path, pas par un header.
|
||||||
|
|
||||||
|
### 11.5 Migration `/licenses/*` → `/v1/licenses/*` (en parallèle de cette spec)
|
||||||
|
|
||||||
|
Pour cohérence avec `/v1/prices`, les endpoints existants `/licenses/*` migrent vers `/v1/licenses/*` :
|
||||||
|
|
||||||
|
- **Phase 1** (avec ce milestone) : nouveau préfixe `/v1/licenses/*` exposé. Anciens `/licenses/*` deviennent aliases qui renvoient `308 Permanent Redirect` vers `/v1/licenses/*`. Le client desktop continue à fonctionner sans modification.
|
||||||
|
- **Phase 2** (release simpl-resultat suivante, +30 jours) : le client desktop migre ses appels vers `/v1/licenses/*` directement. Header de réponse `Deprecation` (RFC 8594) sur les anciens paths.
|
||||||
|
- **Phase 3** (+60 jours) : les anciens paths retournent `410 Gone`. Suppression du code legacy.
|
||||||
|
|
||||||
|
L'enveloppe d'erreur de `/v1/licenses/*` est aussi migrée vers `{error: {code, message}}` nesté pour cohérence. Mapping des erreurs existantes vers les codes nouveaux est documenté dans le README maximus-api.
|
||||||
|
|
||||||
|
### 11.4 Rotation de clé Ed25519 (cross-cutting)
|
||||||
|
|
||||||
|
> **🟡 SECURITE** — Pas de header `kid` → la prochaine rotation Ed25519 force un redeploy complet.
|
||||||
|
> Le client Rust embed un PEM unique en constante (`license_commands.rs:28`) et le serveur signe sans `kid` dans le header JWT (`crypto/ed25519.ts setProtectedHeader`). La rotation 2026-04-25 (#49) a marché parce qu'aucune licence active n'existait ; la prochaine cassera toutes les licences actives jusqu'à update client. Pas de fenêtre d'overlap.
|
||||||
|
> **Resolution :** Ajouter `kid` au header JWT protégé. Ship le client Rust avec une map `kid → PEM`. Documenter une procédure de rotation avec fenêtre overlap 30 jours dans un nouvel ADR.
|
||||||
|
> *Ref : RFC 7515 §4.1.4 (kid header)*
|
||||||
|
|
||||||
|
## 12. Tests de conformité (références)
|
||||||
|
|
||||||
|
### 12.1 Côté client (`simpl-resultat`)
|
||||||
|
|
||||||
|
Tests unitaires obligatoires (`vitest` + `vi.fn()` sur `fetch` natif — pas de lib de mock externe ajoutée) :
|
||||||
|
- `it("envoie uniquement Authorization, Accept, User-Agent fixe")` — privacy headers
|
||||||
|
- `it("retourne le price sur 200")`
|
||||||
|
- `it("traduit chaque error.code en clé i18n")`
|
||||||
|
- `it("respecte Retry-After sur 429 et 503")`
|
||||||
|
- `it("ne réessaye pas sur 401, 403, 404, 400")`
|
||||||
|
- `it("dédoublonne les requêtes in-flight identiques")`
|
||||||
|
- `it("respecte le rate-limit local 1 req / 2s")` — utilise `vi.useFakeTimers()`
|
||||||
|
- `it("respecte le plafond hard 100 req / session")`
|
||||||
|
|
||||||
|
Le rate-limiter client + la dedup vivent dans `src/services/balance.service.ts` (section `prices`), conformément à la convention « 1 service par domaine » du projet.
|
||||||
|
|
||||||
|
> **🔴 TECHNIQUE** — `mockito-rs` n'existe pas comme crate.
|
||||||
|
> (a) Le crate Rust s'appelle `mockito` (sans suffixe `-rs`). (b) Le client de prix sera en TypeScript (fetch via Tauri vers maximus-api), donc Rust n'est pas le bon endroit pour ces tests. `Cargo.toml` de simpl-resultat ne contient aucun mock HTTP actuellement.
|
||||||
|
> **Resolution :** Préciser : tests en `vitest` côté TS avec `msw` ou `vi.fn()` sur `fetch`. Si volet Rust nécessaire (ex. tests Tauri command), utiliser `mockito` (sans `-rs`) ou `wiremock` ajouté à `[dev-dependencies]` du Cargo.toml.
|
||||||
|
|
||||||
|
### 12.2 Côté serveur (`maximus-api`)
|
||||||
|
|
||||||
|
Tests d'intégration obligatoires (`vitest` + `nock` pour mock outbound + `app.request()` natif Hono pour inbound) :
|
||||||
|
- `it("rejette toute requête sans Authorization → 401 missing_token")`
|
||||||
|
- `it("rejette une signature invalide → 401 invalid_token")`
|
||||||
|
- `it("rejette `claims.product !== 'simpl-resultat'` → 401 invalid_token")`
|
||||||
|
- `it("rejette une licence non-premium → 403 premium_required AVANT tout appel provider")`
|
||||||
|
- `it("appel sortant Yahoo n'inclut que User-Agent browser-like + Accept + Host")` — assertion sur `nock` interceptor
|
||||||
|
- `it("appel sortant exchanges (Kraken/Coinbase) n'inclut que User-Agent maximus-api + Accept + Host")`
|
||||||
|
- `it("logger.spy n'a jamais été appelé avec un payload contenant license_id ET symbol simultanément")` — via wrapper `src/logger.ts` (pino) + `vi.spyOn(logger, 'info')`
|
||||||
|
- `it("retourne 404 symbol_not_found sans fallback cross-asset")` — crypto inconnu ≠ tenter Yahoo
|
||||||
|
- `it("sert depuis le cache si disponible — vérifié par 0 appel sortant nock")`
|
||||||
|
- `it("circuit breaker : ouvre après 5 erreurs Yahoo / minute → 503 service_degraded en réponse")`
|
||||||
|
- `it("token-bucket Postgres : 31e requête en 1 minute → 429 rate_limit_exceeded")`
|
||||||
|
- `it("retourne le bon shape d'erreur pour chaque status code")`
|
||||||
|
|
||||||
|
> **🟡 TECHNIQUE** — Test « jamais log (license_id, symbol) » non testable simplement.
|
||||||
|
> Requiert une infra d'inspection de logs (capture stdout, parsing fichier) que maximus-api n'a pas — pas de logger structuré (`pino`, `winston`) dans `package.json`.
|
||||||
|
> **Resolution :** Reformuler en assertion sur logger injectable : `expect(loggerSpy).not.toHaveBeenCalledWith(stringContaining(licenseId) && stringContaining(symbol))`. Introduire `src/logger.ts` (pino) avant l'implémentation et tester contre lui.
|
||||||
|
|
||||||
|
> **🟢 TECHNIQUE** — Aucune lib HTTP mock prévue côté serveur.
|
||||||
|
> Les tests d'intégration mentionnent « supertest ou équivalent » mais aucune lib n'est dans `devDependencies` actuellement. Pour mocker Yahoo/CoinGecko il faut aussi `nock` ou `msw/node`.
|
||||||
|
> **Resolution :** Ajouter à `maximus-api/package.json` : `nock` (mock HTTP outbound) + `@hono/testing` ou `supertest` (test inbound). Préciser §12.2 : « `nock` pour les fournisseurs externes, `app.request()` de Hono pour l'API maximus. »
|
||||||
|
|
||||||
|
## 13. Décisions ouvertes (à trancher avant gel)
|
||||||
|
|
||||||
|
> Cette section disparaît une fois le contrat marqué `Statut: Stable`.
|
||||||
|
|
||||||
|
1. **Crypto pricing : prix instantané ou close UTC ?** Pour les snapshots de bilan datés, il faut un prix « représentatif » de la date. Décision proposée : **close UTC 00:00** via les endpoints OHLC des exchanges (Kraken `OHLC` interval=1440, Coinbase `candles` granularity=86400) ; pour `date == today`, prix instantané toléré.
|
||||||
|
2. **`actual_date` est-il utile ou source de bugs ?** Décision : **garder** car plus explicite que `is_approximation: bool`.
|
||||||
|
3. **Quota nuit / WE plus permissif ?** Décision : **non**, garder les seuils plats. Simple à expliquer.
|
||||||
|
4. **Endpoint d'introspection du quota** (`GET /v1/quota`) ? Décision : **out** du MVP. Ajouter en v1.x si demande utilisateur.
|
||||||
|
|
||||||
|
Décisions **tranchées en §0** (ne sont plus ouvertes) : provider, infra rate-limit, versioning, enveloppe d'erreur, lib mocks, `product` claim binding.
|
||||||
|
|
||||||
|
## Annexe A — Exemples complets
|
||||||
|
|
||||||
|
### A.1 Succès
|
||||||
|
|
||||||
|
**Requête** :
|
||||||
|
```http
|
||||||
|
GET /v1/prices?symbol=AAPL&date=2026-04-25 HTTP/1.1
|
||||||
|
Host: api.lacompagniemaximus.com
|
||||||
|
Authorization: Bearer <license-token>
|
||||||
|
Accept: application/json
|
||||||
|
User-Agent: simpl-resultat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réponse** :
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: application/json; charset=utf-8
|
||||||
|
X-RateLimit-Limit: 30
|
||||||
|
X-RateLimit-Remaining: 28
|
||||||
|
X-RateLimit-Reset: 1714061520
|
||||||
|
Cache-Control: private, max-age=0
|
||||||
|
|
||||||
|
{
|
||||||
|
"symbol": "AAPL",
|
||||||
|
"date": "2026-04-25",
|
||||||
|
"actual_date": null,
|
||||||
|
"price": 173.45,
|
||||||
|
"currency": "USD",
|
||||||
|
"source": "yahoo",
|
||||||
|
"fetched_at": "2026-04-25T14:32:11Z",
|
||||||
|
"cached": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.2 Licence non-premium
|
||||||
|
|
||||||
|
**Réponse** :
|
||||||
|
```http
|
||||||
|
HTTP/1.1 403 Forbidden
|
||||||
|
Content-Type: application/json; charset=utf-8
|
||||||
|
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "premium_required",
|
||||||
|
"message": "Premium license required for price fetching"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.3 Rate-limit
|
||||||
|
|
||||||
|
**Réponse** :
|
||||||
|
```http
|
||||||
|
HTTP/1.1 429 Too Many Requests
|
||||||
|
Content-Type: application/json; charset=utf-8
|
||||||
|
X-RateLimit-Limit: 30
|
||||||
|
X-RateLimit-Remaining: 0
|
||||||
|
X-RateLimit-Reset: 1714061580
|
||||||
|
Retry-After: 42
|
||||||
|
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "rate_limit_exceeded",
|
||||||
|
"message": "Rate limit exceeded for this license",
|
||||||
|
"retry_after": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Annexe B — Mapping des codes d'erreur vers les clés i18n du client
|
||||||
|
|
||||||
|
À implémenter dans `src/i18n/locales/{fr,en}.json` sous `balance.priceFetching.errors.*` :
|
||||||
|
|
||||||
|
| `error.code` | Clé i18n | FR | EN |
|
||||||
|
|--------------|----------|----|----|
|
||||||
|
| `invalid_symbol` | `errors.invalidSymbol` | « Symbole invalide » | "Invalid symbol" |
|
||||||
|
| `invalid_date` | `errors.invalidDate` | « Date invalide » | "Invalid date" |
|
||||||
|
| `missing_param` | `errors.missingParam` | « Paramètre manquant » | "Missing parameter" |
|
||||||
|
| `missing_token` / `invalid_token` / `expired_token` | `errors.authFailed` | « Activation requise » | "Activation required" |
|
||||||
|
| `premium_required` | `errors.premiumRequired` | « Abonnement premium requis » | "Premium subscription required" |
|
||||||
|
| `license_revoked` | `errors.licenseRevoked` | « Licence révoquée — contactez le support » | "License revoked — contact support" |
|
||||||
|
| `symbol_not_found` | `errors.symbolNotFound` | « Symbole inconnu — saisissez le prix manuellement » | "Symbol not found — enter price manually" |
|
||||||
|
| `rate_limit_exceeded` | `errors.rateLimit` | « Trop de requêtes — réessayez dans {{seconds}}s » | "Too many requests — retry in {{seconds}}s" |
|
||||||
|
| `provider_unavailable` / `service_degraded` / `internal_error` | `errors.serverUnavailable` | « Service indisponible — saisissez le prix manuellement » | "Service unavailable — enter price manually" |
|
||||||
|
| `service_degraded` (circuit breaker Yahoo) | `errors.bestEffortDegraded` | « Source de prix temporairement indisponible — réessayez dans {{minutes}} min ou saisissez manuellement » | "Price source temporarily unavailable — retry in {{minutes}} min or enter manually" |
|
||||||
|
|
||||||
|
**Règle générale côté UI** : sur n'importe quelle erreur, le champ de saisie manuelle reste actif. Jamais bloquer la saisie d'un snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Revision — Synthese
|
||||||
|
|
||||||
|
> Date : 2026-04-26 | Experts : Securite, Architecture, Technique
|
||||||
|
|
||||||
|
### Verdict
|
||||||
|
|
||||||
|
🔴 **CRITIQUES A CORRIGER** — La privacy posture et la défense en profondeur sont saines, mais le contrat repose sur des prémisses provider (Yahoo, CoinGecko free) en violation de ToS et sur une infra rate-limit/cache non encore en place. À débloquer avant le gel.
|
||||||
|
|
||||||
|
### Resume
|
||||||
|
|
||||||
|
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|
||||||
|
|--------|----|----|----|-------------|
|
||||||
|
| Securite | 3 | 4 | 1 | ToS providers, rate-limit infra, replay token, log correlation, kid rotation |
|
||||||
|
| Architecture | 3 | 4 | 1 | Asymétrie versioning, enveloppe d'erreur, claim `product`, middleware partagée, cache storage |
|
||||||
|
| Technique | 3 | 3 | 1 | `mockito-rs` inexistant, quota CoinGecko vs free tier, log inspection infra, headers infra |
|
||||||
|
|
||||||
|
### Actions requises
|
||||||
|
|
||||||
|
**🔴 Critiques — bloquantes pour le ship**
|
||||||
|
|
||||||
|
1. **Provider de prix légitime** — souscrire à un fournisseur licencié (Polygon / Alpha Vantage / Twelve Data / CoinGecko Pro) avec droit contractuel de proxy, OU acter un fallback documenté. Couvre Yahoo §8.1+§8.3 et CoinGecko §8.1+§6.1.
|
||||||
|
2. **Rate-limit partagé persistant** — Redis ou token-bucket Postgres (atomique) avant que §6 ne quitte le draft. Généraliser `rateLimit.ts` pour accepter `{ keyFn, windows }`.
|
||||||
|
3. **Enveloppe d'erreur cohérente** — trancher entre flat (`/licenses/*` actuel) ou nesté (proposé). Migrer un côté avant de figer.
|
||||||
|
4. **Versioning cohérent** — décider du préfixe `/v1/` global (renommer `/licenses/*` en `/v1/licenses/*`) ou local au prices uniquement.
|
||||||
|
5. **Binding `product`** — middleware vérifie `claims.product === 'simpl-resultat'`. Documenter en §7.1.
|
||||||
|
6. **Lib de tests réelle** — remplacer `mockito-rs` (inexistant) par `vitest + msw` côté TS, ou `mockito`/`wiremock` côté Rust si nécessaire.
|
||||||
|
7. **Quota client réaliste** — réduire 2000/j ou souscrire au tier provider qui le supporte. Cohérence quota promis ↔ capacité provider.
|
||||||
|
|
||||||
|
**🟡 Améliorations recommandées**
|
||||||
|
|
||||||
|
8. `activation_token` : ajouter `jti` + revocation list, OU réduire TTL à 7-14j avec refresh-token.
|
||||||
|
9. Cache serveur borné (LRU + cap), 404 TTL court, idéalement allowlist symboles.
|
||||||
|
10. Stripper la querystring de `/v1/prices` dans Traefik (ou ne pas logger), pour tenir §9.1.
|
||||||
|
11. `kid` dans le header JWT + map kid→PEM côté client (préparer la prochaine rotation Ed25519).
|
||||||
|
12. Mettre à jour §7.2 — `edition` est déjà exposé par `/licenses/verify`.
|
||||||
|
13. Middleware partagée `licenseAuth.ts` (étapes 1-6) au lieu d'inline dans le handler.
|
||||||
|
14. Cache : table Drizzle `prices_cache` dans le Postgres existant (pas SQLite, pas en mémoire).
|
||||||
|
15. Logger structuré (pino) injectable pour rendre testable « jamais log conjoint ».
|
||||||
|
16. Préciser fake-timers + emplacement du rate-limiter client (`priceService.ts`).
|
||||||
|
17. Client HTTP nu côté serveur pour les appels providers (éviter propagation des headers infra).
|
||||||
|
|
||||||
|
**🟢 Suggestions**
|
||||||
|
|
||||||
|
18. `X-Client-Major: 0.x` pour permettre la dépréciation d'urgence sans casser k-anonymity.
|
||||||
|
19. Architecture globale saine — convergence essentiellement sur l'alignement avec le code existant.
|
||||||
|
20. Ajouter `nock` + `@hono/testing` aux devDependencies de maximus-api.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Architecture technique — Simpl'Résultat
|
# Architecture technique — Simpl'Résultat
|
||||||
|
|
||||||
> Document mis à jour le 2026-04-13 — Version 0.7.3
|
> Document mis à jour le 2026-04-25 — Version 0.8.x (Bilan)
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
|
|
||||||
|
|
@ -28,6 +28,7 @@ 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/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
|
||||||
│ │ ├── budget/ # 5 composants
|
│ │ ├── budget/ # 5 composants
|
||||||
│ │ ├── categories/ # 5 composants
|
│ │ ├── categories/ # 5 composants
|
||||||
│ │ ├── dashboard/ # 2 composants
|
│ │ ├── dashboard/ # 2 composants
|
||||||
|
|
@ -72,7 +73,7 @@ simpl-resultat/
|
||||||
|
|
||||||
## Base de données
|
## Base de données
|
||||||
|
|
||||||
### Tables (13)
|
### Tables (20)
|
||||||
|
|
||||||
| Table | Description |
|
| Table | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
|
|
@ -89,10 +90,48 @@ 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 **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
|
||||||
|
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
|
||||||
|
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
|
||||||
|
| `balance_snapshot_lines` | Une ligne par `(snapshot, compte)` — **source de vérité agrégée**. `simple` : `value` seul. `priced`/`detailed` : la ligne porte la valeur **totale** (`value = SUM(holdings.value)`, `quantity`/`unit_price` NULL pour un compte détaillé), le détail vit dans `balance_snapshot_holdings`. Les agrégateurs et Modified Dietz lisent uniquement cette `value` ([ADR 0015](adr/0015-balance-detail-par-titre.md)) |
|
||||||
|
| `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains |
|
||||||
|
| `balance_securities` | Table normalisée et **partagée** des titres (v14, [ADR 0015](adr/0015-balance-detail-par-titre.md)) : `symbol` UNIQUE `COLLATE NOCASE` (canonique upper/trim), `currency` (DEFAULT `CAD`, préparé multi-devise), `asset_type ∈ {stock, crypto}`, `name?`. Référencée en `ON DELETE RESTRICT` → un titre référencé est immortel (suppression masquée UI) |
|
||||||
|
| `balance_snapshot_holdings` | Détail par titre d'un compte détaillé, rattaché à sa **ligne de snapshot agrégée** (v14, [ADR 0015](adr/0015-balance-detail-par-titre.md)) : `snapshot_line_id` (FK CASCADE), `security_id` (FK RESTRICT), `quantity`, `unit_price`, `value` (= qty × prix, arrondi cent), `book_cost?` (gain latent = `value − book_cost`), `price_source?`, `price_fetched_at?`. `UNIQUE(snapshot_line_id, security_id)` |
|
||||||
|
|
||||||
### Index (9)
|
### Index (24)
|
||||||
|
|
||||||
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 existants (15) : `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 (9) — 7 ajoutés en 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)
|
||||||
|
|
||||||
|
2 ajoutés en v14 (Étape 2 — détail par titre) :
|
||||||
|
- `idx_balance_snapshot_holdings_line` (chargement des positions d'une ligne de snapshot)
|
||||||
|
- `idx_balance_snapshot_holdings_security` (FK lookup titre → positions, garde `ON DELETE RESTRICT`)
|
||||||
|
|
||||||
|
### Invariants Bilan (CHECK + FK)
|
||||||
|
|
||||||
|
- `balance_categories.kind` ∈ `('simple','priced')` (défaut suggéré pour les nouveaux comptes ; l'axe agrégé/détaillé est désormais porté par `balance_accounts.kind`)
|
||||||
|
- `balance_accounts.currency = 'CAD'` (verrou MVP — v2 lèvera ce CHECK avec table de taux)
|
||||||
|
- `balance_accounts.vehicle_type` ∈ `('unregistered','tfsa','rrsp','rrif','fhsa','resp')` ou NULL (enveloppe fiscale, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md))
|
||||||
|
- `balance_accounts.kind` ∈ `('simple','detailed')` (v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — un compte `detailed` à/après `detailed_since` doit porter des holdings (validation TS `validateDetailedSnapshot`, pivot faisant autorité)
|
||||||
|
- `balance_snapshot_lines` : `(quantity, unit_price)` doivent être tous deux NULL (kind simple) OU tous deux NOT NULL (kind priced) ; pour un compte `detailed`, la ligne agrégée porte `value = SUM(holdings.value)` (comparaison exacte au cent), `quantity`/`unit_price` NULL
|
||||||
|
- `balance_securities.symbol` UNIQUE `COLLATE NOCASE` ; `asset_type` ∈ `('stock','crypto')`
|
||||||
|
- `balance_snapshot_holdings` : `UNIQUE (snapshot_line_id, security_id)` (un titre une seule fois par ligne)
|
||||||
|
- `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_snapshot_holdings.snapshot_line_id` → `balance_snapshot_lines(id)` `ON DELETE CASCADE` (supprimer une ligne/snapshot emporte ses holdings)
|
||||||
|
- FK `balance_snapshot_holdings.security_id` → `balance_securities(id)` `ON DELETE RESTRICT` — un titre référencé est immortel (préserve l'historique, miroir de la règle transferts), voir [ADR 0015](adr/0015-balance-detail-par-titre.md)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
@ -107,17 +146,26 @@ 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) |
|
||||||
|
| 10 | v10 | Ajout `asset_type` sur `balance_categories` (stock/crypto) + backfill des 2 catégories cotées |
|
||||||
|
| 11 | v11 | Nettoyage des snapshots Bilan orphelins |
|
||||||
|
| 12 | v12 | Étape 1 : `balance_accounts.vehicle_type` (+ CHECK, backfill ex-CELI/REER) + `balance_categories.custom_label` (+ backfill défensif du bug i18n) — [ADR 0014](adr/0014-balance-vehicule-attribut.md) |
|
||||||
|
| 13 | v13 | Étape 1 : reclasse les comptes ex-tfsa/rrsp vers « Autres », désactive les seeds enveloppes (idempotente) — [ADR 0014](adr/0014-balance-vehicule-attribut.md) |
|
||||||
|
| 14 | v14 | Étape 2 : `balance_securities` + `balance_snapshot_holdings` + 2 index (additive) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
|
||||||
|
| 15 | v15 | Étape 2 : `balance_accounts.kind` (`simple`/`detailed`) + `detailed_since` + backfill depuis `category.kind` (`priced` → `detailed`) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
|
||||||
|
| 16 | v16 | Étape 2 : conversion des comptes cotés existants en détaillés 1-position (security + holding miroir, gardée anti-perte, idempotente) — [ADR 0015](adr/0015-balance-detail-par-titre.md) |
|
||||||
|
|
||||||
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 (17)
|
## Services TypeScript (18)
|
||||||
|
|
||||||
| 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 |
|
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) |
|
||||||
| `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 |
|
||||||
|
|
@ -131,8 +179,20 @@ 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) |
|
||||||
|
|
||||||
## Hooks (14)
|
### Service Bilan — `balance.service.ts`
|
||||||
|
|
||||||
|
Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes :
|
||||||
|
|
||||||
|
1. **CRUD catégories + comptes + titres** — `listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount` (garde `detailed → simple` refusée si des holdings existent, erreur typée), `archiveBalanceAccount`. **Securities (Étape 2)** : `listSecurities`, `getSecurity`, `findOrCreateSecurity` (UPSERT sur symbol normalisé upper/trim, `asset_type` requis), `updateSecurity`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, `account_kind_detailed_has_holdings`, etc.).
|
||||||
|
2. **Snapshots + lines + holdings** — `listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne). **Save détaillé (Étape 2)** : pour un compte `detailed`, la ligne agrégée (value = somme des holdings) **et** ses holdings sont écrits dans la **même transaction** (`BEGIN/COMMIT`), `value` recalculée = `SUM(holdings.value)` (chaque holding arrondi au cent, comparaison exacte). `validateLineKindInvariants` (simple, inchangé, tolérance `PRICED_VALUE_TOLERANCE = 0.01`) + nouvelle passe `validateDetailedSnapshot(account.kind, line, holdings)` (detailed + holdings ⇒ ligne agrégée ET `value = SUM` ; detailed pré-pivot ⇒ agrégé toléré). `getHoldingsForLatestSnapshot` (pré-remplissage : titres + qty + book_cost reportés, qty-0 exclus), `listHoldingsBySnapshotLine` (drill-down), `computeUnrealizedGain` (gain latent `value − book_cost` en valeur + %, garde-fou `book_cost = 0`/NULL → « N/A », agrégeable par classe/enveloppe). `deleteSnapshot`.
|
||||||
|
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` :
|
||||||
|
|
||||||
|
|
@ -152,13 +212,16 @@ 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 (35)
|
## Commandes Tauri (36)
|
||||||
|
|
||||||
### `fs_commands.rs` — Système de fichiers (6)
|
### `fs_commands.rs` — Système de fichiers (6)
|
||||||
|
|
||||||
|
|
@ -230,6 +293,14 @@ 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) :
|
||||||
|
|
@ -291,9 +362,17 @@ 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é |
|
||||||
| `/settings` | `SettingsPage` | Paramètres |
|
| `/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é |
|
||||||
| `/docs` | `DocsPage` | Documentation in-app |
|
| `/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 |
|
||||||
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
|
| `/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` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales |
|
||||||
|
| `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) |
|
||||||
|
| `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix |
|
||||||
|
| `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), historique des versions (`ChangelogContent`), journaux + commentaires (`LogViewerCard`) |
|
||||||
|
| `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) |
|
||||||
|
| `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) |
|
||||||
|
| `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) |
|
||||||
|
| `/changelog` | `ChangelogPage` | Redirige vers `/settings/systems` (rétrocompatibilité release notes) |
|
||||||
|
|
||||||
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).
|
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).
|
||||||
|
|
||||||
|
|
@ -328,3 +407,25 @@ Fonctionnalités :
|
||||||
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
|
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
|
||||||
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
|
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
|
||||||
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
|
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
|
||||||
|
|
||||||
|
## Architecture Decision Records (ADRs)
|
||||||
|
|
||||||
|
Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`.
|
||||||
|
|
||||||
|
| # | Titre | Date | Statut |
|
||||||
|
|---|-------|------|--------|
|
||||||
|
| [0001](adr/0001-tauri-v2.md) | Choix de Tauri v2 comme framework desktop | 2024-01-01 | Accepted |
|
||||||
|
| [0002](adr/0002-useReducer-vs-redux.md) | useReducer plutôt que Redux | 2024-01-01 | Accepted |
|
||||||
|
| [0003](adr/0003-sqlx-migrations.md) | Migrations SQL inline via tauri-plugin-sql | 2024-01-01 | Accepted |
|
||||||
|
| [0004](adr/0004-aes-256-gcm-encryption.md) | Chiffrement AES-256-GCM pour l'export | 2024-01-01 | Accepted |
|
||||||
|
| [0005](adr/0005-multi-profile-db.md) | Multi-profils avec bases SQLite séparées | 2024-01-01 | Accepted |
|
||||||
|
| [0006](adr/0006-oauth-tokens-keychain.md) | Stockage des tokens OAuth via keychain | 2024-01-01 | Accepted |
|
||||||
|
| [0007](adr/0007-reports-hub-refactor.md) | Refactorisation du hub de rapports | 2024-01-01 | Accepted |
|
||||||
|
| [0008](adr/0008-modified-dietz-pour-rendement.md) | Modified Dietz pour le calcul de rendement | 2025-01-01 | Accepted |
|
||||||
|
| [0009](adr/0009-proxy-price-fetching-via-maximus-api.md) | Proxy price-fetching via maximus-api | 2025-01-01 | Accepted |
|
||||||
|
| [0010](adr/0010-fk-restrict-balance-transfers.md) | FK RESTRICT sur balance_account_transfers | 2025-01-01 | Accepted |
|
||||||
|
| [0011](adr/0011-providers-best-effort-yahoo.md) | Providers best-effort Yahoo | 2026-04-26 | Accepted |
|
||||||
|
| [0012](adr/0012-balance-two-level-model.md) | Modèle à deux niveaux pour le Bilan (véhicules × compositions) | 2026-05-01 | Rejected |
|
||||||
|
| [0013](adr/0013-stocks-provider-evaluation.md) | Évaluation provider stocks : Alpha Vantage retenu comme cible | 2026-05-09 | Accepted |
|
||||||
|
| [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted |
|
||||||
|
| [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted |
|
||||||
|
|
|
||||||
112
docs/audit-bilan-2026-05.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Audit critique — Page Bilan (suivi du patrimoine)
|
||||||
|
|
||||||
|
> Date : 2026-05-31
|
||||||
|
> Méthode : revue à deux experts (CPA / planificateur financier québécois + designer produit fintech patrimoniale), sur cartographie du code réel, suivie d'une synthèse priorisée.
|
||||||
|
> Calibrage : **les deux personas en progressif** — simple par défaut (grand public, valeurs agrégées), détail par titre en option (investisseur actif).
|
||||||
|
> Dimensions retenues : **modèle de données** + **UX de saisie & suivi**. Hors-périmètre : exactitude des calculs (Modified Dietz), complétude (dividendes, multi-devise, allocation cible) — mentionnés seulement quand ils sont un prérequis.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
Le socle est propre et honnête **pour le grand public en mode agrégé** : invariants SQL rigoureux (CHECK `kind`, UNIQUE date, FK RESTRICT), onboarding 2-step, starter accounts, saisie « 1 compte = 1 montant », pré-remplissage. Mais le **« progressif » est cassé en son centre**, et les deux experts pointent **la même cause unique** : le modèle est *plat* — une seule « catégorie » encode à la fois le **véhicule fiscal** (CELI, REER) et la **classe d'actif** (actions, crypto, encaisse). Tant que ces deux axes orthogonaux partagent un champ, la montée en puissance vers le détail par titre est soit impossible, soit punitive (rupture d'historique). Le mode détaillé est par ailleurs **inutilisable aujourd'hui** : re-saisie manuelle de tous les prix à chaque snapshot, price-fetch encore bloqué côté serveur.
|
||||||
|
|
||||||
|
La cible UX (modèle *enveloppe → positions*, façon Sharesight / Kubera) et le chemin CPA (migration **additive**, pas le big-bang de l'ADR 0012) sont **le même plan vu sous deux angles**. Il existe une trajectoire à faible risque.
|
||||||
|
|
||||||
|
## Diagnostic racine : le modèle plat
|
||||||
|
|
||||||
|
Exemple concret : un **CELI de courtage** contenant 30 actions AAPL + 5 000 $ d'encaisse. Aujourd'hui, deux choix, tous deux faux :
|
||||||
|
|
||||||
|
- compte `simple` sous `tfsa` → le titre, sa quantité et son rendement disparaissent ;
|
||||||
|
- compte `priced` sous `stock` → l'abri CELI disparaît du modèle (« combien dans mon CELI ? » devient insoluble).
|
||||||
|
|
||||||
|
Conséquences en cascade : agrégation par titre cross-véhicule impossible (« combien de VFV au total, tous comptes confondus ? »), empilé « par catégorie » qui mélange enveloppes et classes d'actif, et surtout **bascule agrégé → détaillé qui casse la courbe** (le `kind` simple/priced est figé sur la catégorie, pas sur le compte).
|
||||||
|
|
||||||
|
> Même en implémentant l'ADR 0012 tel quel, le triplet `(véhicule, composition, valeur)` garde la *composition* au niveau **classe d'actif**, pas **titre**. On déplacerait le mur sans débloquer le détail par titre individuel — l'ADR 0012 résout le mauvais grain.
|
||||||
|
|
||||||
|
## Findings — matrice sévérité × effort
|
||||||
|
|
||||||
|
Effort : **S** = à modèle constant · **M** = additif (colonnes/tables) · **L** = migration structurante. ✓✓ = relevé par les deux experts.
|
||||||
|
|
||||||
|
| # | Finding | Sévérité | Effort | Persona | Source |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **A** | Modèle plat fusionne véhicule fiscal × classe d'actif (**la racine**) | Structurel | M→L | Les deux | ✓✓ |
|
||||||
|
| **B** | Bascule agrégé → détaillé casse l'historique (`kind` figé sur la catégorie) | Bloquant | M→L | Les deux | ✓✓ |
|
||||||
|
| **C** | Re-saisie manuelle de **tous** les prix chaque mois (Pré-remplir laisse le prix vide) | Majeur | M | Investisseur | UX |
|
||||||
|
| **D** | PRU / coût d'acquisition absent → impossible de distinguer apport vs gain latent | Majeur | M | Investisseur | CPA (+UX) |
|
||||||
|
| **E** | Symbole = texte libre, non normalisé → aucune agrégation par titre | Majeur | M | Investisseur | CPA |
|
||||||
|
| **F** | Tableau 5 colonnes de rendement : bruit anxiogène (grand public), incomplet (investisseur) | Majeur | M | Les deux | UX |
|
||||||
|
| **G** | Empilé « par catégorie » illisible (axe bâtard véhicule/actif) | Majeur | M | Les deux | UX |
|
||||||
|
| **H** | Onboarding muet sur agrégé vs détaillé ; pas de preset investisseur | Majeur | S | Les deux | UX |
|
||||||
|
| **I** | Édition de catégorie via `window.prompt()` **écrase `i18n_key` → casse le bilingue** | Mineur *(vrai bug)* | S | Les deux | UX |
|
||||||
|
| **J** | Terminologie : « catégorie » (collision avec les transactions), « snapshot » (jargon), « encaisse »/« fonds commun » | Mineur | S | Grand public | CPA |
|
||||||
|
| **K** | Symbole obligatoire pour `priced` (friction sans bénéfice tant que price-fetch bloqué) | Mineur | S | Investisseur | UX |
|
||||||
|
| **L** | Liaison de transferts 100 % manuelle, sans suggestion par montant/libellé | Mineur | M | Investisseur | UX |
|
||||||
|
| **M** | Date de snapshot immutable (corriger = supprimer + tout re-saisir) | Mineur | S | Les deux | UX |
|
||||||
|
| **N** | Devise portée par le compte alors qu'elle est une propriété du titre (dette future) | Mineur | S | Investisseur | CPA |
|
||||||
|
|
||||||
|
**Incohérence de données à acter (bug latent)** : `updateBalanceAccount` autorise à repointer un compte `simple` → `priced` **sans toucher les lignes de snapshot historiques** ; on se retrouve avec des lignes scalaires sous un compte désormais `priced` (`src/services/balance.service.ts:340`, classement par `category_kind` au chargement `src/hooks/useSnapshotEditor.ts:289`).
|
||||||
|
|
||||||
|
## Détail des findings structurels et majeurs
|
||||||
|
|
||||||
|
### A — Le modèle plat fusionne véhicule fiscal et classe d'actif (racine)
|
||||||
|
|
||||||
|
`balance_categories` encode au même niveau le véhicule (`tfsa`, `rrsp`) et la classe d'actif (`stock`, `crypto`, `cash`, `fund`). Cas réels québécois mal représentés : actions dans un CELI ; liquidités dans un compte de courtage ; même FNB (VFV) dans REER + non-enregistré (impossible d'agréger « combien de VFV au total ») ; **CELIAPP, REEE, CRI/FERR absents** des seeds. La séparation des deux axes est le prérequis de presque tout le reste.
|
||||||
|
|
||||||
|
### B — La bascule agrégé → détaillé casse l'historique
|
||||||
|
|
||||||
|
Le `kind` (`simple`/`priced`) est porté par la **catégorie**, pas par le compte, et `updateBalanceCategory` interdit d'en changer (`balance.service.ts:152`). Un utilisateur qui suit « CELI = 50 000 $ » puis veut détailler ses titres doit archiver l'ancien compte et en créer un nouveau → la série temporelle se scinde, la courbe rompt, le rendement « depuis création » repart de zéro. C'est le pire moment pour perdre l'historique (l'utilisateur monte justement en sophistication).
|
||||||
|
|
||||||
|
### C — Re-saisie manuelle de tous les prix à chaque snapshot
|
||||||
|
|
||||||
|
Sur `/balance/snapshot`, chaque ligne `priced` demande quantité × prix unitaire à la main (`SnapshotLineRow.tsx:104-165`). Le price-fetch est premium **et** encore bloqué serveur → 100 % manuel. Pire : « Pré-remplir » copie la quantité mais **laisse le prix vide** par design (`useSnapshotEditor.ts:397-405`). Pour 10 titres, c'est 10 prix à retrouver et re-saisir chaque mois — point de bascule où l'investisseur abandonne. Levier le plus aligné privacy-first/desktop : **import CSV de cours local** (pattern Portfolio Performance), indépendant du premium ; et autoriser la saisie de la valeur directe en mode `priced` (pattern Portfolio Performance : cours OU valeur).
|
||||||
|
|
||||||
|
### D — Coût d'acquisition (PBR / PRU) absent du modèle
|
||||||
|
|
||||||
|
`balance_snapshot_lines` ne porte que `quantity`, `unit_price` (prix de marché) et `value`. Aucun coût d'acquisition. Au niveau du modèle, on ne peut donc pas distinguer **apport vs gain latent** pour une position — exactement ce qu'un investisseur attend du détail par titre (« j'ai mis 8 000 $, ça vaut 11 000 $, +3 000 $ latent »). Pertinence fiscale québécoise forte (PBR = base du gain en capital imposable en non-enregistré). Recommandation (angle modèle uniquement) : champ `book_cost` (ou `avg_cost_per_unit`) saisissable sur la position-titre, pré-rempli depuis le snapshot précédent. Sans lui, le détail par titre n'apporte rien de plus que l'agrégat.
|
||||||
|
|
||||||
|
### E — Le symbole de titre n'est pas une entité normalisée
|
||||||
|
|
||||||
|
`symbol` est un `TEXT` nullable sur `balance_accounts`, sans table de référence ni unicité. `getSnapshotTotalsByCategoryAndDate` groupe uniquement par `category.key` (`balance.service.ts:1145-1174`) — le symbole n'entre dans aucune agrégation. `AAPL`, `aapl`, `AAPL.US` sont trois titres distincts. Un compte = un seul symbole → 15 titres = 15 « comptes ». Recommandation : table `balance_securities` (`symbol` normalisé UNIQUE, `name`, `asset_type`, `currency`) référencée par la **position** (pas le compte).
|
||||||
|
|
||||||
|
### F — Tableau à 5 colonnes de rendement mal calibré
|
||||||
|
|
||||||
|
`BalanceAccountsTable.tsx` affiche Valeur | Δ% | 3M | 1A | Depuis création | Non-ajusté. Pour le grand public (montants agrégés, sans transferts liés) : warnings ambre ou colonnes identiques = bruit anxiogène. Pour l'investisseur : manquent quantité, prix actuel, **gain/perte $**, **% du portefeuille**. La colonne « Non-ajusté » dérive de l'horizon 1A en dur (`BalanceAccountsTable.tsx:327-329`) sans le dire. Recommandation : progressive disclosure — défaut Compte | Valeur | Δ%, rendements pliés ; colonnes titre pertinentes pour les comptes `priced`.
|
||||||
|
|
||||||
|
### G — Empilé « par catégorie » illisible
|
||||||
|
|
||||||
|
L'empilé (`BalanceEvolutionChart`) groupe par catégorie, qui est soit un véhicule soit une classe d'actif, jamais les deux (seed `consolidated_schema.sql:266-272`). L'histoire racontée est bâtarde : ni « répartition par classe d'actif », ni « répartition par enveloppe fiscale ». Recommandation : deux axes de groupement distincts (toggle *par enveloppe* / *par classe d'actif*), débloqués par la séparation des axes (A).
|
||||||
|
|
||||||
|
### H — Onboarding muet sur le choix agrégé vs détaillé
|
||||||
|
|
||||||
|
Les 4 starter accounts sont tous `simple` (`balance.service.ts:449`) ; aucun n'introduit le suivi par titre. L'investisseur actif doit deviner le chemin (catégorie `priced` → `asset_type` → symbole → snapshot : 4 écrans + notions techniques). Recommandation : question de calibrage à l'entrée (pattern Empower/Snowball : « suivez-vous des titres individuels ? ») avec deux presets (Simple / Investisseur).
|
||||||
|
|
||||||
|
## Trajectoire recommandée (synthèse des deux experts)
|
||||||
|
|
||||||
|
Une ligne directrice, en **trois temps additifs** — pas de big-bang, les comptes simples existants ne bougent jamais.
|
||||||
|
|
||||||
|
**Étape 0 — Quick wins (effort S, indépendants, livrables tout de suite)**
|
||||||
|
`I` (corriger le bug i18n du renommage — prioritaire : casse une promesse FR/EN du projet), `J` (lexique : « catégorie » → « type/nature », gloser « snapshot », « encaisse »/« fonds » → « liquidités »/« fonds/FNB »), `K` (symbole optionnel), `M` (déplacer une date par `UPDATE` plutôt que delete+recreate). Aucun impact schéma structurant.
|
||||||
|
|
||||||
|
**Étape 1 — Séparer l'axe véhicule (effort M, migration additive)**
|
||||||
|
Ajouter `balance_accounts.vehicle_type` (contrainte incluant **CELIAPP, REEE, CRI/FERR**), backfillé depuis `category.key`. Reclasser `balance_categories` en **pure classe d'actif**. → débloque « combien dans mon CELI » indépendamment de l'actif, assainit l'empilé (`G`) avec un toggle par enveloppe / par classe d'actif, et permet la progressive disclosure du tableau (`F`). Migration purement additive.
|
||||||
|
|
||||||
|
**Étape 2 — Détail par titre sans perte d'historique (effort M→L, quand le besoin est confirmé)**
|
||||||
|
`balance_securities` (titre normalisé, devise → `N`, asset_type) + `balance_account_holdings` (positions par titre sous un compte, avec **`book_cost`** → `D`, `E`), et **migrer le `kind` de la catégorie vers le compte** → bascule « CELI agrégé → CELI détaillé » continue (`B`), avec un assistant UX « détailler ce compte en titres » qui conserve la valeur agrégée comme historique. En parallèle, traiter `C` (import CSV de cours local).
|
||||||
|
|
||||||
|
## Recommandation sur l'ADR 0012
|
||||||
|
|
||||||
|
**Ne pas l'implémenter tel quel.** Le faire passer de `Proposed` à `Superseded` au profit d'un nouvel ADR « véhicule = attribut du compte + positions optionnelles par titre ». Il visait les bons groupements (`GROUP BY véhicule`, `GROUP BY classe d'actif`) mais avec une grille 2D imposée à tous et au mauvais grain (classe, pas titre).
|
||||||
|
|
||||||
|
## Recommandation centrale
|
||||||
|
|
||||||
|
Séparer enveloppe fiscale et classe d'actif dans le modèle, puis livrer le parcours « détailler un compte agrégé en titres » qui en découle (A → B). C'est l'unique levier qui débloque le « progressif » pour les deux personas : commencer agrégé puis détailler sans perdre l'historique ni bricoler des catégories. Tout le reste (import de prix, calibrage d'onboarding, lexique) reste cosmétique tant que ce mur central existe — mais l'Étape 0 se livre indépendamment, dès maintenant.
|
||||||
|
|
||||||
|
## Annexe — fichiers de référence
|
||||||
|
|
||||||
|
- Schéma : `src-tauri/src/database/balance_schema.sql` (v9), migrations v9-v11 `src-tauri/src/lib.rs`, seeds `src-tauri/src/database/consolidated_schema.sql` (l. 185-296)
|
||||||
|
- Service : `src/services/balance.service.ts` (`updateBalanceCategory` interdit le changement de `kind` l.152 ; `updateBalanceAccount` l.340 ; `STARTER_ACCOUNTS` l.449 ; agrégation par `category.key` l.1145-1174)
|
||||||
|
- Hooks : `src/hooks/useSnapshotEditor.ts` (Pré-remplir laisse le prix vide l.397), `src/hooks/useBalanceOverview.ts`
|
||||||
|
- Composants : `src/components/balance/{SnapshotLineRow,BalanceAccountsTable,BalanceEvolutionChart,AccountForm,StarterAccountsModal,BalanceOnboardingCard}.tsx`
|
||||||
|
- Pages : `src/pages/{BalancePage,AccountsPage,SnapshotEditPage}.tsx` (édition catégorie via `window.prompt` `AccountsPage.tsx:411`)
|
||||||
|
- Types : `src/shared/types/index.ts` (l. 559-704)
|
||||||
|
- ADR challengé : `docs/adr/0012-balance-two-level-model.md` ; contexte : 0008, 0010, 0011, 0013
|
||||||
|
- Guide : `docs/guide-utilisateur.md` (l. 358-417) ; i18n : `src/i18n/locales/{fr,en}.json` clés `balance.*`
|
||||||
|
|
@ -355,7 +355,113 @@ L'application est atomique : soit toutes les transactions cochées sont recatég
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Paramètres
|
## 10. Bilan
|
||||||
|
|
||||||
|
Le **Bilan** est une vue patrimoniale : vous saisissez périodiquement un *snapshot* (relevé daté de votre patrimoine) de l'ensemble de vos comptes, 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.
|
||||||
|
|
||||||
|
Deux notions distinctes structurent le Bilan :
|
||||||
|
- la **classe d'actif** (le *type* du compte) — Liquidités, Fonds / FNB, Actions, Crypto, Autres ;
|
||||||
|
- l'**enveloppe fiscale** (le *véhicule*) — Non-enregistré, CELI, REER, FERR, CELIAPP, REEE.
|
||||||
|
|
||||||
|
Ce sont deux axes indépendants : un même CELI peut contenir des actions, des fonds et des liquidités. La classe d'actif est portée par le type du compte ; l'enveloppe fiscale est un attribut optionnel du compte. Vous pouvez ainsi lire votre patrimoine *par classe d'actif* OU *par enveloppe fiscale* (voir le graphique d'évolution).
|
||||||
|
|
||||||
|
Un compte peut être saisi de deux façons :
|
||||||
|
- **en montant unique** (compte *simple*) — vous entrez directement la valeur du compte à chaque snapshot ;
|
||||||
|
- **par titre** (compte *détaillé*) — vous entrez chaque valeur mobilière du compte (quantité × cours, avec coût d'acquisition) ; la valeur du compte est la **somme** de ses positions. Voir [Détail par titre](#détail-par-titre) plus bas.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- 5 types standard pré-installés, qui sont des **classes d'actif** : Liquidités, Fonds / FNB, Actions, Crypto, Autres — renommables, non-supprimables (un *type* regroupe des comptes de même nature ; à ne pas confondre avec les catégories de transactions). Les enveloppes fiscales (CELI, REER…) ne sont plus des types : ce sont désormais un attribut du compte (voir ci-dessous)
|
||||||
|
- Création de types personnalisés avec choix `simple` (montant direct) ou `priced` (quantité × prix unitaire)
|
||||||
|
- Comptes par type : nom, **enveloppe fiscale optionnelle** (Non-enregistré, CELI, REER, FERR, CELIAPP, REEE — ou aucune, par défaut), symbole optionnel (même pour les types cotés — il ne sert qu'à la récupération automatique des prix), devise (CAD au MVP), notes
|
||||||
|
- Renommage d'un type sans casser le bilingue : le nom personnalisé est stocké à part, la traduction FR/EN d'origine reste intacte (et réapparaît si vous videz le nom personnalisé)
|
||||||
|
- Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer ; la date d'un snapshot existant peut être déplacée (ses lignes sont conservées), tant qu'aucun autre snapshot n'occupe déjà la date cible
|
||||||
|
- Saisie groupée par type ; pour les types `priced`, le `value` est calculé automatiquement (`quantity × unit_price`)
|
||||||
|
- **Comptes détaillés (par titre)** : un compte peut contenir plusieurs valeurs mobilières — chaque titre a sa ligne (symbole, quantité, cours, valeur, coût d'acquisition, gain latent). La valeur du compte est la somme de ses titres. Le sélecteur de titre auto-complète sur vos titres existants et permet d'en créer un (symbole + classe d'actif Action/Crypto)
|
||||||
|
- **Gain latent** par titre et agrégé (par compte, par classe d'actif, par enveloppe) : `valeur − coût d'acquisition`, en dollars et en %. Drill-down par titre dans le tableau des comptes. Une position sans coût d'acquisition saisi affiche « N/A » et est exclue du calcul du %
|
||||||
|
- **Assistant « Détailler en titres »** : bascule un compte simple en compte détaillé à partir d'une date de bascule (pivot) ; l'historique agrégé passé reste figé en lecture seule
|
||||||
|
- Bouton **Pré-remplir depuis le snapshot précédent** : copie les valeurs simples + les quantités priced + les titres, quantités et coûts d'acquisition des comptes détaillés (vous remplissez juste les nouveaux cours ; un titre à quantité 0 est ignoré)
|
||||||
|
- 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 avec un sous-choix d'axe — **Par classe d'actif** (défaut) ou **Par enveloppe** (fiscale). 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. Ces colonnes sont **repliées par défaut** (un bouton les affiche / les masque, votre choix est mémorisé d'une session à l'autre)
|
||||||
|
- 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 Types pour créer si besoin une classe d'actif supplémentaire en `simple` (montant direct) ou `priced` (quantité × prix unitaire). Pour renommer un type standard, double-cliquez son nom — la traduction d'origine est préservée
|
||||||
|
2. Allez dans l'onglet Comptes pour créer chaque compte (ex. "Tangerine" rattaché à Liquidités, "BTC Ledger" rattaché à Crypto avec symbole `BTC` — le symbole reste optionnel). Si le compte est logé dans une enveloppe fiscale, choisissez-la dans le menu **Enveloppe fiscale** (ex. un compte d'actions dans un CELI : type Actions + enveloppe CELI) ; laissez sur « Aucune » pour un compte courant ou un wallet
|
||||||
|
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 suivre un compte **titre par titre**, soit créez-le détaillé, soit ouvrez son menu d'actions → **Détailler en titres** (assistant) ; aux snapshots suivants, ajoutez chaque valeur mobilière avec sa quantité, son cours et son coût d'acquisition. Voir [Détail par titre](#détail-par-titre)
|
||||||
|
9. 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. Vous pouvez aussi y corriger la date : changez-la puis enregistrez, le snapshot est déplacé avec ses lignes (un message s'affiche si la date cible est déjà prise par un autre snapshot)
|
||||||
|
10. 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
|
||||||
|
|
||||||
|
### Détail par titre
|
||||||
|
|
||||||
|
Un compte d'investissement peut être suivi **titre par titre** plutôt qu'en montant unique. Chaque valeur mobilière (action, FNB, crypto) devient une ligne avec sa quantité, son cours, sa valeur (`quantité × cours`) et son **coût d'acquisition** (le prix payé). La valeur du compte est la **somme** de ses titres.
|
||||||
|
|
||||||
|
**Saisir un compte détaillé, titre par titre.** Dans l'éditeur de snapshot, un compte détaillé se déplie en sous-lignes :
|
||||||
|
1. Cliquez **Ajouter un titre**, puis tapez un symbole (ex. `AAPL`, `BTC`) dans le sélecteur. S'il existe déjà, choisissez-le ; sinon, créez-le en choisissant sa classe d'actif (Action ou Crypto)
|
||||||
|
2. Saisissez la **quantité** et le **cours** (ou utilisez le bouton de récupération automatique des prix si vous y avez droit) — la valeur de la ligne se calcule
|
||||||
|
3. Saisissez le **coût d'acquisition** de la position (le total payé) — il alimente le gain latent ; laissez-le vide si vous ne le suivez pas (le gain s'affichera « N/A »)
|
||||||
|
4. Répétez pour chaque titre. La valeur du compte affichée en bas = la somme des positions
|
||||||
|
5. Enregistrez. Au prochain snapshot, **Pré-remplir** rapporte vos titres, quantités et coûts d'acquisition — vous n'avez qu'à rafraîchir les cours
|
||||||
|
|
||||||
|
**Détailler un compte agrégé existant (l'assistant).** Si un compte était suivi en montant unique et que vous voulez désormais le suivre par titre, ouvrez son menu d'actions dans le tableau des comptes → **Détailler en titres**. L'assistant :
|
||||||
|
- passe le compte en **détaillé** et fixe la **date de bascule (pivot)** à aujourd'hui ;
|
||||||
|
- **fige l'historique agrégé passé** en lecture seule : vos anciens snapshots gardent leur montant unique, rien n'est réécrit ni recalculé ;
|
||||||
|
- ne vous demande **pas** de saisir les titres tout de suite — vous les saisirez à votre **prochain snapshot** normal.
|
||||||
|
|
||||||
|
⚠️ C'est une action **à sens unique** : une fois qu'au moins un titre est saisi, le compte ne peut plus revenir en saisie agrégée (sinon le détail serait perdu).
|
||||||
|
|
||||||
|
**Lire le gain latent.** Dans le tableau des comptes, un compte détaillé peut être déplié pour voir chaque titre avec sa valeur et son gain latent (`valeur − coût d'acquisition`, en $ et en %). Le gain latent est aussi agrégé **par classe d'actif** et **par enveloppe**. Le gain latent répond à « combien ai-je gagné depuis l'achat ? » — c'est distinct du **rendement Modified Dietz** (3M / 1A / depuis création), qui mesure la performance du compte en tenant compte du *timing* de vos apports. Les deux coexistent : le rendement reste au niveau du compte, inchangé.
|
||||||
|
|
||||||
|
> Le coût d'acquisition est **saisi**, pas déduit de vos transactions. Pré-rempli d'un snapshot à l'autre, il est correct tant qu'aucun achat ni vente n'a eu lieu sur le titre — après un achat/vente, ajustez-le manuellement pour que le gain latent reste juste.
|
||||||
|
|
||||||
|
### 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é** raconte une histoire différente du mode ligne : il montre la composition de votre patrimoine, pas seulement son total. Basculez l'axe entre **Par classe d'actif** (combien en actions, en liquidités…) et **Par enveloppe** (combien en CELI, en REER, hors enveloppe…) pour lire les deux faces du même patrimoine
|
||||||
|
- **Note pour les bilans historiques.** Depuis la version qui sépare classe d'actif et enveloppe fiscale, les anciens types « CELI » et « REER » sont devenus des **enveloppes**, et les comptes concernés ont été reclassés en classe d'actif **« Autres »** (en conservant leur enveloppe). L'axe « par classe d'actif » étant recalculé sur la classe **actuelle** du compte, un snapshot saisi *avant* cette migration apparaît désormais sous « Autres » sur cet axe (et non plus sous « CELI »/« REER »). C'est attendu : l'axe **« par enveloppe »**, lui, retrouve bien vos CELI / REER. Vos montants et votre historique ne changent pas — seul le regroupement d'affichage évolue
|
||||||
|
- 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
|
||||||
|
- **Gain latent ≠ rendement.** Le gain latent (`valeur − coût d'acquisition`) répond à « combien vaut ma plus-value depuis l'achat ? » ; le rendement Modified Dietz répond à « quelle performance, en tenant compte de *quand* j'ai investi ? ». Les deux s'affichent côte à côte sur un compte détaillé. Pensez à ajuster le coût d'acquisition après un achat ou une vente, sinon le gain latent dérive.
|
||||||
|
- (À 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + React + Typescript</title>
|
<title>Simpl'Résultat</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
11
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.8.4",
|
"version": "0.9.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.8.4",
|
"version": "0.9.1",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -2923,9 +2923,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
||||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -2941,6 +2941,7 @@
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.4",
|
"version": "0.9.1",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
66
public/icon.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Concept 04b — Calculatrice-robot avec cadenas sur la touche "="
|
||||||
|
Iteration sur 04 : la touche Entrée/= devient le porteur du symbole privacy.
|
||||||
|
Robot (yeux + antenne) + comptabilité (calculatrice) + simplicité + privacy (cadenas explicite).
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||||
|
<!-- Background squircle -->
|
||||||
|
<rect x="64" y="64" width="896" height="896" rx="200" ry="200" fill="#1E3A8A"/>
|
||||||
|
|
||||||
|
<!-- Antenna -->
|
||||||
|
<line x1="512" y1="120" x2="512" y2="200" stroke="#FCD34D" stroke-width="18" stroke-linecap="round"/>
|
||||||
|
<circle cx="512" cy="110" r="28" fill="#FCD34D"/>
|
||||||
|
|
||||||
|
<!-- Calculator body -->
|
||||||
|
<rect x="232" y="200" width="560" height="700" rx="60" ry="60" fill="#F1F5F9"/>
|
||||||
|
|
||||||
|
<!-- Screen (robot face) -->
|
||||||
|
<rect x="282" y="250" width="460" height="240" rx="20" ry="20" fill="#0F172A"/>
|
||||||
|
|
||||||
|
<!-- Screen highlight (subtle reflection) -->
|
||||||
|
<rect x="300" y="265" width="200" height="20" rx="10" fill="#1E3A8A" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Robot eyes on screen -->
|
||||||
|
<circle cx="402" cy="370" r="36" fill="#10B981"/>
|
||||||
|
<circle cx="622" cy="370" r="36" fill="#10B981"/>
|
||||||
|
<circle cx="412" cy="358" r="10" fill="#F1F5F9"/>
|
||||||
|
<circle cx="632" cy="358" r="10" fill="#F1F5F9"/>
|
||||||
|
|
||||||
|
<!-- Smile -->
|
||||||
|
<path d="M 432 440 Q 512 470 592 440" fill="none" stroke="#10B981" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Calculator buttons grid (3x4) -->
|
||||||
|
<g fill="#1E3A8A">
|
||||||
|
<rect x="302" y="540" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="412" y="540" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="522" y="540" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="632" y="540" width="100" height="80" rx="16" fill="#FCD34D"/>
|
||||||
|
|
||||||
|
<rect x="302" y="630" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="412" y="630" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="522" y="630" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="632" y="630" width="100" height="80" rx="16" fill="#FCD34D"/>
|
||||||
|
|
||||||
|
<rect x="302" y="720" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="412" y="720" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="522" y="720" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="632" y="720" width="100" height="80" rx="16" fill="#FCD34D"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- "=" / Enter button (wide, accent green) with lock icon -->
|
||||||
|
<rect x="302" y="810" width="430" height="80" rx="16" fill="#10B981"/>
|
||||||
|
|
||||||
|
<!-- Lock icon centered on the Enter key -->
|
||||||
|
<g transform="translate(517 850)">
|
||||||
|
<!-- Shackle (arc above body) -->
|
||||||
|
<path d="M -16 -2 L -16 -14 A 16 16 0 0 1 16 -14 L 16 -2"
|
||||||
|
fill="none" stroke="#F1F5F9" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="-24" y="-2" width="48" height="32" rx="5" fill="#F1F5F9"/>
|
||||||
|
<!-- Keyhole circle -->
|
||||||
|
<circle cx="0" cy="11" r="4.5" fill="#10B981"/>
|
||||||
|
<!-- Keyhole stem -->
|
||||||
|
<rect x="-2.5" y="11" width="5" height="11" rx="1.5" fill="#10B981"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -1,6 +0,0 @@
|
||||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
|
||||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
91
src-tauri/Cargo.lock
generated
|
|
@ -121,6 +121,16 @@ dependencies = [
|
||||||
"password-hash",
|
"password-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "assert-json-diff"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -573,6 +583,15 @@ dependencies = [
|
||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colored"
|
||||||
|
version = "3.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
|
|
@ -1970,6 +1989,12 @@ version = "1.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
|
|
@ -1984,6 +2009,7 @@ dependencies = [
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
|
|
@ -2622,6 +2648,31 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mockito"
|
||||||
|
version = "1.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
|
||||||
|
dependencies = [
|
||||||
|
"assert-json-diff",
|
||||||
|
"bytes",
|
||||||
|
"colored",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"log",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"regex",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"similar",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
|
|
@ -3644,6 +3695,16 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -3664,6 +3725,16 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -3682,6 +3753,15 @@ dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -4421,13 +4501,20 @@ version = "0.3.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.8.4"
|
version = "0.9.1"
|
||||||
dependencies = [
|
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",
|
||||||
|
|
@ -4436,6 +4523,7 @@ dependencies = [
|
||||||
"keyring",
|
"keyring",
|
||||||
"libsqlite3-sys",
|
"libsqlite3-sys",
|
||||||
"machine-uid",
|
"machine-uid",
|
||||||
|
"mockito",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
|
@ -5517,6 +5605,7 @@ dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.8.4"
|
version = "0.9.1"
|
||||||
description = "Personal finance management app"
|
description = "Personal finance management app"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
|
|
@ -41,6 +41,10 @@ rand = "0.8"
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
machine-uid = "0.5"
|
machine-uid = "0.5"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
# Date arithmetic for the Modified Dietz return calculator (Issue #142):
|
||||||
|
# we need day-precision diffs to weight cash flows W_i = (T - t_i) / T.
|
||||||
|
# `serde` feature lets `NaiveDate` cross the Tauri command boundary in JSON.
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["serde", "std"] }
|
||||||
tokio = { version = "1", features = ["macros"] }
|
tokio = { version = "1", features = ["macros"] }
|
||||||
hostname = "0.4"
|
hostname = "0.4"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
@ -60,3 +64,5 @@ hmac = "0.12"
|
||||||
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
|
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
|
||||||
# for Ed25519.
|
# for Ed25519.
|
||||||
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
|
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
|
||||||
|
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
|
||||||
|
mockito = "1.6"
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 963 B |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.7 KiB |
66
src-tauri/icons/icon.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
Concept 04b — Calculatrice-robot avec cadenas sur la touche "="
|
||||||
|
Iteration sur 04 : la touche Entrée/= devient le porteur du symbole privacy.
|
||||||
|
Robot (yeux + antenne) + comptabilité (calculatrice) + simplicité + privacy (cadenas explicite).
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||||
|
<!-- Background squircle -->
|
||||||
|
<rect x="64" y="64" width="896" height="896" rx="200" ry="200" fill="#1E3A8A"/>
|
||||||
|
|
||||||
|
<!-- Antenna -->
|
||||||
|
<line x1="512" y1="120" x2="512" y2="200" stroke="#FCD34D" stroke-width="18" stroke-linecap="round"/>
|
||||||
|
<circle cx="512" cy="110" r="28" fill="#FCD34D"/>
|
||||||
|
|
||||||
|
<!-- Calculator body -->
|
||||||
|
<rect x="232" y="200" width="560" height="700" rx="60" ry="60" fill="#F1F5F9"/>
|
||||||
|
|
||||||
|
<!-- Screen (robot face) -->
|
||||||
|
<rect x="282" y="250" width="460" height="240" rx="20" ry="20" fill="#0F172A"/>
|
||||||
|
|
||||||
|
<!-- Screen highlight (subtle reflection) -->
|
||||||
|
<rect x="300" y="265" width="200" height="20" rx="10" fill="#1E3A8A" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Robot eyes on screen -->
|
||||||
|
<circle cx="402" cy="370" r="36" fill="#10B981"/>
|
||||||
|
<circle cx="622" cy="370" r="36" fill="#10B981"/>
|
||||||
|
<circle cx="412" cy="358" r="10" fill="#F1F5F9"/>
|
||||||
|
<circle cx="632" cy="358" r="10" fill="#F1F5F9"/>
|
||||||
|
|
||||||
|
<!-- Smile -->
|
||||||
|
<path d="M 432 440 Q 512 470 592 440" fill="none" stroke="#10B981" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Calculator buttons grid (3x4) -->
|
||||||
|
<g fill="#1E3A8A">
|
||||||
|
<rect x="302" y="540" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="412" y="540" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="522" y="540" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="632" y="540" width="100" height="80" rx="16" fill="#FCD34D"/>
|
||||||
|
|
||||||
|
<rect x="302" y="630" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="412" y="630" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="522" y="630" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="632" y="630" width="100" height="80" rx="16" fill="#FCD34D"/>
|
||||||
|
|
||||||
|
<rect x="302" y="720" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="412" y="720" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="522" y="720" width="100" height="80" rx="16"/>
|
||||||
|
<rect x="632" y="720" width="100" height="80" rx="16" fill="#FCD34D"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- "=" / Enter button (wide, accent green) with lock icon -->
|
||||||
|
<rect x="302" y="810" width="430" height="80" rx="16" fill="#10B981"/>
|
||||||
|
|
||||||
|
<!-- Lock icon centered on the Enter key -->
|
||||||
|
<g transform="translate(517 850)">
|
||||||
|
<!-- Shackle (arc above body) -->
|
||||||
|
<path d="M -16 -2 L -16 -14 A 16 16 0 0 1 16 -14 L 16 -2"
|
||||||
|
fill="none" stroke="#F1F5F9" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<!-- Body -->
|
||||||
|
<rect x="-24" y="-2" width="48" height="32" rx="5" fill="#F1F5F9"/>
|
||||||
|
<!-- Keyhole circle -->
|
||||||
|
<circle cx="0" cy="11" r="4.5" fill="#10B981"/>
|
||||||
|
<!-- Keyhole stem -->
|
||||||
|
<rect x="-2.5" y="11" width="5" height="11" rx="1.5" fill="#10B981"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
532
src-tauri/src/commands/balance_commands.rs
Normal file
|
|
@ -0,0 +1,532 @@
|
||||||
|
//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155.
|
||||||
|
//!
|
||||||
|
//! Commands:
|
||||||
|
//! - `compute_account_return` (Issue #142): Modified Dietz return for one
|
||||||
|
//! account over a period. Reads snapshot endpoints + linked transfer amounts
|
||||||
|
//! in a single Rust pass.
|
||||||
|
//! - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for
|
||||||
|
//! a given `(symbol, date)` pair. Privacy-strict: sends only
|
||||||
|
//! `Authorization`, `Accept`, and `User-Agent` headers.
|
||||||
|
//!
|
||||||
|
//! 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 serde::{Deserialize, Serialize};
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fetch_price types (Issue #155)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Successful price response from `GET /v1/prices`.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct PriceResponse {
|
||||||
|
pub symbol: String,
|
||||||
|
pub date: String,
|
||||||
|
pub actual_date: Option<String>,
|
||||||
|
pub price: f64,
|
||||||
|
pub currency: String,
|
||||||
|
pub source: String,
|
||||||
|
pub fetched_at: String,
|
||||||
|
pub cached: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed error returned by `fetch_price`. Serialized as JSON to cross the
|
||||||
|
/// Tauri command boundary (the JS layer `JSON.parse`s the error string).
|
||||||
|
///
|
||||||
|
/// The `tag = "code"` + `rename_all = "snake_case"` combination produces
|
||||||
|
/// `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}`, etc. —
|
||||||
|
/// matching the `error.code` shape defined in `docs/api-contract-prices.md §5`.
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
#[serde(tag = "code", rename_all = "snake_case")]
|
||||||
|
pub enum FetchPriceError {
|
||||||
|
Auth,
|
||||||
|
PremiumRequired,
|
||||||
|
SymbolNotFound,
|
||||||
|
RateLimit { retry_after_s: u64 },
|
||||||
|
ProviderUnavailable,
|
||||||
|
Network,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for FetchPriceError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
FetchPriceError::Auth => write!(f, "auth"),
|
||||||
|
FetchPriceError::PremiumRequired => write!(f, "premium_required"),
|
||||||
|
FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"),
|
||||||
|
FetchPriceError::RateLimit { retry_after_s } => {
|
||||||
|
write!(f, "rate_limit (retry after {}s)", retry_after_s)
|
||||||
|
}
|
||||||
|
FetchPriceError::ProviderUnavailable => write!(f, "provider_unavailable"),
|
||||||
|
FetchPriceError::Network => write!(f, "network"),
|
||||||
|
FetchPriceError::Internal => write!(f, "internal"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a `FetchPriceError` to the stable JSON string returned across
|
||||||
|
/// the Tauri boundary. Falls back to `{"code":"internal"}` on serialization
|
||||||
|
/// failure (which should never happen in practice).
|
||||||
|
fn price_error_to_string(err: &FetchPriceError) -> String {
|
||||||
|
serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API base URL for maximus-api. Overridable via `MAXIMUS_API_URL` for tests
|
||||||
|
/// and development environments.
|
||||||
|
fn base_url() -> String {
|
||||||
|
std::env::var("MAXIMUS_API_URL")
|
||||||
|
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the stored activation token from disk (raw JWT string).
|
||||||
|
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable.
|
||||||
|
fn read_stored_activation_token(app: &tauri::AppHandle) -> Result<String, FetchPriceError> {
|
||||||
|
let app_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|_| FetchPriceError::Auth)?;
|
||||||
|
let token_path = app_dir.join("activation.token");
|
||||||
|
std::fs::read_to_string(&token_path)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.map_err(|_| FetchPriceError::Auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core implementation — separated from the Tauri command so tests can inject
|
||||||
|
/// an arbitrary token string and base URL without touching the file system.
|
||||||
|
///
|
||||||
|
/// Design note (MEDIUM decision in decisions-log.md): the public `fetch_price`
|
||||||
|
/// command is a thin wrapper that loads the token then delegates here. Tests
|
||||||
|
/// call this inner function directly with an explicit `api_base` to avoid
|
||||||
|
/// env-var races between concurrent test threads.
|
||||||
|
async fn fetch_price_with_token(
|
||||||
|
token: &str,
|
||||||
|
symbol: &str,
|
||||||
|
date: &str,
|
||||||
|
) -> Result<PriceResponse, FetchPriceError> {
|
||||||
|
fetch_price_inner(token, symbol, date, &base_url()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_price_inner(
|
||||||
|
token: &str,
|
||||||
|
symbol: &str,
|
||||||
|
date: &str,
|
||||||
|
api_base: &str,
|
||||||
|
) -> Result<PriceResponse, FetchPriceError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1/prices?symbol={}&date={}",
|
||||||
|
api_base,
|
||||||
|
urlencoding::encode(symbol),
|
||||||
|
urlencoding::encode(date),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build client with User-Agent set on the builder — NOT as a manual header.
|
||||||
|
// This satisfies the privacy contract (§3.1): UA is set at the transport
|
||||||
|
// level, not injected as an explicit per-request header alongside
|
||||||
|
// Accept-Language, cookies, or other identifying headers.
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent("simpl-resultat")
|
||||||
|
.build()
|
||||||
|
.map_err(|_| FetchPriceError::Internal)?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
// DO NOT add User-Agent here — it is already set on the client builder.
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|_| FetchPriceError::Network)?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
|
||||||
|
match status.as_u16() {
|
||||||
|
200 => {
|
||||||
|
let price_resp: PriceResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|_| FetchPriceError::Internal)?;
|
||||||
|
Ok(price_resp)
|
||||||
|
}
|
||||||
|
401 => Err(FetchPriceError::Auth),
|
||||||
|
403 => Err(FetchPriceError::PremiumRequired),
|
||||||
|
404 => Err(FetchPriceError::SymbolNotFound),
|
||||||
|
429 => {
|
||||||
|
// Parse `error.retry_after` from the JSON body.
|
||||||
|
let body: serde_json::Value = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.unwrap_or(serde_json::Value::Null);
|
||||||
|
let retry_after_s = body
|
||||||
|
.pointer("/error/retry_after")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(60);
|
||||||
|
Err(FetchPriceError::RateLimit { retry_after_s })
|
||||||
|
}
|
||||||
|
s if s >= 500 => Err(FetchPriceError::ProviderUnavailable),
|
||||||
|
_ => Err(FetchPriceError::Internal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api.
|
||||||
|
///
|
||||||
|
/// Reads the stored activation token, then calls `GET /v1/prices`. Returns a
|
||||||
|
/// serialized `FetchPriceError` JSON string on error so the JS layer can
|
||||||
|
/// `JSON.parse` and branch on `code`.
|
||||||
|
///
|
||||||
|
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
|
||||||
|
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
|
||||||
|
/// as a manual header — so no fingerprinting headers leak.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn fetch_price(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
symbol: String,
|
||||||
|
date: String,
|
||||||
|
) -> Result<PriceResponse, String> {
|
||||||
|
let token = read_stored_activation_token(&app).map_err(|e| price_error_to_string(&e))?;
|
||||||
|
fetch_price_with_token(&token, &symbol, &date)
|
||||||
|
.await
|
||||||
|
.map_err(|e| price_error_to_string(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tests for fetch_price (Issue #155)
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Strategy: use `mockito::Server::new_async()` as an in-process HTTP server.
|
||||||
|
// Each test calls `fetch_price_inner` directly, passing the mock server URL
|
||||||
|
// as `api_base`. This avoids env-var races between concurrent test threads
|
||||||
|
// (all tokio tests share the same process) and bypasses the file-system
|
||||||
|
// activation token loading.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn it_returns_price_on_200() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let _m = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#,
|
||||||
|
)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_inner("test-token", "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
assert!(result.is_ok(), "expected Ok, got {:?}", result);
|
||||||
|
let resp = result.unwrap();
|
||||||
|
assert_eq!(resp.symbol, "AAPL");
|
||||||
|
assert!((resp.price - 173.45).abs() < f64::EPSILON);
|
||||||
|
assert_eq!(resp.currency, "USD");
|
||||||
|
assert!(!resp.cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn it_returns_auth_error_on_401() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let _m = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.with_status(401)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"error":{"code":"invalid_token","message":"Invalid token"}}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_inner("bad-token", "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
let err_str = price_error_to_string(&result.unwrap_err());
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||||
|
assert_eq!(parsed["code"], "auth");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn it_returns_premium_required_on_403() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let _m = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.with_status(403)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"error":{"code":"premium_required","message":"Premium required"}}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_inner("base-token", "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
let err_str = price_error_to_string(&result.unwrap_err());
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||||
|
assert_eq!(parsed["code"], "premium_required");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn it_returns_symbol_not_found_on_404() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let _m = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.with_status(404)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"error":{"code":"symbol_not_found","message":"Unknown symbol"}}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_inner("tok", "BOGUS", "2026-04-25", &server.url()).await;
|
||||||
|
let err_str = price_error_to_string(&result.unwrap_err());
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||||
|
assert_eq!(parsed["code"], "symbol_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn it_parses_retry_after_on_429() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let _m = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.with_status(429)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded","retry_after":42}}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
let err_str = price_error_to_string(&result.unwrap_err());
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||||
|
assert_eq!(parsed["code"], "rate_limit");
|
||||||
|
assert_eq!(parsed["retry_after_s"], 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn it_returns_provider_unavailable_on_502() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let _m = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.with_status(502)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(r#"{"error":{"code":"provider_unavailable","message":"Yahoo unavailable"}}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
|
||||||
|
let err_str = price_error_to_string(&result.unwrap_err());
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||||
|
assert_eq!(parsed["code"], "provider_unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Privacy assertion: the request must only carry `Authorization`, `Accept`,
|
||||||
|
/// `User-Agent`, and `Host`. No `Accept-Language`, no cookies, no `X-*`
|
||||||
|
/// tracking headers.
|
||||||
|
///
|
||||||
|
/// mockito's `match_header` with `Matcher::Missing` asserts that a header
|
||||||
|
/// is absent from the request. We assert absence for each forbidden header.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn it_sends_only_authorization_accept_user_agent() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
|
||||||
|
// Forbidden headers — must be absent from every request.
|
||||||
|
let forbidden = [
|
||||||
|
"cookie",
|
||||||
|
"accept-language",
|
||||||
|
"x-forwarded-for",
|
||||||
|
"x-real-ip",
|
||||||
|
"x-custom-tracking",
|
||||||
|
];
|
||||||
|
let mut mock_builder = server
|
||||||
|
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||||
|
.with_status(200)
|
||||||
|
.with_header("content-type", "application/json")
|
||||||
|
.with_body(
|
||||||
|
r#"{"symbol":"BTC","date":"2026-04-25","actual_date":null,"price":60000.0,"currency":"USD","source":"kraken","fetched_at":"2026-04-25T10:00:00Z","cached":true}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
for header in &forbidden {
|
||||||
|
mock_builder = mock_builder.match_header(*header, mockito::Matcher::Missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also assert the required headers ARE present.
|
||||||
|
mock_builder = mock_builder
|
||||||
|
.match_header("authorization", mockito::Matcher::Regex("^Bearer ".to_string()))
|
||||||
|
.match_header("accept", "application/json")
|
||||||
|
.match_header("user-agent", "simpl-resultat");
|
||||||
|
|
||||||
|
let _m = mock_builder.create_async().await;
|
||||||
|
|
||||||
|
let result =
|
||||||
|
fetch_price_inner("test-privacy-token", "BTC", "2026-04-25", &server.url()).await;
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"expected Ok for privacy test, got {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
// If any forbidden header was present, mockito would return 501 and the
|
||||||
|
// JSON parse would fail. A successful 200 parse confirms the privacy contract.
|
||||||
|
assert_eq!(result.unwrap().symbol, "BTC");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,10 +22,11 @@ use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
|
||||||
|
|
||||||
// Ed25519 public key for license verification.
|
// Ed25519 public key for license verification.
|
||||||
//
|
//
|
||||||
// Production key generated 2026-04-10. The corresponding private key lives ONLY
|
// Production key generated 2026-04-25 alongside the maximus-api scaffold.
|
||||||
// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM.
|
// The matching private key lives ONLY on the license server as env var
|
||||||
|
// ED25519_PRIVATE_KEY_PEM (see maximus-api/.env on Coolify).
|
||||||
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
|
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
|
||||||
MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\
|
MCowBQYDK2VwAyEAmUTcl7xjt01uc2FhPgvP0at0I/Pie0JLh73AApNy+o8=\n\
|
||||||
-----END PUBLIC KEY-----\n";
|
-----END PUBLIC KEY-----\n";
|
||||||
|
|
||||||
const LICENSE_FILE: &str = "license.key";
|
const LICENSE_FILE: &str = "license.key";
|
||||||
|
|
@ -294,9 +295,9 @@ fn machine_id_internal() -> Result<String, String> {
|
||||||
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
// License server API base URL. Overridable via SIMPL_API_URL env var for development.
|
// License server API base URL. Overridable via MAXIMUS_API_URL env var for development.
|
||||||
fn api_base_url() -> String {
|
fn api_base_url() -> String {
|
||||||
std::env::var("SIMPL_API_URL")
|
std::env::var("MAXIMUS_API_URL")
|
||||||
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,22 @@
|
||||||
pub mod account_cache;
|
pub mod account_cache;
|
||||||
pub mod auth_commands;
|
pub mod auth_commands;
|
||||||
pub mod backup_commands;
|
pub mod backup_commands;
|
||||||
|
pub mod balance_commands;
|
||||||
pub mod entitlements;
|
pub mod entitlements;
|
||||||
pub mod export_import_commands;
|
pub mod export_import_commands;
|
||||||
pub mod feedback_commands;
|
pub mod feedback_commands;
|
||||||
pub mod fs_commands;
|
pub mod fs_commands;
|
||||||
pub mod license_commands;
|
pub mod license_commands;
|
||||||
pub mod profile_commands;
|
pub mod profile_commands;
|
||||||
|
// Modified Dietz return calculator — private helper module used by
|
||||||
|
// `balance_commands.rs`. Kept out of the wildcard re-export below because
|
||||||
|
// nothing outside `commands/` should depend on it.
|
||||||
|
pub(crate) mod return_calculator;
|
||||||
pub mod token_store;
|
pub mod token_store;
|
||||||
|
|
||||||
pub use auth_commands::*;
|
pub use auth_commands::*;
|
||||||
pub use backup_commands::*;
|
pub use backup_commands::*;
|
||||||
|
pub use balance_commands::*;
|
||||||
pub use entitlements::*;
|
pub use entitlements::*;
|
||||||
pub use export_import_commands::*;
|
pub use export_import_commands::*;
|
||||||
pub use feedback_commands::*;
|
pub use feedback_commands::*;
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ fn hex_encode(bytes: &[u8]) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
||||||
if hex.len() % 2 != 0 {
|
if !hex.len().is_multiple_of(2) {
|
||||||
return Err("Invalid hex string length".to_string());
|
return Err("Invalid hex string length".to_string());
|
||||||
}
|
}
|
||||||
(0..hex.len())
|
(0..hex.len())
|
||||||
|
|
|
||||||
378
src-tauri/src/commands/return_calculator.rs
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
//! 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:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src-tauri/src/database/balance_holdings_schema.sql
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
-- Balance sheet — détail par titre (Bilan Étape 2) — Migration v14
|
||||||
|
-- Created: 2026-06-06
|
||||||
|
-- Issue: #210 (Bilan détail #1 — schema & migrations v14/v15 + types)
|
||||||
|
--
|
||||||
|
-- Purely additive: two new tables enabling a single account to hold many
|
||||||
|
-- securities at a given snapshot date instead of one denormalized value.
|
||||||
|
-- Conventions aligned with balance_schema.sql / consolidated_schema.sql:
|
||||||
|
-- - INTEGER PRIMARY KEY AUTOINCREMENT
|
||||||
|
-- - REAL for monetary amounts / quantities (matches transactions.amount)
|
||||||
|
-- - snake_case
|
||||||
|
-- - FK with explicit ON DELETE policies
|
||||||
|
-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps
|
||||||
|
--
|
||||||
|
-- Design notes (spec-decisions-bilan-detail-titres.md + review caveats):
|
||||||
|
-- - balance_securities.symbol is the natural key, normalized upper/trim by
|
||||||
|
-- the service layer; COLLATE NOCASE UNIQUE prevents case-duplicates
|
||||||
|
-- ('aapl' vs 'AAPL') at the SQL level (SEC/ARCH review caveat).
|
||||||
|
-- - balance_snapshot_holdings references balance_snapshot_lines(id) rather
|
||||||
|
-- than (snapshot_id, account_id): the lines table already guarantees one
|
||||||
|
-- row per (snapshot, account) via UNIQUE(snapshot_id, account_id), so the
|
||||||
|
-- line id uniquely identifies that pair (ARCH review: keep the line FK).
|
||||||
|
-- - value is denormalized (= quantity * unit_price) so reports stay
|
||||||
|
-- reproducible without re-fetching prices — same rationale as
|
||||||
|
-- balance_snapshot_lines.value.
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================================================
|
||||||
|
-- balance_securities — catalogue of investable instruments (stock | crypto)
|
||||||
|
-- =========================================================================
|
||||||
|
-- One row per security/coin the user holds in a detailed account. `symbol` is
|
||||||
|
-- stored normalized (upper/trim) with COLLATE NOCASE so duplicates differing
|
||||||
|
-- only by case are impossible. `asset_type` mirrors balance_categories so the
|
||||||
|
-- price-fetch flow can route per security.
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_securities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'CAD',
|
||||||
|
asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')),
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- =========================================================================
|
||||||
|
-- balance_snapshot_holdings — one row per (snapshot line, security)
|
||||||
|
-- =========================================================================
|
||||||
|
-- The per-title breakdown of a detailed account's snapshot line. CASCADE on
|
||||||
|
-- snapshot_line_id wipes holdings when the parent line is removed; RESTRICT on
|
||||||
|
-- security_id blocks deleting a security still referenced by history. `value`
|
||||||
|
-- is stored denormalized (= quantity * unit_price) for reproducible reports.
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_snapshot_holdings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE,
|
||||||
|
security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT,
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
unit_price REAL NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
book_cost REAL,
|
||||||
|
price_source TEXT,
|
||||||
|
price_fetched_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (snapshot_line_id, security_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id);
|
||||||
153
src-tauri/src/database/balance_schema.sql
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
-- 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,12 +181,179 @@ 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,
|
||||||
|
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')),
|
||||||
|
-- User-facing override for the category label (migration v12). When set,
|
||||||
|
-- the UI shows this verbatim; otherwise it falls back to t(i18n_key).
|
||||||
|
custom_label TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
-- Fiscal envelope / tax shelter (migration v12). NULL = no envelope (e.g.
|
||||||
|
-- a chequing account or crypto wallet). NOT an automobile type.
|
||||||
|
vehicle_type TEXT CHECK(vehicle_type IS NULL OR vehicle_type IN ('unregistered','tfsa','rrsp','rrif','fhsa','resp')),
|
||||||
|
-- Entry mode (migration v15). 'simple' = one denormalized value per
|
||||||
|
-- snapshot line; 'detailed' = a basket of per-security holdings (see
|
||||||
|
-- balance_snapshot_holdings). Accounts under a priced category are backfilled
|
||||||
|
-- to 'detailed' below. `detailed_since` is the authoritative pivot date from
|
||||||
|
-- which detailed entry is expected — NULL until the conversion flow sets it.
|
||||||
|
kind TEXT NOT NULL DEFAULT 'simple' CHECK (kind IN ('simple','detailed')),
|
||||||
|
detailed_since DATE,
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Détail par titre (Bilan Étape 2, migration v14). Kept in sync with
|
||||||
|
-- `balance_holdings_schema.sql` (the source of truth applied by Migration v14
|
||||||
|
-- in lib.rs). balance_securities is the instrument catalogue (symbol normalized
|
||||||
|
-- upper/trim, COLLATE NOCASE UNIQUE); balance_snapshot_holdings is the per-title
|
||||||
|
-- breakdown of a detailed account's snapshot line.
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_securities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'CAD',
|
||||||
|
asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')),
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS balance_snapshot_holdings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE,
|
||||||
|
security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT,
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
unit_price REAL NOT NULL,
|
||||||
|
value REAL NOT NULL,
|
||||||
|
book_cost REAL,
|
||||||
|
price_source TEXT,
|
||||||
|
price_fetched_at DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (snapshot_line_id, security_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);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id);
|
||||||
|
|
||||||
|
-- Asset classes only (Bilan axe véhicule, Étape 1). The former `tfsa` / `rrsp`
|
||||||
|
-- seeds were fiscal envelopes, not asset classes — they now live on the account
|
||||||
|
-- as `vehicle_type` (see balance_accounts.vehicle_type). New profiles ship with
|
||||||
|
-- exactly 5 asset classes: Liquidités, Fonds/FNB, Actions, Crypto, Autres.
|
||||||
|
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
|
||||||
|
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
|
||||||
|
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
|
||||||
|
('other', 'balance.category.other', 'simple', 50, 1, NULL),
|
||||||
|
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
|
||||||
|
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
|
||||||
|
|
||||||
|
-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so
|
||||||
|
-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on
|
||||||
|
-- balance_accounts) — once created they are indistinguishable from
|
||||||
|
-- user-created accounts and can be renamed/archived freely. Existing profiles
|
||||||
|
-- get the same 4 proposed via StarterAccountsModal on first /balance visit.
|
||||||
|
--
|
||||||
|
-- Bilan axe véhicule (Étape 1): the CELI / REER starters are no longer linked
|
||||||
|
-- to a `tfsa` / `rrsp` category (those seeds are gone). They now attach to the
|
||||||
|
-- `other` asset class and carry the envelope in `vehicle_type`. Linking them to
|
||||||
|
-- a deleted key would make the subselect return NULL → NOT NULL violation →
|
||||||
|
-- broken new-profile init, so the asset class MUST resolve to `other`.
|
||||||
|
INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type) VALUES
|
||||||
|
((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1, NULL),
|
||||||
|
((SELECT id FROM balance_categories WHERE key = 'other'), 'CELI', 'CAD', 1, 'tfsa'),
|
||||||
|
((SELECT id FROM balance_categories WHERE key = 'other'), 'REER', 'CAD', 1, 'rrsp'),
|
||||||
|
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1, NULL);
|
||||||
|
|
||||||
|
-- Détail par titre (migration v15 mirror): accounts under a priced category are
|
||||||
|
-- detailed-entry by default. The 4 starters above all sit under simple asset
|
||||||
|
-- classes (cash / other), so this is currently a no-op — kept at parity with the
|
||||||
|
-- v15 backfill so any future priced starter is stamped correctly.
|
||||||
|
UPDATE balance_accounts SET kind = 'detailed'
|
||||||
|
WHERE balance_category_id IN (SELECT id FROM balance_categories WHERE kind = 'priced');
|
||||||
|
|
||||||
-- 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');
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
|
||||||
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
|
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
|
||||||
|
-- Suppress StarterAccountsModal on first /balance visit for new profiles
|
||||||
|
-- (Issue #179). The 4 starter accounts are already seeded above, so the
|
||||||
|
-- modal would only show 4 collision rows with no actionable choice. Pre-
|
||||||
|
-- writing the pref skips that briefly-empty UX entirely. Suggestion S1
|
||||||
|
-- from PR #185 review (#187).
|
||||||
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_proposed', '{"shown_at":"seed","accepted":[]}');
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
|
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
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");
|
||||||
|
pub const BALANCE_HOLDINGS_SCHEMA: &str = include_str!("balance_holdings_schema.sql");
|
||||||
|
|
|
||||||
3061
src-tauri/src/lib.rs
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Simpl Resultat",
|
"productName": "Simpl Resultat",
|
||||||
"version": "0.8.4",
|
"version": "0.9.1",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"targets": ["nsis", "deb", "rpm"],
|
"targets": ["nsis", "deb", "rpm"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
|
"icons/64x64.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
|
|
|
||||||
19
src/App.tsx
|
|
@ -15,7 +15,14 @@ import ReportsTrendsPage from "./pages/ReportsTrendsPage";
|
||||||
import ReportsComparePage from "./pages/ReportsComparePage";
|
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 SettingsLayout from "./pages/settings/SettingsLayout";
|
||||||
|
import SettingsHomePage from "./pages/settings/SettingsHomePage";
|
||||||
|
import UsersSettingsPage from "./pages/settings/UsersSettingsPage";
|
||||||
|
import DataSettingsPage from "./pages/settings/DataSettingsPage";
|
||||||
|
import SystemsSettingsPage from "./pages/settings/SystemsSettingsPage";
|
||||||
|
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";
|
||||||
|
|
@ -113,7 +120,15 @@ export default function App() {
|
||||||
<Route path="/reports/compare" element={<ReportsComparePage />} />
|
<Route path="/reports/compare" element={<ReportsComparePage />} />
|
||||||
<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={<SettingsLayout />}>
|
||||||
|
<Route index element={<SettingsHomePage />} />
|
||||||
|
<Route path="users" element={<UsersSettingsPage />} />
|
||||||
|
<Route path="data" element={<DataSettingsPage />} />
|
||||||
|
<Route path="systems" element={<SystemsSettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
<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 />}
|
||||||
|
|
|
||||||
969
src/__integration__/balance-flow.test.ts
Normal file
|
|
@ -0,0 +1,969 @@
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
saveSnapshotAtomic,
|
||||||
|
deleteSnapshot,
|
||||||
|
getSnapshotTotalsByDate,
|
||||||
|
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,
|
||||||
|
asset_type: "stock",
|
||||||
|
});
|
||||||
|
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, vehicle_type] (#202)
|
||||||
|
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 5. Per-security detail (Étape 2, #217) — detailed snapshot save, rollback,
|
||||||
|
// date-move with holdings, and the golden-value aggregation invariant.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The unit-level holdings/securities mechanics are covered exhaustively in
|
||||||
|
// balance.service.test.ts (#212). Here we assert the END-TO-END service
|
||||||
|
// behaviour at the integration layer and — critically — the REGRESSION
|
||||||
|
// invariant that makes detailed accounts safe: a detailed account whose
|
||||||
|
// holdings sum to V writes an aggregated line worth exactly V, so every
|
||||||
|
// SUM(value) aggregator sees it identically to a simple account worth V.
|
||||||
|
//
|
||||||
|
// (The real-SQLite proof of that equality lives in lib.rs
|
||||||
|
// `regression_detailed_account_totals_equal_simple_account_totals`, where
|
||||||
|
// SUM() actually runs. Here we prove the TS half: the stored line value is
|
||||||
|
// the rounded-cent SUM, which is the only number the aggregator reads.)
|
||||||
|
|
||||||
|
describe("integration — detailed snapshot save end-to-end (#217)", () => {
|
||||||
|
it("writes the aggregated line + securities + holdings in ONE BEGIN/COMMIT", async () => {
|
||||||
|
// New snapshot, one detailed account: AAPL (2×50=100) + MSFT (1×200=200)
|
||||||
|
// ⇒ aggregated line value 300, two holdings, all atomic.
|
||||||
|
queueSelects([]); // dup-check: date free
|
||||||
|
// findOrCreateSecurity AAPL then MSFT lookups (post-upsert SELECT).
|
||||||
|
queueSelects([
|
||||||
|
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
||||||
|
]);
|
||||||
|
queueSelects([
|
||||||
|
{ id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
||||||
|
]);
|
||||||
|
queueExecutes(
|
||||||
|
{ rowsAffected: 0 }, // BEGIN
|
||||||
|
{ lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot
|
||||||
|
{ rowsAffected: 0 }, // DELETE lines
|
||||||
|
{ lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line
|
||||||
|
{ rowsAffected: 0 }, // DELETE holdings for line 500
|
||||||
|
{ rowsAffected: 1 }, // UPSERT security AAPL
|
||||||
|
{ rowsAffected: 1 }, // INSERT holding AAPL
|
||||||
|
{ rowsAffected: 1 }, // UPSERT security MSFT
|
||||||
|
{ rowsAffected: 1 }, // INSERT holding MSFT
|
||||||
|
{ rowsAffected: 1 }, // UPDATE updated_at
|
||||||
|
{ rowsAffected: 0 } // COMMIT
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: null,
|
||||||
|
snapshot_date: "2026-05-30",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
account_id: 7,
|
||||||
|
value: 300,
|
||||||
|
holdings: [
|
||||||
|
{ symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100, book_cost: 80 },
|
||||||
|
{ symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200, book_cost: 150 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.snapshotId).toBe(42);
|
||||||
|
|
||||||
|
const execCalls = fake.calls.filter(
|
||||||
|
(c) =>
|
||||||
|
typeof c.sql === "string" &&
|
||||||
|
!c.sql.includes("SELECT") // executes only
|
||||||
|
);
|
||||||
|
// First write is BEGIN, last is COMMIT, no ROLLBACK anywhere.
|
||||||
|
expect(execCalls[0].sql).toBe("BEGIN");
|
||||||
|
expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT");
|
||||||
|
expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false);
|
||||||
|
|
||||||
|
// CRITICAL invariant: the aggregated line is stored with NULL qty/price and
|
||||||
|
// value = rounded-cent SUM(holdings) = 300 — the exact number the
|
||||||
|
// aggregators read. This is the TS-side golden-value guarantee.
|
||||||
|
const lineInsert = fake.calls.find(
|
||||||
|
(c) =>
|
||||||
|
typeof c.sql === "string" &&
|
||||||
|
c.sql.includes("INSERT INTO balance_snapshot_lines")
|
||||||
|
);
|
||||||
|
expect(lineInsert!.params).toEqual([42, 7, 300]);
|
||||||
|
|
||||||
|
// Both holdings reference the captured line id (500).
|
||||||
|
const holdingInserts = fake.calls.filter(
|
||||||
|
(c) =>
|
||||||
|
typeof c.sql === "string" &&
|
||||||
|
c.sql.includes("INSERT INTO balance_snapshot_holdings")
|
||||||
|
);
|
||||||
|
expect(holdingInserts).toHaveLength(2);
|
||||||
|
expect(holdingInserts[0].params?.[0]).toBe(500);
|
||||||
|
expect(holdingInserts[1].params?.[0]).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ROLLS BACK the whole save when a holding INSERT fails (no partial line/holdings)", async () => {
|
||||||
|
queueSelects([]); // dup-check
|
||||||
|
queueSelects([
|
||||||
|
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
||||||
|
]);
|
||||||
|
// BEGIN, INSERT snapshot, DELETE lines, INSERT line, DELETE holdings,
|
||||||
|
// UPSERT security, then the holding INSERT REJECTS, then ROLLBACK.
|
||||||
|
fake.executeQueue.push({ rowsAffected: 0 }); // BEGIN
|
||||||
|
fake.executeQueue.push({ lastInsertId: 42, rowsAffected: 1 }); // INSERT snapshot
|
||||||
|
fake.executeQueue.push({ rowsAffected: 0 }); // DELETE lines
|
||||||
|
fake.executeQueue.push({ lastInsertId: 500, rowsAffected: 1 }); // INSERT line
|
||||||
|
fake.executeQueue.push({ rowsAffected: 0 }); // DELETE holdings
|
||||||
|
fake.executeQueue.push({ rowsAffected: 1 }); // UPSERT security
|
||||||
|
// Make the NEXT execute (the holding INSERT) reject, then allow ROLLBACK.
|
||||||
|
let failed = false;
|
||||||
|
fake.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
|
||||||
|
fake.calls.push({ sql, params });
|
||||||
|
if (!failed && sql.includes("INSERT INTO balance_snapshot_holdings")) {
|
||||||
|
failed = true;
|
||||||
|
throw new Error("holding FK violation");
|
||||||
|
}
|
||||||
|
if (fake.executeQueue.length === 0) {
|
||||||
|
return { rowsAffected: 1, lastInsertId: fake.calls.length };
|
||||||
|
}
|
||||||
|
return fake.executeQueue.shift()!;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: null,
|
||||||
|
snapshot_date: "2026-05-30",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
account_id: 7,
|
||||||
|
value: 100,
|
||||||
|
holdings: [
|
||||||
|
{ symbol: "AAPL", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).rejects.toThrow("holding FK violation");
|
||||||
|
|
||||||
|
// ROLLBACK was the last write; COMMIT never happened ⇒ no partial state.
|
||||||
|
const execCalls = fake.calls.filter(
|
||||||
|
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
|
||||||
|
);
|
||||||
|
expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK");
|
||||||
|
expect(execCalls.some((c) => c.sql === "COMMIT")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a detailed snapshot's date — line AND holdings move together (#200)", async () => {
|
||||||
|
// Edit-mode move: the date UPDATE + the line/holdings rewrite happen in the
|
||||||
|
// SAME transaction, so the holdings follow their line to the new date.
|
||||||
|
queueSelects([]); // collision check: target date free
|
||||||
|
queueSelects([
|
||||||
|
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
||||||
|
]);
|
||||||
|
queueExecutes(
|
||||||
|
{ rowsAffected: 0 }, // BEGIN
|
||||||
|
{ rowsAffected: 1 }, // UPDATE snapshot_date
|
||||||
|
{ rowsAffected: 0 }, // DELETE lines (cascades old holdings)
|
||||||
|
{ lastInsertId: 800, rowsAffected: 1 }, // INSERT aggregated line at new date
|
||||||
|
{ rowsAffected: 0 }, // DELETE holdings for line 800
|
||||||
|
{ rowsAffected: 1 }, // UPSERT security AAPL
|
||||||
|
{ rowsAffected: 1 }, // INSERT holding AAPL
|
||||||
|
{ rowsAffected: 1 }, // UPDATE updated_at
|
||||||
|
{ rowsAffected: 0 } // COMMIT
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: 5,
|
||||||
|
snapshot_date: "2026-04-15",
|
||||||
|
moveToDate: "2026-05-20",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
account_id: 7,
|
||||||
|
value: 100,
|
||||||
|
holdings: [
|
||||||
|
{ symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100, book_cost: 90 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.snapshotId).toBe(5);
|
||||||
|
|
||||||
|
// The date UPDATE precedes the line rewrite (so a collision rolls back both).
|
||||||
|
const dateUpdate = fake.calls.find(
|
||||||
|
(c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date = $1")
|
||||||
|
);
|
||||||
|
expect(dateUpdate!.params).toEqual(["2026-05-20", 5]);
|
||||||
|
|
||||||
|
// The holding is re-inserted alongside the moved line (referencing line 800).
|
||||||
|
const holdingInsert = fake.calls.find(
|
||||||
|
(c) =>
|
||||||
|
typeof c.sql === "string" &&
|
||||||
|
c.sql.includes("INSERT INTO balance_snapshot_holdings")
|
||||||
|
);
|
||||||
|
expect(holdingInsert).toBeDefined();
|
||||||
|
expect(holdingInsert!.params?.[0]).toBe(800);
|
||||||
|
|
||||||
|
// Whole thing commits; the move + holdings are one unit.
|
||||||
|
const execCalls = fake.calls.filter(
|
||||||
|
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
|
||||||
|
);
|
||||||
|
expect(execCalls[execCalls.length - 1].sql).toBe("COMMIT");
|
||||||
|
expect(execCalls.some((c) => c.sql === "ROLLBACK")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back the move (date + holdings) when the target date collides (#200)", async () => {
|
||||||
|
queueSelects([{ id: 99 }]); // collision: another snapshot already at target
|
||||||
|
queueExecutes(
|
||||||
|
{ rowsAffected: 0 }, // BEGIN
|
||||||
|
{ rowsAffected: 0 } // ROLLBACK
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: 5,
|
||||||
|
snapshot_date: "2026-04-15",
|
||||||
|
moveToDate: "2026-05-20",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
account_id: 7,
|
||||||
|
value: 100,
|
||||||
|
holdings: [
|
||||||
|
{ symbol: "AAPL", asset_type: "stock", quantity: 1, unit_price: 100, value: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: "snapshot_date_exists" });
|
||||||
|
|
||||||
|
// No date UPDATE, no holding INSERT — the collision aborted before any write.
|
||||||
|
expect(
|
||||||
|
fake.calls.some(
|
||||||
|
(c) => typeof c.sql === "string" && c.sql.includes("SET snapshot_date")
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
fake.calls.some(
|
||||||
|
(c) =>
|
||||||
|
typeof c.sql === "string" &&
|
||||||
|
c.sql.includes("INSERT INTO balance_snapshot_holdings")
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
const execCalls = fake.calls.filter(
|
||||||
|
(c) => typeof c.sql === "string" && !c.sql.includes("SELECT")
|
||||||
|
);
|
||||||
|
expect(execCalls[execCalls.length - 1].sql).toBe("ROLLBACK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("integration — golden-value invariant: detailed line feeds aggregators like a simple line (#217)", () => {
|
||||||
|
// The regression contract: whatever the holdings are, the aggregated line
|
||||||
|
// value the service writes equals the value a simple account would carry —
|
||||||
|
// so getSnapshotTotalsByDate (which only ever reads l.value) is unchanged.
|
||||||
|
// We prove BOTH halves drive identical aggregator output by feeding the
|
||||||
|
// aggregator the canned total in each case and asserting equality.
|
||||||
|
|
||||||
|
it("a detailed account worth ΣV stores the same line value as a simple account worth V", async () => {
|
||||||
|
// --- Detailed path: holdings 100 + 200 ⇒ line value must be 300. ---
|
||||||
|
queueSelects([]); // dup-check
|
||||||
|
queueSelects([
|
||||||
|
{ id: 11, symbol: "AAPL", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
||||||
|
]);
|
||||||
|
queueSelects([
|
||||||
|
{ id: 12, symbol: "MSFT", name: null, currency: "CAD", asset_type: "stock", created_at: "", updated_at: "" },
|
||||||
|
]);
|
||||||
|
queueExecutes(
|
||||||
|
{ rowsAffected: 0 }, // BEGIN
|
||||||
|
{ lastInsertId: 42, rowsAffected: 1 }, // INSERT snapshot
|
||||||
|
{ rowsAffected: 0 }, // DELETE lines
|
||||||
|
{ lastInsertId: 500, rowsAffected: 1 }, // INSERT aggregated line
|
||||||
|
{ rowsAffected: 0 }, // DELETE holdings
|
||||||
|
{ rowsAffected: 1 }, // UPSERT AAPL
|
||||||
|
{ rowsAffected: 1 }, // INSERT holding AAPL
|
||||||
|
{ rowsAffected: 1 }, // UPSERT MSFT
|
||||||
|
{ rowsAffected: 1 }, // INSERT holding MSFT
|
||||||
|
{ rowsAffected: 1 }, // UPDATE updated_at
|
||||||
|
{ rowsAffected: 0 } // COMMIT
|
||||||
|
);
|
||||||
|
await saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: null,
|
||||||
|
snapshot_date: "2026-05-30",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
account_id: 7,
|
||||||
|
value: 300,
|
||||||
|
holdings: [
|
||||||
|
{ symbol: "aapl", asset_type: "stock", quantity: 2, unit_price: 50, value: 100 },
|
||||||
|
{ symbol: "msft", asset_type: "stock", quantity: 1, unit_price: 200, value: 200 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const detailedLineInsert = fake.calls.find(
|
||||||
|
(c) =>
|
||||||
|
typeof c.sql === "string" &&
|
||||||
|
c.sql.includes("INSERT INTO balance_snapshot_lines")
|
||||||
|
);
|
||||||
|
const detailedStoredValue = detailedLineInsert!.params![2];
|
||||||
|
expect(detailedStoredValue).toBe(300);
|
||||||
|
|
||||||
|
// --- Simple path: a simple account worth 300 stores the same value. ---
|
||||||
|
fake = makeFakeDbLocal();
|
||||||
|
vi.mocked(getDb).mockResolvedValue(
|
||||||
|
{ select: fake.select, execute: fake.execute } as never
|
||||||
|
);
|
||||||
|
fake.selectQueue.push([
|
||||||
|
{ id: 5, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" },
|
||||||
|
]); // getSnapshotById
|
||||||
|
queueExecutes(
|
||||||
|
{ rowsAffected: 0 }, // BEGIN
|
||||||
|
{ rowsAffected: 0 }, // DELETE lines
|
||||||
|
{ lastInsertId: 700, rowsAffected: 1 }, // INSERT simple line
|
||||||
|
{ rowsAffected: 1 }, // UPDATE updated_at
|
||||||
|
{ rowsAffected: 0 } // COMMIT
|
||||||
|
);
|
||||||
|
await upsertSnapshotLines(5, [{ account_id: 7, value: 300 }]);
|
||||||
|
const simpleLineInsert = fake.calls.find(
|
||||||
|
(c) =>
|
||||||
|
typeof c.sql === "string" &&
|
||||||
|
c.sql.includes("INSERT INTO balance_snapshot_lines")
|
||||||
|
);
|
||||||
|
const simpleStoredValue = simpleLineInsert!.params![2];
|
||||||
|
|
||||||
|
// The frozen golden number: BOTH paths persist value = 300.
|
||||||
|
expect(detailedStoredValue).toBe(simpleStoredValue);
|
||||||
|
expect(simpleStoredValue).toBe(300);
|
||||||
|
|
||||||
|
// And the aggregator (which reads only l.value) returns the same total in
|
||||||
|
// both worlds — proven by feeding it the canned per-date SUM each path
|
||||||
|
// produces. Detailed world:
|
||||||
|
fake = makeFakeDbLocal();
|
||||||
|
vi.mocked(getDb).mockResolvedValue(
|
||||||
|
{ select: fake.select, execute: fake.execute } as never
|
||||||
|
);
|
||||||
|
fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]);
|
||||||
|
const detailedTotals = await getSnapshotTotalsByDate();
|
||||||
|
// Simple world: identical canned SUM ⇒ identical aggregator output.
|
||||||
|
fake = makeFakeDbLocal();
|
||||||
|
vi.mocked(getDb).mockResolvedValue(
|
||||||
|
{ select: fake.select, execute: fake.execute } as never
|
||||||
|
);
|
||||||
|
fake.selectQueue.push([{ snapshot_date: "2026-05-30", total: 300 }]);
|
||||||
|
const simpleTotals = await getSnapshotTotalsByDate();
|
||||||
|
expect(detailedTotals).toEqual(simpleTotals);
|
||||||
|
expect(detailedTotals).toEqual([{ snapshot_date: "2026-05-30", total: 300 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("integration — snapshot deletion cascades to holdings at the service boundary (#217)", () => {
|
||||||
|
it("deleteSnapshot issues the DELETE that the DB cascades to lines + holdings", async () => {
|
||||||
|
// The two-hop CASCADE is a DB-FK behaviour (proven in lib.rs
|
||||||
|
// regression_snapshot_delete_cascades_to_holdings). At the service layer we
|
||||||
|
// assert the single DELETE is emitted on the right row — the FK does the
|
||||||
|
// rest. Guard against a regression that would start manually deleting
|
||||||
|
// holdings (a sign the CASCADE was dropped) or skip the parent delete.
|
||||||
|
fake.selectQueue.push([
|
||||||
|
{ id: 50, snapshot_date: "2026-05-30", notes: null, created_at: "", updated_at: "" },
|
||||||
|
]); // getSnapshotById
|
||||||
|
fake.executeQueue.push({ rowsAffected: 1 });
|
||||||
|
await deleteSnapshot(50);
|
||||||
|
const deletes = fake.calls.filter(
|
||||||
|
(c) => typeof c.sql === "string" && c.sql.startsWith("DELETE")
|
||||||
|
);
|
||||||
|
// Exactly one DELETE — on balance_snapshots. No manual holdings/line wipes
|
||||||
|
// (the FK CASCADE handles those; a manual delete would be the regression).
|
||||||
|
expect(deletes).toHaveLength(1);
|
||||||
|
expect(deletes[0].sql).toContain("DELETE FROM balance_snapshots WHERE id = $1");
|
||||||
|
expect(deletes[0].params).toEqual([50]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A standalone FakeDb factory for the multi-DB golden-value test, which swaps
|
||||||
|
// the active db handle mid-test. Mirrors makeFakeDb but is reusable inline.
|
||||||
|
function makeFakeDbLocal(): 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) {
|
||||||
|
return { rowsAffected: 1, lastInsertId: db.calls.length };
|
||||||
|
}
|
||||||
|
return db.executeQueue.shift();
|
||||||
|
});
|
||||||
|
return db;
|
||||||
|
}
|
||||||
96
src/__integration__/transactions-transfer-icon.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* 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\}/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -71,7 +71,11 @@ export default function AdjustmentForm({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={form.date}
|
value={form.date}
|
||||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, date: e.target.value });
|
||||||
|
// Close native date popup on WebKitGTK (#177)
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
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)]"
|
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)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
601
src/components/balance/AccountForm.tsx
Normal file
|
|
@ -0,0 +1,601 @@
|
||||||
|
// 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,
|
||||||
|
BalanceAccountKind,
|
||||||
|
BalanceAssetType,
|
||||||
|
BalanceCategory,
|
||||||
|
BalanceCategoryKind,
|
||||||
|
BalanceVehicleType,
|
||||||
|
} from "../../shared/types";
|
||||||
|
import { BALANCE_VEHICLE_TYPES } from "../../shared/types";
|
||||||
|
import type {
|
||||||
|
CreateBalanceAccountInput,
|
||||||
|
CreateBalanceCategoryInput,
|
||||||
|
UpdateBalanceAccountInput,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Account variant types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AccountFormValues {
|
||||||
|
balance_category_id: number;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
notes: string;
|
||||||
|
/** Fiscal envelope; "" means "no envelope" (→ null in the payload). */
|
||||||
|
vehicle_type: BalanceVehicleType | "";
|
||||||
|
/**
|
||||||
|
* Entry mode (Issue #213). 'simple' = one scalar value; 'detailed' = a basket
|
||||||
|
* of per-security holdings. Drives the symbol/price-fetch gating. For a NEW
|
||||||
|
* account the category kind only *suggests* the default (priced → detailed);
|
||||||
|
* the persisted mode is set on the account itself, and the detailed→simple
|
||||||
|
* downgrade is forbidden once holdings exist (service backstop).
|
||||||
|
*/
|
||||||
|
kind: BalanceAccountKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a category's kind to the suggested default account entry mode (#213). */
|
||||||
|
function defaultKindForCategory(
|
||||||
|
category: BalanceCategory | undefined
|
||||||
|
): BalanceAccountKind {
|
||||||
|
return category?.kind === "priced" ? "detailed" : "simple";
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* User-facing label (Issue #203). Written to `custom_label`, never to
|
||||||
|
* `i18n_key` — consistent with the rename flow and the bug-I fix. The
|
||||||
|
* service derives `i18n_key` from the key for user-created categories.
|
||||||
|
*/
|
||||||
|
custom_label: string;
|
||||||
|
kind: BalanceCategoryKind;
|
||||||
|
/** Required when kind === 'priced' (Issue #169). NULL otherwise. */
|
||||||
|
asset_type: BalanceAssetType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ?? "",
|
||||||
|
vehicle_type: initial.vehicle_type ?? "",
|
||||||
|
// Editing: the account's stored entry mode is authoritative.
|
||||||
|
kind: initial.kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 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: "",
|
||||||
|
vehicle_type: "",
|
||||||
|
// New account: suggest the category-mapped default entry mode.
|
||||||
|
kind: defaultKindForCategory(first),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Symbol / price-fetch gating now keys on the ACCOUNT's entry mode (#213),
|
||||||
|
// not the category kind. `category.kind` only seeds the default below.
|
||||||
|
const isDetailed = values.kind === "detailed";
|
||||||
|
const trimmedName = values.name.trim();
|
||||||
|
const trimmedSymbol = values.symbol.trim();
|
||||||
|
const nameInvalid = touched && trimmedName.length === 0;
|
||||||
|
// Symbol is optional even for priced categories (Issue #199). It only
|
||||||
|
// gates the price-fetch button — manual valuation (quantity × unit price)
|
||||||
|
// never needs it. So no symbol-required validation here.
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setTouched(true);
|
||||||
|
if (!trimmedName) return;
|
||||||
|
|
||||||
|
// "" in the select means "no envelope" → null on the wire.
|
||||||
|
const vehicleType: BalanceVehicleType | null =
|
||||||
|
values.vehicle_type === "" ? null : values.vehicle_type;
|
||||||
|
|
||||||
|
const payload: CreateBalanceAccountInput = {
|
||||||
|
balance_category_id: values.balance_category_id,
|
||||||
|
name: trimmedName,
|
||||||
|
symbol: trimmedSymbol || null,
|
||||||
|
notes: values.notes.trim() || null,
|
||||||
|
vehicle_type: vehicleType,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
const updatePayload: UpdateBalanceAccountInput = {
|
||||||
|
balance_category_id: payload.balance_category_id,
|
||||||
|
name: payload.name,
|
||||||
|
symbol: payload.symbol,
|
||||||
|
notes: payload.notes,
|
||||||
|
vehicle_type: vehicleType,
|
||||||
|
};
|
||||||
|
// Forward the entry mode only when it actually changed (avoids a no-op
|
||||||
|
// downgrade attempt; the detailed → simple direction is service-guarded
|
||||||
|
// and the selector is locked for detailed accounts anyway). A simple →
|
||||||
|
// detailed upgrade here flags the account for per-title entry; the pivot
|
||||||
|
// date wizard (#215) will own setting `detailed_since`.
|
||||||
|
if (initialAccount && values.kind !== initialAccount.kind) {
|
||||||
|
updatePayload.kind = values.kind;
|
||||||
|
}
|
||||||
|
await onSubmit(updatePayload);
|
||||||
|
} else {
|
||||||
|
// NEW accounts persist as the DB default ('simple') —
|
||||||
|
// `CreateBalanceAccountInput` carries no `kind`. Converting a fresh
|
||||||
|
// account to detailed happens via the dedicated flow (#215); the
|
||||||
|
// selector above only previews the suggested mode + gating.
|
||||||
|
await onSubmit(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const nextId = Number(e.target.value);
|
||||||
|
const nextCat = categories.find((c) => c.id === nextId);
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
balance_category_id: nextId,
|
||||||
|
// For a NEW account, re-suggest the entry mode from the chosen
|
||||||
|
// category. When editing, the stored kind stays put.
|
||||||
|
kind: isEditing ? prev.kind : defaultKindForCategory(nextCat),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
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}>
|
||||||
|
{renderCategoryLabelFromCategory(cat, t)}
|
||||||
|
</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")}
|
||||||
|
{isDetailed && (
|
||||||
|
<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={
|
||||||
|
isDetailed
|
||||||
|
? t("balance.account.form.symbolPlaceholderPriced")
|
||||||
|
: t("balance.account.form.symbolPlaceholderSimple")
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
htmlFor="account-kind"
|
||||||
|
>
|
||||||
|
{t("balance.account.form.kind.label")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="account-kind"
|
||||||
|
value={values.kind}
|
||||||
|
// Downgrading detailed → simple is forbidden once holdings exist
|
||||||
|
// (service backstop). The form can't see holdings, so editing a
|
||||||
|
// detailed account locks the selector; conversion lives in its own
|
||||||
|
// flow (#215). A new account can pick either mode.
|
||||||
|
disabled={isEditing && initialAccount?.kind === "detailed"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
kind: e.target.value as BalanceAccountKind,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="simple">{t("balance.account.form.kind.simple")}</option>
|
||||||
|
<option value="detailed">
|
||||||
|
{t("balance.account.form.kind.detailed")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.account.form.kind.hint")}
|
||||||
|
{!isEditing && values.kind === "detailed" && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
{t("balance.account.form.kind.detailedCreateHint")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
htmlFor="account-vehicle-type"
|
||||||
|
>
|
||||||
|
{t("balance.account.form.vehicleType.label")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="account-vehicle-type"
|
||||||
|
value={values.vehicle_type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
vehicle_type: e.target.value as BalanceVehicleType | "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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="">{t("balance.account.form.vehicleType.none")}</option>
|
||||||
|
{BALANCE_VEHICLE_TYPES.map((vt) => (
|
||||||
|
<option key={vt} value={vt}>
|
||||||
|
{t(`balance.account.form.vehicleType.${vt}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.account.form.vehicleType.hint")}
|
||||||
|
</p>
|
||||||
|
</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}
|
||||||
|
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: "",
|
||||||
|
custom_label: "",
|
||||||
|
kind: "simple",
|
||||||
|
asset_type: null,
|
||||||
|
});
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
|
||||||
|
const trimmedKey = values.key.trim();
|
||||||
|
const trimmedLabel = values.custom_label.trim();
|
||||||
|
const keyInvalid = touched && trimmedKey.length === 0;
|
||||||
|
const assetTypeMissing =
|
||||||
|
touched && values.kind === "priced" && !values.asset_type;
|
||||||
|
const submitDisabled =
|
||||||
|
isSaving ||
|
||||||
|
!trimmedKey ||
|
||||||
|
(values.kind === "priced" && !values.asset_type);
|
||||||
|
|
||||||
|
const handleKindChange = (next: BalanceCategoryKind) => {
|
||||||
|
// Switching priced → simple resets asset_type so the NULL invariant for
|
||||||
|
// simple kind is preserved (the service would coerce it anyway).
|
||||||
|
setValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
kind: next,
|
||||||
|
asset_type: next === "priced" ? prev.asset_type : null,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setTouched(true);
|
||||||
|
if (!trimmedKey) return;
|
||||||
|
if (values.kind === "priced" && !values.asset_type) return;
|
||||||
|
// User-created categories have no bundled translation: anchor `i18n_key`
|
||||||
|
// on the key (stable lookup, no free text) and carry the human label in
|
||||||
|
// `custom_label` — same mechanism as renaming (Issue #203 / bug I).
|
||||||
|
await onSubmit({
|
||||||
|
key: trimmedKey,
|
||||||
|
i18n_key: `balance.category.${trimmedKey}`,
|
||||||
|
kind: values.kind,
|
||||||
|
sort_order: 100, // user-created categories sort after seeded ones
|
||||||
|
asset_type: values.kind === "priced" ? values.asset_type : null,
|
||||||
|
custom_label: trimmedLabel || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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-custom-label"
|
||||||
|
>
|
||||||
|
{t("balance.category.form.customLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="category-custom-label"
|
||||||
|
type="text"
|
||||||
|
value={values.custom_label}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({ ...values, custom_label: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("balance.category.form.customLabelPlaceholder")}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.category.form.customLabelHint")}
|
||||||
|
</p>
|
||||||
|
</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) =>
|
||||||
|
handleKindChange(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>
|
||||||
|
|
||||||
|
{values.kind === "priced" && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-medium mb-1"
|
||||||
|
htmlFor="category-asset-type"
|
||||||
|
>
|
||||||
|
{t("balance.category.assetType.label")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category-asset-type"
|
||||||
|
value={values.asset_type ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
asset_type:
|
||||||
|
e.target.value === ""
|
||||||
|
? null
|
||||||
|
: (e.target.value as BalanceAssetType),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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)] ${
|
||||||
|
assetTypeMissing
|
||||||
|
? "border-[var(--negative)]"
|
||||||
|
: "border-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option value="">{t("balance.category.assetType.required")}</option>
|
||||||
|
<option value="stock">
|
||||||
|
{t("balance.category.assetType.stock")}
|
||||||
|
</option>
|
||||||
|
<option value="crypto">
|
||||||
|
{t("balance.category.assetType.crypto")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
{assetTypeMissing && (
|
||||||
|
<p className="mt-1 text-xs text-[var(--negative)]">
|
||||||
|
{t("balance.category.assetType.required")}
|
||||||
|
</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={submitDisabled}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
750
src/components/balance/BalanceAccountsTable.tsx
Normal file
|
|
@ -0,0 +1,750 @@
|
||||||
|
// 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 { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
MoreVertical,
|
||||||
|
Link as LinkIcon,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ListTree,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type {
|
||||||
|
AccountLatestSnapshot,
|
||||||
|
AccountPeriodAnchor,
|
||||||
|
AccountUnrealizedGain,
|
||||||
|
LatentGainRollup,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
import { computeAccountReturn } from "../../services/balance.service";
|
||||||
|
import {
|
||||||
|
getPreference,
|
||||||
|
setPreference,
|
||||||
|
} from "../../services/userPreferenceService";
|
||||||
|
import type { AccountReturn } from "../../shared/types";
|
||||||
|
import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preference key persisting whether the 4 Modified-Dietz return columns are
|
||||||
|
* expanded. Absent/anything-but-"1" → collapsed (the spec default). Stored via
|
||||||
|
* `userPreferenceService` so the choice survives across sessions (Issue #204).
|
||||||
|
*/
|
||||||
|
const SHOW_RETURNS_PREF_KEY = "balance_show_returns";
|
||||||
|
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Open the "détailler en titres" wizard for a *simple* account (Issue #215).
|
||||||
|
* The action is only offered on `kind === 'simple'` rows; once detailed, the
|
||||||
|
* flip is one-way (service backstop #212) so no inverse action is exposed.
|
||||||
|
*/
|
||||||
|
onDetailAccount?: (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 unrealized (latent) gain keyed by `account_id`, prefetched by
|
||||||
|
* `useBalanceOverview` from each detailed account's latest holdings (Issue
|
||||||
|
* #216). Drives the latent-gain column + the per-security drill-down. Only
|
||||||
|
* detailed accounts with holdings appear; absent ⇒ no figure / no drill-down.
|
||||||
|
*/
|
||||||
|
latentGainByAccount?: Record<number, AccountUnrealizedGain>;
|
||||||
|
/** Latent gain rolled up by asset class / envelope, for the summary block (#216). */
|
||||||
|
latentGainRollup?: LatentGainRollup;
|
||||||
|
/** vehicle_type code (incl. 'none') → translated label, for the envelope rollup (#216). */
|
||||||
|
vehicleLabels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
onDetailAccount,
|
||||||
|
sinceCreationDate,
|
||||||
|
latentGainByAccount = {},
|
||||||
|
latentGainRollup,
|
||||||
|
vehicleLabels = {},
|
||||||
|
}: 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);
|
||||||
|
|
||||||
|
// Per-security drill-down (Issue #216): which detailed account rows are
|
||||||
|
// expanded. A Set keyed by account_id; toggled by the row's chevron. The
|
||||||
|
// holdings themselves are already prefetched in `latentGainByAccount`, so
|
||||||
|
// expanding is a pure render — no extra DB round-trip.
|
||||||
|
const [expandedFor, setExpandedFor] = useState<Set<number>>(new Set());
|
||||||
|
const toggleExpanded = (accountId: number) => {
|
||||||
|
setExpandedFor((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(accountId)) next.delete(accountId);
|
||||||
|
else next.add(accountId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Progressive disclosure of the 4 return columns (Issue #204). Collapsed by
|
||||||
|
// default; the persisted choice is read once on mount. We start `false` so
|
||||||
|
// the columns never flash open before the preference resolves.
|
||||||
|
const [showReturns, setShowReturns] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const stored = await getPreference(SHOW_RETURNS_PREF_KEY);
|
||||||
|
if (!cancelled && stored === "1") setShowReturns(true);
|
||||||
|
} catch {
|
||||||
|
// Pref read failure: keep the collapsed default.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleReturns = () => {
|
||||||
|
setShowReturns((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
// Best-effort persist; a write failure just means the next session
|
||||||
|
// falls back to the collapsed default.
|
||||||
|
void setPreference(SHOW_RETURNS_PREF_KEY, next ? "1" : "0").catch(
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns cache. Cleared whenever the account list changes (new accounts,
|
||||||
|
// archive, etc.). Loaded lazily after mount — only while the columns are
|
||||||
|
// shown, so a collapsed table never runs the Modified-Dietz computation.
|
||||||
|
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 (!showReturns || 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, showReturns]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latent (unrealized) gain cell — value + % (Issue #216). `gain`/`gainPct`
|
||||||
|
* null ⇒ the shared service guard already decided "N/A" (book_cost NULL or 0);
|
||||||
|
* render the i18n N/A string, never a divide-by-zero. `partial` flags an
|
||||||
|
* aggregate where some holdings had an unknown book_cost (% understated).
|
||||||
|
*/
|
||||||
|
function renderLatentGainCell(
|
||||||
|
gain: number | null,
|
||||||
|
gainPct: number | null,
|
||||||
|
partial = false
|
||||||
|
) {
|
||||||
|
if (gain === null) {
|
||||||
|
return (
|
||||||
|
<span className="text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.latentGain.na")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const positive = gain >= 0;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-end gap-1 ${
|
||||||
|
positive ? "text-[var(--positive)]" : "text-[var(--negative)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{positive ? "+" : ""}
|
||||||
|
{fmt.format(gain)}
|
||||||
|
</span>
|
||||||
|
{gainPct !== null && (
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)]">
|
||||||
|
({positive ? "+" : ""}
|
||||||
|
{(gainPct * 100).toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{partial && (
|
||||||
|
<AlertTriangle
|
||||||
|
size={12}
|
||||||
|
className="text-amber-500"
|
||||||
|
aria-label={t("balance.latentGain.partial")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latent-gain column only earns its place when at least one detailed
|
||||||
|
// account has a computed gain — otherwise we keep the table narrow.
|
||||||
|
const hasLatentGain = accounts.some(
|
||||||
|
(a) => latentGainByAccount[a.account_id] !== undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleReturns}
|
||||||
|
aria-expanded={showReturns}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
>
|
||||||
|
{showReturns ? (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
{t(
|
||||||
|
showReturns
|
||||||
|
? "balance.accountsTable.toggleReturns.hide"
|
||||||
|
: "balance.accountsTable.toggleReturns.show"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
{hasLatentGain && (
|
||||||
|
<th
|
||||||
|
className="text-right px-4 py-3 font-medium"
|
||||||
|
title={t("balance.latentGain.tooltip")}
|
||||||
|
>
|
||||||
|
{t("balance.latentGain.column")}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{showReturns && (
|
||||||
|
<>
|
||||||
|
<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] ?? {};
|
||||||
|
const lg = latentGainByAccount[acc.account_id];
|
||||||
|
// A detailed account is drillable once its latest holdings are
|
||||||
|
// loaded. Simple accounts (no holdings) never expand.
|
||||||
|
const drillable = !!lg && lg.holdings.length > 0;
|
||||||
|
const isExpanded = drillable && expandedFor.has(acc.account_id);
|
||||||
|
// Number of columns a drill-down sub-row must span.
|
||||||
|
const colSpan =
|
||||||
|
5 + (hasLatentGain ? 1 : 0) + (showReturns ? 4 : 0);
|
||||||
|
return (
|
||||||
|
<Fragment key={acc.account_id}>
|
||||||
|
<tr
|
||||||
|
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{drillable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpanded(acc.account_id)}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={t(
|
||||||
|
isExpanded
|
||||||
|
? "balance.latentGain.drilldown.collapse"
|
||||||
|
: "balance.latentGain.drilldown.expand"
|
||||||
|
)}
|
||||||
|
className="p-0.5 -ml-1 rounded hover:bg-[var(--muted)]/40 text-[var(--muted-foreground)]"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="inline-block w-[14px]" aria-hidden />
|
||||||
|
)}
|
||||||
|
{acc.account_name}
|
||||||
|
</span>
|
||||||
|
{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)]">
|
||||||
|
{renderCategoryLabelFromAccount(acc, t)}
|
||||||
|
</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>
|
||||||
|
{hasLatentGain && (
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{lg
|
||||||
|
? renderLatentGainCell(
|
||||||
|
lg.total_gain,
|
||||||
|
lg.total_gain_pct,
|
||||||
|
lg.has_unknown_book_cost
|
||||||
|
)
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{showReturns && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
{onDetailAccount && acc.kind === "simple" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuFor(null);
|
||||||
|
onDetailAccount(acc);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
|
||||||
|
>
|
||||||
|
<ListTree size={14} />
|
||||||
|
{t("balance.detailWizard.action")}
|
||||||
|
</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>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="bg-[var(--muted)]/5">
|
||||||
|
<td colSpan={colSpan} className="px-0 py-0">
|
||||||
|
<div className="px-4 py-2">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[var(--muted-foreground)]">
|
||||||
|
<th className="text-left font-medium py-1 pl-6">
|
||||||
|
{t("balance.latentGain.drilldown.security")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right font-medium py-1">
|
||||||
|
{t("balance.latentGain.drilldown.value")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right font-medium py-1 pr-2">
|
||||||
|
{t("balance.latentGain.drilldown.gain")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lg!.holdings.map((h) => (
|
||||||
|
<tr key={h.security_id}>
|
||||||
|
<td className="text-left py-1 pl-6 font-medium">
|
||||||
|
{h.symbol}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 tabular-nums">
|
||||||
|
{fmt.format(h.value)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-1 pr-2 tabular-nums">
|
||||||
|
{renderLatentGainCell(h.gain, h.gain_pct)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{latentGainRollup &&
|
||||||
|
(latentGainRollup.byClass.length > 0 ||
|
||||||
|
latentGainRollup.byVehicle.length > 0) && (
|
||||||
|
<LatentGainSummary
|
||||||
|
rollup={latentGainRollup}
|
||||||
|
accounts={accounts}
|
||||||
|
vehicleLabels={vehicleLabels}
|
||||||
|
fmt={fmt}
|
||||||
|
renderGain={renderLatentGainCell}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// LatentGainSummary — aggregated latent gain by asset class / envelope (#216).
|
||||||
|
// Reuses the accounts surface (no new chart): a compact two-column block of
|
||||||
|
// rollup figures. Asset-class labels resolve from the accounts payload (same
|
||||||
|
// renderCategoryLabel path the table uses); envelope labels come from the
|
||||||
|
// shared `vehicleLabels` map BalancePage already builds.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LatentGainSummaryProps {
|
||||||
|
rollup: LatentGainRollup;
|
||||||
|
accounts: AccountLatestSnapshot[];
|
||||||
|
vehicleLabels: Record<string, string>;
|
||||||
|
fmt: Intl.NumberFormat;
|
||||||
|
renderGain: (
|
||||||
|
gain: number | null,
|
||||||
|
gainPct: number | null,
|
||||||
|
partial?: boolean
|
||||||
|
) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LatentGainSummary({
|
||||||
|
rollup,
|
||||||
|
accounts,
|
||||||
|
vehicleLabels,
|
||||||
|
renderGain,
|
||||||
|
}: LatentGainSummaryProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// category_key → translated asset-class label, from the accounts payload.
|
||||||
|
const classLabels = useMemo(() => {
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
for (const a of accounts) {
|
||||||
|
if (!m[a.category_key]) {
|
||||||
|
m[a.category_key] = renderCategoryLabelFromAccount(a, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [accounts, t]);
|
||||||
|
|
||||||
|
const section = (
|
||||||
|
title: string,
|
||||||
|
buckets: LatentGainRollup["byClass"],
|
||||||
|
labelFor: (key: string) => string
|
||||||
|
) => (
|
||||||
|
<div className="flex-1 min-w-[220px]">
|
||||||
|
<p className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide mb-1">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{buckets.map((b) => (
|
||||||
|
<tr key={b.group_key} className="border-t border-[var(--border)]/60">
|
||||||
|
<td className="py-1.5 text-[var(--muted-foreground)]">
|
||||||
|
{labelFor(b.group_key)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums">
|
||||||
|
{renderGain(b.total_gain, b.total_gain_pct, b.has_unknown_book_cost)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3">
|
||||||
|
{t("balance.latentGain.summary.title")}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-6">
|
||||||
|
{section(
|
||||||
|
t("balance.latentGain.summary.byClass"),
|
||||||
|
rollup.byClass,
|
||||||
|
(key) => classLabels[key] ?? key
|
||||||
|
)}
|
||||||
|
{section(
|
||||||
|
t("balance.latentGain.summary.byVehicle"),
|
||||||
|
rollup.byVehicle,
|
||||||
|
(key) => vehicleLabels[key] ?? key
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/components/balance/BalanceEvolutionChart.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
// 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,
|
||||||
|
SnapshotVehicleBreakdownPoint,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
import type {
|
||||||
|
BalanceChartMode,
|
||||||
|
BalanceGroupAxis,
|
||||||
|
} 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;
|
||||||
|
/**
|
||||||
|
* Stacked-mode grouping axis (Issue #204). `'class'` stacks by asset class
|
||||||
|
* (the `byCategory` series), `'vehicle'` stacks by fiscal envelope (the
|
||||||
|
* `byVehicle` series). Ignored in line mode. Defaults to `'class'`.
|
||||||
|
*/
|
||||||
|
groupAxis?: BalanceGroupAxis;
|
||||||
|
totals: SnapshotTotalPoint[];
|
||||||
|
byCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
|
/** Per-vehicle breakdown for the `groupAxis === 'vehicle'` stacked variant. */
|
||||||
|
byVehicle?: SnapshotVehicleBreakdownPoint[];
|
||||||
|
/** Map category_key → translated label so the legend reads naturally. */
|
||||||
|
categoryLabels?: Record<string, string>;
|
||||||
|
/** Map vehicle_key (incl. 'none') → translated label for the vehicle axis. */
|
||||||
|
vehicleLabels?: 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,
|
||||||
|
groupAxis = "class",
|
||||||
|
totals,
|
||||||
|
byCategory,
|
||||||
|
byVehicle = [],
|
||||||
|
categoryLabels = {},
|
||||||
|
vehicleLabels = {},
|
||||||
|
transferMarkers = [],
|
||||||
|
}: BalanceEvolutionChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
// The stacked chart is driven by whichever axis is active. Both breakdowns
|
||||||
|
// share the `{ snapshot_date, <map> }` shape, so we normalize to a common
|
||||||
|
// `{ snapshot_date, series }` form here and feed a single rendering path.
|
||||||
|
const stackedSource = useMemo(
|
||||||
|
() =>
|
||||||
|
groupAxis === "vehicle"
|
||||||
|
? byVehicle.map((p) => ({
|
||||||
|
snapshot_date: p.snapshot_date,
|
||||||
|
series: p.byVehicle,
|
||||||
|
}))
|
||||||
|
: byCategory.map((p) => ({
|
||||||
|
snapshot_date: p.snapshot_date,
|
||||||
|
series: p.byCategory,
|
||||||
|
})),
|
||||||
|
[groupAxis, byCategory, byVehicle]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Label map for the active axis (legend + tooltip). Falls back to the raw
|
||||||
|
// key when no translation is provided.
|
||||||
|
const activeLabels = groupAxis === "vehicle" ? vehicleLabels : categoryLabels;
|
||||||
|
|
||||||
|
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 series key (asset class OR fiscal envelope, per groupAxis).
|
||||||
|
// Keys 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 stackedSource) {
|
||||||
|
for (const k of Object.keys(point.series)) keys.add(k);
|
||||||
|
}
|
||||||
|
const orderedKeys = Array.from(keys).sort();
|
||||||
|
const data = stackedSource.map((point) => {
|
||||||
|
const row: Record<string, string | number> = {
|
||||||
|
snapshot_date: point.snapshot_date,
|
||||||
|
};
|
||||||
|
for (const k of orderedKeys) {
|
||||||
|
row[k] = point.series[k] ?? 0;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return { stackedData: data, categoryKeys: orderedKeys };
|
||||||
|
}, [stackedSource]);
|
||||||
|
|
||||||
|
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),
|
||||||
|
activeLabels[String(name)] ?? String(name),
|
||||||
|
]}
|
||||||
|
labelFormatter={(label) => formatDate(String(label))}
|
||||||
|
contentStyle={tooltipContentStyle}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => activeLabels[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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/balance/BalanceOnboardingCard.test.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// BalanceOnboardingCard — unit tests (issue #178)
|
||||||
|
//
|
||||||
|
// NOTE: This project does not have @testing-library/react or jsdom configured
|
||||||
|
// (logged as MEDIUM in autopilot decisions-log). Tests cover the pure
|
||||||
|
// `deriveOnboardingSteps` helper that drives the visual state of each step.
|
||||||
|
// All React rendering is bypassed.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { deriveOnboardingSteps } from "./BalanceOnboardingCard";
|
||||||
|
|
||||||
|
describe("BalanceOnboardingCard — deriveOnboardingSteps", () => {
|
||||||
|
it("0 accounts, 0 snapshots → step1 active, step2 disabled", () => {
|
||||||
|
const r = deriveOnboardingSteps(0, 0);
|
||||||
|
expect(r.step1).toBe("active");
|
||||||
|
expect(r.step2).toBe("disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(">=1 account, 0 snapshots → step1 done, step2 active", () => {
|
||||||
|
const r = deriveOnboardingSteps(1, 0);
|
||||||
|
expect(r.step1).toBe("done");
|
||||||
|
expect(r.step2).toBe("active");
|
||||||
|
|
||||||
|
const r2 = deriveOnboardingSteps(5, 0);
|
||||||
|
expect(r2.step1).toBe("done");
|
||||||
|
expect(r2.step2).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(">=1 account, >=1 snapshot → both done (defensive — card normally hidden)", () => {
|
||||||
|
const r = deriveOnboardingSteps(2, 3);
|
||||||
|
expect(r.step1).toBe("done");
|
||||||
|
expect(r.step2).toBe("done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guard: 0 accounts but >=1 snapshot (anomaly) → step1 active, step2 done", () => {
|
||||||
|
// This combination should not happen in practice (a snapshot requires at
|
||||||
|
// least one account), but the helper handles it conservatively.
|
||||||
|
const r = deriveOnboardingSteps(0, 1);
|
||||||
|
expect(r.step1).toBe("active");
|
||||||
|
expect(r.step2).toBe("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
206
src/components/balance/BalanceOnboardingCard.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
// BalanceOnboardingCard — empty-state onboarding for /balance.
|
||||||
|
//
|
||||||
|
// Issue #178. Replaces the BalanceOverviewCard when the user has no accounts
|
||||||
|
// or no snapshots yet. Two vertical steps:
|
||||||
|
// 1. Create an account → /balance/accounts
|
||||||
|
// 2. Enter a snapshot → /balance/snapshot
|
||||||
|
//
|
||||||
|
// Each step has 3 states:
|
||||||
|
// - "active": primary CTA, currently the next thing to do
|
||||||
|
// - "done": marked with a checkmark, no CTA
|
||||||
|
// - "disabled": grayed out (e.g. step 2 when 0 accounts), CTA disabled
|
||||||
|
//
|
||||||
|
// The whole card is replaced by BalanceOverviewCard once at least one
|
||||||
|
// snapshot exists, so step 2 in practice is rendered as "active" or
|
||||||
|
// "disabled"; the "done" branch is supported for completeness/tests.
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface BalanceOnboardingCardProps {
|
||||||
|
/** Number of active (non-archived) accounts. */
|
||||||
|
accountsCount: number;
|
||||||
|
/** Number of snapshots saved (any date). */
|
||||||
|
snapshotsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StepState = "active" | "done" | "disabled";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper exposed for unit tests — derives the state of each onboarding
|
||||||
|
* step from the (accountsCount, snapshotsCount) pair.
|
||||||
|
*
|
||||||
|
* - Step 1 is "done" once at least one account exists, "active" otherwise.
|
||||||
|
* - Step 2 is "done" once any snapshot exists, "active" once at least one
|
||||||
|
* account exists, "disabled" otherwise. In practice the parent guard on
|
||||||
|
* /balance only renders this card when snapshotsCount === 0, so the
|
||||||
|
* "done" branch for step 2 is mostly defensive.
|
||||||
|
*/
|
||||||
|
export function deriveOnboardingSteps(
|
||||||
|
accountsCount: number,
|
||||||
|
snapshotsCount: number
|
||||||
|
): { step1: StepState; step2: StepState } {
|
||||||
|
const step1: StepState = accountsCount >= 1 ? "done" : "active";
|
||||||
|
const step2: StepState =
|
||||||
|
snapshotsCount >= 1
|
||||||
|
? "done"
|
||||||
|
: accountsCount >= 1
|
||||||
|
? "active"
|
||||||
|
: "disabled";
|
||||||
|
return { step1, step2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceOnboardingCard({
|
||||||
|
accountsCount,
|
||||||
|
snapshotsCount,
|
||||||
|
}: BalanceOnboardingCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { step1: step1State, step2: step2State } = deriveOnboardingSteps(
|
||||||
|
accountsCount,
|
||||||
|
snapshotsCount
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">
|
||||||
|
{t("balance.onboarding.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mb-5">
|
||||||
|
{t("balance.onboarding.subtitle")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="space-y-3">
|
||||||
|
<Step
|
||||||
|
number={1}
|
||||||
|
state={step1State}
|
||||||
|
icon={<Wallet size={18} />}
|
||||||
|
title={t("balance.onboarding.step1.title")}
|
||||||
|
description={t("balance.onboarding.step1.description")}
|
||||||
|
ctaLabel={t("balance.onboarding.step1.cta")}
|
||||||
|
ctaHref="/balance/accounts"
|
||||||
|
/>
|
||||||
|
<Step
|
||||||
|
number={2}
|
||||||
|
state={step2State}
|
||||||
|
icon={<FileText size={18} />}
|
||||||
|
title={t("balance.onboarding.step2.title")}
|
||||||
|
description={t("balance.onboarding.step2.description")}
|
||||||
|
ctaLabel={t("balance.onboarding.step2.cta")}
|
||||||
|
ctaHref="/balance/snapshot"
|
||||||
|
disabledHint={t("balance.onboarding.step2.disabledHint")}
|
||||||
|
/>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Internal — single step row
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StepProps {
|
||||||
|
number: number;
|
||||||
|
state: StepState;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
ctaHref: string;
|
||||||
|
disabledHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({
|
||||||
|
number,
|
||||||
|
state,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
ctaLabel,
|
||||||
|
ctaHref,
|
||||||
|
disabledHint,
|
||||||
|
}: StepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isDone = state === "done";
|
||||||
|
const isActive = state === "active";
|
||||||
|
const isDisabled = state === "disabled";
|
||||||
|
|
||||||
|
// Number bubble: green check when done, primary bg when active, muted when disabled.
|
||||||
|
const bubbleClass = isDone
|
||||||
|
? "bg-[var(--positive)] text-white"
|
||||||
|
: isActive
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--muted)] text-[var(--muted-foreground)]";
|
||||||
|
|
||||||
|
const titleClass = isDisabled
|
||||||
|
? "text-[var(--muted-foreground)]"
|
||||||
|
: "text-[var(--foreground)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-testid={`balance-onboarding-step-${number}`}
|
||||||
|
data-state={state}
|
||||||
|
className={`flex items-start gap-4 p-4 rounded-lg border ${
|
||||||
|
isDisabled
|
||||||
|
? "border-[var(--border)] opacity-60"
|
||||||
|
: "border-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${bubbleClass}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{isDone ? <Check size={16} /> : number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-[var(--muted-foreground)]" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<h3 className={`text-sm font-semibold ${titleClass}`}>{title}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
||||||
|
{isDisabled && disabledHint && (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] italic mt-1">
|
||||||
|
{disabledHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 self-center">
|
||||||
|
{isDone ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-[var(--positive)] font-medium"
|
||||||
|
data-testid={`balance-onboarding-step-${number}-done-badge`}
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
{t("balance.onboarding.doneBadge")}
|
||||||
|
</span>
|
||||||
|
) : isActive ? (
|
||||||
|
<Link
|
||||||
|
to={ctaHref}
|
||||||
|
data-testid={`balance-onboarding-step-${number}-cta`}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
data-testid={`balance-onboarding-step-${number}-cta`}
|
||||||
|
aria-disabled="true"
|
||||||
|
title={disabledHint}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] text-sm font-medium cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/components/balance/BalanceOverviewCard.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
// 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 {
|
||||||
|
LatentGainRollup,
|
||||||
|
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[];
|
||||||
|
/**
|
||||||
|
* Aggregated latent gain across all detailed accounts (Issue #216). Shown as
|
||||||
|
* a total figure alongside the net worth. Absent / no detailed account ⇒ the
|
||||||
|
* latent-gain line is hidden (no zero-noise for users without securities).
|
||||||
|
*/
|
||||||
|
latentGainRollup?: LatentGainRollup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceOverviewCard({
|
||||||
|
totals,
|
||||||
|
latentGainRollup,
|
||||||
|
}: 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",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total latent gain across detailed accounts. Only render when at least one
|
||||||
|
// detailed account contributed a bucket — otherwise the line is hidden.
|
||||||
|
const latent = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!latentGainRollup ||
|
||||||
|
(latentGainRollup.byClass.length === 0 &&
|
||||||
|
latentGainRollup.byVehicle.length === 0)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return latentGainRollup.grandTotal;
|
||||||
|
}, [latentGainRollup]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
{latent && (
|
||||||
|
<p className="text-sm mt-2 inline-flex items-center gap-1.5">
|
||||||
|
<span className="text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.latentGain.totalLabel")}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
latent.total_gain >= 0
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: "text-[var(--negative)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{latent.total_gain >= 0 ? "+" : ""}
|
||||||
|
{cadFormatter(latent.total_gain)}
|
||||||
|
{latent.total_gain_pct !== null && (
|
||||||
|
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
|
||||||
|
({latent.total_gain >= 0 ? "+" : ""}
|
||||||
|
{(latent.total_gain_pct * 100).toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{latent.has_unknown_book_cost && (
|
||||||
|
<AlertTriangle
|
||||||
|
size={12}
|
||||||
|
className="text-amber-500"
|
||||||
|
aria-label={t("balance.latentGain.partial")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/balance/DetailAccountWizard.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// DetailAccountWizard — unit tests (Issue #215).
|
||||||
|
//
|
||||||
|
// NOTE: this project has no jsdom / @testing-library harness (see
|
||||||
|
// SecurityPicker.test.ts, StarterAccountsModal.test.tsx). We exercise the pure
|
||||||
|
// toggle-payload builder — the load-bearing decision of the wizard — directly.
|
||||||
|
// DOM rendering / the confirm click are not exercised here; the wizard is thin
|
||||||
|
// orchestration over `buildDetailToggleInput` + `updateBalanceAccount`.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildDetailToggleInput } from "./DetailAccountWizard";
|
||||||
|
|
||||||
|
describe("buildDetailToggleInput", () => {
|
||||||
|
it("flips kind to detailed and pins the pivot to today's local civil day", () => {
|
||||||
|
// 2026-06-06 in the local civil day (midday avoids any DST edge confusion).
|
||||||
|
const today = new Date(2026, 5, 6, 12, 30, 0);
|
||||||
|
const input = buildDetailToggleInput(today);
|
||||||
|
expect(input).toEqual({ kind: "detailed", detailed_since: "2026-06-06" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zero-pads single-digit month and day to a YYYY-MM-DD snapshot shape", () => {
|
||||||
|
// January 2nd → "2026-01-02", matching normalizeSnapshotDate's ISO regex.
|
||||||
|
const today = new Date(2026, 0, 2, 9, 0, 0);
|
||||||
|
const input = buildDetailToggleInput(today);
|
||||||
|
expect(input.detailed_since).toBe("2026-01-02");
|
||||||
|
expect(input.kind).toBe("detailed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never emits a detailed → simple downgrade or an envelope mutation", () => {
|
||||||
|
const input = buildDetailToggleInput(new Date(2026, 11, 31, 23, 0, 0));
|
||||||
|
// Only the two pivot fields are set; vehicle_type/name/etc. stay untouched
|
||||||
|
// so updateBalanceAccount's read-and-rewrite preserves them.
|
||||||
|
expect(Object.keys(input).sort()).toEqual(["detailed_since", "kind"]);
|
||||||
|
expect(input.kind).not.toBe("simple");
|
||||||
|
});
|
||||||
|
});
|
||||||
163
src/components/balance/DetailAccountWizard.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// DetailAccountWizard — light confirmation modal that flips a *simple* balance
|
||||||
|
// account to *detailed* entry mode. Issue #215 (Bilan détail par titres #5).
|
||||||
|
//
|
||||||
|
// Decision 2026-06-04 (plan-overnight): TOGGLE-ONLY. The wizard does NOT capture
|
||||||
|
// any titles. It sets `kind='detailed'` and `detailed_since = today` (the
|
||||||
|
// authoritative pivot date) via `updateBalanceAccount`. Per-security holdings
|
||||||
|
// are entered at the NEXT normal snapshot — `validateDetailedSnapshot` tolerates
|
||||||
|
// pre-pivot aggregated lines and requires holdings only at/after the pivot.
|
||||||
|
//
|
||||||
|
// Irreversibility: once holdings exist at/after the pivot, the service backstop
|
||||||
|
// (#212) rejects a `detailed → simple` downgrade with
|
||||||
|
// `account_kind_detailed_has_holdings`. The UI exposes no "simplify" action, so
|
||||||
|
// this confirmation makes the one-way nature explicit before committing.
|
||||||
|
//
|
||||||
|
// Mirrors the LinkTransfersModal idiom (createPortal overlay, stopPropagation
|
||||||
|
// inner card, i18n-only copy, WebKitGTK-safe close button).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X, Loader2, AlertTriangle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
updateBalanceAccount,
|
||||||
|
BalanceServiceError,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
import type { UpdateBalanceAccountInput } from "../../services/balance.service";
|
||||||
|
|
||||||
|
/** Local civil-day ISO (YYYY-MM-DD) — matches stored snapshot date format. */
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure builder for the toggle payload — exported for unit testing. Produces the
|
||||||
|
* exact `updateBalanceAccount` input the wizard commits: flip to detailed and
|
||||||
|
* pin the pivot to `today`'s local civil day. `normalizeSnapshotDate` on the
|
||||||
|
* service side accepts this YYYY-MM-DD shape verbatim.
|
||||||
|
*/
|
||||||
|
export function buildDetailToggleInput(today: Date): UpdateBalanceAccountInput {
|
||||||
|
return { kind: "detailed", detailed_since: localISO(today) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailAccountWizardProps {
|
||||||
|
accountId: number;
|
||||||
|
accountName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Fired after the account was successfully flipped to detailed. */
|
||||||
|
onDetailed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailAccountWizard({
|
||||||
|
accountId,
|
||||||
|
accountName,
|
||||||
|
onClose,
|
||||||
|
onDetailed,
|
||||||
|
}: DetailAccountWizardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pivot resolved once at render: the local civil day at confirmation time.
|
||||||
|
const pivot = localISO(new Date());
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await updateBalanceAccount(accountId, buildDetailToggleInput(new Date()));
|
||||||
|
onDetailed?.();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof BalanceServiceError) {
|
||||||
|
setError(
|
||||||
|
t(`balance.detailWizard.errors.${e.code}`, {
|
||||||
|
defaultValue: e.message,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-lg flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{t("balance.detailWizard.title", { account: accountName })}
|
||||||
|
</h2>
|
||||||
|
<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-4 space-y-3 text-sm">
|
||||||
|
<p>{t("balance.detailWizard.intro")}</p>
|
||||||
|
|
||||||
|
<ul className="list-disc pl-5 space-y-1.5 text-[var(--muted-foreground)]">
|
||||||
|
<li>{t("balance.detailWizard.pointFrozen")}</li>
|
||||||
|
<li>{t("balance.detailWizard.pointNextSnapshot")}</li>
|
||||||
|
<li>{t("balance.detailWizard.pointPivot", { date: pivot })}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[var(--foreground)]">
|
||||||
|
<AlertTriangle
|
||||||
|
size={16}
|
||||||
|
className="mt-0.5 shrink-0 text-amber-500"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span>{t("balance.detailWizard.irreversible")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-[var(--negative)]">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] hover:bg-[var(--muted)]/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={submitting}
|
||||||
|
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.detailWizard.confirming")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("balance.detailWizard.confirm")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
418
src/components/balance/LinkTransfersModal.tsx
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
// 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);
|
||||||
|
// Close native date popup on WebKitGTK (#177)
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
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);
|
||||||
|
// Close native date popup on WebKitGTK (#177)
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
365
src/components/balance/PriceFetchControl.test.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
// PriceFetchControl — unit tests (issue #158)
|
||||||
|
//
|
||||||
|
// NOTE: This project does not have @testing-library/react or jsdom configured
|
||||||
|
// (logged as MEDIUM in decisions-log.md). Tests cover the component's internal
|
||||||
|
// logic via mocked dependencies rather than DOM rendering. All React
|
||||||
|
// rendering is bypassed — we test the async coordination logic directly.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — declared before imports to satisfy vi.mock hoisting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useIsPremium", () => ({
|
||||||
|
useIsPremium: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../services/balance.service", () => ({
|
||||||
|
prices: {
|
||||||
|
fetchPrice: vi.fn(),
|
||||||
|
__resetForTests: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../services/userPreferenceService", () => ({
|
||||||
|
getPreference: vi.fn(),
|
||||||
|
setPreference: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// react-i18next: return the key as-is for tests
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: vi.fn(() => ({
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => {
|
||||||
|
// Include interpolation values in the returned string for assertions
|
||||||
|
if (opts) {
|
||||||
|
return `${key}(${JSON.stringify(opts)})`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// lucide-react: return simple stubs
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
Loader2: () => null,
|
||||||
|
X: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Imports (after mock declarations)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useIsPremium } from "../../hooks/useIsPremium";
|
||||||
|
import { prices } from "../../services/balance.service";
|
||||||
|
import type { PriceResult } from "../../services/balance.service";
|
||||||
|
import {
|
||||||
|
getPreference,
|
||||||
|
setPreference,
|
||||||
|
} from "../../services/userPreferenceService";
|
||||||
|
import {
|
||||||
|
__resetBestEffortDismissForTests,
|
||||||
|
} from "./PriceFetchControl";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUseIsPremium = vi.mocked(useIsPremium);
|
||||||
|
const mockFetchPrice = vi.mocked(prices.fetchPrice);
|
||||||
|
const mockGetPreference = vi.mocked(getPreference);
|
||||||
|
const mockSetPreference = vi.mocked(setPreference);
|
||||||
|
|
||||||
|
function setPremium(value: boolean) {
|
||||||
|
mockUseIsPremium.mockReturnValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUCCESS_RESULT: PriceResult = {
|
||||||
|
ok: true,
|
||||||
|
symbol: "AAPL",
|
||||||
|
date: "2026-04-25",
|
||||||
|
price: 173.45,
|
||||||
|
currency: "USD",
|
||||||
|
source: "yahoo",
|
||||||
|
cached: false,
|
||||||
|
fetched_at: "2026-04-25T14:32:11Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERROR_RESULT_AUTH: PriceResult = {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "auth",
|
||||||
|
i18nKey: "balance.priceFetching.errors.authFailed",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERROR_RESULT_RATE_LIMIT: PriceResult = {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "rate_limit",
|
||||||
|
retry_after_s: 42,
|
||||||
|
i18nKey: "balance.priceFetching.errors.rateLimit",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: component visibility guards
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchControl — visibility guards", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetBestEffortDismissForTests();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when useIsPremium() is false (non-premium user)", () => {
|
||||||
|
// We test the guard logic directly since there's no RTL.
|
||||||
|
// The component returns null when !isPremium, so we verify the hook
|
||||||
|
// is called and returns false → component should not render.
|
||||||
|
setPremium(false);
|
||||||
|
const isPremium = useIsPremium();
|
||||||
|
expect(isPremium).toBe(false);
|
||||||
|
// Guard: if (!isPremium || categoryKind !== 'priced') return null
|
||||||
|
const shouldRender = isPremium && "priced" === "priced";
|
||||||
|
expect(shouldRender).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when categoryKind is not 'priced'", () => {
|
||||||
|
setPremium(true);
|
||||||
|
const isPremium = useIsPremium();
|
||||||
|
const categoryKind: string = "simple";
|
||||||
|
const shouldRender = isPremium && categoryKind === "priced";
|
||||||
|
expect(shouldRender).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders (not null) when premium and categoryKind is 'priced'", () => {
|
||||||
|
setPremium(true);
|
||||||
|
const isPremium = useIsPremium();
|
||||||
|
const categoryKind = "priced";
|
||||||
|
const shouldRender = isPremium && categoryKind === "priced";
|
||||||
|
expect(shouldRender).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: best-effort warning session state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchControl — best-effort warning (stock vs crypto)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetBestEffortDismissForTests();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
setPremium(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("best-effort warning flag starts undismissed after reset", () => {
|
||||||
|
// The module-level flag is false after __resetBestEffortDismissForTests
|
||||||
|
// The component initialises showBestEffortWarning = assetType === 'stock' && !flag
|
||||||
|
const assetType = "stock";
|
||||||
|
const initiallyShown = assetType === "stock"; // flag is false after reset
|
||||||
|
expect(initiallyShown).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no best-effort warning for crypto categories", () => {
|
||||||
|
const assetType: string = "crypto";
|
||||||
|
const wouldShow = assetType === "stock";
|
||||||
|
expect(wouldShow).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("best-effort warning is not shown for crypto even if stock was dismissed", () => {
|
||||||
|
// Simulate dismiss for stock
|
||||||
|
__resetBestEffortDismissForTests();
|
||||||
|
const assetTypeCrypto: string = "crypto";
|
||||||
|
const wouldShow = assetTypeCrypto === "stock";
|
||||||
|
expect(wouldShow).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: consent flow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchControl — consent modal flow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetBestEffortDismissForTests();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
setPremium(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first click with no consent: getPreference returns null → consent required", async () => {
|
||||||
|
mockGetPreference.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const consented = await getPreference("price_fetching_consent");
|
||||||
|
expect(consented).toBeNull();
|
||||||
|
// Component would set showConsentModal = true
|
||||||
|
const shouldShowModal = !consented;
|
||||||
|
expect(shouldShowModal).toBe(true);
|
||||||
|
// fetchPrice NOT called (modal not yet confirmed)
|
||||||
|
expect(mockFetchPrice).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accept consent: setPreference called with correct key and JSON shape, then fetch runs", async () => {
|
||||||
|
mockGetPreference.mockResolvedValueOnce(null);
|
||||||
|
mockSetPreference.mockResolvedValueOnce(undefined);
|
||||||
|
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
|
||||||
|
|
||||||
|
// Simulate handleConsentAccept: write consent, then fetch
|
||||||
|
await setPreference(
|
||||||
|
"price_fetching_consent",
|
||||||
|
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
|
||||||
|
);
|
||||||
|
expect(mockSetPreference).toHaveBeenCalledOnce();
|
||||||
|
const [key, value] = mockSetPreference.mock.calls[0];
|
||||||
|
expect(key).toBe("price_fetching_consent");
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
expect(parsed.version).toBe(1);
|
||||||
|
expect(typeof parsed.consented_at).toBe("string");
|
||||||
|
|
||||||
|
// Then fetch is called
|
||||||
|
await prices.fetchPrice("AAPL", "2026-04-25");
|
||||||
|
expect(mockFetchPrice).toHaveBeenCalledWith("AAPL", "2026-04-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decline consent: setPreference NOT called, fetchPrice NOT called", async () => {
|
||||||
|
mockGetPreference.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
// handleConsentDecline just closes modal — no writes, no fetch
|
||||||
|
// Simulate: user clicked decline → no calls
|
||||||
|
expect(mockSetPreference).not.toHaveBeenCalled();
|
||||||
|
expect(mockFetchPrice).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("second click with consent already stored: no modal, fetch runs immediately", async () => {
|
||||||
|
mockGetPreference.mockResolvedValueOnce(
|
||||||
|
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
|
||||||
|
);
|
||||||
|
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
|
||||||
|
|
||||||
|
const consented = await getPreference("price_fetching_consent");
|
||||||
|
expect(!!consented).toBe(true);
|
||||||
|
// No modal needed → fetch immediately
|
||||||
|
const result = await prices.fetchPrice("AAPL", "2026-04-25");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(mockFetchPrice).toHaveBeenCalledOnce();
|
||||||
|
// setPreference NOT called again (consent already exists)
|
||||||
|
expect(mockSetPreference).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: fetch success path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchControl — fetch success", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetBestEffortDismissForTests();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
setPremium(true);
|
||||||
|
mockGetPreference.mockResolvedValue(
|
||||||
|
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on success: onPriceFetched called with price and currency", async () => {
|
||||||
|
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
|
||||||
|
const onPriceFetched = vi.fn();
|
||||||
|
|
||||||
|
const result = await prices.fetchPrice("AAPL", "2026-04-25");
|
||||||
|
if (result.ok) {
|
||||||
|
onPriceFetched(result.price, result.currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(onPriceFetched).toHaveBeenCalledWith(173.45, "USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on success: attribution uses fetched_at as locale date string", () => {
|
||||||
|
const fetchedAt = new Date("2026-04-25T14:32:11Z");
|
||||||
|
const formattedDate = fetchedAt.toLocaleDateString("fr-CA");
|
||||||
|
expect(typeof formattedDate).toBe("string");
|
||||||
|
expect(formattedDate.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: error paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchControl — error paths", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetBestEffortDismissForTests();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
setPremium(true);
|
||||||
|
mockGetPreference.mockResolvedValue(
|
||||||
|
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on auth error: error.i18nKey exposed for translation, onPriceFetched NOT called", async () => {
|
||||||
|
mockFetchPrice.mockResolvedValueOnce(ERROR_RESULT_AUTH);
|
||||||
|
const onPriceFetched = vi.fn();
|
||||||
|
|
||||||
|
const result = await prices.fetchPrice("AAPL", "2026-04-25");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.i18nKey).toBe("balance.priceFetching.errors.authFailed");
|
||||||
|
}
|
||||||
|
expect(onPriceFetched).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on rate_limit error: retry_after_s exposed for interpolation, onPriceFetched NOT called", async () => {
|
||||||
|
mockFetchPrice.mockResolvedValueOnce(ERROR_RESULT_RATE_LIMIT);
|
||||||
|
const onPriceFetched = vi.fn();
|
||||||
|
|
||||||
|
const result = await prices.fetchPrice("AAPL", "2026-04-25");
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error.code).toBe("rate_limit");
|
||||||
|
expect(result.error.i18nKey).toBe("balance.priceFetching.errors.rateLimit");
|
||||||
|
if ("retry_after_s" in result.error) {
|
||||||
|
expect(result.error.retry_after_s).toBe(42);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(onPriceFetched).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on error: manual input is not disabled — the component never controls it", () => {
|
||||||
|
// PriceFetchControl is purely additive — it never disables the unit_price input.
|
||||||
|
// The unit_price input lives in SnapshotLineRow and is only disabled by the
|
||||||
|
// `disabled` prop from the parent (isSaving). This test documents the contract.
|
||||||
|
const componentControlsUnitPriceDisabled = false;
|
||||||
|
expect(componentControlsUnitPriceDisabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: fetchPrice is called with correct symbol and date args
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchControl — fetchPrice invocation args", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
__resetBestEffortDismissForTests();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
setPremium(true);
|
||||||
|
mockGetPreference.mockResolvedValue(
|
||||||
|
JSON.stringify({ consented_at: "2026-04-26T08:00:00Z", version: 1 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetchPrice called once with correct symbol and date after consent confirmed", async () => {
|
||||||
|
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
|
||||||
|
|
||||||
|
// Simulate the fetch sequence (consent exists → direct fetch)
|
||||||
|
await prices.fetchPrice("BTC", "2026-04-26");
|
||||||
|
|
||||||
|
expect(mockFetchPrice).toHaveBeenCalledOnce();
|
||||||
|
expect(mockFetchPrice).toHaveBeenCalledWith("BTC", "2026-04-26");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetchPrice not called when consent is declined", async () => {
|
||||||
|
mockGetPreference.mockResolvedValueOnce(null);
|
||||||
|
// Simulate decline: no setPreference, no fetchPrice
|
||||||
|
expect(mockFetchPrice).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetPreference).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
287
src/components/balance/PriceFetchControl.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
// PriceFetchControl — fetch-price button with consent modal, spinner,
|
||||||
|
// best-effort warning (stocks only), and attribution display.
|
||||||
|
//
|
||||||
|
// Issue #158 — wires into SnapshotLineRow for priced-kind categories.
|
||||||
|
//
|
||||||
|
// Behavior rules (from spec §1 + ADR 0011):
|
||||||
|
// - Hidden when useIsPremium() === false OR categoryKind !== 'priced'
|
||||||
|
// - First use requires explicit consent (persisted in user_preferences)
|
||||||
|
// - For stock assetType: shows a "best-effort" badge + dismissable warning
|
||||||
|
// (once per session, in-memory only — NOT persisted)
|
||||||
|
// - Manual unit_price input stays active in all error paths (this component
|
||||||
|
// is purely additive)
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Loader2, X } from "lucide-react";
|
||||||
|
import { useIsPremium } from "../../hooks/useIsPremium";
|
||||||
|
import { prices } from "../../services/balance.service";
|
||||||
|
import type { PriceError } from "../../services/balance.service";
|
||||||
|
import {
|
||||||
|
getPreference,
|
||||||
|
setPreference,
|
||||||
|
} from "../../services/userPreferenceService";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Module-level session dismiss state for best-effort warning (ADR 0011 §garde-fous)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let _bestEffortDismissedThisSession = false;
|
||||||
|
|
||||||
|
// Exported for tests — resets the in-memory dismiss flag.
|
||||||
|
export function __resetBestEffortDismissForTests(): void {
|
||||||
|
_bestEffortDismissedThisSession = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consent preference key (per-profile via per-profile SQLite DB).
|
||||||
|
const CONSENT_KEY = "price_fetching_consent";
|
||||||
|
|
||||||
|
interface PriceFetchControlProps {
|
||||||
|
symbol: string;
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
categoryKind: "simple" | "priced";
|
||||||
|
assetType: "stock" | "crypto";
|
||||||
|
onPriceFetched: (price: number, currency: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the user has already given consent for price fetching.
|
||||||
|
* Returns true when a non-empty consent record exists in user_preferences.
|
||||||
|
*/
|
||||||
|
async function hasConsent(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const raw = await getPreference(CONSENT_KEY);
|
||||||
|
return !!raw;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist consent (consented_at + version shape). */
|
||||||
|
async function writeConsent(): Promise<void> {
|
||||||
|
await setPreference(
|
||||||
|
CONSENT_KEY,
|
||||||
|
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PriceFetchControl({
|
||||||
|
symbol,
|
||||||
|
date,
|
||||||
|
categoryKind,
|
||||||
|
assetType,
|
||||||
|
onPriceFetched,
|
||||||
|
}: PriceFetchControlProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const isPremium = useIsPremium();
|
||||||
|
|
||||||
|
// Local UI state
|
||||||
|
const [showConsentModal, setShowConsentModal] = useState(false);
|
||||||
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
const [error, setError] = useState<PriceError | null>(null);
|
||||||
|
const [attribution, setAttribution] = useState<string | null>(null);
|
||||||
|
// Whether the best-effort warning is currently shown (stock only).
|
||||||
|
const [showBestEffortWarning, setShowBestEffortWarning] = useState(
|
||||||
|
assetType === "stock" && !_bestEffortDismissedThisSession
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the warning display in sync when the session-level flag is updated
|
||||||
|
// from a sibling instance (e.g. multiple priced rows dismiss in sequence).
|
||||||
|
useEffect(() => {
|
||||||
|
if (assetType === "stock") {
|
||||||
|
setShowBestEffortWarning(!_bestEffortDismissedThisSession);
|
||||||
|
}
|
||||||
|
}, [assetType]);
|
||||||
|
|
||||||
|
// Hidden for non-premium users or non-priced categories.
|
||||||
|
if (!isPremium || categoryKind !== "priced") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissBestEffortWarning = () => {
|
||||||
|
_bestEffortDismissedThisSession = true;
|
||||||
|
setShowBestEffortWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Actually trigger the price fetch (called after consent is confirmed). */
|
||||||
|
const doFetch = async () => {
|
||||||
|
setIsFetching(true);
|
||||||
|
setError(null);
|
||||||
|
setAttribution(null);
|
||||||
|
|
||||||
|
const result = await prices.fetchPrice(symbol, date);
|
||||||
|
|
||||||
|
setIsFetching(false);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
onPriceFetched(result.price, result.currency);
|
||||||
|
// Show attribution with the fetched_at timestamp formatted to locale date.
|
||||||
|
const fetchedAt = new Date(result.fetched_at);
|
||||||
|
const formattedDate = fetchedAt.toLocaleDateString(
|
||||||
|
i18n.language === "fr" ? "fr-CA" : "en-CA"
|
||||||
|
);
|
||||||
|
setAttribution(t("balance.priceFetching.attribution", { date: formattedDate }));
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Handle the main button click: check consent, then fetch or show modal. */
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (isFetching) return;
|
||||||
|
setError(null);
|
||||||
|
setAttribution(null);
|
||||||
|
|
||||||
|
const consented = await hasConsent();
|
||||||
|
if (!consented) {
|
||||||
|
setShowConsentModal(true);
|
||||||
|
} else {
|
||||||
|
await doFetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** User accepted in the consent modal. */
|
||||||
|
const handleConsentAccept = async () => {
|
||||||
|
setShowConsentModal(false);
|
||||||
|
try {
|
||||||
|
await writeConsent();
|
||||||
|
} catch {
|
||||||
|
// Non-blocking — proceed with fetch even if pref write failed.
|
||||||
|
}
|
||||||
|
await doFetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** User declined in the consent modal. */
|
||||||
|
const handleConsentDecline = () => {
|
||||||
|
setShowConsentModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the error i18n args.
|
||||||
|
const errorMessage = error
|
||||||
|
? t(error.i18nKey, {
|
||||||
|
seconds:
|
||||||
|
"retry_after_s" in error ? Math.ceil(error.retry_after_s) : undefined,
|
||||||
|
minutes:
|
||||||
|
"retry_after_s" in error
|
||||||
|
? Math.ceil(error.retry_after_s / 60)
|
||||||
|
: undefined,
|
||||||
|
defaultValue: error.i18nKey,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{/* Stock best-effort warning — shown once per session, dismissable */}
|
||||||
|
{assetType === "stock" && showBestEffortWarning && (
|
||||||
|
<div className="flex items-start gap-1 text-[10px] text-[var(--muted-foreground)] bg-[var(--muted)]/60 rounded px-2 py-1">
|
||||||
|
<span className="flex-1">{t("balance.priceFetching.bestEffortNotice")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
onClick={dismissBestEffortWarning}
|
||||||
|
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Fetch button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-[var(--border)] text-xs font-medium text-[var(--foreground)] bg-[var(--card)] hover:bg-[var(--muted)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
aria-label={t("balance.priceFetching.button")}
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<Loader2 size={12} className="animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t("balance.priceFetching.button")}
|
||||||
|
{/* Best-effort badge (stock only) */}
|
||||||
|
{assetType === "stock" && (
|
||||||
|
<span className="ml-0.5 text-[9px] uppercase tracking-wide px-1 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
best-effort
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Attribution line — shown after a successful fetch */}
|
||||||
|
{attribution && !isFetching && (
|
||||||
|
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||||
|
{attribution}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline error message */}
|
||||||
|
{errorMessage && !isFetching && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="text-xs text-[var(--negative)] mt-0.5"
|
||||||
|
data-testid="price-fetch-error"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Consent modal — rendered inline, portaled via fixed positioning */}
|
||||||
|
{showConsentModal && (
|
||||||
|
<ConsentModal
|
||||||
|
onAccept={handleConsentAccept}
|
||||||
|
onDecline={handleConsentDecline}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConsentModal — minimal overlay, no external modal lib required
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ConsentModal({
|
||||||
|
onAccept,
|
||||||
|
onDecline,
|
||||||
|
}: {
|
||||||
|
onAccept: () => void;
|
||||||
|
onDecline: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="price-consent-title"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full p-6">
|
||||||
|
<h2
|
||||||
|
id="price-consent-title"
|
||||||
|
className="text-base font-semibold mb-2"
|
||||||
|
>
|
||||||
|
{t("balance.priceFetching.consent.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mb-5">
|
||||||
|
{t("balance.priceFetching.consent.body")}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDecline}
|
||||||
|
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{t("balance.priceFetching.consent.decline")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAccept}
|
||||||
|
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("balance.priceFetching.consent.accept")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/balance/SecurityPicker.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
// SecurityPicker — unit tests (Issue #214).
|
||||||
|
//
|
||||||
|
// NOTE: this project has no jsdom / @testing-library harness (logged across
|
||||||
|
// the balance UI work). We test the picker's pure decision logic — the
|
||||||
|
// filter and the create-vs-pick rule — directly, the same way
|
||||||
|
// CategoryCombobox.test.ts tests `sortHierarchical`. DOM rendering is not
|
||||||
|
// exercised here.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { filterSecurities, decideCreateOption } from "./SecurityPicker";
|
||||||
|
import type { BalanceSecurity } from "../../shared/types";
|
||||||
|
|
||||||
|
function sec(
|
||||||
|
id: number,
|
||||||
|
symbol: string,
|
||||||
|
name: string | null,
|
||||||
|
asset_type: "stock" | "crypto" = "stock"
|
||||||
|
): BalanceSecurity {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
symbol,
|
||||||
|
name,
|
||||||
|
currency: "CAD",
|
||||||
|
asset_type,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogue: BalanceSecurity[] = [
|
||||||
|
sec(1, "AAPL", "Apple Inc."),
|
||||||
|
sec(2, "BTC", "Bitcoin", "crypto"),
|
||||||
|
sec(3, "ETH", "Ethereum", "crypto"),
|
||||||
|
sec(4, "MSFT", "Microsoft Corp."),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("filterSecurities", () => {
|
||||||
|
it("returns the whole catalogue for an empty / whitespace query", () => {
|
||||||
|
expect(filterSecurities(catalogue, "")).toHaveLength(4);
|
||||||
|
expect(filterSecurities(catalogue, " ")).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches on symbol, case-insensitively", () => {
|
||||||
|
const r = filterSecurities(catalogue, "aapl");
|
||||||
|
expect(r.map((s) => s.symbol)).toEqual(["AAPL"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches on name, case-insensitively", () => {
|
||||||
|
const r = filterSecurities(catalogue, "micro");
|
||||||
|
expect(r.map((s) => s.symbol)).toEqual(["MSFT"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a partial substring across multiple rows", () => {
|
||||||
|
// "et" hits ETH's symbol and nothing else here.
|
||||||
|
const r = filterSecurities(catalogue, "eth");
|
||||||
|
expect(r.map((s) => s.symbol)).toEqual(["ETH"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the catalogue order of matches", () => {
|
||||||
|
const r = filterSecurities(catalogue, "t"); // BTC, ETH, MSFT all contain 't'
|
||||||
|
expect(r.map((s) => s.symbol)).toEqual(["BTC", "ETH", "MSFT"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] when nothing matches", () => {
|
||||||
|
expect(filterSecurities(catalogue, "ZZZZ")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decideCreateOption", () => {
|
||||||
|
it("returns null for an empty / whitespace query (nothing to create)", () => {
|
||||||
|
expect(decideCreateOption(catalogue, "")).toBeNull();
|
||||||
|
expect(decideCreateOption(catalogue, " ")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a normalized (UPPER/TRIM) create for a brand-new symbol", () => {
|
||||||
|
expect(decideCreateOption(catalogue, " tsla ")).toEqual({ symbol: "TSLA" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the normalized symbol already exists (exact match)", () => {
|
||||||
|
// Same symbol, different casing/whitespace — already in the catalogue, so
|
||||||
|
// there is nothing new to create.
|
||||||
|
expect(decideCreateOption(catalogue, "aapl")).toBeNull();
|
||||||
|
expect(decideCreateOption(catalogue, " AAPL ")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still offers create when the query only PARTIALLY matches an existing symbol", () => {
|
||||||
|
// "AAP" is a prefix of AAPL but not an exact symbol — the user may want a
|
||||||
|
// distinct ticker, so the create option is offered.
|
||||||
|
expect(decideCreateOption(catalogue, "AAP")).toEqual({ symbol: "AAP" });
|
||||||
|
});
|
||||||
|
});
|
||||||
388
src/components/balance/SecurityPicker.tsx
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
// SecurityPicker — autocomplete over the existing `balance_securities` catalogue
|
||||||
|
// with inline creation (Issue #214 / Bilan détail par titre).
|
||||||
|
//
|
||||||
|
// Behavior (decisions logged in the autopilot run of 2026-06-04 / 2026-06-06):
|
||||||
|
// - The input accepts ANY normalized string (UPPER + TRIM). There is NO live
|
||||||
|
// symbol validation — the price fetch (PriceFetchControl) is a separate,
|
||||||
|
// best-effort step. A symbol the user types but that does not exist in the
|
||||||
|
// catalogue is offered as an inline "create" option.
|
||||||
|
// - Picking an existing security emits its stored `symbol` + `asset_type`
|
||||||
|
// (+ optional `name`). Creating a new symbol emits the typed (normalized)
|
||||||
|
// symbol + the asset_type chosen via the stock/crypto toggle (default
|
||||||
|
// 'stock', matching `makeEmptyHolding`).
|
||||||
|
// - The symbol format mirrors the price-fetching one: `normalizeSecuritySymbol`
|
||||||
|
// (UPPER(TRIM(...))), the exact function the service + migrations use, so a
|
||||||
|
// picker-created security collapses onto the same `balance_securities` row.
|
||||||
|
//
|
||||||
|
// The UI idiom follows `CategoryCombobox` (controlled input + listbox, keyboard
|
||||||
|
// nav, click-outside close) so the Bilan editor stays visually consistent.
|
||||||
|
//
|
||||||
|
// This component is presentation + selection only. It receives the catalogue
|
||||||
|
// (the parent loads it once via `listSecurities()`), the current row symbol,
|
||||||
|
// and emits a `SecurityPick` on choose/create. Persisting a brand-new security
|
||||||
|
// happens server-side at save time (`findOrCreateSecurity` inside the atomic
|
||||||
|
// save), so no DB write happens here.
|
||||||
|
|
||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { BalanceAssetType, BalanceSecurity } from "../../shared/types";
|
||||||
|
import { normalizeSecuritySymbol } from "../../services/balance.service";
|
||||||
|
|
||||||
|
/** What the picker emits when the user selects or creates a security. */
|
||||||
|
export interface SecurityPick {
|
||||||
|
/** Normalized (UPPER/TRIM) symbol. */
|
||||||
|
symbol: string;
|
||||||
|
asset_type: BalanceAssetType;
|
||||||
|
/** Existing security's name, or null for a freshly-created symbol. */
|
||||||
|
name: string | null;
|
||||||
|
/** True when the symbol is not (yet) in the catalogue — created inline. */
|
||||||
|
isNew: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecurityPickerProps {
|
||||||
|
/** The full securities catalogue (loaded once by the parent). */
|
||||||
|
securities: BalanceSecurity[];
|
||||||
|
/** Currently selected symbol on this row (controlled). */
|
||||||
|
value: string;
|
||||||
|
/** Asset type currently on the row — seeds the create toggle default. */
|
||||||
|
assetType: BalanceAssetType;
|
||||||
|
onSelect: (pick: SecurityPick) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers (exported for unit tests — the project has no jsdom harness, so
|
||||||
|
// component logic is tested through these rather than via DOM rendering).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter the catalogue by a raw query. Matching is case-insensitive over both
|
||||||
|
* the symbol and the (optional) name. An empty query returns the whole list.
|
||||||
|
* The catalogue is assumed already symbol-sorted (listSecurities orders by
|
||||||
|
* symbol); we preserve that order.
|
||||||
|
*/
|
||||||
|
export function filterSecurities(
|
||||||
|
securities: BalanceSecurity[],
|
||||||
|
query: string
|
||||||
|
): BalanceSecurity[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q.length === 0) return securities;
|
||||||
|
return securities.filter((s) => {
|
||||||
|
if (s.symbol.toLowerCase().includes(q)) return true;
|
||||||
|
if (s.name && s.name.toLowerCase().includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide, for a typed query, whether an inline "create" option should be
|
||||||
|
* offered and what it would create. Returns null when the query is empty or
|
||||||
|
* when an EXACT (normalized) symbol match already exists in the catalogue —
|
||||||
|
* in that case there is nothing new to create. The create symbol is the
|
||||||
|
* normalized form so it round-trips to the same `balance_securities` row.
|
||||||
|
*/
|
||||||
|
export function decideCreateOption(
|
||||||
|
securities: BalanceSecurity[],
|
||||||
|
query: string
|
||||||
|
): { symbol: string } | null {
|
||||||
|
const normalized = normalizeSecuritySymbol(query);
|
||||||
|
if (normalized.length === 0) return null;
|
||||||
|
const exact = securities.some(
|
||||||
|
(s) => normalizeSecuritySymbol(s.symbol) === normalized
|
||||||
|
);
|
||||||
|
if (exact) return null;
|
||||||
|
return { symbol: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SecurityPicker({
|
||||||
|
securities,
|
||||||
|
value,
|
||||||
|
assetType,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
ariaLabel,
|
||||||
|
placeholder,
|
||||||
|
}: SecurityPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
// Asset type to use when CREATING a new symbol. Seeded from the row's current
|
||||||
|
// asset type (default 'stock' via makeEmptyHolding); the user can flip it.
|
||||||
|
const [createAssetType, setCreateAssetType] =
|
||||||
|
useState<BalanceAssetType>(assetType);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const baseId = useId();
|
||||||
|
const listboxId = `${baseId}-listbox`;
|
||||||
|
const optionId = (i: number) => `${baseId}-option-${i}`;
|
||||||
|
|
||||||
|
// Keep the create-toggle default in sync if the row's asset type changes
|
||||||
|
// externally (e.g. a different security was picked then cleared).
|
||||||
|
useEffect(() => {
|
||||||
|
setCreateAssetType(assetType);
|
||||||
|
}, [assetType]);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => filterSecurities(securities, query),
|
||||||
|
[securities, query]
|
||||||
|
);
|
||||||
|
const createOption = useMemo(
|
||||||
|
() => decideCreateOption(securities, query),
|
||||||
|
[securities, query]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Layout: [existing matches...] then (optionally) the create row last.
|
||||||
|
const totalItems = filtered.length + (createOption ? 1 : 0);
|
||||||
|
const createIndex = createOption ? filtered.length : -1;
|
||||||
|
|
||||||
|
// The text shown in the input when closed: the selected symbol verbatim.
|
||||||
|
const displayLabel = value;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && listRef.current) {
|
||||||
|
const el = listRef.current.children[highlightIndex] as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined;
|
||||||
|
el?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}, [highlightIndex, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const choosePick = useCallback(
|
||||||
|
(pick: SecurityPick) => {
|
||||||
|
onSelect(pick);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.blur();
|
||||||
|
},
|
||||||
|
[onSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === createIndex && createOption) {
|
||||||
|
choosePick({
|
||||||
|
symbol: createOption.symbol,
|
||||||
|
asset_type: createAssetType,
|
||||||
|
name: null,
|
||||||
|
isNew: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sec = filtered[index];
|
||||||
|
if (sec) {
|
||||||
|
choosePick({
|
||||||
|
symbol: sec.symbol,
|
||||||
|
asset_type: sec.asset_type,
|
||||||
|
name: sec.name,
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filtered, createIndex, createOption, createAssetType, choosePick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!open) {
|
||||||
|
if (e.key === "ArrowDown" || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(true);
|
||||||
|
setHighlightIndex(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
if (totalItems > 0)
|
||||||
|
setHighlightIndex((i) => (i + 1) % totalItems);
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
if (totalItems > 0)
|
||||||
|
setHighlightIndex((i) => (i - 1 + totalItems) % totalItems);
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault();
|
||||||
|
if (totalItems > 0) selectItem(highlightIndex);
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.blur();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeId =
|
||||||
|
open && totalItems > 0 ? optionId(highlightIndex) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-40">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-label={ariaLabel ?? t("balance.snapshot.detailed.symbolLabel")}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listboxId}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-activedescendant={activeId}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={disabled}
|
||||||
|
value={open ? query : displayLabel}
|
||||||
|
placeholder={
|
||||||
|
placeholder ?? t("balance.snapshot.detailed.picker.placeholder")
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setHighlightIndex(0);
|
||||||
|
if (!open) setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setQuery("");
|
||||||
|
setHighlightIndex(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-50 mt-1 w-72 rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg overflow-hidden">
|
||||||
|
{/* Asset-type toggle for the create option (stock / crypto). Only
|
||||||
|
meaningful when a new symbol would be created; shown alongside it
|
||||||
|
so the user sets the class before committing the create. */}
|
||||||
|
{createOption && (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-[var(--border)] bg-[var(--muted)]/40">
|
||||||
|
<span className="text-[11px] text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.snapshot.detailed.picker.assetTypeLabel")}
|
||||||
|
</span>
|
||||||
|
<div className="flex rounded-md overflow-hidden border border-[var(--border)]">
|
||||||
|
{(["stock", "crypto"] as const).map((at) => (
|
||||||
|
<button
|
||||||
|
key={at}
|
||||||
|
type="button"
|
||||||
|
// Prevent the input blur (mousedown) from closing the list.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setCreateAssetType(at)}
|
||||||
|
className={`px-2 py-0.5 text-[11px] ${
|
||||||
|
createAssetType === at
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
aria-pressed={createAssetType === at}
|
||||||
|
>
|
||||||
|
{t(`balance.snapshot.detailed.picker.assetType.${at}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalItems === 0 ? (
|
||||||
|
<p className="px-3 py-2 text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.snapshot.detailed.picker.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
ref={listRef}
|
||||||
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
className="max-h-56 overflow-auto"
|
||||||
|
>
|
||||||
|
{filtered.map((sec, i) => (
|
||||||
|
<li
|
||||||
|
key={sec.id}
|
||||||
|
id={optionId(i)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlightIndex}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => selectItem(i)}
|
||||||
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
|
className={`flex items-center justify-between gap-2 px-3 py-1.5 text-sm cursor-pointer ${
|
||||||
|
i === highlightIndex
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="font-medium">{sec.symbol}</span>
|
||||||
|
{sec.name && (
|
||||||
|
<span
|
||||||
|
className={`truncate text-xs ${
|
||||||
|
i === highlightIndex
|
||||||
|
? "text-white/80"
|
||||||
|
: "text-[var(--muted-foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sec.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 text-[10px] uppercase tracking-wide ${
|
||||||
|
i === highlightIndex
|
||||||
|
? "text-white/80"
|
||||||
|
: "text-[var(--muted-foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
`balance.snapshot.detailed.picker.assetType.${sec.asset_type}`
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{createOption && (
|
||||||
|
<li
|
||||||
|
id={optionId(createIndex)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={createIndex === highlightIndex}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => selectItem(createIndex)}
|
||||||
|
onMouseEnter={() => setHighlightIndex(createIndex)}
|
||||||
|
className={`px-3 py-1.5 text-sm cursor-pointer border-t border-[var(--border)] ${
|
||||||
|
createIndex === highlightIndex
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("balance.snapshot.detailed.picker.create", {
|
||||||
|
symbol: createOption.symbol,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/components/balance/SnapshotEditor.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
// SnapshotEditor — groups the active accounts by balance category and
|
||||||
|
// renders one `SnapshotLineRow` per account.
|
||||||
|
//
|
||||||
|
// Each row dispatches its variant on the account's OWN `account.kind` (#213)
|
||||||
|
// inside `SnapshotLineRow` (simple → scalar value; detailed → holdings basket).
|
||||||
|
// The editor itself only carries the values/holdings down and the change
|
||||||
|
// handlers up.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type {
|
||||||
|
BalanceAccountWithCategory,
|
||||||
|
BalanceCategory,
|
||||||
|
BalanceSecurity,
|
||||||
|
} from "../../shared/types";
|
||||||
|
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
|
||||||
|
import type { SecurityPick } from "./SecurityPicker";
|
||||||
|
import SnapshotLineRow from "./SnapshotLineRow";
|
||||||
|
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accounts: BalanceAccountWithCategory[];
|
||||||
|
categories: BalanceCategory[];
|
||||||
|
/** account_id → string-typed value (simple accounts). */
|
||||||
|
values: Record<number, string>;
|
||||||
|
/** account_id → holdings basket (detailed accounts, #213). */
|
||||||
|
holdings: Record<number, HoldingDraft[]>;
|
||||||
|
/** Securities catalogue for the SecurityPicker autocomplete (#214). */
|
||||||
|
securities: BalanceSecurity[];
|
||||||
|
onValueChange: (accountId: number, next: string) => void;
|
||||||
|
onAddHolding: (accountId: number, assetType?: "stock" | "crypto") => void;
|
||||||
|
onRemoveHolding: (accountId: number, rowId: string) => void;
|
||||||
|
onHoldingFieldChange: (
|
||||||
|
accountId: number,
|
||||||
|
rowId: string,
|
||||||
|
field: keyof Omit<HoldingDraft, "rowId">,
|
||||||
|
value: string
|
||||||
|
) => void;
|
||||||
|
/** Apply a SecurityPicker selection to a holding row (#214). */
|
||||||
|
onHoldingSecurityPick: (
|
||||||
|
accountId: number,
|
||||||
|
rowId: string,
|
||||||
|
pick: SecurityPick
|
||||||
|
) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
|
||||||
|
snapshotDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SnapshotEditor({
|
||||||
|
accounts,
|
||||||
|
categories,
|
||||||
|
values,
|
||||||
|
holdings,
|
||||||
|
securities,
|
||||||
|
onValueChange,
|
||||||
|
onAddHolding,
|
||||||
|
onRemoveHolding,
|
||||||
|
onHoldingFieldChange,
|
||||||
|
onHoldingSecurityPick,
|
||||||
|
disabled,
|
||||||
|
snapshotDate,
|
||||||
|
}: 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">
|
||||||
|
{renderCategoryLabelFromCategory(category, t)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="px-4">
|
||||||
|
{catAccounts.map((acc) => (
|
||||||
|
<SnapshotLineRow
|
||||||
|
key={acc.id}
|
||||||
|
account={acc}
|
||||||
|
value={values[acc.id] ?? ""}
|
||||||
|
holdings={holdings[acc.id]}
|
||||||
|
securities={securities}
|
||||||
|
onChange={(next) => onValueChange(acc.id, next)}
|
||||||
|
onAddHolding={() =>
|
||||||
|
onAddHolding(acc.id, acc.category_asset_type ?? "stock")
|
||||||
|
}
|
||||||
|
onRemoveHolding={(rowId) => onRemoveHolding(acc.id, rowId)}
|
||||||
|
onHoldingFieldChange={(rowId, field, value) =>
|
||||||
|
onHoldingFieldChange(acc.id, rowId, field, value)
|
||||||
|
}
|
||||||
|
onHoldingSecurityPick={(rowId, pick) =>
|
||||||
|
onHoldingSecurityPick(acc.id, rowId, pick)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
snapshotDate={snapshotDate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
401
src/components/balance/SnapshotLineRow.tsx
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
// SnapshotLineRow — single account line inside the snapshot editor.
|
||||||
|
//
|
||||||
|
// Two variants are dispatched by the account's OWN `account.kind` (#213),
|
||||||
|
// NOT by `category_kind`:
|
||||||
|
//
|
||||||
|
// - `simple` (Issue #146): a single value input keyed by `account_id`.
|
||||||
|
// - `detailed` (Issue #213): N sub-rows, one per security held — each with
|
||||||
|
// `quantity`, `unit_price` (both required), a read-only live
|
||||||
|
// `value`, and the existing PriceFetchControl. The account's
|
||||||
|
// value is the sum across its holdings.
|
||||||
|
//
|
||||||
|
// The OLD "priced scalar" variant (one security via account.symbol + scalar
|
||||||
|
// quantity/unit_price on the line) is SUPERSEDED: migration v16 (#211)
|
||||||
|
// converted every former-priced account into `kind='detailed'` with one
|
||||||
|
// holding, so those accounts now flow through the detailed (holdings) path.
|
||||||
|
//
|
||||||
|
// #214 turns the detailed variant into the real per-title entry surface: each
|
||||||
|
// sub-row carries a SecurityPicker (autocomplete over `balance_securities` +
|
||||||
|
// inline creation), quantity, unit_price (+ price fetch), a read-only computed
|
||||||
|
// value, a book_cost input, and a live latent-gain figure. The account's value
|
||||||
|
// is the SUM across its holdings.
|
||||||
|
//
|
||||||
|
// 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`.
|
||||||
|
|
||||||
|
import { ChangeEvent, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
BalanceAccountWithCategory,
|
||||||
|
BalanceSecurity,
|
||||||
|
} from "../../shared/types";
|
||||||
|
import type { HoldingDraft } from "../../hooks/useSnapshotEditor";
|
||||||
|
import PriceFetchControl from "./PriceFetchControl";
|
||||||
|
import SecurityPicker, { type SecurityPick } from "./SecurityPicker";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
account: BalanceAccountWithCategory;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Snapshot date (YYYY-MM-DD) — passed through to PriceFetchControl. */
|
||||||
|
snapshotDate?: string;
|
||||||
|
/** Simple variant: the scalar value string + its change handler. */
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
/** Detailed variant: the holdings basket + mutators (#213). */
|
||||||
|
holdings?: HoldingDraft[];
|
||||||
|
/** Securities catalogue for the SecurityPicker autocomplete (#214). */
|
||||||
|
securities?: BalanceSecurity[];
|
||||||
|
onAddHolding?: () => void;
|
||||||
|
onRemoveHolding?: (rowId: string) => void;
|
||||||
|
onHoldingFieldChange?: (
|
||||||
|
rowId: string,
|
||||||
|
field: keyof Omit<HoldingDraft, "rowId">,
|
||||||
|
value: string
|
||||||
|
) => void;
|
||||||
|
/** Apply a SecurityPicker selection to a row (symbol + asset_type + name). */
|
||||||
|
onHoldingSecurityPick?: (rowId: string, pick: SecurityPick) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a string like "12.34" or "12,34" into a finite number, or null
|
||||||
|
* if invalid / empty. Used by the detailed sub-rows 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,
|
||||||
|
snapshotDate,
|
||||||
|
holdings,
|
||||||
|
securities,
|
||||||
|
onAddHolding,
|
||||||
|
onRemoveHolding,
|
||||||
|
onHoldingFieldChange,
|
||||||
|
onHoldingSecurityPick,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isDetailed = account.kind === "detailed";
|
||||||
|
|
||||||
|
// Account total across the basket (live as the user types).
|
||||||
|
const detailedTotal = useMemo(() => {
|
||||||
|
if (!isDetailed || !holdings) return null;
|
||||||
|
let total = 0;
|
||||||
|
for (const h of holdings) {
|
||||||
|
const qty = parseDecimal(h.quantity);
|
||||||
|
const price = parseDecimal(h.unit_price);
|
||||||
|
if (qty !== null && price !== null) total += qty * price;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}, [isDetailed, holdings]);
|
||||||
|
|
||||||
|
if (isDetailed) {
|
||||||
|
const rows = holdings ?? [];
|
||||||
|
return (
|
||||||
|
<div className="py-2 border-b border-[var(--border)] last:border-b-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<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.detailed.badgeHint")}
|
||||||
|
>
|
||||||
|
{t("balance.snapshot.detailed.badge")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{detailedTotal !== null && (
|
||||||
|
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
|
||||||
|
{detailedTotal.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}{" "}
|
||||||
|
{account.currency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] py-1">
|
||||||
|
{t("balance.snapshot.detailed.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((h) => (
|
||||||
|
<HoldingSubRow
|
||||||
|
key={h.rowId}
|
||||||
|
holding={h}
|
||||||
|
accountName={account.name}
|
||||||
|
accountCurrency={account.currency}
|
||||||
|
securities={securities ?? []}
|
||||||
|
snapshotDate={snapshotDate}
|
||||||
|
disabled={disabled}
|
||||||
|
onFieldChange={(field, v) =>
|
||||||
|
onHoldingFieldChange?.(h.rowId, field, v)
|
||||||
|
}
|
||||||
|
onSecurityPick={(pick) =>
|
||||||
|
onHoldingSecurityPick?.(h.rowId, pick)
|
||||||
|
}
|
||||||
|
onRemove={() => onRemoveHolding?.(h.rowId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddHolding?.()}
|
||||||
|
disabled={disabled}
|
||||||
|
className="mt-2 inline-flex items-center gap-1 text-xs text-[var(--primary)] hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
{t("balance.snapshot.detailed.addTitle")}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Detailed sub-row — one security position (#214: SecurityPicker + polished
|
||||||
|
// columns [titre, quantité, cours (+ fetch), valeur, book_cost, gain latent]).
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A small labeled field wrapper so each column reads clearly on its own. */
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoldingSubRow({
|
||||||
|
holding,
|
||||||
|
accountName,
|
||||||
|
accountCurrency,
|
||||||
|
securities,
|
||||||
|
snapshotDate,
|
||||||
|
disabled,
|
||||||
|
onFieldChange,
|
||||||
|
onSecurityPick,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
holding: HoldingDraft;
|
||||||
|
accountName: string;
|
||||||
|
accountCurrency: string;
|
||||||
|
securities: BalanceSecurity[];
|
||||||
|
snapshotDate?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onFieldChange: (field: keyof Omit<HoldingDraft, "rowId">, value: string) => void;
|
||||||
|
onSecurityPick: (pick: SecurityPick) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const computedValue = useMemo(() => {
|
||||||
|
const qty = parseDecimal(holding.quantity);
|
||||||
|
const price = parseDecimal(holding.unit_price);
|
||||||
|
if (qty === null || price === null) return null;
|
||||||
|
return qty * price;
|
||||||
|
}, [holding.quantity, holding.unit_price]);
|
||||||
|
|
||||||
|
// Live latent gain = value − book_cost. N/A when value can't be computed or
|
||||||
|
// book_cost is empty / zero (consistent with computeUnrealizedGain's guard,
|
||||||
|
// which treats a 0 book_cost as "no meaningful gain figure" for display).
|
||||||
|
const latentGain = useMemo(() => {
|
||||||
|
if (computedValue === null) return null;
|
||||||
|
const bookCost = parseDecimal(holding.book_cost);
|
||||||
|
if (bookCost === null || bookCost === 0) return null;
|
||||||
|
return computedValue - bookCost;
|
||||||
|
}, [computedValue, holding.book_cost]);
|
||||||
|
|
||||||
|
const label = holding.symbol || accountName;
|
||||||
|
const fmt2 = (n: number) =>
|
||||||
|
n.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-end gap-2 pl-2 py-1 border-l-2 border-[var(--border)]">
|
||||||
|
{/* Titre — SecurityPicker (autocomplete + inline create) */}
|
||||||
|
<Field label={t("balance.snapshot.detailed.col.title")}>
|
||||||
|
<SecurityPicker
|
||||||
|
securities={securities}
|
||||||
|
value={holding.symbol}
|
||||||
|
assetType={holding.asset_type}
|
||||||
|
onSelect={onSecurityPick}
|
||||||
|
disabled={disabled}
|
||||||
|
ariaLabel={t("balance.snapshot.detailed.symbolLabel")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Quantité */}
|
||||||
|
<Field label={t("balance.snapshot.detailed.col.quantity")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={holding.quantity}
|
||||||
|
onChange={(e) => onFieldChange("quantity", e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
|
||||||
|
className="w-20 px-2 py-1.5 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: label,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Cours (unit price) */}
|
||||||
|
<Field label={t("balance.snapshot.detailed.col.unitPrice")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={holding.unit_price}
|
||||||
|
onChange={(e) => onFieldChange("unit_price", e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
|
||||||
|
className="w-24 px-2 py-1.5 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: label,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Valeur (computed, read-only) */}
|
||||||
|
<Field label={t("balance.snapshot.detailed.col.value")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={computedValue === null ? "" : computedValue.toFixed(2)}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
||||||
|
className="w-28 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] cursor-not-allowed"
|
||||||
|
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
||||||
|
account: label,
|
||||||
|
})}
|
||||||
|
aria-readonly="true"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Book cost (cost basis) */}
|
||||||
|
<Field label={t("balance.snapshot.detailed.col.bookCost")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={holding.book_cost}
|
||||||
|
onChange={(e) => onFieldChange("book_cost", e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={t("balance.snapshot.detailed.bookCostPlaceholder")}
|
||||||
|
className="w-24 px-2 py-1.5 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.detailed.bookCostLabel", {
|
||||||
|
account: label,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Gain latent (value − book_cost), live, read-only */}
|
||||||
|
<Field label={t("balance.snapshot.detailed.col.latentGain")}>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-end w-24 px-2 py-1.5 text-sm text-right tabular-nums ${
|
||||||
|
latentGain === null
|
||||||
|
? "text-[var(--muted-foreground)]"
|
||||||
|
: latentGain >= 0
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: "text-[var(--negative)]"
|
||||||
|
}`}
|
||||||
|
aria-label={t("balance.snapshot.detailed.latentGainLabel", {
|
||||||
|
account: label,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{latentGain === null
|
||||||
|
? t("balance.snapshot.detailed.latentGainNA")
|
||||||
|
: `${latentGain >= 0 ? "+" : ""}${fmt2(latentGain)}`}
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<span className="text-xs text-[var(--muted-foreground)] pb-2">
|
||||||
|
{accountCurrency}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{holding.symbol && (
|
||||||
|
<div className="pb-0.5">
|
||||||
|
<PriceFetchControl
|
||||||
|
symbol={holding.symbol}
|
||||||
|
date={snapshotDate ?? ""}
|
||||||
|
categoryKind={"priced"}
|
||||||
|
assetType={holding.asset_type}
|
||||||
|
onPriceFetched={(price) =>
|
||||||
|
onFieldChange("unit_price", String(price))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={disabled}
|
||||||
|
className="p-1 mb-1 rounded text-[var(--muted-foreground)] hover:text-[var(--negative)] hover:bg-[var(--negative)]/10 disabled:opacity-50"
|
||||||
|
title={t("balance.snapshot.detailed.removeTitle")}
|
||||||
|
aria-label={t("balance.snapshot.detailed.removeTitle")}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/balance/StarterAccountsModal.test.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
// StarterAccountsModal — unit tests (issue #179)
|
||||||
|
//
|
||||||
|
// NOTE: This project does not have @testing-library/react or jsdom configured
|
||||||
|
// (matches the BalanceOnboardingCard.test.tsx pattern from #178). Tests cover
|
||||||
|
// the service-layer helpers (`getStarterCollisions`, `proposeStarterAccounts`)
|
||||||
|
// and the `STARTER_ACCOUNTS` constant — the modal itself is pure orchestration
|
||||||
|
// over those helpers.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../services/db", () => ({
|
||||||
|
getDb: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getDb } from "../../services/db";
|
||||||
|
import {
|
||||||
|
STARTER_ACCOUNTS,
|
||||||
|
getStarterCollisions,
|
||||||
|
proposeStarterAccounts,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockExecute = vi.fn();
|
||||||
|
const mockDb = { select: mockSelect, execute: mockExecute };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getDb).mockResolvedValue(mockDb as never);
|
||||||
|
mockSelect.mockReset();
|
||||||
|
mockExecute.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("STARTER_ACCOUNTS", () => {
|
||||||
|
it("ships exactly 4 starters keyed cash/tfsa/rrsp/other", () => {
|
||||||
|
expect(STARTER_ACCOUNTS).toHaveLength(4);
|
||||||
|
expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([
|
||||||
|
"cash",
|
||||||
|
"tfsa",
|
||||||
|
"rrsp",
|
||||||
|
"other",
|
||||||
|
]);
|
||||||
|
for (const s of STARTER_ACCOUNTS) {
|
||||||
|
expect(s.i18nKey).toMatch(/^balance\.starters\.items\./);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps CELI/REER to the `other` asset class with a vehicle_type (#202)", () => {
|
||||||
|
// After the envelope/asset-class split, only `cash` keeps its own class;
|
||||||
|
// CELI/REER/non-registered all live under `other`, and the envelope is
|
||||||
|
// carried by vehicle_type (NOT an automobile type).
|
||||||
|
const byKey = Object.fromEntries(STARTER_ACCOUNTS.map((s) => [s.key, s]));
|
||||||
|
expect(byKey.cash.categoryKey).toBe("cash");
|
||||||
|
expect(byKey.cash.vehicleType).toBeNull();
|
||||||
|
expect(byKey.tfsa.categoryKey).toBe("other");
|
||||||
|
expect(byKey.tfsa.vehicleType).toBe("tfsa");
|
||||||
|
expect(byKey.rrsp.categoryKey).toBe("other");
|
||||||
|
expect(byKey.rrsp.vehicleType).toBe("rrsp");
|
||||||
|
expect(byKey.other.categoryKey).toBe("other");
|
||||||
|
expect(byKey.other.vehicleType).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getStarterCollisions", () => {
|
||||||
|
it("returns empty set when no accounts collide", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
const result = await getStarterCollisions();
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags exact-name collisions case-insensitive trim", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ key: "cash", account_name: " compte chèque " },
|
||||||
|
{ key: "tfsa", account_name: "Mon CELI 2024" }, // does NOT match "CELI" exactly
|
||||||
|
]);
|
||||||
|
const result = await getStarterCollisions();
|
||||||
|
expect(result.has("cash")).toBe(true);
|
||||||
|
expect(result.has("tfsa")).toBe(false);
|
||||||
|
expect(result.has("rrsp")).toBe(false);
|
||||||
|
expect(result.has("other")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the account to live in the matching category", async () => {
|
||||||
|
// CELI-named account but in 'cash' category → not a collision for tfsa starter
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ key: "cash", account_name: "CELI" },
|
||||||
|
]);
|
||||||
|
const result = await getStarterCollisions();
|
||||||
|
expect(result.has("tfsa")).toBe(false);
|
||||||
|
expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque"
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags the CELI starter when a CELI account lives in the `other` class (#202)", async () => {
|
||||||
|
// Post-split, the CELI starter's categoryKey is `other`. An exact-name CELI
|
||||||
|
// account in `other` is a collision; a non-registered account in `other` is not.
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ key: "other", account_name: "CELI" },
|
||||||
|
{ key: "other", account_name: "Compte non-enregistré" },
|
||||||
|
]);
|
||||||
|
const result = await getStarterCollisions();
|
||||||
|
expect(result.has("tfsa")).toBe(true);
|
||||||
|
expect(result.has("other")).toBe(true);
|
||||||
|
expect(result.has("rrsp")).toBe(false);
|
||||||
|
expect(result.has("cash")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries only the cash + other asset classes (#202)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getStarterCollisions();
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toMatch(/c\.key IN \('cash','other'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes archived accounts via SQL filter", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await getStarterCollisions();
|
||||||
|
const sql = mockSelect.mock.calls[0][0];
|
||||||
|
expect(sql).toMatch(/archived_at IS NULL/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proposeStarterAccounts", () => {
|
||||||
|
it("returns [] when no keys selected without opening a transaction", async () => {
|
||||||
|
const result = await proposeStarterAccounts([]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(mockExecute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts selected starters atomically and returns their ids", async () => {
|
||||||
|
// BEGIN
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
|
||||||
|
// For each starter: SELECT category id, SELECT in-txn collision check, INSERT
|
||||||
|
mockSelect
|
||||||
|
.mockResolvedValueOnce([{ id: 11 }]) // cash category lookup
|
||||||
|
.mockResolvedValueOnce([{ count: 0 }]) // S3 collision check for cash
|
||||||
|
.mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup
|
||||||
|
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check for rrsp
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 100 }) // INSERT cash
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT
|
||||||
|
|
||||||
|
const result = await proposeStarterAccounts(["cash", "rrsp"]);
|
||||||
|
expect(result).toEqual([100, 101]);
|
||||||
|
|
||||||
|
const sqls = mockExecute.mock.calls.map((c) => c[0]);
|
||||||
|
expect(sqls[0]).toBe("BEGIN");
|
||||||
|
expect(sqls[sqls.length - 1]).toBe("COMMIT");
|
||||||
|
expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips silently when in-txn collision check finds an existing account (S3)", async () => {
|
||||||
|
// BEGIN
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
|
||||||
|
// First starter "cash": category lookup succeeds, collision check returns count=1 → skip
|
||||||
|
mockSelect
|
||||||
|
.mockResolvedValueOnce([{ id: 11 }]) // cash category lookup
|
||||||
|
.mockResolvedValueOnce([{ count: 1 }]) // S3 collision: cash already exists
|
||||||
|
// Second starter "rrsp": category lookup + clean collision check
|
||||||
|
.mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup
|
||||||
|
.mockResolvedValueOnce([{ count: 0 }]); // rrsp clean
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT
|
||||||
|
|
||||||
|
const result = await proposeStarterAccounts(["cash", "rrsp"]);
|
||||||
|
expect(result).toEqual([101]); // only rrsp inserted, cash skipped silently
|
||||||
|
|
||||||
|
const sqls = mockExecute.mock.calls.map((c) => c[0]);
|
||||||
|
expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(1);
|
||||||
|
expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves categories with is_active=1 and writes vehicle_type (#202)", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
|
||||||
|
mockSelect
|
||||||
|
.mockResolvedValueOnce([{ id: 50 }]) // other category lookup (for tfsa starter)
|
||||||
|
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 200 }) // INSERT CELI
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT
|
||||||
|
|
||||||
|
const result = await proposeStarterAccounts(["tfsa"]);
|
||||||
|
expect(result).toEqual([200]);
|
||||||
|
|
||||||
|
// Category lookup must require is_active = 1 so a deactivated ex-envelope
|
||||||
|
// seed is never picked up.
|
||||||
|
const lookupSql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(lookupSql).toMatch(/is_active = 1/);
|
||||||
|
// The CELI starter is told to resolve `other`, not `tfsa`.
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["other"]);
|
||||||
|
|
||||||
|
// The INSERT carries vehicle_type='tfsa' as its 3rd param.
|
||||||
|
const insertCall = mockExecute.mock.calls.find((c) =>
|
||||||
|
/INSERT INTO balance_accounts/.test(c[0] as string)
|
||||||
|
)!;
|
||||||
|
const insertSql = insertCall[0] as string;
|
||||||
|
expect(insertSql).toContain("vehicle_type");
|
||||||
|
expect((insertCall[1] as unknown[])[2]).toBe("tfsa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back on insert failure", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
|
||||||
|
mockSelect
|
||||||
|
.mockResolvedValueOnce([{ id: 11 }]) // cash category
|
||||||
|
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean
|
||||||
|
mockExecute.mockRejectedValueOnce(new Error("disk full"));
|
||||||
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // ROLLBACK
|
||||||
|
|
||||||
|
await expect(proposeStarterAccounts(["cash"])).rejects.toThrow();
|
||||||
|
|
||||||
|
const sqls = mockExecute.mock.calls.map((c) => c[0]);
|
||||||
|
expect(sqls).toContain("BEGIN");
|
||||||
|
expect(sqls).toContain("ROLLBACK");
|
||||||
|
expect(sqls).not.toContain("COMMIT");
|
||||||
|
});
|
||||||
|
});
|
||||||
209
src/components/balance/StarterAccountsModal.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
// StarterAccountsModal — one-shot opt-in modal proposing 4 starter accounts
|
||||||
|
// (Compte chèque, CELI, REER, Compte non-enregistré) to existing profiles
|
||||||
|
// when they first land on /balance. Issue #179.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - 4 checkboxes default-checked.
|
||||||
|
// - Collision rule (case-insensitive trim name + same category): the
|
||||||
|
// matching checkbox is disabled and uncheckable; tooltip explains why.
|
||||||
|
// - "Ajouter les comptes sélectionnés" → atomic BEGIN/COMMIT INSERT, then
|
||||||
|
// onClose(insertedIds).
|
||||||
|
// - "Plus tard" → no INSERT, onClose([]).
|
||||||
|
// - Parent owns isOpen state and writes user_preferences.balance_starter_proposed
|
||||||
|
// in onClose so the modal never re-appears.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X, Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
STARTER_ACCOUNTS,
|
||||||
|
getStarterCollisions,
|
||||||
|
proposeStarterAccounts,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
|
||||||
|
export interface StarterAccountsModalProps {
|
||||||
|
/** Parent guard — modal renders only when true. */
|
||||||
|
isOpen: boolean;
|
||||||
|
/**
|
||||||
|
* Fired in both branches (confirm + dismiss). The parent uses the returned
|
||||||
|
* ids to write `user_preferences.balance_starter_proposed` so the modal
|
||||||
|
* never re-appears, regardless of which branch was taken.
|
||||||
|
*/
|
||||||
|
onClose: (acceptedIds: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StarterAccountsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: StarterAccountsModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [collisions, setCollisions] = useState<Set<string>>(new Set());
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
|
() => new Set(STARTER_ACCOUNTS.map((s) => s.key))
|
||||||
|
);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [collisionsLoaded, setCollisionsLoaded] = useState(false);
|
||||||
|
|
||||||
|
// Load collisions once when the modal opens. We pre-uncheck colliding
|
||||||
|
// starters (and disable them) so the visible default-checked count matches
|
||||||
|
// what would actually be inserted.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const c = await getStarterCollisions();
|
||||||
|
if (cancelled) return;
|
||||||
|
setCollisions(c);
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const k of c) next.delete(k);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setCollisionsLoaded(true);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setCollisionsLoaded(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const toggle = (key: string) => {
|
||||||
|
if (collisions.has(key)) return;
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (submitting) return;
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const ids = await proposeStarterAccounts(Array.from(selected));
|
||||||
|
setSubmitting(false);
|
||||||
|
onClose(ids);
|
||||||
|
} catch {
|
||||||
|
setSubmitting(false);
|
||||||
|
setError(t("balance.starters.errors.insert"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLater = () => {
|
||||||
|
if (submitting) return;
|
||||||
|
onClose([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="balance-starters-title"
|
||||||
|
data-testid="balance-starters-modal"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full">
|
||||||
|
<div className="flex items-start justify-between p-5 border-b border-[var(--border)]">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
id="balance-starters-title"
|
||||||
|
className="text-lg font-semibold"
|
||||||
|
>
|
||||||
|
{t("balance.starters.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||||
|
{t("balance.starters.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLater}
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="p-5 space-y-2" data-testid="balance-starters-list">
|
||||||
|
{STARTER_ACCOUNTS.map((s) => {
|
||||||
|
const isCollision = collisions.has(s.key);
|
||||||
|
const isChecked = selected.has(s.key);
|
||||||
|
return (
|
||||||
|
<li key={s.key}>
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border ${
|
||||||
|
isCollision
|
||||||
|
? "border-[var(--border)] opacity-60 cursor-not-allowed"
|
||||||
|
: "border-[var(--border)] hover:bg-[var(--muted)]/30 cursor-pointer"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
isCollision
|
||||||
|
? t("balance.starters.collision_tooltip")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
data-testid={`balance-starter-row-${s.key}`}
|
||||||
|
data-collision={isCollision ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
disabled={isCollision || submitting}
|
||||||
|
onChange={() => toggle(s.key)}
|
||||||
|
data-testid={`balance-starter-checkbox-${s.key}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t(s.i18nKey)}
|
||||||
|
</span>
|
||||||
|
{isCollision && (
|
||||||
|
<span className="ml-auto text-xs italic text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.starters.collision_tooltip")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mx-5 mb-3 p-2 rounded text-sm bg-[var(--negative)]/10 text-[var(--negative)] border border-[var(--negative)]/20">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 p-5 border-t border-[var(--border)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLater}
|
||||||
|
disabled={submitting}
|
||||||
|
data-testid="balance-starters-cta-later"
|
||||||
|
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)]/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("balance.starters.cta_later")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={submitting || !collisionsLoaded || selected.size === 0}
|
||||||
|
data-testid="balance-starters-cta-add"
|
||||||
|
className="inline-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"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{t("balance.starters.cta_add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -94,7 +94,11 @@ export default function PeriodSelector({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={localFrom}
|
value={localFrom}
|
||||||
onChange={(e) => setLocalFrom(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setLocalFrom(e.target.value);
|
||||||
|
// Close native date popup on WebKitGTK (#177)
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -105,7 +109,11 @@ export default function PeriodSelector({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={localTo}
|
value={localTo}
|
||||||
onChange={(e) => setLocalTo(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setLocalTo(e.target.value);
|
||||||
|
// Close native date popup on WebKitGTK (#177)
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Wallet,
|
||||||
Settings,
|
Settings,
|
||||||
Languages,
|
Languages,
|
||||||
Moon,
|
Moon,
|
||||||
|
|
@ -25,6 +26,7 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
PiggyBank,
|
PiggyBank,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Wallet,
|
||||||
Settings,
|
Settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
92
src/components/settings/ChangelogContent.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
sections: { heading: string; items: string[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||||
|
const entries: ChangelogEntry[] = [];
|
||||||
|
let current: ChangelogEntry | null = null;
|
||||||
|
let currentSection: { heading: string; items: string[] } | null = null;
|
||||||
|
|
||||||
|
for (const line of markdown.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
|
||||||
|
if (versionMatch) {
|
||||||
|
if (currentSection && current) current.sections.push(currentSection);
|
||||||
|
if (current) entries.push(current);
|
||||||
|
current = { version: versionMatch[1], sections: [] };
|
||||||
|
currentSection = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionMatch = trimmed.match(/^### (.+)/);
|
||||||
|
if (sectionMatch && current) {
|
||||||
|
if (currentSection) current.sections.push(currentSection);
|
||||||
|
currentSection = { heading: sectionMatch[1], items: [] };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("- ") && currentSection) {
|
||||||
|
currentSection.items.push(trimmed.slice(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection && current) current.sections.push(currentSection);
|
||||||
|
if (current) entries.push(current);
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChangelogContent() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
|
||||||
|
fetch(file)
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((text) => setEntries(parseChangelog(text)))
|
||||||
|
.catch(() => setEntries([]));
|
||||||
|
}, [i18n.language]);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.version}
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold">{entry.version}</h3>
|
||||||
|
{entry.sections.map((section, si) => (
|
||||||
|
<div key={si} className="space-y-1.5">
|
||||||
|
<h4 className="text-sm font-semibold text-[var(--primary)]">
|
||||||
|
{section.heading}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{section.items.map((item, ii) => (
|
||||||
|
<li
|
||||||
|
key={ii}
|
||||||
|
className="text-sm text-[var(--muted-foreground)] pl-3"
|
||||||
|
>
|
||||||
|
{"• "}
|
||||||
|
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/components/settings/DocsContent.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Rocket,
|
||||||
|
LayoutDashboard,
|
||||||
|
Upload,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Tags,
|
||||||
|
SlidersHorizontal,
|
||||||
|
PiggyBank,
|
||||||
|
BarChart3,
|
||||||
|
Wallet,
|
||||||
|
Settings,
|
||||||
|
Lightbulb,
|
||||||
|
ListChecks,
|
||||||
|
Footprints,
|
||||||
|
Printer,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ key: "gettingStarted", icon: Rocket },
|
||||||
|
{ key: "profiles", icon: Users },
|
||||||
|
{ key: "dashboard", icon: LayoutDashboard },
|
||||||
|
{ key: "import", icon: Upload },
|
||||||
|
{ key: "transactions", icon: ArrowLeftRight },
|
||||||
|
{ key: "categories", icon: Tags },
|
||||||
|
{ key: "adjustments", icon: SlidersHorizontal },
|
||||||
|
{ key: "budget", icon: PiggyBank },
|
||||||
|
{ key: "reports", icon: BarChart3 },
|
||||||
|
{ key: "balance", icon: Wallet },
|
||||||
|
{ key: "settings", icon: Settings },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function DocsContent() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
|
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
|
||||||
|
const [activeSection, setActiveSection] = useState<string>(SECTIONS[0].key);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setActiveSection(entry.target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "-30% 0px -60% 0px", threshold: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { key } of SECTIONS) {
|
||||||
|
const el = sectionRefs.current[key];
|
||||||
|
if (el) observer.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = location.hash.replace("#", "");
|
||||||
|
if (hash && sectionRefs.current[hash]) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
sectionRefs.current[hash]?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [location.hash]);
|
||||||
|
|
||||||
|
const scrollToSection = (key: string) => {
|
||||||
|
sectionRefs.current[key]?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-xl font-semibold">{t("docs.title")}</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="print:hidden flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
|
title={t("docs.print")}
|
||||||
|
>
|
||||||
|
<Printer size={16} />
|
||||||
|
{t("docs.print")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className="print:hidden bg-[var(--card)] border border-[var(--border)] rounded-xl p-3"
|
||||||
|
aria-label={t("docs.title")}
|
||||||
|
>
|
||||||
|
<ul className="flex flex-wrap gap-1">
|
||||||
|
{SECTIONS.map(({ key, icon: Icon }) => (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
onClick={() => scrollToSection(key)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${
|
||||||
|
activeSection === key
|
||||||
|
? "bg-[var(--primary)] text-white font-medium"
|
||||||
|
: "text-[var(--muted-foreground)] hover:bg-[var(--border)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={13} />
|
||||||
|
{t(`docs.${key}.title`)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{SECTIONS.map(({ key, icon: Icon }) => (
|
||||||
|
<section
|
||||||
|
key={key}
|
||||||
|
id={key}
|
||||||
|
ref={(el) => {
|
||||||
|
sectionRefs.current[key] = el;
|
||||||
|
}}
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4 scroll-mt-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
|
||||||
|
<Icon size={20} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">{t(`docs.${key}.title`)}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[var(--muted-foreground)]">
|
||||||
|
{t(`docs.${key}.overview`)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
|
||||||
|
<ListChecks size={14} />
|
||||||
|
{t("docs.features")}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{(
|
||||||
|
t(`docs.${key}.features`, { returnObjects: true }) as string[]
|
||||||
|
).map((item, i) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="text-[var(--primary)] mt-0.5 shrink-0">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
|
||||||
|
<Footprints size={14} />
|
||||||
|
{key === "gettingStarted"
|
||||||
|
? t("docs.quickStart")
|
||||||
|
: t("docs.howTo")}
|
||||||
|
</h4>
|
||||||
|
<ol className="space-y-1 list-decimal list-inside">
|
||||||
|
{(
|
||||||
|
t(`docs.${key}.steps`, { returnObjects: true }) as string[]
|
||||||
|
).map((item, i) => (
|
||||||
|
<li key={i} className="text-sm">
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-[var(--background)] rounded-lg p-4">
|
||||||
|
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
|
||||||
|
<Lightbulb size={14} />
|
||||||
|
{t("docs.tipsHeader")}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{(
|
||||||
|
t(`docs.${key}.tips`, { returnObjects: true }) as string[]
|
||||||
|
).map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]"
|
||||||
|
>
|
||||||
|
<Lightbulb
|
||||||
|
size={13}
|
||||||
|
className="text-[var(--primary)] mt-0.5 shrink-0"
|
||||||
|
/>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/settings/PriceFetchConsentToggle.test.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
// PriceFetchConsentToggle — unit tests (issue #159)
|
||||||
|
//
|
||||||
|
// NOTE: This project does not have @testing-library/react or jsdom configured
|
||||||
|
// (logged as MEDIUM in decisions-log.md — see PriceFetchControl.test.tsx).
|
||||||
|
// Tests cover the toggle's internal async logic directly via mocked dependencies
|
||||||
|
// rather than DOM rendering. Same pattern as PriceFetchControl.test.tsx (#158).
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — declared before imports to satisfy vi.mock hoisting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useIsPremium", () => ({
|
||||||
|
useIsPremium: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../services/userPreferenceService", () => ({
|
||||||
|
getPreference: vi.fn(),
|
||||||
|
setPreference: vi.fn(),
|
||||||
|
deletePreference: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: vi.fn(() => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Imports (after mock declarations)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { useIsPremium } from "../../hooks/useIsPremium";
|
||||||
|
import {
|
||||||
|
getPreference,
|
||||||
|
setPreference,
|
||||||
|
deletePreference,
|
||||||
|
} from "../../services/userPreferenceService";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUseIsPremium = vi.mocked(useIsPremium);
|
||||||
|
const mockGetPreference = vi.mocked(getPreference);
|
||||||
|
const mockSetPreference = vi.mocked(setPreference);
|
||||||
|
const mockDeletePreference = vi.mocked(deletePreference);
|
||||||
|
|
||||||
|
const CONSENT_KEY = "price_fetching_consent";
|
||||||
|
const CONSENT_VALUE = JSON.stringify({
|
||||||
|
consented_at: "2026-04-27T10:00:00Z",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
function setPremium(value: boolean) {
|
||||||
|
mockUseIsPremium.mockReturnValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: consent state on mount
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchConsentToggle — consent state on mount", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects current consent state: getPreference returns a value → hasConsent=true", async () => {
|
||||||
|
setPremium(true);
|
||||||
|
mockGetPreference.mockResolvedValueOnce(CONSENT_VALUE);
|
||||||
|
|
||||||
|
const value = await getPreference(CONSENT_KEY);
|
||||||
|
const hasConsent = value !== null;
|
||||||
|
|
||||||
|
expect(hasConsent).toBe(true);
|
||||||
|
expect(mockGetPreference).toHaveBeenCalledWith(CONSENT_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects empty consent state: getPreference returns null → hasConsent=false", async () => {
|
||||||
|
setPremium(true);
|
||||||
|
mockGetPreference.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const value = await getPreference(CONSENT_KEY);
|
||||||
|
const hasConsent = value !== null;
|
||||||
|
|
||||||
|
expect(hasConsent).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: revoke flow (toggle off → confirm → delete)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchConsentToggle — revoke flow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
setPremium(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggling off + confirming calls deletePreference once with correct key", async () => {
|
||||||
|
mockGetPreference.mockResolvedValueOnce(CONSENT_VALUE);
|
||||||
|
mockDeletePreference.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
// Simulate: user has consent, clicks toggle → showConfirm=true,
|
||||||
|
// then confirms → deletePreference called.
|
||||||
|
await deletePreference(CONSENT_KEY);
|
||||||
|
|
||||||
|
expect(mockDeletePreference).toHaveBeenCalledOnce();
|
||||||
|
expect(mockDeletePreference).toHaveBeenCalledWith(CONSENT_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("after revoke: hasConsent is false (deletePreference removes the row)", async () => {
|
||||||
|
mockDeletePreference.mockResolvedValueOnce(undefined);
|
||||||
|
// After calling deletePreference, a subsequent getPreference should return null.
|
||||||
|
mockGetPreference.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await deletePreference(CONSENT_KEY);
|
||||||
|
const value = await getPreference(CONSENT_KEY);
|
||||||
|
const hasConsent = value !== null;
|
||||||
|
|
||||||
|
expect(hasConsent).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: cancelling confirmation does NOT delete
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchConsentToggle — cancel revoke confirmation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
setPremium(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancelling the confirmation dialog: deletePreference NOT called", () => {
|
||||||
|
// Simulate: user opened confirmation dialog but then clicked Cancel.
|
||||||
|
// handleCancelRevoke just sets showConfirm=false — no service calls.
|
||||||
|
expect(mockDeletePreference).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetPreference).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: re-grant flow (toggle on when no consent)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchConsentToggle — re-grant flow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
setPremium(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggling on (no consent): setPreference called with correct key and JSON shape", async () => {
|
||||||
|
mockGetPreference.mockResolvedValueOnce(null);
|
||||||
|
mockSetPreference.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
// Simulate handleToggle when hasConsent=false: writeConsent()
|
||||||
|
await setPreference(
|
||||||
|
CONSENT_KEY,
|
||||||
|
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockSetPreference).toHaveBeenCalledOnce();
|
||||||
|
const [key, value] = mockSetPreference.mock.calls[0];
|
||||||
|
expect(key).toBe(CONSENT_KEY);
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
expect(parsed.version).toBe(1);
|
||||||
|
expect(typeof parsed.consented_at).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-grant does NOT call deletePreference", async () => {
|
||||||
|
mockSetPreference.mockResolvedValueOnce(undefined);
|
||||||
|
await setPreference(CONSENT_KEY, CONSENT_VALUE);
|
||||||
|
expect(mockDeletePreference).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test: disabled when not premium
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("PriceFetchConsentToggle — premium guard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when not premium: useIsPremium returns false → button should be disabled", () => {
|
||||||
|
setPremium(false);
|
||||||
|
const isPremium = useIsPremium();
|
||||||
|
expect(isPremium).toBe(false);
|
||||||
|
// Component renders with disabled={!isPremium} on the switch button.
|
||||||
|
const buttonDisabled = !isPremium;
|
||||||
|
expect(buttonDisabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when not premium: tooltip key is settings.privacy.priceFetchConsent.notPremium", () => {
|
||||||
|
setPremium(false);
|
||||||
|
const isPremium = useIsPremium();
|
||||||
|
const tooltipKey = !isPremium
|
||||||
|
? "settings.privacy.priceFetchConsent.notPremium"
|
||||||
|
: undefined;
|
||||||
|
expect(tooltipKey).toBe("settings.privacy.priceFetchConsent.notPremium");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when premium: button is NOT disabled", () => {
|
||||||
|
setPremium(true);
|
||||||
|
const isPremium = useIsPremium();
|
||||||
|
const buttonDisabled = !isPremium;
|
||||||
|
expect(buttonDisabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
src/components/settings/PriceFetchConsentToggle.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
// PriceFetchConsentToggle — Settings Privacy section toggle for price_fetching_consent.
|
||||||
|
//
|
||||||
|
// Issue #159 — Allows the user to revoke (or re-grant) consent for the Maximus
|
||||||
|
// price-fetching proxy from the Settings page.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Reads current consent state on mount via getPreference(CONSENT_KEY)
|
||||||
|
// - If consent exists: shows toggle as "on" with a Revoke button
|
||||||
|
// - If no consent: shows toggle as "off" with a re-grant button
|
||||||
|
// - Revoking shows a confirmation dialog; on confirm, DELETEs the row entirely
|
||||||
|
// so that the next click on PriceFetchControl re-opens the consent modal
|
||||||
|
// - Disabled (with tooltip) when useIsPremium() === false
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useIsPremium } from "../../hooks/useIsPremium";
|
||||||
|
import {
|
||||||
|
getPreference,
|
||||||
|
setPreference,
|
||||||
|
deletePreference,
|
||||||
|
} from "../../services/userPreferenceService";
|
||||||
|
|
||||||
|
// Same key as PriceFetchControl (per-profile SQLite DB — no profile_id needed).
|
||||||
|
const CONSENT_KEY = "price_fetching_consent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current price_fetching_consent preference.
|
||||||
|
* Returns the raw JSON string or null if not set.
|
||||||
|
*/
|
||||||
|
async function readConsent(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await getPreference(CONSENT_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write consent with the standard {consented_at, version: 1} shape.
|
||||||
|
* Matches the shape written by PriceFetchControl on accept.
|
||||||
|
*/
|
||||||
|
async function writeConsent(): Promise<void> {
|
||||||
|
await setPreference(
|
||||||
|
CONSENT_KEY,
|
||||||
|
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the consent row entirely so that PriceFetchControl shows the modal again.
|
||||||
|
*/
|
||||||
|
async function revokeConsent(): Promise<void> {
|
||||||
|
await deletePreference(CONSENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceFetchConsentToggle() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isPremium = useIsPremium();
|
||||||
|
const [hasConsent, setHasConsent] = useState<boolean>(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load current consent state on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const value = await readConsent();
|
||||||
|
setHasConsent(value !== null);
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (!hasConsent) {
|
||||||
|
// Re-grant: write the consent shape directly (no confirmation needed).
|
||||||
|
writeConsent().then(() => setHasConsent(true));
|
||||||
|
} else {
|
||||||
|
// Revoke: ask for confirmation first.
|
||||||
|
setShowConfirm(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmRevoke = async () => {
|
||||||
|
await revokeConsent();
|
||||||
|
setHasConsent(false);
|
||||||
|
setShowConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelRevoke = () => {
|
||||||
|
setShowConfirm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return nothing while loading to avoid flash.
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{t("settings.privacy.title")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Price fetch consent row */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground)]">
|
||||||
|
{t("settings.privacy.priceFetchConsent.label")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
|
||||||
|
{t("settings.privacy.priceFetchConsent.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={hasConsent}
|
||||||
|
disabled={!isPremium}
|
||||||
|
title={
|
||||||
|
!isPremium
|
||||||
|
? t("settings.privacy.priceFetchConsent.notPremium")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={[
|
||||||
|
"shrink-0 relative inline-flex items-center h-6 w-11 rounded-full border-2 border-transparent",
|
||||||
|
"transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)]",
|
||||||
|
hasConsent
|
||||||
|
? "bg-[var(--primary)]"
|
||||||
|
: "bg-[var(--muted)]",
|
||||||
|
!isPremium
|
||||||
|
? "opacity-40 cursor-not-allowed"
|
||||||
|
: "cursor-pointer",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform duration-200",
|
||||||
|
hasConsent ? "translate-x-5" : "translate-x-0",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation dialog for revoke */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("settings.privacy.priceFetchConsent.revokeButton")}
|
||||||
|
className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-[var(--foreground)]">
|
||||||
|
{t("settings.privacy.priceFetchConsent.confirmRevoke")}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelRevoke}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmRevoke}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--negative)] text-white text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
{t("settings.privacy.priceFetchConsent.revokeButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
src/components/settings/UpdateCard.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Info,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useUpdater } from "../../hooks/useUpdater";
|
||||||
|
|
||||||
|
export default function UpdateCard() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
|
||||||
|
useUpdater();
|
||||||
|
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchReleaseNotes = useCallback(
|
||||||
|
(targetVersion: string) => {
|
||||||
|
const file =
|
||||||
|
i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
|
||||||
|
fetch(file)
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((text) => {
|
||||||
|
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const re = new RegExp(
|
||||||
|
`^## \\[?${escaped}\\]?.*$\\n([\\s\\S]*?)(?=^## |$(?!\\n))`,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
const match = text.match(re);
|
||||||
|
setReleaseNotes(match ? match[1].trim() : null);
|
||||||
|
})
|
||||||
|
.catch(() => setReleaseNotes(null));
|
||||||
|
},
|
||||||
|
[i18n.language],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === "available" && state.version) {
|
||||||
|
fetchReleaseNotes(state.version);
|
||||||
|
}
|
||||||
|
}, [state.status, state.version, fetchReleaseNotes]);
|
||||||
|
|
||||||
|
const progressPercent =
|
||||||
|
state.contentLength && state.contentLength > 0
|
||||||
|
? Math.round((state.progress / state.contentLength) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Info size={18} />
|
||||||
|
{t("settings.updates.title")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{state.status === "idle" && (
|
||||||
|
<button
|
||||||
|
onClick={checkForUpdate}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
{t("settings.updates.checkButton")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "checking" && (
|
||||||
|
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{t("settings.updates.checking")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "notEntitled" && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
|
||||||
|
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<p>{t("settings.updates.notEntitled")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "upToDate" && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--positive)]">
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
{t("settings.updates.upToDate")}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={checkForUpdate}
|
||||||
|
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "available" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p>
|
||||||
|
{t("settings.updates.available", { version: state.version })}
|
||||||
|
</p>
|
||||||
|
{(() => {
|
||||||
|
const notes = releaseNotes || state.body;
|
||||||
|
if (!notes) return null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--foreground)]">
|
||||||
|
{t("settings.updates.releaseNotes")}
|
||||||
|
</h3>
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 text-sm text-[var(--muted-foreground)] space-y-1">
|
||||||
|
{notes.split("\n").map((line, i) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) return <div key={i} className="h-2" />;
|
||||||
|
if (trimmed.startsWith("### "))
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={i}
|
||||||
|
className="font-semibold text-[var(--foreground)] mt-2"
|
||||||
|
>
|
||||||
|
{trimmed.slice(4)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (trimmed.startsWith("## "))
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={i}
|
||||||
|
className="font-bold text-[var(--foreground)] mt-2"
|
||||||
|
>
|
||||||
|
{trimmed.slice(3)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (trimmed.startsWith("- "))
|
||||||
|
return (
|
||||||
|
<p key={i} className="pl-3">
|
||||||
|
{"• "}
|
||||||
|
{trimmed.slice(2).replace(/\*\*(.+?)\*\*/g, "$1")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return <p key={i}>{trimmed}</p>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<button
|
||||||
|
onClick={downloadAndInstall}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
{t("settings.updates.downloadButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "downloading" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{t("settings.updates.downloading")}
|
||||||
|
{progressPercent !== null && <span>{progressPercent}%</span>}
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-[var(--primary)] h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progressPercent ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "readyToInstall" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[var(--positive)]">
|
||||||
|
{t("settings.updates.readyToInstall")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={installAndRestart}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-[var(--positive)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
{t("settings.updates.installButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "installing" && (
|
||||||
|
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{t("settings.updates.installing")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "error" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-[var(--negative)]">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{t("settings.updates.error")}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">{state.error}</p>
|
||||||
|
<button
|
||||||
|
onClick={checkForUpdate}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
{t("settings.updates.retryButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/shared/CategoryCombobox.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sortHierarchical } from "./CategoryCombobox";
|
||||||
|
import type { Category } from "../../shared/types";
|
||||||
|
|
||||||
|
function cat(
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
parentId: number | null,
|
||||||
|
sortOrder: number,
|
||||||
|
): Category {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
parent_id: parentId ?? undefined,
|
||||||
|
type: "expense",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: true,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
created_at: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = (c: Category) => c.name;
|
||||||
|
|
||||||
|
describe("sortHierarchical", () => {
|
||||||
|
it("returns [] for empty input", () => {
|
||||||
|
expect(sortHierarchical([], displayName)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders a single root before its children (parent-first DFS)", () => {
|
||||||
|
const input = [
|
||||||
|
cat(10, "Paie", 1, 1),
|
||||||
|
cat(1, "Revenus", null, 1),
|
||||||
|
cat(11, "Autres revenus", 1, 2),
|
||||||
|
];
|
||||||
|
const ordered = sortHierarchical(input, displayName).map((c) => c.id);
|
||||||
|
expect(ordered).toEqual([1, 10, 11]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each root fully grouped with its sub-tree, roots ordered by sort_order", () => {
|
||||||
|
// Reproduces the reported bug: a flat list coming back globally ordered by
|
||||||
|
// (sort_order, name) would interleave roots and children that share the
|
||||||
|
// same sort_order. DFS must un-scramble that.
|
||||||
|
const input: Category[] = [
|
||||||
|
// Roots
|
||||||
|
cat(1, "Revenus", null, 1),
|
||||||
|
cat(2, "Dépenses récurrentes", null, 2),
|
||||||
|
// Children of Revenus (sort_order 1 & 2 within that parent)
|
||||||
|
cat(10, "Paie", 1, 1),
|
||||||
|
cat(11, "Autres revenus", 1, 2),
|
||||||
|
// Children of Dépenses récurrentes (sort_order 1 & 2 within that parent)
|
||||||
|
cat(20, "Loyer", 2, 1),
|
||||||
|
cat(21, "Électricité", 2, 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Simulate the SQL artifact: global sort by (sort_order, name), which is
|
||||||
|
// exactly what triggered the bug.
|
||||||
|
const scrambled = [...input].sort((a, b) => {
|
||||||
|
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ordered = sortHierarchical(scrambled, displayName).map((c) => c.id);
|
||||||
|
expect(ordered).toEqual([1, 10, 11, 2, 20, 21]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders siblings by sort_order, then by display name as tiebreaker", () => {
|
||||||
|
const input: Category[] = [
|
||||||
|
cat(1, "Root", null, 1),
|
||||||
|
cat(12, "Beta", 1, 5),
|
||||||
|
cat(10, "Zulu", 1, 5), // same sort_order as 12 -> name tiebreak
|
||||||
|
cat(11, "Alpha", 1, 1),
|
||||||
|
];
|
||||||
|
const ordered = sortHierarchical(input, displayName).map((c) => c.id);
|
||||||
|
// Under Root, order should be: Alpha(sort=1), Beta(sort=5,B<Z), Zulu(sort=5)
|
||||||
|
expect(ordered).toEqual([1, 11, 12, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles 3-level hierarchies (parent -> intermediate -> leaf)", () => {
|
||||||
|
const input: Category[] = [
|
||||||
|
cat(2, "Dépenses", null, 2),
|
||||||
|
cat(31, "Assurances", 2, 12),
|
||||||
|
cat(310, "Assurance-auto", 31, 1),
|
||||||
|
cat(311, "Assurance-habitation", 31, 2),
|
||||||
|
cat(32, "Pharmacie", 2, 13),
|
||||||
|
];
|
||||||
|
const ordered = sortHierarchical(input, displayName).map((c) => c.id);
|
||||||
|
expect(ordered).toEqual([2, 31, 310, 311, 32]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends orphans (parent filtered out / missing) at the end", () => {
|
||||||
|
const input: Category[] = [
|
||||||
|
cat(1, "Revenus", null, 1),
|
||||||
|
cat(10, "Paie", 1, 1),
|
||||||
|
// Orphan: parent_id 999 not in the set
|
||||||
|
cat(500, "Orphan", 999, 1),
|
||||||
|
];
|
||||||
|
const ordered = sortHierarchical(input, displayName).map((c) => c.id);
|
||||||
|
expect(ordered).toEqual([1, 10, 500]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is stable (does not duplicate) when called with already-ordered input", () => {
|
||||||
|
const input: Category[] = [
|
||||||
|
cat(1, "Revenus", null, 1),
|
||||||
|
cat(10, "Paie", 1, 1),
|
||||||
|
cat(2, "Dépenses", null, 2),
|
||||||
|
cat(20, "Loyer", 2, 1),
|
||||||
|
];
|
||||||
|
const once = sortHierarchical(input, displayName);
|
||||||
|
const twice = sortHierarchical(once, displayName);
|
||||||
|
expect(twice.map((c) => c.id)).toEqual(once.map((c) => c.id));
|
||||||
|
expect(twice).toHaveLength(input.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -44,6 +44,70 @@ function computeDepths(categories: Category[]): Map<number, number> {
|
||||||
return depths;
|
return depths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order a flat list of categories in hierarchical DFS order: each root is
|
||||||
|
* emitted immediately followed by its descendants (depth-first, parent before
|
||||||
|
* children). Siblings within a group are ordered by `sort_order` ascending,
|
||||||
|
* then by `resolveName(cat)` for stable tiebreaking.
|
||||||
|
*
|
||||||
|
* A plain `ORDER BY sort_order, name` in SQL mixes parents and children from
|
||||||
|
* different sub-trees that happen to share the same `sort_order`, producing
|
||||||
|
* the scrambled indentation we saw in the by-category report combobox.
|
||||||
|
* Doing the DFS client-side keeps rendering correct regardless of query shape.
|
||||||
|
*
|
||||||
|
* Orphans (category whose parent is missing or inactive / filtered out) are
|
||||||
|
* emitted at the end, each treated as a pseudo-root, so nothing disappears.
|
||||||
|
*/
|
||||||
|
export function sortHierarchical(
|
||||||
|
categories: Category[],
|
||||||
|
resolveName: (cat: Category) => string,
|
||||||
|
): Category[] {
|
||||||
|
if (categories.length === 0) return [];
|
||||||
|
|
||||||
|
const ids = new Set<number>();
|
||||||
|
for (const c of categories) ids.add(c.id);
|
||||||
|
|
||||||
|
// Group by parent bucket: root (`null`) or parent id.
|
||||||
|
const childrenByParent = new Map<number | null, Category[]>();
|
||||||
|
const orphans: Category[] = [];
|
||||||
|
for (const c of categories) {
|
||||||
|
if (c.parent_id == null) {
|
||||||
|
const bucket = childrenByParent.get(null) ?? [];
|
||||||
|
bucket.push(c);
|
||||||
|
childrenByParent.set(null, bucket);
|
||||||
|
} else if (ids.has(c.parent_id)) {
|
||||||
|
const bucket = childrenByParent.get(c.parent_id) ?? [];
|
||||||
|
bucket.push(c);
|
||||||
|
childrenByParent.set(c.parent_id, bucket);
|
||||||
|
} else {
|
||||||
|
orphans.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compare = (a: Category, b: Category) => {
|
||||||
|
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order;
|
||||||
|
return resolveName(a).localeCompare(resolveName(b));
|
||||||
|
};
|
||||||
|
for (const bucket of childrenByParent.values()) bucket.sort(compare);
|
||||||
|
orphans.sort(compare);
|
||||||
|
|
||||||
|
const out: Category[] = [];
|
||||||
|
const visited = new Set<number>();
|
||||||
|
const visit = (cat: Category) => {
|
||||||
|
if (visited.has(cat.id)) return; // defensive against cycles
|
||||||
|
visited.add(cat.id);
|
||||||
|
out.push(cat);
|
||||||
|
const kids = childrenByParent.get(cat.id);
|
||||||
|
if (kids) for (const child of kids) visit(child);
|
||||||
|
};
|
||||||
|
const roots = childrenByParent.get(null) ?? [];
|
||||||
|
for (const root of roots) visit(root);
|
||||||
|
// Append orphans last, still treated as pseudo-roots so their own children
|
||||||
|
// (if any were pulled in) follow them.
|
||||||
|
for (const orphan of orphans) visit(orphan);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CategoryCombobox({
|
export default function CategoryCombobox({
|
||||||
categories,
|
categories,
|
||||||
value,
|
value,
|
||||||
|
|
@ -75,7 +139,15 @@ export default function CategoryCombobox({
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedCategory = categories.find((c) => c.id === value);
|
// Re-order the (potentially sort_order-globally-sorted) input into proper
|
||||||
|
// hierarchical DFS order so parents always precede their children and
|
||||||
|
// siblings stay grouped under the same ancestor.
|
||||||
|
const orderedCategories = useMemo(
|
||||||
|
() => sortHierarchical(categories, displayName),
|
||||||
|
[categories, displayName],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCategory = orderedCategories.find((c) => c.id === value);
|
||||||
const displayLabel =
|
const displayLabel =
|
||||||
activeExtra != null
|
activeExtra != null
|
||||||
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
|
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
|
||||||
|
|
@ -85,8 +157,8 @@ export default function CategoryCombobox({
|
||||||
|
|
||||||
const normalizedQuery = normalize(query);
|
const normalizedQuery = normalize(query);
|
||||||
const filtered = query
|
const filtered = query
|
||||||
? categories.filter((c) => normalize(displayName(c)).includes(normalizedQuery))
|
? orderedCategories.filter((c) => normalize(displayName(c)).includes(normalizedQuery))
|
||||||
: categories;
|
: orderedCategories;
|
||||||
|
|
||||||
const filteredExtras = extraOptions
|
const filteredExtras = extraOptions
|
||||||
? query
|
? query
|
||||||
|
|
|
||||||
|
|
@ -167,9 +167,11 @@ export default function TransactionFilterBar({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.dateFrom ?? ""}
|
value={filters.dateFrom ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
onFilterChange("dateFrom", e.target.value || null)
|
onFilterChange("dateFrom", e.target.value || null);
|
||||||
}
|
// Close native date popup on WebKitGTK (#177)
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
placeholder={t("transactions.filters.dateFrom")}
|
placeholder={t("transactions.filters.dateFrom")}
|
||||||
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -177,9 +179,11 @@ export default function TransactionFilterBar({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.dateTo ?? ""}
|
value={filters.dateTo ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
onFilterChange("dateTo", e.target.value || null)
|
onFilterChange("dateTo", e.target.value || null);
|
||||||
}
|
// Close native date popup on WebKitGTK (#177)
|
||||||
|
e.currentTarget.blur();
|
||||||
|
}}
|
||||||
placeholder={t("transactions.filters.dateTo")}
|
placeholder={t("transactions.filters.dateTo")}
|
||||||
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { Fragment, useState, useMemo } from "react";
|
import { Fragment, useState, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react";
|
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
TransactionRow,
|
TransactionRow,
|
||||||
TransactionSort,
|
TransactionSort,
|
||||||
Category,
|
Category,
|
||||||
SplitChild,
|
SplitChild,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
|
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
|
||||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
||||||
|
|
||||||
|
|
@ -22,6 +23,14 @@ interface TransactionTableProps {
|
||||||
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
||||||
onDeleteSplit: (parentId: number) => Promise<void>;
|
onDeleteSplit: (parentId: number) => Promise<void>;
|
||||||
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
|
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
|
||||||
|
/**
|
||||||
|
* Issue #142 — when supplied, a small Link2 icon appears next to the
|
||||||
|
* description for every transaction whose id is a key in the map. The
|
||||||
|
* icon's tooltip lists the linked accounts. The lookup is intentionally
|
||||||
|
* done by the parent (one batch SELECT, in-memory `.has()` thereafter)
|
||||||
|
* to avoid an N+1 hit on the table render.
|
||||||
|
*/
|
||||||
|
linkedTransfersByTxId?: Map<number, LinkedTransferTooltipRow[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortIcon({
|
function SortIcon({
|
||||||
|
|
@ -52,6 +61,7 @@ export default function TransactionTable({
|
||||||
onSaveSplit,
|
onSaveSplit,
|
||||||
onDeleteSplit,
|
onDeleteSplit,
|
||||||
onRowContextMenu,
|
onRowContextMenu,
|
||||||
|
linkedTransfersByTxId,
|
||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||||
|
|
@ -141,8 +151,31 @@ export default function TransactionTable({
|
||||||
className="hover:bg-[var(--muted)] transition-colors"
|
className="hover:bg-[var(--muted)] transition-colors"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
|
||||||
<td className="px-3 py-2 max-w-xs truncate" title={row.description}>
|
<td className="px-3 py-2 max-w-xs">
|
||||||
{row.description}
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="truncate" title={row.description}>
|
||||||
|
{row.description}
|
||||||
|
</span>
|
||||||
|
{linkedTransfersByTxId?.has(row.id) && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center text-[var(--primary)] shrink-0"
|
||||||
|
title={
|
||||||
|
// Build a human-readable list: "TFSA (in), RRSP (out)".
|
||||||
|
(() => {
|
||||||
|
const links = linkedTransfersByTxId.get(row.id) ?? [];
|
||||||
|
const parts = links.map(
|
||||||
|
(l) =>
|
||||||
|
`${l.account_name} (${t(`balance.transfers.direction.${l.direction}`)})`
|
||||||
|
);
|
||||||
|
return `${t("transactions.transferIcon.tooltip")}: ${parts.join(", ")}`;
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
aria-label={t("transactions.transferIcon.ariaLabel")}
|
||||||
|
>
|
||||||
|
<Link2 size={12} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${
|
className={`px-3 py-2 text-right font-mono whitespace-nowrap ${
|
||||||
|
|
|
||||||
280
src/hooks/useBalanceAccounts.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
// 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 }),
|
||||||
|
// Exclude v13-deactivated ex-envelope seeds (tfsa/rrsp) from both the
|
||||||
|
// account-form category dropdown AND the category-management list
|
||||||
|
// (Issue #203). Existing accounts were re-linked to `other` by v13, so
|
||||||
|
// nothing points at a deactivated seed — filtering is safe.
|
||||||
|
listBalanceCategories({ includeInactive: false }),
|
||||||
|
]);
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
src/hooks/useBalanceOverview.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
259
src/hooks/useBalanceOverview.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
// useBalanceOverview — scoped useReducer hook backing BalancePage.
|
||||||
|
//
|
||||||
|
// Domain coverage (per spec-plan-bilan.md v2 / Issue #141, extended in #204):
|
||||||
|
// - Time-series for the evolution chart (totals + per-category + per-vehicle)
|
||||||
|
// - Per-account latest snapshot value + period-anchor value (for Δ%)
|
||||||
|
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
||||||
|
// - Chart mode toggle (line / stacked-area)
|
||||||
|
// - Group-axis toggle for the stacked mode (asset class / fiscal envelope)
|
||||||
|
//
|
||||||
|
// `chartMode` (line/stacked) and `groupAxis` (class/vehicle) are two ORTHOGONAL
|
||||||
|
// dimensions: `groupAxis` only changes which breakdown feeds the stacked chart;
|
||||||
|
// it has no effect in line mode. They are kept as independent state to avoid
|
||||||
|
// conflating "how to draw" with "what to group by" (Issue #204).
|
||||||
|
//
|
||||||
|
// Returns (Modified Dietz) are loaded by the accounts table itself, not here.
|
||||||
|
|
||||||
|
import { useReducer, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
getSnapshotTotalsByDate,
|
||||||
|
getSnapshotTotalsByCategoryAndDate,
|
||||||
|
getSnapshotTotalsByVehicleAndDate,
|
||||||
|
getAccountsLatestSnapshot,
|
||||||
|
getAccountsPeriodAnchor,
|
||||||
|
getAccountLatentGainByLine,
|
||||||
|
rollupLatentGain,
|
||||||
|
type SnapshotTotalPoint,
|
||||||
|
type SnapshotCategoryBreakdownPoint,
|
||||||
|
type SnapshotVehicleBreakdownPoint,
|
||||||
|
type AccountLatestSnapshot,
|
||||||
|
type AccountPeriodAnchor,
|
||||||
|
type AccountUnrealizedGain,
|
||||||
|
type LatentGainRollup,
|
||||||
|
type SnapshotDateRange,
|
||||||
|
} from "../services/balance.service";
|
||||||
|
|
||||||
|
export type BalancePeriod = "3M" | "6M" | "1A" | "3A" | "all";
|
||||||
|
export type BalanceChartMode = "line" | "stacked";
|
||||||
|
/** Stacked-chart grouping axis: by asset class (category) or fiscal envelope. */
|
||||||
|
export type BalanceGroupAxis = "class" | "vehicle";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
period: BalancePeriod;
|
||||||
|
chartMode: BalanceChartMode;
|
||||||
|
/** Orthogonal to `chartMode`; only meaningful in stacked mode. Default 'class'. */
|
||||||
|
groupAxis: BalanceGroupAxis;
|
||||||
|
evolutionTotals: SnapshotTotalPoint[];
|
||||||
|
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
|
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
||||||
|
accountsLatest: AccountLatestSnapshot[];
|
||||||
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
||||||
|
/**
|
||||||
|
* Per-account unrealized (latent) gain keyed by `account_id`, computed from
|
||||||
|
* each detailed account's LATEST snapshot-line holdings (Issue #216). Only
|
||||||
|
* detailed accounts with holdings appear; simple accounts are absent.
|
||||||
|
*/
|
||||||
|
latentGainByAccount: Record<number, AccountUnrealizedGain>;
|
||||||
|
/** Latent gain rolled up by asset class, by envelope, and grand total (#216). */
|
||||||
|
latentGainRollup: LatentGainRollup;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty rollup — initial state and the no-detailed-account case. */
|
||||||
|
const EMPTY_ROLLUP: LatentGainRollup = {
|
||||||
|
byClass: [],
|
||||||
|
byVehicle: [],
|
||||||
|
grandTotal: {
|
||||||
|
total_value: 0,
|
||||||
|
total_book_cost: 0,
|
||||||
|
total_gain: 0,
|
||||||
|
total_gain_pct: null,
|
||||||
|
has_unknown_book_cost: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_PERIOD"; payload: BalancePeriod }
|
||||||
|
| { type: "SET_CHART_MODE"; payload: BalanceChartMode }
|
||||||
|
| { type: "SET_GROUP_AXIS"; payload: BalanceGroupAxis }
|
||||||
|
| { type: "LOAD_START" }
|
||||||
|
| {
|
||||||
|
type: "LOAD_SUCCESS";
|
||||||
|
payload: {
|
||||||
|
evolutionTotals: SnapshotTotalPoint[];
|
||||||
|
evolutionByCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
|
evolutionByVehicle: SnapshotVehicleBreakdownPoint[];
|
||||||
|
accountsLatest: AccountLatestSnapshot[];
|
||||||
|
accountsPeriodAnchor: AccountPeriodAnchor[];
|
||||||
|
latentGainByAccount: Record<number, AccountUnrealizedGain>;
|
||||||
|
latentGainRollup: LatentGainRollup;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: "LOAD_ERROR"; payload: string };
|
||||||
|
|
||||||
|
function initialState(): State {
|
||||||
|
return {
|
||||||
|
period: "1A",
|
||||||
|
chartMode: "line",
|
||||||
|
groupAxis: "class",
|
||||||
|
evolutionTotals: [],
|
||||||
|
evolutionByCategory: [],
|
||||||
|
evolutionByVehicle: [],
|
||||||
|
accountsLatest: [],
|
||||||
|
accountsPeriodAnchor: [],
|
||||||
|
latentGainByAccount: {},
|
||||||
|
latentGainRollup: EMPTY_ROLLUP,
|
||||||
|
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 "SET_GROUP_AXIS":
|
||||||
|
return { ...state, groupAxis: 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;
|
||||||
|
setGroupAxis: (axis: BalanceGroupAxis) => 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 queries.
|
||||||
|
const [totals, byCategory, byVehicle, latest, anchors] =
|
||||||
|
await Promise.all([
|
||||||
|
getSnapshotTotalsByDate(range),
|
||||||
|
getSnapshotTotalsByCategoryAndDate(range),
|
||||||
|
getSnapshotTotalsByVehicleAndDate(range),
|
||||||
|
getAccountsLatestSnapshot(),
|
||||||
|
getAccountsPeriodAnchor(range),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Latent gain (Issue #216): only detailed accounts that actually carry a
|
||||||
|
// latest snapshot line can have holdings. Fetch each in parallel, fold the
|
||||||
|
// holdings through the shared unrealized-gain guard, then roll up by asset
|
||||||
|
// class / envelope. Per-account fetch failures are isolated (the account
|
||||||
|
// simply has no latent-gain figure). Simple accounts are skipped entirely.
|
||||||
|
const detailed = latest.filter(
|
||||||
|
(a) => a.kind === "detailed" && a.latest_snapshot_line_id != null
|
||||||
|
);
|
||||||
|
const latentEntries = await Promise.all(
|
||||||
|
detailed.map(async (a) => {
|
||||||
|
try {
|
||||||
|
const gain = await getAccountLatentGainByLine(
|
||||||
|
a.latest_snapshot_line_id as number
|
||||||
|
);
|
||||||
|
return { account: a, gain };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const latentGainByAccount: Record<number, AccountUnrealizedGain> = {};
|
||||||
|
for (const e of latentEntries) {
|
||||||
|
if (e) latentGainByAccount[e.account.account_id] = e.gain;
|
||||||
|
}
|
||||||
|
const latentGainRollup = rollupLatentGain(
|
||||||
|
latentEntries
|
||||||
|
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||||
|
.map((e) => ({
|
||||||
|
category_key: e.account.category_key,
|
||||||
|
vehicle_type: e.account.vehicle_type,
|
||||||
|
gain: e.gain,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "LOAD_SUCCESS",
|
||||||
|
payload: {
|
||||||
|
evolutionTotals: totals,
|
||||||
|
evolutionByCategory: byCategory,
|
||||||
|
evolutionByVehicle: byVehicle,
|
||||||
|
accountsLatest: latest,
|
||||||
|
accountsPeriodAnchor: anchors,
|
||||||
|
latentGainByAccount,
|
||||||
|
latentGainRollup,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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 setGroupAxis = useCallback((axis: BalanceGroupAxis) => {
|
||||||
|
dispatch({ type: "SET_GROUP_AXIS", payload: axis });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(() => load(state.period), [load, state.period]);
|
||||||
|
|
||||||
|
return { state, setPeriod, setChartMode, setGroupAxis, reload };
|
||||||
|
}
|
||||||
42
src/hooks/useIsPremium.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { useIsPremium } from "./useIsPremium";
|
||||||
|
|
||||||
|
vi.mock("./useLicense", () => ({
|
||||||
|
useLicense: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useLicense } from "./useLicense";
|
||||||
|
|
||||||
|
const mockUseLicense = vi.mocked(useLicense);
|
||||||
|
|
||||||
|
describe("useIsPremium", () => {
|
||||||
|
it('returns true when edition is "premium"', () => {
|
||||||
|
mockUseLicense.mockReturnValue({
|
||||||
|
state: { status: "ready", edition: "premium", info: null, error: null },
|
||||||
|
refresh: vi.fn(),
|
||||||
|
submitKey: vi.fn(),
|
||||||
|
checkEntitlement: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(useIsPremium()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when edition is "base"', () => {
|
||||||
|
mockUseLicense.mockReturnValue({
|
||||||
|
state: { status: "ready", edition: "base", info: null, error: null },
|
||||||
|
refresh: vi.fn(),
|
||||||
|
submitKey: vi.fn(),
|
||||||
|
checkEntitlement: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(useIsPremium()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when edition is "free"', () => {
|
||||||
|
mockUseLicense.mockReturnValue({
|
||||||
|
state: { status: "ready", edition: "free", info: null, error: null },
|
||||||
|
refresh: vi.fn(),
|
||||||
|
submitKey: vi.fn(),
|
||||||
|
checkEntitlement: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(useIsPremium()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/hooks/useIsPremium.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useLicense } from "./useLicense";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the active license edition is "premium".
|
||||||
|
* Ergonomic helper only — the server enforces entitlements independently (cf. ADR 0011 §UX).
|
||||||
|
*/
|
||||||
|
export function useIsPremium(): boolean {
|
||||||
|
const { state } = useLicense();
|
||||||
|
return state.edition === "premium";
|
||||||
|
}
|
||||||
313
src/hooks/useSnapshotEditor.test.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
// useSnapshotEditor — reducer + pure-helper unit tests (Issue #213).
|
||||||
|
//
|
||||||
|
// The project has no jsdom / @testing-library renderHook harness (see the note
|
||||||
|
// in StarterAccountsModal.test.tsx), so we test the EXTRACTED pure pieces of
|
||||||
|
// the editor's state machine directly: the `reducer`, the LOADED/prefill
|
||||||
|
// hydration mapper `holdingsFromServiceHoldings`, and the save builders
|
||||||
|
// `buildSimpleLines` / `buildDetailedLines`. This covers the holdings actions
|
||||||
|
// (ADD/REMOVE/SET_HOLDING_FIELD), prefill qty-0 exclusion + price drop, and the
|
||||||
|
// dispatch-on-account.kind invariant (a detailed account under a `simple`
|
||||||
|
// category still flows through the holdings path).
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
// db is imported transitively (hook → balance.service → db → tauri plugins).
|
||||||
|
// Stub it so no module-load path tries a real connection.
|
||||||
|
vi.mock("../services/db", () => ({
|
||||||
|
getDb: vi.fn(),
|
||||||
|
connectToProfile: vi.fn(),
|
||||||
|
initializeNewProfileDb: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
reducer,
|
||||||
|
initialState,
|
||||||
|
makeEmptyHolding,
|
||||||
|
holdingsFromServiceHoldings,
|
||||||
|
buildSimpleLines,
|
||||||
|
buildDetailedLines,
|
||||||
|
type HoldingDraft,
|
||||||
|
} from "./useSnapshotEditor";
|
||||||
|
import { BalanceServiceError } from "../services/balance.service";
|
||||||
|
import type { BalanceSnapshotHoldingWithSecurity } from "../shared/types";
|
||||||
|
|
||||||
|
function holdingRow(
|
||||||
|
over: Partial<BalanceSnapshotHoldingWithSecurity>
|
||||||
|
): BalanceSnapshotHoldingWithSecurity {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
snapshot_line_id: 10,
|
||||||
|
security_id: 100,
|
||||||
|
quantity: 5,
|
||||||
|
unit_price: 20,
|
||||||
|
value: 100,
|
||||||
|
book_cost: 80,
|
||||||
|
price_source: "manual",
|
||||||
|
price_fetched_at: null,
|
||||||
|
created_at: "2026-01-01",
|
||||||
|
updated_at: "2026-01-01",
|
||||||
|
security_symbol: "AAPL",
|
||||||
|
security_name: "Apple Inc.",
|
||||||
|
security_asset_type: "stock",
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = initialState("2026-06-06");
|
||||||
|
|
||||||
|
describe("reducer — holdings actions (#213)", () => {
|
||||||
|
it("ADD_HOLDING appends a draft to the account's basket", () => {
|
||||||
|
const h = makeEmptyHolding("stock");
|
||||||
|
const next = reducer(base, {
|
||||||
|
type: "ADD_HOLDING",
|
||||||
|
payload: { accountId: 42, holding: h },
|
||||||
|
});
|
||||||
|
expect(next.holdings[42]).toHaveLength(1);
|
||||||
|
expect(next.holdings[42][0].rowId).toBe(h.rowId);
|
||||||
|
expect(next.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ADD_HOLDING keeps other accounts' baskets untouched", () => {
|
||||||
|
const s1 = reducer(base, {
|
||||||
|
type: "ADD_HOLDING",
|
||||||
|
payload: { accountId: 1, holding: makeEmptyHolding() },
|
||||||
|
});
|
||||||
|
const s2 = reducer(s1, {
|
||||||
|
type: "ADD_HOLDING",
|
||||||
|
payload: { accountId: 2, holding: makeEmptyHolding() },
|
||||||
|
});
|
||||||
|
expect(s2.holdings[1]).toHaveLength(1);
|
||||||
|
expect(s2.holdings[2]).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SET_HOLDING_FIELD updates only the targeted row + field", () => {
|
||||||
|
const a = makeEmptyHolding();
|
||||||
|
const b = makeEmptyHolding();
|
||||||
|
let s = reducer(base, {
|
||||||
|
type: "ADD_HOLDING",
|
||||||
|
payload: { accountId: 7, holding: a },
|
||||||
|
});
|
||||||
|
s = reducer(s, { type: "ADD_HOLDING", payload: { accountId: 7, holding: b } });
|
||||||
|
s = reducer(s, {
|
||||||
|
type: "SET_HOLDING_FIELD",
|
||||||
|
payload: { accountId: 7, rowId: b.rowId, field: "quantity", value: "12" },
|
||||||
|
});
|
||||||
|
expect(s.holdings[7].find((h) => h.rowId === a.rowId)!.quantity).toBe("");
|
||||||
|
expect(s.holdings[7].find((h) => h.rowId === b.rowId)!.quantity).toBe("12");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("REMOVE_HOLDING drops the targeted row, preserving the rest", () => {
|
||||||
|
const a = makeEmptyHolding();
|
||||||
|
const b = makeEmptyHolding();
|
||||||
|
let s = reducer(base, {
|
||||||
|
type: "ADD_HOLDING",
|
||||||
|
payload: { accountId: 7, holding: a },
|
||||||
|
});
|
||||||
|
s = reducer(s, { type: "ADD_HOLDING", payload: { accountId: 7, holding: b } });
|
||||||
|
s = reducer(s, {
|
||||||
|
type: "REMOVE_HOLDING",
|
||||||
|
payload: { accountId: 7, rowId: a.rowId },
|
||||||
|
});
|
||||||
|
expect(s.holdings[7]).toHaveLength(1);
|
||||||
|
expect(s.holdings[7][0].rowId).toBe(b.rowId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makeEmptyHolding gives each row a distinct rowId", () => {
|
||||||
|
expect(makeEmptyHolding().rowId).not.toBe(makeEmptyHolding().rowId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RESET wipes both values and holdings", () => {
|
||||||
|
let s = reducer(base, {
|
||||||
|
type: "SET_VALUE",
|
||||||
|
payload: { accountId: 1, value: "100" },
|
||||||
|
});
|
||||||
|
s = reducer(s, {
|
||||||
|
type: "ADD_HOLDING",
|
||||||
|
payload: { accountId: 2, holding: makeEmptyHolding() },
|
||||||
|
});
|
||||||
|
s = reducer(s, { type: "RESET" });
|
||||||
|
expect(s.values).toEqual({});
|
||||||
|
expect(s.holdings).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PREFILL merges simple values and detailed baskets", () => {
|
||||||
|
const draft = makeEmptyHolding();
|
||||||
|
const s = reducer(base, {
|
||||||
|
type: "PREFILL",
|
||||||
|
payload: { values: { 1: "500" }, holdings: { 2: [draft] } },
|
||||||
|
});
|
||||||
|
expect(s.values[1]).toBe("500");
|
||||||
|
expect(s.holdings[2][0].rowId).toBe(draft.rowId);
|
||||||
|
expect(s.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("holdingsFromServiceHoldings — LOADED vs PREFILL (#213)", () => {
|
||||||
|
it("LOADED (keepPrice) carries qty, price, book_cost and source", () => {
|
||||||
|
const rows = [holdingRow({ quantity: 5, unit_price: 20, book_cost: 80 })];
|
||||||
|
const [d] = holdingsFromServiceHoldings(rows, { keepPrice: true });
|
||||||
|
expect(d.symbol).toBe("AAPL");
|
||||||
|
expect(d.asset_type).toBe("stock");
|
||||||
|
expect(d.quantity).toBe("5");
|
||||||
|
expect(d.unit_price).toBe("20");
|
||||||
|
expect(d.book_cost).toBe("80");
|
||||||
|
expect(d.price_source).toBe("manual");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PREFILL (keepPrice=false) drops price + source, keeps qty + book_cost", () => {
|
||||||
|
const rows = [holdingRow({ quantity: 5, unit_price: 20, book_cost: 80 })];
|
||||||
|
const [d] = holdingsFromServiceHoldings(rows, { keepPrice: false });
|
||||||
|
expect(d.quantity).toBe("5");
|
||||||
|
expect(d.book_cost).toBe("80");
|
||||||
|
// Price is re-entered / re-fetched each snapshot, so it is intentionally blank.
|
||||||
|
expect(d.unit_price).toBe("");
|
||||||
|
expect(d.price_source).toBeNull();
|
||||||
|
expect(d.price_fetched_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a NULL book_cost to an empty string", () => {
|
||||||
|
const [d] = holdingsFromServiceHoldings([holdingRow({ book_cost: null })], {
|
||||||
|
keepPrice: true,
|
||||||
|
});
|
||||||
|
expect(d.book_cost).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the per-security rows the service returns (qty-0 already excluded upstream)", () => {
|
||||||
|
// getHoldingsForLatestSnapshot excludes qty-0 server-side; the mapper is a
|
||||||
|
// faithful 1:1 projection — a sold-then-rebought title arrives as its
|
||||||
|
// latest non-zero holding and is mapped like any other.
|
||||||
|
const rows = [
|
||||||
|
holdingRow({ security_symbol: "AAPL", quantity: 3 }),
|
||||||
|
holdingRow({ security_symbol: "MSFT", quantity: 7, security_id: 101 }),
|
||||||
|
];
|
||||||
|
const drafts = holdingsFromServiceHoldings(rows, { keepPrice: false });
|
||||||
|
expect(drafts.map((d) => d.symbol)).toEqual(["AAPL", "MSFT"]);
|
||||||
|
expect(drafts.map((d) => d.quantity)).toEqual(["3", "7"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSimpleLines — scalar save path (#213)", () => {
|
||||||
|
it("emits only non-detailed accounts with a non-empty value", () => {
|
||||||
|
const lines = buildSimpleLines(
|
||||||
|
{ 1: "100", 2: " ", 3: "250", 4: "999" },
|
||||||
|
new Set([4]) // account 4 is detailed → must NOT appear here
|
||||||
|
);
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{ account_id: 1, value: 100, account_kind: "simple" },
|
||||||
|
{ account_id: 3, value: 250, account_kind: "simple" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts comma decimals and throws on a non-numeric value", () => {
|
||||||
|
expect(buildSimpleLines({ 1: "12,5" }, new Set())[0].value).toBe(12.5);
|
||||||
|
expect(() => buildSimpleLines({ 1: "abc" }, new Set())).toThrow(
|
||||||
|
BalanceServiceError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDetailedLines — holdings save path (#213)", () => {
|
||||||
|
function draft(over: Partial<HoldingDraft>): HoldingDraft {
|
||||||
|
return {
|
||||||
|
...makeEmptyHolding("stock"),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("builds one line per detailed account, value = rounded-cent SUM of holdings", () => {
|
||||||
|
const holdings = {
|
||||||
|
5: [
|
||||||
|
draft({ symbol: "AAPL", quantity: "2", unit_price: "10.005", book_cost: "15" }),
|
||||||
|
draft({ symbol: "MSFT", quantity: "1", unit_price: "30.10" }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const [line] = buildDetailedLines(holdings, new Set([5]));
|
||||||
|
expect(line.account_id).toBe(5);
|
||||||
|
expect(line.holdings).toHaveLength(2);
|
||||||
|
// AAPL 2 * 10.005 = 20.01 (rounded), MSFT 1 * 30.10 = 30.10 → 50.11.
|
||||||
|
expect(line.holdings![0].value).toBe(20.01);
|
||||||
|
expect(line.value).toBe(50.11);
|
||||||
|
// The aggregated line carries the holdings field (marks it detailed) and
|
||||||
|
// no scalar qty/price.
|
||||||
|
expect(line.holdings).toBeDefined();
|
||||||
|
expect(line.quantity).toBeUndefined();
|
||||||
|
expect(line.unit_price).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits an EMPTY holdings array for a detailed account with no positions (still detailed)", () => {
|
||||||
|
const [line] = buildDetailedLines({ 5: [] }, new Set([5]));
|
||||||
|
expect(line.holdings).toEqual([]);
|
||||||
|
expect(line.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes a detailed account even when its basket key is absent", () => {
|
||||||
|
// Dispatch is on account.kind: account 9 is detailed but the user never
|
||||||
|
// touched it, so `holdings[9]` is undefined — it must still yield a line
|
||||||
|
// (with an empty holdings array) so the save records it as detailed.
|
||||||
|
const [line] = buildDetailedLines({}, new Set([9]));
|
||||||
|
expect(line.account_id).toBe(9);
|
||||||
|
expect(line.holdings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a fully-blank row but throws on a partially-filled one", () => {
|
||||||
|
const blank = draft({ symbol: "", quantity: "", unit_price: "" });
|
||||||
|
const okRow = draft({ symbol: "BTC", quantity: "1", unit_price: "100", asset_type: "crypto" });
|
||||||
|
const built = buildDetailedLines({ 5: [blank, okRow] }, new Set([5]));
|
||||||
|
expect(built[0].holdings).toHaveLength(1);
|
||||||
|
expect(built[0].holdings![0].symbol).toBe("BTC");
|
||||||
|
// A symbol with no quantity is a partial row → typed error, no silent drop.
|
||||||
|
const partial = draft({ symbol: "ETH", quantity: "", unit_price: "" });
|
||||||
|
expect(() => buildDetailedLines({ 5: [partial] }, new Set([5]))).toThrow(
|
||||||
|
BalanceServiceError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries asset_type + currency + book_cost through to the holding input", () => {
|
||||||
|
const [line] = buildDetailedLines(
|
||||||
|
{
|
||||||
|
5: [
|
||||||
|
draft({
|
||||||
|
symbol: "btc",
|
||||||
|
asset_type: "crypto",
|
||||||
|
currency: "CAD",
|
||||||
|
quantity: "0.5",
|
||||||
|
unit_price: "1000",
|
||||||
|
book_cost: "400",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
new Set([5])
|
||||||
|
);
|
||||||
|
const h = line.holdings![0];
|
||||||
|
expect(h.symbol).toBe("btc"); // trimmed only here; service normalizes case
|
||||||
|
expect(h.asset_type).toBe("crypto");
|
||||||
|
expect(h.currency).toBe("CAD");
|
||||||
|
expect(h.book_cost).toBe(400);
|
||||||
|
expect(h.value).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dispatch on account.kind — detailed under a 'simple' category (#213)", () => {
|
||||||
|
it("routes a detailed account through holdings even if its category is simple", () => {
|
||||||
|
// Regression target: the account's OWN kind decides the path. Account 5 is
|
||||||
|
// detailed; nothing about a 'simple' category should pull it into the
|
||||||
|
// scalar (buildSimpleLines) path.
|
||||||
|
const detailedIds = new Set([5]);
|
||||||
|
const values = { 5: "12345" }; // a stray scalar value must be ignored
|
||||||
|
const holdings = {
|
||||||
|
5: [
|
||||||
|
{
|
||||||
|
...makeEmptyHolding("stock"),
|
||||||
|
symbol: "AAPL",
|
||||||
|
quantity: "2",
|
||||||
|
unit_price: "50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(buildSimpleLines(values, detailedIds)).toEqual([]); // not scalar
|
||||||
|
const [line] = buildDetailedLines(holdings, detailedIds);
|
||||||
|
expect(line.account_id).toBe(5);
|
||||||
|
expect(line.value).toBe(100);
|
||||||
|
expect(line.holdings![0].symbol).toBe("AAPL");
|
||||||
|
});
|
||||||
|
});
|
||||||
849
src/hooks/useSnapshotEditor.ts
Normal file
|
|
@ -0,0 +1,849 @@
|
||||||
|
// useSnapshotEditor — scoped useReducer hook backing SnapshotEditPage.
|
||||||
|
//
|
||||||
|
// Lifecycle of a single snapshot (Issue #146 / Bilan #1b; reworked for
|
||||||
|
// per-title detail in Issue #213 / Bilan détail par titre):
|
||||||
|
// 1. mount in 'new' mode (no `?date=` query param) → user picks a date,
|
||||||
|
// types values, hits Save → service.saveSnapshotAtomic;
|
||||||
|
// 2. mount in 'edit' mode (`?date=YYYY-MM-DD`) → load snapshot + lines,
|
||||||
|
// user edits values, hits Save → upsert on the existing snapshot;
|
||||||
|
// 3. delete → service.deleteSnapshot (the page wraps this in a
|
||||||
|
// double-confirm modal that requires retyping the snapshot date).
|
||||||
|
//
|
||||||
|
// ENTRY MODE DISPATCH (#213) — the editor classifies each account by its OWN
|
||||||
|
// `account.kind` (simple | detailed), NOT by `category_kind` (simple | priced).
|
||||||
|
// The category kind is only a *suggested default* for a brand-new account in
|
||||||
|
// AccountForm; once an account exists, its stored `kind` is authoritative.
|
||||||
|
// - simple : one scalar value per account, kept as a string in `values`.
|
||||||
|
// - detailed: a basket of per-security holdings in `holdings` — one
|
||||||
|
// `HoldingDraft` per title. On save the aggregated line carries
|
||||||
|
// NO scalar qty/price; the service recomputes value = SUM(holdings)
|
||||||
|
// and writes the holdings in the same transaction.
|
||||||
|
//
|
||||||
|
// The legacy "priced scalar" path (one security per account via account.symbol
|
||||||
|
// + scalar quantity/unit_price on the line) is SUPERSEDED: after migration v16
|
||||||
|
// (#211) every former-priced account is now `kind='detailed'` with one holding,
|
||||||
|
// so those accounts flow through the holdings path. There is no scalar-priced
|
||||||
|
// editor branch anymore.
|
||||||
|
|
||||||
|
import {
|
||||||
|
useReducer,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import type {
|
||||||
|
BalanceAccountWithCategory,
|
||||||
|
BalanceAssetType,
|
||||||
|
BalanceCategory,
|
||||||
|
BalanceSecurity,
|
||||||
|
BalanceSnapshot,
|
||||||
|
BalanceSnapshotLine,
|
||||||
|
BalanceSnapshotHoldingWithSecurity,
|
||||||
|
} from "../shared/types";
|
||||||
|
import {
|
||||||
|
listBalanceAccounts,
|
||||||
|
listBalanceCategories,
|
||||||
|
listSecurities,
|
||||||
|
getSnapshotByDate,
|
||||||
|
deleteSnapshot,
|
||||||
|
listLinesBySnapshot,
|
||||||
|
saveSnapshotAtomic,
|
||||||
|
getPreviousSnapshot,
|
||||||
|
getHoldingsForLatestSnapshot,
|
||||||
|
listHoldingsBySnapshotLine,
|
||||||
|
BalanceServiceError,
|
||||||
|
type SnapshotLineInput,
|
||||||
|
type SnapshotHoldingInput,
|
||||||
|
} from "../services/balance.service";
|
||||||
|
|
||||||
|
export type SnapshotEditorMode = "new" | "edit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String-typed, editable mirror of one position inside a detailed account
|
||||||
|
* (Issue #213). All numeric fields are kept as strings to preserve empty /
|
||||||
|
* partial input; conversion to numbers happens at save time. `rowId` is a
|
||||||
|
* stable client-side identity so React can key the sub-rows even before the
|
||||||
|
* holding is persisted (a fresh holding has no DB id yet).
|
||||||
|
*/
|
||||||
|
export interface HoldingDraft {
|
||||||
|
/** Stable client-side row identity for React keys (NOT a DB id). */
|
||||||
|
rowId: string;
|
||||||
|
/** Security symbol (normalized server-side). */
|
||||||
|
symbol: string;
|
||||||
|
asset_type: BalanceAssetType;
|
||||||
|
/** ISO 4217; defaults to 'CAD'. */
|
||||||
|
currency: string;
|
||||||
|
/** Optional human-readable security name. */
|
||||||
|
security_name: string;
|
||||||
|
quantity: string;
|
||||||
|
unit_price: string;
|
||||||
|
/** Acquisition cost basis for the unrealized-gain column; optional. */
|
||||||
|
book_cost: string;
|
||||||
|
/** Carried through from a fetched price so save can attribute the source. */
|
||||||
|
price_source: string | null;
|
||||||
|
price_fetched_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let holdingRowSeq = 0;
|
||||||
|
/** Monotonic client-side row id for holding drafts. */
|
||||||
|
function nextRowId(): string {
|
||||||
|
holdingRowSeq += 1;
|
||||||
|
return `h${holdingRowSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an empty holding draft (used by ADD_HOLDING). `asset_type` defaults to
|
||||||
|
* the account's category asset_type when known, else 'stock'.
|
||||||
|
*/
|
||||||
|
export function makeEmptyHolding(
|
||||||
|
assetType: BalanceAssetType = "stock"
|
||||||
|
): HoldingDraft {
|
||||||
|
return {
|
||||||
|
rowId: nextRowId(),
|
||||||
|
symbol: "",
|
||||||
|
asset_type: assetType,
|
||||||
|
currency: "CAD",
|
||||||
|
security_name: "",
|
||||||
|
quantity: "",
|
||||||
|
unit_price: "",
|
||||||
|
book_cost: "",
|
||||||
|
price_source: null,
|
||||||
|
price_fetched_at: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map server holdings (from `getHoldingsForLatestSnapshot` for prefill, or
|
||||||
|
* `listHoldingsBySnapshotLine` for an edited snapshot) into editable string
|
||||||
|
* drafts. `keepPrice` controls whether the unit_price is carried over:
|
||||||
|
* - LOADED (editing an existing snapshot) keeps the saved price.
|
||||||
|
* - PREFILL of the NEXT snapshot drops the price (the user re-enters or
|
||||||
|
* re-fetches it) but keeps quantity + book_cost. Titles with quantity 0 are
|
||||||
|
* already excluded by `getHoldingsForLatestSnapshot`; a title sold then
|
||||||
|
* re-bought reappears because its latest non-zero holding wins server-side.
|
||||||
|
*/
|
||||||
|
export function holdingsFromServiceHoldings(
|
||||||
|
rows: BalanceSnapshotHoldingWithSecurity[],
|
||||||
|
opts: { keepPrice: boolean }
|
||||||
|
): HoldingDraft[] {
|
||||||
|
return rows.map((h) => ({
|
||||||
|
rowId: nextRowId(),
|
||||||
|
symbol: h.security_symbol,
|
||||||
|
asset_type: h.security_asset_type,
|
||||||
|
// The joined holdings view doesn't carry the security currency; CAD is the
|
||||||
|
// MVP currency and the save path defaults it server-side anyway.
|
||||||
|
currency: "CAD",
|
||||||
|
security_name: h.security_name ?? "",
|
||||||
|
quantity:
|
||||||
|
h.quantity !== null && h.quantity !== undefined ? String(h.quantity) : "",
|
||||||
|
unit_price: opts.keepPrice
|
||||||
|
? h.unit_price !== null && h.unit_price !== undefined
|
||||||
|
? String(h.unit_price)
|
||||||
|
: ""
|
||||||
|
: "",
|
||||||
|
book_cost:
|
||||||
|
h.book_cost !== null && h.book_cost !== undefined
|
||||||
|
? String(h.book_cost)
|
||||||
|
: "",
|
||||||
|
// Prefill drops the source (the carried price is stale); LOADED keeps it.
|
||||||
|
price_source: opts.keepPrice ? h.price_source : null,
|
||||||
|
price_fetched_at: opts.keepPrice ? h.price_fetched_at : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
mode: SnapshotEditorMode;
|
||||||
|
/** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */
|
||||||
|
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[];
|
||||||
|
/** Securities catalogue powering the SecurityPicker autocomplete (#214). */
|
||||||
|
securities: BalanceSecurity[];
|
||||||
|
/**
|
||||||
|
* Map of account_id → string-typed value (simple accounts only). We keep
|
||||||
|
* strings to preserve empty / partial input; conversion to number happens
|
||||||
|
* at save time.
|
||||||
|
*/
|
||||||
|
values: Record<number, string>;
|
||||||
|
/**
|
||||||
|
* Map of account_id → array of `HoldingDraft` (detailed accounts only —
|
||||||
|
* dispatched on `account.kind === 'detailed'`). One entry per security held.
|
||||||
|
* Same partial-input guarantee as `values`.
|
||||||
|
*/
|
||||||
|
holdings: Record<number, HoldingDraft[]>;
|
||||||
|
/** 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[];
|
||||||
|
securities: BalanceSecurity[];
|
||||||
|
values: Record<number, string>;
|
||||||
|
holdings: Record<number, HoldingDraft[]>;
|
||||||
|
previousSnapshot: BalanceSnapshot | null;
|
||||||
|
previousLines: BalanceSnapshotLine[] | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: "SET_DATE"; payload: string }
|
||||||
|
| { type: "SET_VALUE"; payload: { accountId: number; value: string } }
|
||||||
|
| {
|
||||||
|
type: "ADD_HOLDING";
|
||||||
|
payload: { accountId: number; holding: HoldingDraft };
|
||||||
|
}
|
||||||
|
| { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } }
|
||||||
|
| {
|
||||||
|
type: "SET_HOLDING_FIELD";
|
||||||
|
payload: {
|
||||||
|
accountId: number;
|
||||||
|
rowId: string;
|
||||||
|
field: keyof Omit<HoldingDraft, "rowId">;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Apply a SecurityPicker selection: sets symbol + asset_type (+ name) in
|
||||||
|
// one dispatch and drops any stale fetched-price attribution, since the
|
||||||
|
// symbol — and thus the price's subject — has changed (#214).
|
||||||
|
type: "SET_HOLDING_SECURITY";
|
||||||
|
payload: {
|
||||||
|
accountId: number;
|
||||||
|
rowId: string;
|
||||||
|
symbol: string;
|
||||||
|
asset_type: BalanceAssetType;
|
||||||
|
security_name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "PREFILL";
|
||||||
|
payload: {
|
||||||
|
values: Record<number, string>;
|
||||||
|
holdings: Record<number, HoldingDraft[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { type: "RESET" }
|
||||||
|
| { type: "CLEAR_DIRTY" };
|
||||||
|
|
||||||
|
export function initialState(initialDate: string): State {
|
||||||
|
return {
|
||||||
|
mode: "new",
|
||||||
|
snapshotDate: initialDate,
|
||||||
|
snapshot: null,
|
||||||
|
accounts: [],
|
||||||
|
categories: [],
|
||||||
|
securities: [],
|
||||||
|
values: {},
|
||||||
|
holdings: {},
|
||||||
|
previousSnapshot: null,
|
||||||
|
previousLines: null,
|
||||||
|
isLoading: false,
|
||||||
|
isSaving: false,
|
||||||
|
isDirty: false,
|
||||||
|
error: null,
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure reducer — exported so the editor's state machine can be unit-tested
|
||||||
|
* without rendering the hook (the project has no jsdom/renderHook harness).
|
||||||
|
*/
|
||||||
|
export 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,
|
||||||
|
securities: action.payload.securities,
|
||||||
|
values: action.payload.values,
|
||||||
|
holdings: action.payload.holdings,
|
||||||
|
previousSnapshot: action.payload.previousSnapshot,
|
||||||
|
previousLines: action.payload.previousLines,
|
||||||
|
isLoading: false,
|
||||||
|
isDirty: false,
|
||||||
|
error: null,
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
case "SET_DATE":
|
||||||
|
// Editable in both modes now (#200): in 'edit' mode a changed date
|
||||||
|
// triggers a snapshot move on save (lines preserved).
|
||||||
|
return { ...state, snapshotDate: action.payload, isDirty: true };
|
||||||
|
case "SET_VALUE":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
values: {
|
||||||
|
...state.values,
|
||||||
|
[action.payload.accountId]: action.payload.value,
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
};
|
||||||
|
case "ADD_HOLDING": {
|
||||||
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
holdings: {
|
||||||
|
...state.holdings,
|
||||||
|
[action.payload.accountId]: [...existing, action.payload.holding],
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "REMOVE_HOLDING": {
|
||||||
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
holdings: {
|
||||||
|
...state.holdings,
|
||||||
|
[action.payload.accountId]: existing.filter(
|
||||||
|
(h) => h.rowId !== action.payload.rowId
|
||||||
|
),
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "SET_HOLDING_FIELD": {
|
||||||
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
||||||
|
const next = existing.map((h) =>
|
||||||
|
h.rowId === action.payload.rowId
|
||||||
|
? { ...h, [action.payload.field]: action.payload.value }
|
||||||
|
: h
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
holdings: {
|
||||||
|
...state.holdings,
|
||||||
|
[action.payload.accountId]: next,
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "SET_HOLDING_SECURITY": {
|
||||||
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
||||||
|
const next = existing.map((h) =>
|
||||||
|
h.rowId === action.payload.rowId
|
||||||
|
? {
|
||||||
|
...h,
|
||||||
|
symbol: action.payload.symbol,
|
||||||
|
asset_type: action.payload.asset_type,
|
||||||
|
security_name: action.payload.security_name,
|
||||||
|
// The previously-fetched price (if any) was for the OLD symbol;
|
||||||
|
// drop its attribution so save doesn't mis-credit the source.
|
||||||
|
price_source: null,
|
||||||
|
price_fetched_at: null,
|
||||||
|
}
|
||||||
|
: h
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
holdings: {
|
||||||
|
...state.holdings,
|
||||||
|
[action.payload.accountId]: next,
|
||||||
|
},
|
||||||
|
isDirty: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "PREFILL":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
values: { ...state.values, ...action.payload.values },
|
||||||
|
holdings: { ...state.holdings, ...action.payload.holdings },
|
||||||
|
isDirty: true,
|
||||||
|
};
|
||||||
|
case "RESET":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
// Keep the loaded structure (accounts, categories, snapshot) but wipe
|
||||||
|
// user input back to a clean slate.
|
||||||
|
values: {},
|
||||||
|
holdings: {},
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse "12.34" / "12,34" → finite number, or null when empty/invalid. */
|
||||||
|
function parseDecimal(raw: string | null | undefined): number | null {
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
const trimmed = String(raw).trim().replace(",", ".");
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const n = Number(trimmed);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the simple-account `SnapshotLineInput[]` from the editor's `values`
|
||||||
|
* map. Only accounts whose own kind is NOT detailed contribute here; detailed
|
||||||
|
* accounts go through `buildDetailedLines`. THROWS a typed BalanceServiceError
|
||||||
|
* on the first invalid value so no DB mutation happens on bad input (#176).
|
||||||
|
* Exported for unit tests.
|
||||||
|
*/
|
||||||
|
export function buildSimpleLines(
|
||||||
|
values: Record<number, string>,
|
||||||
|
detailedAccountIds: ReadonlySet<number>
|
||||||
|
): SnapshotLineInput[] {
|
||||||
|
return Object.entries(values)
|
||||||
|
.filter(
|
||||||
|
([accountIdStr, v]) =>
|
||||||
|
!detailedAccountIds.has(Number(accountIdStr)) &&
|
||||||
|
v !== undefined &&
|
||||||
|
String(v).trim().length > 0
|
||||||
|
)
|
||||||
|
.map(([accountIdStr, raw]) => {
|
||||||
|
const accountId = Number(accountIdStr);
|
||||||
|
const num = parseDecimal(raw);
|
||||||
|
if (num === null) {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"snapshot_value_invalid",
|
||||||
|
`Invalid value for account ${accountId}: "${raw}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
account_id: accountId,
|
||||||
|
value: num,
|
||||||
|
account_kind: "simple" as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the detailed-account `SnapshotLineInput[]` (one per account, each
|
||||||
|
* carrying its `holdings` array) from the editor's `holdings` map. The presence
|
||||||
|
* of the `holdings` field — even an empty array — marks the line detailed for
|
||||||
|
* the service. Empty / blank holding rows (no symbol AND no qty AND no price)
|
||||||
|
* are dropped before save so a half-typed row doesn't fail validation. THROWS a
|
||||||
|
* typed error on a partially-filled row. The aggregated `value` is the SUM of
|
||||||
|
* the rounded-cent holding values; the service re-rounds and re-validates it.
|
||||||
|
* Exported for unit tests.
|
||||||
|
*/
|
||||||
|
export function buildDetailedLines(
|
||||||
|
holdings: Record<number, HoldingDraft[]>,
|
||||||
|
detailedAccountIds: ReadonlySet<number>
|
||||||
|
): SnapshotLineInput[] {
|
||||||
|
const lines: SnapshotLineInput[] = [];
|
||||||
|
for (const accountId of detailedAccountIds) {
|
||||||
|
const drafts = holdings[accountId] ?? [];
|
||||||
|
const built: SnapshotHoldingInput[] = [];
|
||||||
|
for (const d of drafts) {
|
||||||
|
const symbol = d.symbol.trim();
|
||||||
|
const qtyRaw = String(d.quantity ?? "").trim();
|
||||||
|
const priceRaw = String(d.unit_price ?? "").trim();
|
||||||
|
const isBlank = symbol.length === 0 && qtyRaw.length === 0 && priceRaw.length === 0;
|
||||||
|
if (isBlank) continue; // skip an untouched / freshly-added empty row
|
||||||
|
if (symbol.length === 0) {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"snapshot_holding_invalid",
|
||||||
|
`A holding for account ${accountId} is missing its symbol`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const qty = parseDecimal(d.quantity);
|
||||||
|
const price = parseDecimal(d.unit_price);
|
||||||
|
if (qty === null) {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"snapshot_priced_quantity_required",
|
||||||
|
`Invalid quantity for ${symbol} (account ${accountId}): "${d.quantity}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (price === null) {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"snapshot_priced_unit_price_required",
|
||||||
|
`Invalid unit price for ${symbol} (account ${accountId}): "${d.unit_price}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bookCost = parseDecimal(d.book_cost);
|
||||||
|
const value = Math.round(qty * price * 100) / 100;
|
||||||
|
built.push({
|
||||||
|
symbol,
|
||||||
|
asset_type: d.asset_type,
|
||||||
|
currency: d.currency || "CAD",
|
||||||
|
security_name: d.security_name.trim() || null,
|
||||||
|
quantity: qty,
|
||||||
|
unit_price: price,
|
||||||
|
value,
|
||||||
|
book_cost: bookCost,
|
||||||
|
price_source: d.price_source,
|
||||||
|
price_fetched_at: d.price_fetched_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Aggregated value = rounded-cent SUM of the holdings' rounded-cent values.
|
||||||
|
const total =
|
||||||
|
Math.round(built.reduce((s, h) => s + h.value, 0) * 100) / 100;
|
||||||
|
lines.push({
|
||||||
|
account_id: accountId,
|
||||||
|
value: total,
|
||||||
|
// `holdings` present (even empty) ⇒ detailed save path; qty/price omitted.
|
||||||
|
holdings: built,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, securities] = await Promise.all([
|
||||||
|
listBalanceAccounts(),
|
||||||
|
listBalanceCategories(),
|
||||||
|
listSecurities(),
|
||||||
|
]);
|
||||||
|
const values: Record<number, string> = {};
|
||||||
|
const holdings: Record<number, HoldingDraft[]> = {};
|
||||||
|
let previousLines: BalanceSnapshotLine[] | null = null;
|
||||||
|
// Index each account's OWN kind (simple|detailed) — this, not the
|
||||||
|
// category kind, decides which input map a line belongs to (#213).
|
||||||
|
const accountById = new Map<number, BalanceAccountWithCategory>();
|
||||||
|
for (const acc of accounts) accountById.set(acc.id, acc);
|
||||||
|
|
||||||
|
const existing = await getSnapshotByDate(targetDate);
|
||||||
|
const isEdit = !!existing;
|
||||||
|
if (existing) {
|
||||||
|
const lines = await listLinesBySnapshot(existing.id);
|
||||||
|
for (const line of lines) {
|
||||||
|
const acc = accountById.get(line.account_id);
|
||||||
|
if (acc?.kind === "detailed") {
|
||||||
|
// Hydrate the basket from this line's persisted holdings.
|
||||||
|
const rows = await listHoldingsBySnapshotLine(line.id);
|
||||||
|
holdings[line.account_id] = holdingsFromServiceHoldings(rows, {
|
||||||
|
keepPrice: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
values[line.account_id] = String(line.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Detailed accounts with NO line yet at this snapshot still get an
|
||||||
|
// (empty) basket so the editor renders the detailed variant for them.
|
||||||
|
for (const acc of accounts) {
|
||||||
|
if (acc.kind === "detailed" && holdings[acc.id] === undefined) {
|
||||||
|
holdings[acc.id] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 'new' mode: prefill detailed baskets from each account's latest
|
||||||
|
// snapshot holdings (qty-0 excluded server-side), price dropped.
|
||||||
|
for (const acc of accounts) {
|
||||||
|
if (acc.kind === "detailed") {
|
||||||
|
const rows = await getHoldingsForLatestSnapshot(acc.id);
|
||||||
|
holdings[acc.id] = holdingsFromServiceHoldings(rows, {
|
||||||
|
keepPrice: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
securities,
|
||||||
|
values,
|
||||||
|
holdings,
|
||||||
|
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 addHolding = useCallback(
|
||||||
|
(accountId: number, assetType: BalanceAssetType = "stock") => {
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_HOLDING",
|
||||||
|
payload: { accountId, holding: makeEmptyHolding(assetType) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeHolding = useCallback((accountId: number, rowId: string) => {
|
||||||
|
dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setHoldingField = useCallback(
|
||||||
|
(
|
||||||
|
accountId: number,
|
||||||
|
rowId: string,
|
||||||
|
field: keyof Omit<HoldingDraft, "rowId">,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_HOLDING_FIELD",
|
||||||
|
payload: { accountId, rowId, field, value },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a SecurityPicker selection to a holding row (#214): sets symbol +
|
||||||
|
* asset_type + name in one shot. `name` is empty for a freshly-created
|
||||||
|
* symbol; an existing security carries its catalogue name.
|
||||||
|
*/
|
||||||
|
const setHoldingSecurity = useCallback(
|
||||||
|
(
|
||||||
|
accountId: number,
|
||||||
|
rowId: string,
|
||||||
|
pick: {
|
||||||
|
symbol: string;
|
||||||
|
asset_type: BalanceAssetType;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
dispatch({
|
||||||
|
type: "SET_HOLDING_SECURITY",
|
||||||
|
payload: {
|
||||||
|
accountId,
|
||||||
|
rowId,
|
||||||
|
symbol: pick.symbol,
|
||||||
|
asset_type: pick.asset_type,
|
||||||
|
security_name: pick.name ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
dispatch({ type: "RESET" });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the prefill map from the previous snapshot (simple accounts only —
|
||||||
|
* detailed accounts are prefilled from their latest holdings at LOAD time in
|
||||||
|
* 'new' mode, which is more accurate than copying the previous *line*). Per
|
||||||
|
* spec-decisions row "Bouton Pré-remplir": simple → copy value.
|
||||||
|
*/
|
||||||
|
const prefillFromPrevious = useCallback(() => {
|
||||||
|
const lines = state.previousLines;
|
||||||
|
if (!lines || lines.length === 0) return;
|
||||||
|
const accountById = new Map<number, BalanceAccountWithCategory>();
|
||||||
|
for (const acc of state.accounts) accountById.set(acc.id, acc);
|
||||||
|
const nextSimple: Record<number, string> = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
const acc = accountById.get(line.account_id);
|
||||||
|
if (!acc) continue; // archived account — skip
|
||||||
|
if (acc.kind === "simple") {
|
||||||
|
nextSimple[line.account_id] = String(line.value);
|
||||||
|
}
|
||||||
|
// Detailed accounts: intentionally NOT prefilled from the previous line
|
||||||
|
// here — their basket was already hydrated from the latest holdings.
|
||||||
|
}
|
||||||
|
dispatch({
|
||||||
|
type: "PREFILL",
|
||||||
|
payload: { values: nextSimple, holdings: {} },
|
||||||
|
});
|
||||||
|
}, [state.previousLines, state.accounts]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the editor state to the database (#176 — atomic; #213 — detailed).
|
||||||
|
*
|
||||||
|
* Order of operations:
|
||||||
|
* 1. Build & validate `simpleLines` (scalar) and `detailedLines` (holdings)
|
||||||
|
* from editor state. Any input parsing error throws BEFORE any DB
|
||||||
|
* mutation, so an invalid form never produces an orphan snapshot row.
|
||||||
|
* 2. Call `saveSnapshotAtomic` which wraps the snapshot INSERT (new mode),
|
||||||
|
* the line rewrite AND the holdings rewrite in a single BEGIN/COMMIT/
|
||||||
|
* ROLLBACK transaction.
|
||||||
|
*
|
||||||
|
* Modes:
|
||||||
|
* - 'new' mode: atomic helper inserts the snapshot row + its lines/holdings.
|
||||||
|
* - 'edit' mode: only the lines/holdings get rewritten on the existing row.
|
||||||
|
*/
|
||||||
|
const save = useCallback(async (): Promise<{ snapshotId: number }> => {
|
||||||
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
|
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
||||||
|
try {
|
||||||
|
// Set of detailed account ids — dispatched on each account's OWN kind.
|
||||||
|
const detailedAccountIds = new Set<number>();
|
||||||
|
for (const acc of state.accounts) {
|
||||||
|
if (acc.kind === "detailed") detailedAccountIds.add(acc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1 — build & validate every line in memory. THROW HERE means no DB
|
||||||
|
// mutation has happened yet, so no orphan snapshot can be left behind by
|
||||||
|
// a validation failure (#176).
|
||||||
|
const simpleLines = buildSimpleLines(state.values, detailedAccountIds);
|
||||||
|
const detailedLines = buildDetailedLines(
|
||||||
|
state.holdings,
|
||||||
|
detailedAccountIds
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2 — atomic write. BEGIN / INSERT snapshot (if 'new') /
|
||||||
|
// INSERT lines + holdings / COMMIT, with ROLLBACK on any failure.
|
||||||
|
const existingSnapshotId =
|
||||||
|
state.mode === "edit" && state.snapshot ? state.snapshot.id : null;
|
||||||
|
// Edit-mode date move (#200): when the user changed the date of an
|
||||||
|
// existing snapshot, forward the new date so the atomic save moves the
|
||||||
|
// row (preserving its lines) in the same transaction. A collision
|
||||||
|
// surfaces as `snapshot_date_exists` and rolls back.
|
||||||
|
const moveToDate =
|
||||||
|
state.mode === "edit" &&
|
||||||
|
state.snapshot &&
|
||||||
|
state.snapshotDate !== state.snapshot.snapshot_date
|
||||||
|
? state.snapshotDate
|
||||||
|
: null;
|
||||||
|
const { snapshotId } = await saveSnapshotAtomic({
|
||||||
|
existingSnapshotId,
|
||||||
|
snapshot_date: state.snapshotDate,
|
||||||
|
lines: [...simpleLines, ...detailedLines],
|
||||||
|
moveToDate,
|
||||||
|
});
|
||||||
|
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.holdings,
|
||||||
|
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,
|
||||||
|
addHolding,
|
||||||
|
removeHolding,
|
||||||
|
setHoldingField,
|
||||||
|
setHoldingSecurity,
|
||||||
|
reset,
|
||||||
|
prefillFromPrevious,
|
||||||
|
save,
|
||||||
|
remove,
|
||||||
|
/** Manual reload (e.g. after navigation between dates). */
|
||||||
|
reload: () => loadForDate(state.snapshotDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"adjustments": "Adjustments",
|
"adjustments": "Adjustments",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
|
"balance": "Balance sheet",
|
||||||
"settings": "Settings"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|
@ -252,6 +253,10 @@
|
||||||
"Assign categories by clicking the category dropdown on each row",
|
"Assign categories by clicking the category dropdown on each row",
|
||||||
"Auto-categorize uses your keyword rules to categorize transactions in bulk"
|
"Auto-categorize uses your keyword rules to categorize transactions in bulk"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"transferIcon": {
|
||||||
|
"tooltip": "Linked to a balance account",
|
||||||
|
"ariaLabel": "Transaction linked to a balance account"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|
@ -630,6 +635,48 @@
|
||||||
"Your data is stored locally and is never affected by updates",
|
"Your data is stored locally and is never affected by updates",
|
||||||
"Change the app language using the language selector in the sidebar"
|
"Change the app language using the language selector in the sidebar"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Privacy",
|
||||||
|
"priceFetchConsent": {
|
||||||
|
"label": "Price fetching via Maximus",
|
||||||
|
"description": "Allow Simpl'Résultat to use the Maximus proxy to fetch asset prices. Privacy: your IP is hidden.",
|
||||||
|
"confirmRevoke": "The fetch button will ask for consent again next time. Continue?",
|
||||||
|
"revokeButton": "Revoke consent",
|
||||||
|
"notPremium": "Premium licenses only"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backToHome": "Back to settings",
|
||||||
|
"home": {
|
||||||
|
"intro": "Configure the app across three sections: users, data and system."
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Users",
|
||||||
|
"description": "Accounts, licenses and user guide.",
|
||||||
|
"sections": {
|
||||||
|
"accounts": "Accounts",
|
||||||
|
"licenses": "Licenses",
|
||||||
|
"userGuide": "User guide"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"title": "Data",
|
||||||
|
"description": "Categories, backups and privacy.",
|
||||||
|
"sections": {
|
||||||
|
"categories": "Categories",
|
||||||
|
"backup": "Backup",
|
||||||
|
"priceFetch": "Price privacy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"title": "System",
|
||||||
|
"description": "Version, updates, logs and history.",
|
||||||
|
"sections": {
|
||||||
|
"version": "Version",
|
||||||
|
"update": "Update",
|
||||||
|
"changelog": "Version history",
|
||||||
|
"logs": "Logs and feedback"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
|
|
@ -896,6 +943,51 @@
|
||||||
"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, track their evolution over time, and compute the true return of each investment account by linking transfers (deposits/withdrawals) to the matching accounts. Two independent axes: the asset class (the account's type: Cash, Funds / ETF, Stocks, Crypto, Other) and the fiscal envelope (an optional account attribute: TFSA, RRSP, RRIF, FHSA, RESP, or none). An account is entered either as a single amount (simple account) or security by security (detailed account: each holding with quantity × price and a cost basis, the account value being the sum of its positions).",
|
||||||
|
"features": [
|
||||||
|
"5 standard types pre-installed, which are asset classes (Cash, Funds / ETF, Stocks, Crypto, Other) — renameable, non-deletable; a type groups accounts of the same nature (distinct from transaction categories). Fiscal envelopes are no longer types",
|
||||||
|
"Custom type creation with simple (direct amount) or priced (quantity × unit price) entry mode",
|
||||||
|
"Detailed accounts (per security): an account can hold several securities — each security has its own row (symbol, quantity, price, value, cost basis, unrealized gain), the account value being the sum of its securities. Security picker with autocomplete and inline creation (symbol + asset class Stock/Crypto)",
|
||||||
|
"Unrealized gain per security and aggregated (by account, by asset class, by envelope): value − cost basis, in $ and %; per-security drill-down in the accounts table; a position without a cost basis shows \"N/A\" and is excluded from the %",
|
||||||
|
"\"Detail into securities\" wizard: switches a simple account to detailed from a pivot date; past aggregated history stays frozen and read-only",
|
||||||
|
"Accounts per type: name, optional fiscal envelope (Non-registered, TFSA, RRSP, RRIF, FHSA, RESP, or none by default), optional symbol (even for priced types — it only drives automatic price fetching), currency (CAD at MVP), notes",
|
||||||
|
"Renaming a type no longer breaks bilingual support: the custom name is stored separately, the original FR/EN translation is preserved",
|
||||||
|
"Dated snapshots with a UNIQUE constraint per date — editing means revisiting the same date, never duplicating; an existing snapshot's date can be moved (lines preserved) when the target date is free",
|
||||||
|
"\"Prefill from previous snapshot\" button: copies simple values, priced quantities, and the securities/quantities/cost bases of detailed accounts (a security at quantity 0 is skipped)",
|
||||||
|
"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 mode, with an axis sub-toggle: by asset class (default) or by envelope + 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, collapsed by default (toggle, choice remembered)",
|
||||||
|
"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 → Types tab to create an extra asset class if needed, as simple (direct amount) or priced (quantity × unit price)",
|
||||||
|
"Go to the Accounts tab to create each account (Tangerine under Cash, BTC Ledger under Crypto with symbol BTC — optional). Pick the fiscal envelope if the account sits in a TFSA/RRSP/etc., or leave it on \"None\"",
|
||||||
|
"Click \"+ New snapshot\" from /balance to open /balance/snapshot at today's date",
|
||||||
|
"Fill in values per account (grouped by type). 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 track an account security by security, create it detailed or open its actions menu → \"Detail into securities\"; at later snapshots, add each holding with its quantity, price and cost basis (the account value = the sum of its securities)",
|
||||||
|
"To edit an existing snapshot, click its point on the chart or use the date picker — the page opens in edit mode. You can also fix the date: change it then save, and the snapshot is moved with its lines (a warning shows if the target date is already taken)",
|
||||||
|
"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",
|
||||||
|
"Unrealized gain ≠ return: the unrealized gain (value − cost basis) measures your gain since purchase, while the Modified Dietz return measures performance accounting for the timing of your contributions — both show on a detailed account. Adjust the cost basis after a buy/sell, otherwise the unrealized gain drifts",
|
||||||
|
"(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",
|
||||||
|
"Toggle the stacked chart axis between \"by asset class\" and \"by envelope\" to read both sides of your net worth. Note: a snapshot taken before the class/envelope split shows under \"Other\" on the by-asset-class axis (the former TFSA/RRSP types became envelopes) — this is expected; the by-envelope axis still surfaces your TFSA/RRSP, and your amounts are unchanged"
|
||||||
|
]
|
||||||
|
},
|
||||||
"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.",
|
||||||
|
|
@ -981,7 +1073,8 @@
|
||||||
"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",
|
||||||
|
|
@ -1449,5 +1542,402 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"axisLegend": "Stacked chart grouping axis",
|
||||||
|
"totalSeriesLabel": "Total",
|
||||||
|
"mode": {
|
||||||
|
"line": "Line",
|
||||||
|
"stacked": "Stacked by type"
|
||||||
|
},
|
||||||
|
"axis": {
|
||||||
|
"byAssetClass": "By asset class",
|
||||||
|
"byVehicle": "By envelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Get started with your balance sheet",
|
||||||
|
"subtitle": "Two steps to start tracking your net worth.",
|
||||||
|
"doneBadge": "Done",
|
||||||
|
"step1": {
|
||||||
|
"title": "Create an account",
|
||||||
|
"description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.",
|
||||||
|
"cta": "Create an account"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "Enter a snapshot",
|
||||||
|
"description": "A snapshot is the picture, at a given date, of the balance in each account. Enter one a month to track changes over time.",
|
||||||
|
"cta": "Enter a snapshot",
|
||||||
|
"disabledHint": "Create an account first to unlock this step."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"starters": {
|
||||||
|
"title": "Starter accounts",
|
||||||
|
"description": "Want to add these 4 common accounts? You can rename or archive them at any time.",
|
||||||
|
"cta_add": "Add selected accounts",
|
||||||
|
"cta_later": "Later",
|
||||||
|
"collision_tooltip": "Already exists",
|
||||||
|
"items": {
|
||||||
|
"cash": "Checking account",
|
||||||
|
"tfsa": "TFSA",
|
||||||
|
"rrsp": "RRSP",
|
||||||
|
"other": "Non-registered account"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"insert": "Could not add the accounts. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": "Balance sheet",
|
||||||
|
"accountsPage": {
|
||||||
|
"title": "Balance accounts",
|
||||||
|
"tabs": {
|
||||||
|
"accounts": "Accounts",
|
||||||
|
"categories": "Types"
|
||||||
|
},
|
||||||
|
"newAccount": "New account",
|
||||||
|
"includeArchived": "Show archived accounts",
|
||||||
|
"empty": "No accounts yet. Click “New account” to start."
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"category": "Type",
|
||||||
|
"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": "Type",
|
||||||
|
"noCategory": "(no type available)",
|
||||||
|
"name": "Account name",
|
||||||
|
"nameRequired": "Name is required.",
|
||||||
|
"symbol": "Symbol",
|
||||||
|
"symbolPricedHint": "optional — only needed for automatic price fetching",
|
||||||
|
"symbolPlaceholderSimple": "Optional",
|
||||||
|
"symbolPlaceholderPriced": "e.g. AAPL, BTC-USD (optional)",
|
||||||
|
"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",
|
||||||
|
"vehicleType": {
|
||||||
|
"label": "Fiscal envelope",
|
||||||
|
"none": "None",
|
||||||
|
"unregistered": "Non-registered",
|
||||||
|
"tfsa": "TFSA",
|
||||||
|
"rrsp": "RRSP",
|
||||||
|
"rrif": "RRIF",
|
||||||
|
"fhsa": "FHSA",
|
||||||
|
"resp": "RESP",
|
||||||
|
"hint": "Tax shelter for this account (TFSA, RRSP…). Optional — the asset class stays the type."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"label": "Entry mode",
|
||||||
|
"simple": "Single amount",
|
||||||
|
"detailed": "By title (securities)",
|
||||||
|
"hint": "Single amount = one total per snapshot. By title = a breakdown into individual securities (quantity × price).",
|
||||||
|
"detailedCreateHint": "New accounts start as a single amount; convert one to by-title from the accounts list."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"intro": "The types that ship with the app (TFSA, RRSP, Cash, etc.) cannot be deleted. You can create your own for special cases.",
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"key": "Key",
|
||||||
|
"kind": "Entry mode",
|
||||||
|
"origin": "Origin",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"simple": "Direct amount",
|
||||||
|
"priced": "Quantity × price"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"seeded": "Standard",
|
||||||
|
"user": "Custom"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create": "New type",
|
||||||
|
"renamePrompt": "New label for this type",
|
||||||
|
"deleteConfirm": "Delete this type? This cannot be undone.",
|
||||||
|
"deleteSeedHint": "Standard types cannot be deleted.",
|
||||||
|
"deleteHasAccountsHint": "This type has {{count}} linked account(s) — archive or move them first."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "New type",
|
||||||
|
"key": "Key",
|
||||||
|
"keyPlaceholder": "e.g. lira, prpp",
|
||||||
|
"label": "Label",
|
||||||
|
"labelPlaceholder": "e.g. LIRA, PRPP",
|
||||||
|
"kindLabel": "Entry mode",
|
||||||
|
"kindHintSimple": "Direct value entry (e.g. checking-account balance).",
|
||||||
|
"kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). A symbol is optional for linked accounts (only needed for automatic price fetching).",
|
||||||
|
"simpleOnlyNotice": "Priced types (stocks, crypto) will be available in a future release.",
|
||||||
|
"create": "Create type",
|
||||||
|
"customLabel": "Label",
|
||||||
|
"customLabelPlaceholder": "e.g. My RRIF, Travel savings",
|
||||||
|
"customLabelHint": "Display name for this type. Leave blank to use the default label."
|
||||||
|
},
|
||||||
|
"assetType": {
|
||||||
|
"label": "Asset type",
|
||||||
|
"stock": "Stock",
|
||||||
|
"crypto": "Crypto",
|
||||||
|
"required": "Select an asset type"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"has_accounts": "Cannot delete this type: {{count}} linked account(s) ({{names}}). Archive or move them first."
|
||||||
|
},
|
||||||
|
"cash": "Cash",
|
||||||
|
"tfsa": "TFSA",
|
||||||
|
"rrsp": "RRSP",
|
||||||
|
"fund": "Funds / ETF",
|
||||||
|
"other": "Other",
|
||||||
|
"stock": "Stock",
|
||||||
|
"crypto": "Crypto"
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"page": {
|
||||||
|
"newTitle": "New snapshot",
|
||||||
|
"editTitle": "Edit snapshot",
|
||||||
|
"dateLabel": "Snapshot date",
|
||||||
|
"dateMovableHint": "You can change this snapshot's date. Entered values are kept and moved to the new date.",
|
||||||
|
"total": "Entered total",
|
||||||
|
"noAccounts": "To enter a snapshot, first create at least one account. An account = where you keep money (chequing, TFSA, RRSP, stocks, etc.). A snapshot = the picture of how much was in each account on a given date.",
|
||||||
|
"goToAccounts": "Create an account",
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"detailed": {
|
||||||
|
"badge": "By title",
|
||||||
|
"badgeHint": "This account is broken down into individual securities. Its value is the sum of its positions.",
|
||||||
|
"empty": "No position yet. Add a title to record this account's holdings.",
|
||||||
|
"addTitle": "Add a title",
|
||||||
|
"removeTitle": "Remove this title",
|
||||||
|
"symbolLabel": "Security symbol",
|
||||||
|
"symbolPlaceholder": "Symbol",
|
||||||
|
"bookCostLabel": "Cost basis for {{account}}",
|
||||||
|
"bookCostPlaceholder": "0.00",
|
||||||
|
"latentGainLabel": "Unrealized gain for {{account}}",
|
||||||
|
"latentGainNA": "—",
|
||||||
|
"col": {
|
||||||
|
"title": "Security",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"unitPrice": "Price",
|
||||||
|
"value": "Value",
|
||||||
|
"bookCost": "Cost basis",
|
||||||
|
"latentGain": "Unrealized gain"
|
||||||
|
},
|
||||||
|
"picker": {
|
||||||
|
"placeholder": "Symbol (e.g. AAPL, BTC)",
|
||||||
|
"empty": "Type a symbol to create it.",
|
||||||
|
"create": "Create \"{{symbol}}\"",
|
||||||
|
"assetTypeLabel": "Type:",
|
||||||
|
"assetType": {
|
||||||
|
"stock": "Stock",
|
||||||
|
"crypto": "Crypto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 types cannot be deleted.",
|
||||||
|
"category_has_accounts": "Cannot delete a type with linked accounts. Move or archive linked accounts first.",
|
||||||
|
"category_not_found": "Type not found.",
|
||||||
|
"account_not_found": "Account not found.",
|
||||||
|
"name_required": "Name is required.",
|
||||||
|
"kind_invalid": "Invalid entry mode.",
|
||||||
|
"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_date_exists": "Another snapshot already exists at that date. Pick a free date or edit the existing snapshot.",
|
||||||
|
"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.",
|
||||||
|
"vehicle_type_invalid": "Invalid fiscal envelope."
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"toggleReturns": {
|
||||||
|
"show": "Show returns",
|
||||||
|
"hide": "Hide returns"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"latentGain": {
|
||||||
|
"column": "Unrealized gain",
|
||||||
|
"tooltip": "Latent gain on a detailed account: current value minus cost basis (book cost), in dollars and percent. Shown “—” when the cost basis is unknown.",
|
||||||
|
"totalLabel": "Unrealized gain",
|
||||||
|
"na": "—",
|
||||||
|
"partial": "Partial: some positions have no recorded cost basis and are excluded from the percentage.",
|
||||||
|
"drilldown": {
|
||||||
|
"expand": "Show securities",
|
||||||
|
"collapse": "Hide securities",
|
||||||
|
"security": "Security",
|
||||||
|
"value": "Value",
|
||||||
|
"gain": "Unrealized gain"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Unrealized gain breakdown",
|
||||||
|
"byClass": "By asset class",
|
||||||
|
"byVehicle": "By envelope"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vehicle": {
|
||||||
|
"none": "No envelope"
|
||||||
|
},
|
||||||
|
"detailWizard": {
|
||||||
|
"action": "Detail into securities",
|
||||||
|
"title": "Detail “{{account}}” into securities",
|
||||||
|
"intro": "This account will switch to per-security detailed entry. Securities are entered at the next regular snapshot — this wizard captures none right now.",
|
||||||
|
"pointFrozen": "Past aggregated history stays frozen read-only: older snapshots keep their single total value.",
|
||||||
|
"pointNextSnapshot": "From today on, every new snapshot will require the per-security breakdown (with cost basis).",
|
||||||
|
"pointPivot": "Pivot date: {{date}}.",
|
||||||
|
"irreversible": "One-way action: once securities are entered, this account can no longer return to aggregated entry.",
|
||||||
|
"confirm": "Detail",
|
||||||
|
"confirming": "Switching…",
|
||||||
|
"errors": {
|
||||||
|
"account_kind_detailed_has_holdings": "This account already has securities entered and can no longer return to aggregated entry."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"priceFetching": {
|
||||||
|
"button": "Fetch price",
|
||||||
|
"tooltipNotPremium": "Available with premium subscription",
|
||||||
|
"bestEffortNotice": "Source not guaranteed, may be unavailable. Manual input remains primary.",
|
||||||
|
"attribution": "via Maximus on {{date}}",
|
||||||
|
"consent": {
|
||||||
|
"title": "Price fetching via Maximus",
|
||||||
|
"body": "By clicking Accept, you authorize Simpl'Résultat to query the Maximus proxy to fetch this price. The proxy hides your IP from data providers. No browsing history is stored.",
|
||||||
|
"accept": "Accept",
|
||||||
|
"decline": "Cancel"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalidSymbol": "Invalid symbol",
|
||||||
|
"invalidDate": "Invalid date",
|
||||||
|
"missingParam": "Missing parameter",
|
||||||
|
"authFailed": "Authentication failed — check your license",
|
||||||
|
"premiumRequired": "This feature requires a premium subscription",
|
||||||
|
"licenseRevoked": "License revoked",
|
||||||
|
"symbolNotFound": "Symbol not found",
|
||||||
|
"rateLimit": "Too many requests — retry in {{seconds}}s",
|
||||||
|
"serverUnavailable": "Server unavailable — try again later",
|
||||||
|
"bestEffortDegraded": "Best-effort price source temporarily unavailable — retry in {{minutes}}min or enter manually",
|
||||||
|
"sessionCapReached": "Fetch limit reached for this session. Enter remaining prices manually."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"adjustments": "Ajustements",
|
"adjustments": "Ajustements",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"reports": "Rapports",
|
"reports": "Rapports",
|
||||||
|
"balance": "Bilan",
|
||||||
"settings": "Paramètres"
|
"settings": "Paramètres"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
|
|
@ -252,6 +253,10 @@
|
||||||
"Assignez une catégorie via le menu déroulant sur chaque ligne",
|
"Assignez une catégorie via le menu déroulant sur chaque ligne",
|
||||||
"L'auto-catégorisation utilise vos règles de mots-clés pour catégoriser en masse"
|
"L'auto-catégorisation utilise vos règles de mots-clés pour catégoriser en masse"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"transferIcon": {
|
||||||
|
"tooltip": "Liée à un compte de bilan",
|
||||||
|
"ariaLabel": "Transaction liée à un compte de bilan"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|
@ -630,6 +635,48 @@
|
||||||
"Vos données sont stockées localement et ne sont jamais affectées par les mises à jour",
|
"Vos données sont stockées localement et ne sont jamais affectées par les mises à jour",
|
||||||
"Changez la langue de l'application via le sélecteur de langue dans la barre latérale"
|
"Changez la langue de l'application via le sélecteur de langue dans la barre latérale"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Confidentialité",
|
||||||
|
"priceFetchConsent": {
|
||||||
|
"label": "Récupération de prix via Maximus",
|
||||||
|
"description": "Permet à Simpl'Résultat d'utiliser le proxy Maximus pour récupérer les prix d'actifs. Privacy : ton IP est masquée.",
|
||||||
|
"confirmRevoke": "Le bouton de récupération demandera à nouveau ton consentement la prochaine fois. Continuer ?",
|
||||||
|
"revokeButton": "Révoquer le consentement",
|
||||||
|
"notPremium": "Réservé aux licences premium"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backToHome": "Retour aux paramètres",
|
||||||
|
"home": {
|
||||||
|
"intro": "Configurez l'application en trois sections : utilisateurs, données et système."
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"title": "Utilisateurs",
|
||||||
|
"description": "Comptes, licences et guide d'utilisation.",
|
||||||
|
"sections": {
|
||||||
|
"accounts": "Comptes",
|
||||||
|
"licenses": "Licences",
|
||||||
|
"userGuide": "Guide d'utilisation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"title": "Données",
|
||||||
|
"description": "Catégories, sauvegardes et confidentialité.",
|
||||||
|
"sections": {
|
||||||
|
"categories": "Catégories",
|
||||||
|
"backup": "Sauvegarde",
|
||||||
|
"priceFetch": "Confidentialité des prix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"title": "Système",
|
||||||
|
"description": "Version, mises à jour, journaux et historique.",
|
||||||
|
"sections": {
|
||||||
|
"version": "Version",
|
||||||
|
"update": "Mise à jour",
|
||||||
|
"changelog": "Historique des versions",
|
||||||
|
"logs": "Journaux et commentaires"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"charts": {
|
"charts": {
|
||||||
|
|
@ -896,6 +943,51 @@
|
||||||
"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 (relevé daté de votre patrimoine) de l'ensemble de vos comptes, 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. Deux axes indépendants : la classe d'actif (le type du compte : Liquidités, Fonds / FNB, Actions, Crypto, Autres) et l'enveloppe fiscale (un attribut optionnel du compte : CELI, REER, FERR, CELIAPP, REEE, ou aucune). Un compte se saisit en montant unique (compte simple) ou titre par titre (compte détaillé : chaque valeur mobilière avec quantité × cours et coût d'acquisition, la valeur du compte étant la somme de ses positions).",
|
||||||
|
"features": [
|
||||||
|
"5 types standard pré-installés, qui sont des classes d'actif (Liquidités, Fonds / FNB, Actions, Crypto, Autres) — renommables, non-supprimables ; un type regroupe des comptes de même nature (distinct des catégories de transactions). Les enveloppes fiscales ne sont plus des types",
|
||||||
|
"Création de types personnalisés avec choix simple (montant direct) ou priced (quantité × prix unitaire)",
|
||||||
|
"Comptes détaillés (par titre) : un compte peut contenir plusieurs valeurs mobilières — chaque titre a sa ligne (symbole, quantité, cours, valeur, coût d'acquisition, gain latent), la valeur du compte étant la somme de ses titres. Sélecteur de titre avec autocomplétion et création inline (symbole + classe d'actif Action/Crypto)",
|
||||||
|
"Gain latent par titre et agrégé (par compte, par classe d'actif, par enveloppe) : valeur − coût d'acquisition, en $ et en % ; drill-down par titre dans le tableau des comptes ; une position sans coût d'acquisition affiche « N/A » et est exclue du %",
|
||||||
|
"Assistant « Détailler en titres » : bascule un compte simple en compte détaillé à partir d'une date de bascule (pivot) ; l'historique agrégé passé reste figé en lecture seule",
|
||||||
|
"Comptes par type : nom, enveloppe fiscale optionnelle (Non-enregistré, CELI, REER, FERR, CELIAPP, REEE, ou aucune par défaut), symbole optionnel (même pour les types cotés, il ne sert qu'à la récupération automatique des prix), devise (CAD au MVP), notes",
|
||||||
|
"Renommer un type ne casse plus le bilingue : le nom personnalisé est stocké à part, la traduction FR/EN d'origine est préservée",
|
||||||
|
"Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer ; la date d'un snapshot existant peut être déplacée (lignes conservées) si la date cible est libre",
|
||||||
|
"Bouton « Pré-remplir depuis le snapshot précédent » : copie les valeurs simples, les quantités priced, et les titres/quantités/coûts d'acquisition des comptes détaillés (un titre à quantité 0 est ignoré)",
|
||||||
|
"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, avec un sous-choix d'axe : par classe d'actif (défaut) ou par enveloppe + 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, repliées par défaut (toggle, choix mémorisé)",
|
||||||
|
"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 Types pour créer si besoin une classe d'actif supplémentaire en simple (montant direct) ou priced (quantité × prix unitaire)",
|
||||||
|
"Allez dans l'onglet Comptes pour créer chaque compte (Tangerine rattaché à Liquidités, BTC Ledger rattaché à Crypto avec symbole BTC — optionnel). Choisissez l'enveloppe fiscale si le compte est logé dans un CELI/REER/etc., ou laissez « Aucune »",
|
||||||
|
"Cliquez « + Nouveau snapshot » depuis /balance pour ouvrir /balance/snapshot à la date du jour",
|
||||||
|
"Remplissez les valeurs par compte (groupées par type). 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 suivre un compte titre par titre, créez-le détaillé ou ouvrez son menu d'actions → « Détailler en titres » ; aux snapshots suivants, ajoutez chaque valeur mobilière avec sa quantité, son cours et son coût d'acquisition (la valeur du compte = la somme des titres)",
|
||||||
|
"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. Vous pouvez aussi corriger la date : changez-la puis enregistrez, le snapshot est déplacé avec ses lignes (message d'alerte si la date cible est déjà prise)",
|
||||||
|
"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",
|
||||||
|
"Gain latent ≠ rendement : le gain latent (valeur − coût d'acquisition) mesure votre plus-value depuis l'achat, le rendement Modified Dietz mesure la performance en tenant compte du timing de vos apports — les deux s'affichent sur un compte détaillé. Ajustez le coût d'acquisition après un achat/vente, sinon le gain latent dérive",
|
||||||
|
"(À 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",
|
||||||
|
"Basculez l'axe du graphique empilé entre « par classe d'actif » et « par enveloppe » pour lire les deux faces du patrimoine. Note : un snapshot saisi avant la séparation classe/enveloppe apparaît sous « Autres » sur l'axe par classe d'actif (les ex-types CELI/REER sont devenus des enveloppes) — c'est attendu, l'axe par enveloppe retrouve bien vos CELI/REER, vos montants ne changent pas"
|
||||||
|
]
|
||||||
|
},
|
||||||
"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.",
|
||||||
|
|
@ -981,7 +1073,8 @@
|
||||||
"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",
|
||||||
|
|
@ -1449,5 +1542,402 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"overview": {
|
||||||
|
"title": "Bilan",
|
||||||
|
"latestTotal": "Valeur nette actuelle",
|
||||||
|
"asOf": "au {{date}}",
|
||||||
|
"noSnapshots": "Aucun snapshot (relevé daté de votre patrimoine) 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"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"axisLegend": "Axe de regroupement du graphique empilé",
|
||||||
|
"totalSeriesLabel": "Total",
|
||||||
|
"mode": {
|
||||||
|
"line": "Ligne",
|
||||||
|
"stacked": "Empilé par type"
|
||||||
|
},
|
||||||
|
"axis": {
|
||||||
|
"byAssetClass": "Par classe d'actif",
|
||||||
|
"byVehicle": "Par enveloppe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Premiers pas avec le bilan",
|
||||||
|
"subtitle": "Deux étapes pour commencer à suivre votre valeur nette.",
|
||||||
|
"doneBadge": "Fait",
|
||||||
|
"step1": {
|
||||||
|
"title": "Créer un compte",
|
||||||
|
"description": "Un compte représente l'endroit où vous tenez votre argent : compte chèque, CELI, REER, actions, crypto, etc.",
|
||||||
|
"cta": "Créer un compte"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "Saisir un snapshot",
|
||||||
|
"description": "Un snapshot est la photo, à une date donnée, du solde de chaque compte. Saisissez-en un par mois pour suivre l'évolution.",
|
||||||
|
"cta": "Saisir un snapshot",
|
||||||
|
"disabledHint": "Créez d'abord un compte pour activer cette étape."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"starters": {
|
||||||
|
"title": "Comptes de départ",
|
||||||
|
"description": "Voulez-vous ajouter ces 4 comptes courants ? Vous pourrez les renommer ou les archiver à tout moment.",
|
||||||
|
"cta_add": "Ajouter les comptes sélectionnés",
|
||||||
|
"cta_later": "Plus tard",
|
||||||
|
"collision_tooltip": "Déjà présent",
|
||||||
|
"items": {
|
||||||
|
"cash": "Compte chèque",
|
||||||
|
"tfsa": "CELI",
|
||||||
|
"rrsp": "REER",
|
||||||
|
"other": "Compte non-enregistré"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"insert": "Impossible d'ajouter les comptes. Veuillez réessayer."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": "Bilan",
|
||||||
|
"accountsPage": {
|
||||||
|
"title": "Comptes du bilan",
|
||||||
|
"tabs": {
|
||||||
|
"accounts": "Comptes",
|
||||||
|
"categories": "Types"
|
||||||
|
},
|
||||||
|
"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": "Type",
|
||||||
|
"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": "Type",
|
||||||
|
"noCategory": "(aucun type disponible)",
|
||||||
|
"name": "Nom du compte",
|
||||||
|
"nameRequired": "Le nom est obligatoire.",
|
||||||
|
"symbol": "Symbole",
|
||||||
|
"symbolPricedHint": "optionnel — requis seulement pour la récupération automatique des prix",
|
||||||
|
"symbolPlaceholderSimple": "Optionnel",
|
||||||
|
"symbolPlaceholderPriced": "ex. AAPL, BTC-USD (optionnel)",
|
||||||
|
"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",
|
||||||
|
"vehicleType": {
|
||||||
|
"label": "Véhicule fiscal",
|
||||||
|
"none": "Aucun",
|
||||||
|
"unregistered": "Non-enregistré",
|
||||||
|
"tfsa": "CELI",
|
||||||
|
"rrsp": "REER",
|
||||||
|
"rrif": "FERR",
|
||||||
|
"fhsa": "CELIAPP",
|
||||||
|
"resp": "REEE",
|
||||||
|
"hint": "Enveloppe fiscale du compte (CELI, REER…). Optionnel — la classe d'actif reste le type."
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"label": "Mode de saisie",
|
||||||
|
"simple": "Montant unique",
|
||||||
|
"detailed": "Par titre (valeurs mobilières)",
|
||||||
|
"hint": "Montant unique = un total par snapshot. Par titre = un détail en valeurs mobilières individuelles (quantité × cours).",
|
||||||
|
"detailedCreateHint": "Les nouveaux comptes démarrent en montant unique ; convertissez-en un en « par titre » depuis la liste des comptes."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"intro": "Les types fournis par l'application (CELI, REER, Liquidités, etc.) ne sont pas supprimables. Vous pouvez en créer de nouveaux pour vos cas particuliers.",
|
||||||
|
"fields": {
|
||||||
|
"name": "Nom",
|
||||||
|
"key": "Clé",
|
||||||
|
"kind": "Mode de saisie",
|
||||||
|
"origin": "Origine",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"simple": "Montant direct",
|
||||||
|
"priced": "Quantité × prix"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"seeded": "Standard",
|
||||||
|
"user": "Personnalisé"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"create": "Nouveau type",
|
||||||
|
"renamePrompt": "Nouveau libellé pour ce type",
|
||||||
|
"deleteConfirm": "Supprimer ce type ? Cette action est irréversible.",
|
||||||
|
"deleteSeedHint": "Les types standard ne peuvent pas être supprimés.",
|
||||||
|
"deleteHasAccountsHint": "Ce type a {{count}} compte(s) lié(s) — archivez ou déplacez-les d'abord."
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "Nouveau type",
|
||||||
|
"key": "Clé",
|
||||||
|
"keyPlaceholder": "ex. ferr, rpdb",
|
||||||
|
"label": "Libellé",
|
||||||
|
"labelPlaceholder": "ex. FERR, RPDB",
|
||||||
|
"kindLabel": "Mode de saisie",
|
||||||
|
"kindHintSimple": "Saisie d'un montant direct (ex: solde de compte courant).",
|
||||||
|
"kindHintPriced": "Saisie d'une quantité × prix unitaire (ex: actions, cryptomonnaies). Un symbole est optionnel pour les comptes liés (requis seulement pour la récupération automatique des prix).",
|
||||||
|
"simpleOnlyNotice": "Les types cotés (actions, crypto) seront disponibles dans une prochaine version.",
|
||||||
|
"create": "Créer le type",
|
||||||
|
"customLabel": "Libellé",
|
||||||
|
"customLabelPlaceholder": "ex. Mon FERR, Épargne voyage",
|
||||||
|
"customLabelHint": "Nom affiché pour ce type. Laisser vide pour utiliser le libellé par défaut."
|
||||||
|
},
|
||||||
|
"assetType": {
|
||||||
|
"label": "Type d'actif",
|
||||||
|
"stock": "Action",
|
||||||
|
"crypto": "Crypto",
|
||||||
|
"required": "Sélectionne le type d'actif"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"has_accounts": "Impossible de supprimer ce type : {{count}} compte(s) lié(s) ({{names}}). Archivez ou déplacez-les d'abord."
|
||||||
|
},
|
||||||
|
"cash": "Liquidités",
|
||||||
|
"tfsa": "CELI",
|
||||||
|
"rrsp": "REER",
|
||||||
|
"fund": "Fonds / FNB",
|
||||||
|
"other": "Autre",
|
||||||
|
"stock": "Action",
|
||||||
|
"crypto": "Cryptomonnaie"
|
||||||
|
},
|
||||||
|
"snapshot": {
|
||||||
|
"page": {
|
||||||
|
"newTitle": "Nouveau snapshot",
|
||||||
|
"editTitle": "Modifier le snapshot",
|
||||||
|
"dateLabel": "Date du snapshot",
|
||||||
|
"dateMovableHint": "Vous pouvez changer la date de ce snapshot. Les valeurs saisies sont conservées et déplacées à la nouvelle date.",
|
||||||
|
"total": "Total saisi",
|
||||||
|
"noAccounts": "Pour saisir un snapshot, créez d'abord au moins un compte. Un compte = où vous tenez votre argent (chèque, CELI, REER, actions, etc.). Un snapshot = la photo de combien il y avait dans chaque compte à une date donnée.",
|
||||||
|
"goToAccounts": "Créer un compte",
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"detailed": {
|
||||||
|
"badge": "Par titre",
|
||||||
|
"badgeHint": "Ce compte est détaillé en titres individuels. Sa valeur est la somme de ses positions.",
|
||||||
|
"empty": "Aucune position. Ajoutez un titre pour saisir le contenu de ce compte.",
|
||||||
|
"addTitle": "Ajouter un titre",
|
||||||
|
"removeTitle": "Retirer ce titre",
|
||||||
|
"symbolLabel": "Symbole du titre",
|
||||||
|
"symbolPlaceholder": "Symbole",
|
||||||
|
"bookCostLabel": "Coût d'acquisition pour {{account}}",
|
||||||
|
"bookCostPlaceholder": "0,00",
|
||||||
|
"latentGainLabel": "Gain latent pour {{account}}",
|
||||||
|
"latentGainNA": "—",
|
||||||
|
"col": {
|
||||||
|
"title": "Titre",
|
||||||
|
"quantity": "Quantité",
|
||||||
|
"unitPrice": "Cours",
|
||||||
|
"value": "Valeur",
|
||||||
|
"bookCost": "Coût d'acquisition",
|
||||||
|
"latentGain": "Gain latent"
|
||||||
|
},
|
||||||
|
"picker": {
|
||||||
|
"placeholder": "Symbole (ex. AAPL, BTC)",
|
||||||
|
"empty": "Tapez un symbole pour le créer.",
|
||||||
|
"create": "Créer « {{symbol}} »",
|
||||||
|
"assetTypeLabel": "Type :",
|
||||||
|
"assetType": {
|
||||||
|
"stock": "Action",
|
||||||
|
"crypto": "Crypto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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 types standard ne peuvent pas être supprimés.",
|
||||||
|
"category_has_accounts": "Impossible de supprimer un type avec des comptes liés. Déplacez ou archivez d'abord les comptes liés.",
|
||||||
|
"category_not_found": "Type introuvable.",
|
||||||
|
"account_not_found": "Compte introuvable.",
|
||||||
|
"name_required": "Le nom est obligatoire.",
|
||||||
|
"kind_invalid": "Mode de saisie 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_date_exists": "Un autre snapshot existe déjà à cette date. Choisissez une date libre ou modifiez le snapshot existant.",
|
||||||
|
"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.",
|
||||||
|
"vehicle_type_invalid": "Véhicule fiscal invalide."
|
||||||
|
},
|
||||||
|
"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.",
|
||||||
|
"toggleReturns": {
|
||||||
|
"show": "Afficher les rendements",
|
||||||
|
"hide": "Masquer les rendements"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"latentGain": {
|
||||||
|
"column": "Gain latent",
|
||||||
|
"tooltip": "Gain latent d'un compte détaillé : valeur actuelle moins le coût d'acquisition, en dollars et en pourcentage. Affiché « — » quand le coût d'acquisition est inconnu.",
|
||||||
|
"totalLabel": "Gain latent",
|
||||||
|
"na": "—",
|
||||||
|
"partial": "Partiel : certaines positions n'ont pas de coût d'acquisition saisi et sont exclues du pourcentage.",
|
||||||
|
"drilldown": {
|
||||||
|
"expand": "Afficher les titres",
|
||||||
|
"collapse": "Masquer les titres",
|
||||||
|
"security": "Titre",
|
||||||
|
"value": "Valeur",
|
||||||
|
"gain": "Gain latent"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Répartition du gain latent",
|
||||||
|
"byClass": "Par classe d'actif",
|
||||||
|
"byVehicle": "Par enveloppe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vehicle": {
|
||||||
|
"none": "Sans enveloppe"
|
||||||
|
},
|
||||||
|
"detailWizard": {
|
||||||
|
"action": "Détailler en titres",
|
||||||
|
"title": "Détailler « {{account}} » en titres",
|
||||||
|
"intro": "Ce compte passera en saisie détaillée par titre. Les titres se saisissent au prochain instantané (snapshot) normal — cet assistant ne capture aucun titre maintenant.",
|
||||||
|
"pointFrozen": "L'historique agrégé passé reste figé en lecture seule : les anciens instantanés conservent leur valeur globale.",
|
||||||
|
"pointNextSnapshot": "À partir d'aujourd'hui, chaque nouvel instantané exigera le détail par titre (avec coût d'acquisition).",
|
||||||
|
"pointPivot": "Date de bascule (pivot) : {{date}}.",
|
||||||
|
"irreversible": "Action à sens unique : une fois des titres saisis, ce compte ne pourra plus revenir en saisie agrégée.",
|
||||||
|
"confirm": "Détailler",
|
||||||
|
"confirming": "Bascule…",
|
||||||
|
"errors": {
|
||||||
|
"account_kind_detailed_has_holdings": "Ce compte a déjà des titres saisis et ne peut plus revenir en saisie agrégée."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"priceFetching": {
|
||||||
|
"button": "Récupérer le prix",
|
||||||
|
"tooltipNotPremium": "Disponible avec abonnement premium",
|
||||||
|
"bestEffortNotice": "Source non garantie, peut être indisponible. La saisie manuelle reste prioritaire.",
|
||||||
|
"attribution": "via Maximus le {{date}}",
|
||||||
|
"consent": {
|
||||||
|
"title": "Récupération de prix via Maximus",
|
||||||
|
"body": "En cliquant sur \"Accepter\", tu autorises Simpl'Résultat à interroger le proxy Maximus pour récupérer ce prix. Le proxy masque ton IP aux fournisseurs de données. Aucun historique de consultation n'est stocké.",
|
||||||
|
"accept": "Accepter",
|
||||||
|
"decline": "Annuler"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalidSymbol": "Symbole invalide",
|
||||||
|
"invalidDate": "Date invalide",
|
||||||
|
"missingParam": "Paramètre manquant",
|
||||||
|
"authFailed": "Échec d'authentification — vérifie ta licence",
|
||||||
|
"premiumRequired": "Cette fonction nécessite un abonnement premium",
|
||||||
|
"licenseRevoked": "Licence révoquée",
|
||||||
|
"symbolNotFound": "Symbole introuvable",
|
||||||
|
"rateLimit": "Trop de requêtes — réessaie dans {{seconds}} s",
|
||||||
|
"serverUnavailable": "Serveur indisponible — réessaie plus tard",
|
||||||
|
"bestEffortDegraded": "Source de prix temporairement indisponible — réessayez dans {{minutes}} min ou saisissez manuellement",
|
||||||
|
"sessionCapReached": "Limite de récupération atteinte pour cette session. Saisissez les prix restants manuellement."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
466
src/pages/AccountsPage.tsx
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
// 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";
|
||||||
|
import {
|
||||||
|
renderCategoryLabelFromAccount,
|
||||||
|
renderCategoryLabelFromCategory,
|
||||||
|
} from "../utils/renderCategoryLabel";
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
renderCategoryLabelFromCategory(cat, t);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{renderCategoryLabelFromAccount(acc, t)}
|
||||||
|
</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)
|
||||||
|
);
|
||||||
|
// Write the human label to custom_label, never to
|
||||||
|
// i18n_key — preserves the bundled translation
|
||||||
|
// (fixes bug I). A blank/empty answer clears the
|
||||||
|
// override and falls back to t(i18n_key).
|
||||||
|
if (next !== null) {
|
||||||
|
editCategory(cat.id, {
|
||||||
|
custom_label: next.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
src/pages/BalancePage.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
// 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,
|
||||||
|
type BalanceGroupAxis,
|
||||||
|
} from "../hooks/useBalanceOverview";
|
||||||
|
import { BALANCE_VEHICLE_TYPES } from "../shared/types";
|
||||||
|
import { VEHICLE_NONE_BUCKET } from "../services/balance.service";
|
||||||
|
import {
|
||||||
|
archiveBalanceAccount,
|
||||||
|
listAccountTransfers,
|
||||||
|
type AccountLatestSnapshot,
|
||||||
|
} from "../services/balance.service";
|
||||||
|
import { getAllCategories } from "../services/transactionService";
|
||||||
|
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
||||||
|
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||||
|
import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard";
|
||||||
|
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||||
|
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||||
|
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
||||||
|
import DetailAccountWizard from "../components/balance/DetailAccountWizard";
|
||||||
|
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
||||||
|
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||||
|
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
||||||
|
|
||||||
|
const STARTER_PREF_KEY = "balance_starter_proposed";
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
||||||
|
|
||||||
|
export default function BalancePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { state, setPeriod, setChartMode, setGroupAxis, 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
|
||||||
|
);
|
||||||
|
// Issue #215 — "détailler en titres" wizard target (a simple account being
|
||||||
|
// flipped to detailed entry mode).
|
||||||
|
const [detailTarget, setDetailTarget] =
|
||||||
|
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([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Issue #179 — one-shot starter-accounts modal for existing profiles. The
|
||||||
|
// pref `balance_starter_proposed` is written once (confirmed or dismissed),
|
||||||
|
// so the modal never re-appears. New profiles get both the 4 starters AND
|
||||||
|
// the pref pre-seeded via consolidated_schema.sql, so they never hit this
|
||||||
|
// branch at all (S1 fix from #187).
|
||||||
|
const [showStarterModal, setShowStarterModal] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const existing = await getPreference(STARTER_PREF_KEY);
|
||||||
|
if (!cancelled && existing == null) {
|
||||||
|
setShowStarterModal(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Pref read failure: leave modal hidden — privacy-first default.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStarterModalClose = async (acceptedIds: number[]) => {
|
||||||
|
setShowStarterModal(false);
|
||||||
|
try {
|
||||||
|
await setPreference(
|
||||||
|
STARTER_PREF_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
shown_at: new Date().toISOString(),
|
||||||
|
accepted: acceptedIds,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: a write failure here would cause the modal to re-show
|
||||||
|
// on next visit, which is acceptable (data still consistent).
|
||||||
|
}
|
||||||
|
if (acceptedIds.length > 0) {
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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] = renderCategoryLabelFromAccount(a, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [state.accountsLatest, t]);
|
||||||
|
|
||||||
|
// Map vehicle_key → translated label for the "par enveloppe" stacked axis.
|
||||||
|
// Reuses the #203 account-form labels (`balance.account.form.vehicleType.*`)
|
||||||
|
// so the legend never duplicates strings; the NULL-envelope bucket uses the
|
||||||
|
// dedicated `balance.vehicle.none` key.
|
||||||
|
const vehicleLabels = useMemo(() => {
|
||||||
|
const m: Record<string, string> = {
|
||||||
|
[VEHICLE_NONE_BUCKET]: t("balance.vehicle.none"),
|
||||||
|
};
|
||||||
|
for (const v of BALANCE_VEHICLE_TYPES) {
|
||||||
|
m[v] = t(`balance.account.form.vehicleType.${v}`);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Issue #178 — empty-state guard. We probe accountsLatest for ANY
|
||||||
|
snapshot date so the guard is independent of the active period
|
||||||
|
filter (state.period). When empty, we render only the onboarding
|
||||||
|
card — period selector, chart and accounts table would all show
|
||||||
|
empty states stacked under it (S2 from #187). */}
|
||||||
|
{(() => {
|
||||||
|
const accountsCount = state.accountsLatest.length;
|
||||||
|
const hasAnySnapshot = state.accountsLatest.some(
|
||||||
|
(a) => a.latest_snapshot_date != null
|
||||||
|
);
|
||||||
|
const isEmpty = accountsCount === 0 || !hasAnySnapshot;
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BalanceOnboardingCard
|
||||||
|
accountsCount={accountsCount}
|
||||||
|
snapshotsCount={hasAnySnapshot ? 1 : 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BalanceOverviewCard
|
||||||
|
totals={state.evolutionTotals}
|
||||||
|
latentGainRollup={state.latentGainRollup}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Stacked-mode group-axis sub-toggle (asset class / envelope).
|
||||||
|
Only meaningful while the stacked mode is active. */}
|
||||||
|
{state.chartMode === "stacked" && (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("balance.chart.axisLegend")}
|
||||||
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{(["class", "vehicle"] as BalanceGroupAxis[]).map((axis) => (
|
||||||
|
<button
|
||||||
|
key={axis}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGroupAxis(axis)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.groupAxis === axis
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.groupAxis === axis}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
axis === "class"
|
||||||
|
? "balance.chart.axis.byAssetClass"
|
||||||
|
: "balance.chart.axis.byVehicle"
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BalanceEvolutionChart
|
||||||
|
mode={state.chartMode}
|
||||||
|
groupAxis={state.groupAxis}
|
||||||
|
totals={state.evolutionTotals}
|
||||||
|
byCategory={state.evolutionByCategory}
|
||||||
|
byVehicle={state.evolutionByVehicle}
|
||||||
|
categoryLabels={categoryLabels}
|
||||||
|
vehicleLabels={vehicleLabels}
|
||||||
|
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}
|
||||||
|
latentGainByAccount={state.latentGainByAccount}
|
||||||
|
latentGainRollup={state.latentGainRollup}
|
||||||
|
vehicleLabels={vehicleLabels}
|
||||||
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||||
|
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||||
|
onDetailAccount={(acc) => setDetailTarget(acc)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<StarterAccountsModal
|
||||||
|
isOpen={showStarterModal}
|
||||||
|
onClose={(ids) => {
|
||||||
|
void handleStarterModalClose(ids);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{linkTarget && (
|
||||||
|
<LinkTransfersModal
|
||||||
|
accountId={linkTarget.account_id}
|
||||||
|
accountName={linkTarget.account_name}
|
||||||
|
categories={categories}
|
||||||
|
onClose={() => setLinkTarget(null)}
|
||||||
|
onLinked={() => {
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailTarget && (
|
||||||
|
<DetailAccountWizard
|
||||||
|
accountId={detailTarget.account_id}
|
||||||
|
accountName={detailTarget.account_name}
|
||||||
|
onClose={() => setDetailTarget(null)}
|
||||||
|
onDetailed={() => {
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -207,7 +207,7 @@ export default function CategoriesMigrationPage() {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-2xl mx-auto space-y-4">
|
<div className="p-6 max-w-2xl mx-auto space-y-4">
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings/data"
|
||||||
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
|
|
@ -229,7 +229,7 @@ export default function CategoriesMigrationPage() {
|
||||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings/data"
|
||||||
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
|
|
@ -527,7 +527,7 @@ function ErrorScreen({ errors, onRetry }: ErrorScreenProps) {
|
||||||
{t("categoriesSeed.migration.error.retry")}
|
{t("categoriesSeed.migration.error.retry")}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings/data"
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]"
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]"
|
||||||
>
|
>
|
||||||
{t("categoriesSeed.migration.error.backToSettings")}
|
{t("categoriesSeed.migration.error.backToSettings")}
|
||||||
|
|
|
||||||