Two-expert critique (CPA/financial planner + fintech product designer) of the Balance page, prioritized by severity x effort. Root finding: the flat model conflates tax vehicle and asset class, blocking the aggregated -> detailed progression. Defines a 3-step additive trajectory (quick wins -> vehicle axis -> per-security holdings) and recommends superseding ADR 0012. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
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
simplesoustfsa→ le titre, sa quantité et son rendement disparaissent ; - compte
pricedsousstock→ 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-v11src-tauri/src/lib.rs, seedssrc-tauri/src/database/consolidated_schema.sql(l. 185-296) - Service :
src/services/balance.service.ts(updateBalanceCategoryinterdit le changement dekindl.152 ;updateBalanceAccountl.340 ;STARTER_ACCOUNTSl.449 ; agrégation parcategory.keyl.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 viawindow.promptAccountsPage.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}.jsonclésbalance.*