feat(balance): audit quick wins — terminology, optional symbol, movable snapshot date #201
11 changed files with 390 additions and 123 deletions
|
|
@ -2,6 +2,18 @@
|
|||
|
||||
## [Non publié]
|
||||
|
||||
### Ajouté
|
||||
|
||||
- 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).
|
||||
|
||||
### 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).
|
||||
|
||||
### 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é
|
||||
|
|
|
|||
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -2,6 +2,18 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- 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).
|
||||
|
||||
### 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).
|
||||
|
||||
### 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
|
||||
|
|
|
|||
112
docs/audit-bilan-2026-05.md
Normal file
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.*`
|
||||
|
|
@ -357,7 +357,7 @@ L'application est atomique : soit toutes les transactions cochées sont recatég
|
|||
|
||||
## 10. Bilan
|
||||
|
||||
Le **Bilan** est une vue patrimoniale : vous saisissez périodiquement un *snapshot* daté de l'ensemble de vos comptes (encaisse, REER, CELI, fonds, actions, crypto, autres), vous suivez leur évolution dans le temps, et vous calculez le **vrai rendement** de chaque compte d'investissement en liant les transferts (apports / retraits) aux comptes correspondants.
|
||||
Le **Bilan** est une vue patrimoniale : vous saisissez périodiquement un *snapshot* (relevé daté de votre patrimoine) de l'ensemble de vos comptes (liquidités, REER, CELI, fonds, actions, crypto, autres), vous suivez leur évolution dans le temps, et vous calculez le **vrai rendement** de chaque compte d'investissement en liant les transferts (apports / retraits) aux comptes correspondants.
|
||||
|
||||
Trois pages composent le module Bilan :
|
||||
- `/balance` — vue d'ensemble (graphique + tableau des comptes)
|
||||
|
|
@ -368,11 +368,11 @@ L'entrée **Bilan** dans la barre latérale (icône portefeuille) donne accès
|
|||
|
||||
### Fonctionnalités
|
||||
|
||||
- 7 catégories standard pré-installées : Encaisse, CELI, REER, Fonds, Actions, Crypto, Autres — renommables, non-supprimables
|
||||
- Création de catégories personnalisées (ex. FERR, RPDB) avec choix `simple` (montant direct) ou `priced` (quantité × prix unitaire)
|
||||
- Comptes par catégorie : nom, symbole optionnel, devise (CAD au MVP), notes
|
||||
- Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer
|
||||
- Saisie groupée par catégorie ; pour les catégories `priced`, le `value` est calculé automatiquement (`quantity × unit_price`)
|
||||
- 7 types standard pré-installés : Liquidités, CELI, REER, 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)
|
||||
- Création de types personnalisés (ex. FERR, RPDB) avec choix `simple` (montant direct) ou `priced` (quantité × prix unitaire)
|
||||
- Comptes par type : nom, 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
|
||||
- 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`)
|
||||
- Bouton **Pré-remplir depuis le snapshot précédent** : copie les valeurs simples + les quantités priced (vous remplissez juste les nouveaux prix)
|
||||
- Liaison de transactions existantes à un compte de bilan (modal avec filtres par période / catégorie / recherche, sens auto-proposé selon le signe)
|
||||
- Icône d'attribution dans la page Transactions pour les transactions liées à un transfert
|
||||
|
|
@ -385,14 +385,14 @@ L'entrée **Bilan** dans la barre latérale (icône portefeuille) donne accès
|
|||
|
||||
### Comment faire
|
||||
|
||||
1. Allez dans `/balance/accounts` → onglet Catégories pour créer si besoin une catégorie supplémentaire (ex. "FERR" en `simple`, ou "Stocks Wealthsimple" en `priced`)
|
||||
2. Allez dans l'onglet Comptes pour créer chaque compte (ex. "TFSA Tangerine" rattaché à CELI, "BTC Ledger" rattaché à Crypto avec symbole `BTC`)
|
||||
1. Allez dans `/balance/accounts` → onglet Types pour créer si besoin un type supplémentaire (ex. "FERR" en `simple`, ou "Stocks Wealthsimple" en `priced`)
|
||||
2. Allez dans l'onglet Comptes pour créer chaque compte (ex. "TFSA Tangerine" rattaché à CELI, "BTC Ledger" rattaché à Crypto avec symbole `BTC` — le symbole reste optionnel)
|
||||
3. Cliquez **+ Nouveau snapshot** depuis `/balance` pour ouvrir `/balance/snapshot` à la date du jour
|
||||
4. Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée
|
||||
5. Enregistrez. Le graphique sur `/balance` s'actualise immédiatement
|
||||
6. Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions du compte → **Lier transferts** → cochez les transactions qui correspondent à des apports / retraits (un dépôt CELI, un achat d'actions, etc.). Le sens (in/out) est proposé automatiquement selon le signe de la transaction
|
||||
7. Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création. Le rendement non-ajusté à droite vous permet de comparer "valeur du compte" et "vraie performance"
|
||||
8. Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition (la date est immutable)
|
||||
8. Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition. 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)
|
||||
9. Pour supprimer un snapshot, cliquez **Supprimer** dans son éditeur et re-saisissez la date pour confirmer
|
||||
|
||||
### Lecture des rendements multi-horizons
|
||||
|
|
|
|||
|
|
@ -127,14 +127,14 @@ function AccountVariant({
|
|||
const trimmedName = values.name.trim();
|
||||
const trimmedSymbol = values.symbol.trim();
|
||||
const nameInvalid = touched && trimmedName.length === 0;
|
||||
// Priced categories require a symbol — surfaced as a validation error.
|
||||
const symbolMissingForPriced = touched && isPriced && trimmedSymbol.length === 0;
|
||||
// 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;
|
||||
if (isPriced && !trimmedSymbol) return;
|
||||
|
||||
const payload: CreateBalanceAccountInput = {
|
||||
balance_category_id: values.balance_category_id,
|
||||
|
|
@ -233,18 +233,9 @@ function AccountVariant({
|
|||
? t("balance.account.form.symbolPlaceholderPriced")
|
||||
: t("balance.account.form.symbolPlaceholderSimple")
|
||||
}
|
||||
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
|
||||
symbolMissingForPriced
|
||||
? "border-[var(--negative)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
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"
|
||||
/>
|
||||
{symbolMissingForPriced && (
|
||||
<p className="mt-1 text-xs text-[var(--negative)]">
|
||||
{t("balance.account.form.symbolRequiredForPriced")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -275,12 +266,7 @@ function AccountVariant({
|
|||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSaving ||
|
||||
!trimmedName ||
|
||||
categories.length === 0 ||
|
||||
(isPriced && !trimmedSymbol)
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export interface PricedEntry {
|
|||
|
||||
interface State {
|
||||
mode: SnapshotEditorMode;
|
||||
/** ISO YYYY-MM-DD; controlled in 'new' mode, frozen in 'edit'. */
|
||||
/** 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;
|
||||
|
|
@ -164,7 +164,8 @@ function reducer(state: State, action: Action): State {
|
|||
errorCode: null,
|
||||
};
|
||||
case "SET_DATE":
|
||||
// Only meaningful in 'new' mode — the page guards against this in 'edit'.
|
||||
// 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 {
|
||||
|
|
@ -495,10 +496,21 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
// INSERT lines / 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, ...pricedLines],
|
||||
moveToDate,
|
||||
});
|
||||
dispatch({ type: "CLEAR_DIRTY" });
|
||||
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
|
||||
|
|
|
|||
|
|
@ -947,14 +947,14 @@
|
|||
"title": "Balance Sheet",
|
||||
"overview": "The Balance Sheet is a net-worth view: you periodically enter a dated snapshot of all your accounts (cash, RRSP, TFSA, funds, stocks, crypto, other), track their evolution over time, and compute the true return of each investment account by linking transfers (deposits/withdrawals) to the matching accounts.",
|
||||
"features": [
|
||||
"7 standard categories pre-installed (Cash, TFSA, RRSP, Fund, Stock, Crypto, Other) — renameable, non-deletable",
|
||||
"Custom category creation with simple (direct amount) or priced (quantity × unit price) kind",
|
||||
"Accounts per category: name, optional symbol, currency (CAD at MVP), notes",
|
||||
"Dated snapshots with a UNIQUE constraint per date — editing means revisiting the same date, never duplicating",
|
||||
"7 standard types pre-installed (Cash, TFSA, RRSP, Funds / ETF, Stock, Crypto, Other) — renameable, non-deletable; a type groups accounts of the same nature (distinct from transaction categories)",
|
||||
"Custom type creation with simple (direct amount) or priced (quantity × unit price) entry mode",
|
||||
"Accounts per type: name, optional symbol (even for priced types — it only drives automatic price fetching), currency (CAD at MVP), notes",
|
||||
"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",
|
||||
"Linking existing transactions to a balance account (modal with filters and auto-suggested direction)",
|
||||
"Attribution icon in the Transactions page for transactions linked to a transfer",
|
||||
"Evolution chart with line or stacked-area-by-category mode + vertical markers for tagged transfers (green = in, red = out)",
|
||||
"Evolution chart with line or stacked-area-by-type mode + vertical markers for tagged transfers (green = in, red = out)",
|
||||
"Accounts table with 3 Modified Dietz return columns (3M / 1Y / since inception) + side-by-side unadjusted return column",
|
||||
"Warning if the latest snapshot is more than 60 days old",
|
||||
"Soft-delete of accounts (Archive) — hidden from new snapshots, preserved in history",
|
||||
|
|
@ -962,14 +962,14 @@
|
|||
"Privacy-first: everything stays local, no outbound calls at MVP"
|
||||
],
|
||||
"steps": [
|
||||
"Go to /balance/accounts → Categories tab to create an extra category if needed (RRIF as simple, or Stocks Wealthsimple as priced)",
|
||||
"Go to the Accounts tab to create each account (TFSA Tangerine under TFSA, BTC Ledger under Crypto with symbol BTC)",
|
||||
"Go to /balance/accounts → Types tab to create an extra type if needed (RRIF as simple, or Stocks Wealthsimple as priced)",
|
||||
"Go to the Accounts tab to create each account (TFSA Tangerine under TFSA, BTC Ledger under Crypto with symbol BTC — the symbol stays optional)",
|
||||
"Click \"+ New snapshot\" from /balance to open /balance/snapshot at today's date",
|
||||
"Fill in values per account (grouped by category). For priced accounts, enter quantity and unit price — value is computed",
|
||||
"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 edit an existing snapshot, click its point on the chart or use the date picker — the page opens in edit mode (date is immutable)",
|
||||
"To 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": [
|
||||
|
|
@ -1566,7 +1566,7 @@
|
|||
"totalSeriesLabel": "Total",
|
||||
"mode": {
|
||||
"line": "Line",
|
||||
"stacked": "Stacked by category"
|
||||
"stacked": "Stacked by type"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
|
|
@ -1606,7 +1606,7 @@
|
|||
"title": "Balance accounts",
|
||||
"tabs": {
|
||||
"accounts": "Accounts",
|
||||
"categories": "Categories"
|
||||
"categories": "Types"
|
||||
},
|
||||
"newAccount": "New account",
|
||||
"includeArchived": "Show archived accounts",
|
||||
|
|
@ -1615,7 +1615,7 @@
|
|||
"account": {
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"category": "Category",
|
||||
"category": "Type",
|
||||
"symbol": "Symbol",
|
||||
"currency": "Currency",
|
||||
"status": "Status",
|
||||
|
|
@ -1632,15 +1632,14 @@
|
|||
"form": {
|
||||
"createTitle": "New account",
|
||||
"editTitle": "Edit account",
|
||||
"category": "Category",
|
||||
"noCategory": "(no category available)",
|
||||
"category": "Type",
|
||||
"noCategory": "(no type available)",
|
||||
"name": "Account name",
|
||||
"nameRequired": "Name is required.",
|
||||
"symbol": "Symbol",
|
||||
"symbolPricedHint": "required for priced categories",
|
||||
"symbolRequiredForPriced": "A symbol is required for priced categories.",
|
||||
"symbolPricedHint": "optional — only needed for automatic price fetching",
|
||||
"symbolPlaceholderSimple": "Optional",
|
||||
"symbolPlaceholderPriced": "e.g. AAPL, BTC-USD",
|
||||
"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",
|
||||
|
|
@ -1648,11 +1647,11 @@
|
|||
}
|
||||
},
|
||||
"category": {
|
||||
"intro": "Seeded categories (TFSA, RRSP, Cash, etc.) ship with the app. You can create your own for special cases.",
|
||||
"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": "Kind",
|
||||
"kind": "Entry mode",
|
||||
"origin": "Origin",
|
||||
"actions": "Actions"
|
||||
},
|
||||
|
|
@ -1665,23 +1664,23 @@
|
|||
"user": "Custom"
|
||||
},
|
||||
"actions": {
|
||||
"create": "New category",
|
||||
"renamePrompt": "New label for this category",
|
||||
"deleteConfirm": "Delete this category? This cannot be undone.",
|
||||
"deleteSeedHint": "Standard categories cannot be deleted.",
|
||||
"deleteHasAccountsHint": "This category has {{count}} linked account(s) — archive or move them first."
|
||||
"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 category",
|
||||
"createTitle": "New type",
|
||||
"key": "Key",
|
||||
"keyPlaceholder": "e.g. lira, prpp",
|
||||
"label": "Label",
|
||||
"labelPlaceholder": "e.g. LIRA, PRPP",
|
||||
"kindLabel": "Category kind",
|
||||
"kindLabel": "Entry mode",
|
||||
"kindHintSimple": "Direct value entry (e.g. checking-account balance).",
|
||||
"kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). Linked accounts will require a symbol.",
|
||||
"simpleOnlyNotice": "Priced categories (stocks, crypto) will be available in a future release.",
|
||||
"create": "Create category"
|
||||
"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"
|
||||
},
|
||||
"assetType": {
|
||||
"label": "Asset type",
|
||||
|
|
@ -1690,12 +1689,12 @@
|
|||
"required": "Select an asset type"
|
||||
},
|
||||
"error": {
|
||||
"has_accounts": "Cannot delete this category: {{count}} linked account(s) ({{names}}). Archive or move them first."
|
||||
"has_accounts": "Cannot delete this type: {{count}} linked account(s) ({{names}}). Archive or move them first."
|
||||
},
|
||||
"cash": "Cash",
|
||||
"tfsa": "TFSA",
|
||||
"rrsp": "RRSP",
|
||||
"fund": "Mutual fund",
|
||||
"fund": "Funds / ETF",
|
||||
"other": "Other",
|
||||
"stock": "Stock",
|
||||
"crypto": "Crypto"
|
||||
|
|
@ -1705,7 +1704,7 @@
|
|||
"newTitle": "New snapshot",
|
||||
"editTitle": "Edit snapshot",
|
||||
"dateLabel": "Snapshot date",
|
||||
"dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.",
|
||||
"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",
|
||||
|
|
@ -1745,14 +1744,15 @@
|
|||
},
|
||||
"errors": {
|
||||
"currency_unsupported": "Only CAD is supported at the MVP.",
|
||||
"category_seed_protected": "Standard categories cannot be deleted.",
|
||||
"category_has_accounts": "Cannot delete a category with linked accounts. Move or archive linked accounts first.",
|
||||
"category_not_found": "Category not found.",
|
||||
"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 category kind.",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -945,16 +945,16 @@
|
|||
},
|
||||
"balance": {
|
||||
"title": "Bilan",
|
||||
"overview": "Le Bilan est une vue patrimoniale : vous saisissez périodiquement un snapshot daté de l'ensemble de vos comptes (encaisse, REER, CELI, fonds, actions, crypto, autres), suivez leur évolution dans le temps et calculez le vrai rendement de chaque compte d'investissement en liant les transferts (apports/retraits) aux comptes correspondants.",
|
||||
"overview": "Le Bilan est une vue patrimoniale : vous saisissez périodiquement un snapshot (relevé daté de votre patrimoine) de l'ensemble de vos comptes (liquidités, REER, CELI, fonds, actions, crypto, autres), suivez leur évolution dans le temps et calculez le vrai rendement de chaque compte d'investissement en liant les transferts (apports/retraits) aux comptes correspondants.",
|
||||
"features": [
|
||||
"7 catégories standard pré-installées (Encaisse, CELI, REER, Fonds, Actions, Crypto, Autres) — renommables, non-supprimables",
|
||||
"Création de catégories personnalisées avec choix simple (montant direct) ou priced (quantité × prix unitaire)",
|
||||
"Comptes par catégorie : nom, symbole optionnel, devise (CAD au MVP), notes",
|
||||
"Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer",
|
||||
"7 types standard pré-installés (Liquidités, CELI, REER, Fonds / FNB, Actions, Crypto, Autres) — renommables, non-supprimables ; un type regroupe des comptes de même nature (distinct des catégories de transactions)",
|
||||
"Création de types personnalisés avec choix simple (montant direct) ou priced (quantité × prix unitaire)",
|
||||
"Comptes par type : nom, 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",
|
||||
"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",
|
||||
"Liaison de transactions existantes à un compte de bilan (modal avec filtres et sens auto-proposé)",
|
||||
"Icône d'attribution dans la page Transactions pour les transactions liées à un transfert",
|
||||
"Graphique d'évolution avec mode courbe ou aire empilée par catégorie + marqueurs verticaux pour les transferts (vert = in, rouge = out)",
|
||||
"Graphique d'évolution avec mode courbe ou aire empilée par type + marqueurs verticaux pour les transferts (vert = in, rouge = out)",
|
||||
"Tableau des comptes avec 3 colonnes de rendement Modified Dietz (3M / 1A / depuis création) + colonne rendement non-ajusté côte-à-côte",
|
||||
"Avertissement si le dernier snapshot remonte à plus de 60 jours",
|
||||
"Soft-delete des comptes (Archiver) — masqués des nouveaux snapshots, conservés dans l'historique",
|
||||
|
|
@ -962,14 +962,14 @@
|
|||
"Privacy-first : tout est local, aucun appel sortant au MVP"
|
||||
],
|
||||
"steps": [
|
||||
"Allez dans /balance/accounts → onglet Catégories pour créer si besoin une catégorie supplémentaire (FERR en simple, ou Stocks Wealthsimple en priced)",
|
||||
"Allez dans l'onglet Comptes pour créer chaque compte (TFSA Tangerine rattaché à CELI, BTC Ledger rattaché à Crypto avec symbole BTC)",
|
||||
"Allez dans /balance/accounts → onglet Types pour créer si besoin un type supplémentaire (FERR en simple, ou Stocks Wealthsimple en priced)",
|
||||
"Allez dans l'onglet Comptes pour créer chaque compte (TFSA Tangerine rattaché à CELI, BTC Ledger rattaché à Crypto avec symbole BTC — le symbole reste optionnel)",
|
||||
"Cliquez « + Nouveau snapshot » depuis /balance pour ouvrir /balance/snapshot à la date du jour",
|
||||
"Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée",
|
||||
"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 éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition (la date est immutable)",
|
||||
"Pour é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": [
|
||||
|
|
@ -1541,7 +1541,7 @@
|
|||
"title": "Bilan",
|
||||
"latestTotal": "Valeur nette actuelle",
|
||||
"asOf": "au {{date}}",
|
||||
"noSnapshots": "Aucun snapshot pour l'instant. Créez-en un pour suivre l'évolution de votre bilan.",
|
||||
"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.",
|
||||
|
|
@ -1566,7 +1566,7 @@
|
|||
"totalSeriesLabel": "Total",
|
||||
"mode": {
|
||||
"line": "Ligne",
|
||||
"stacked": "Empilé par catégorie"
|
||||
"stacked": "Empilé par type"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
|
|
@ -1606,7 +1606,7 @@
|
|||
"title": "Comptes du bilan",
|
||||
"tabs": {
|
||||
"accounts": "Comptes",
|
||||
"categories": "Catégories"
|
||||
"categories": "Types"
|
||||
},
|
||||
"newAccount": "Nouveau compte",
|
||||
"includeArchived": "Afficher les comptes archivés",
|
||||
|
|
@ -1615,7 +1615,7 @@
|
|||
"account": {
|
||||
"fields": {
|
||||
"name": "Nom",
|
||||
"category": "Catégorie",
|
||||
"category": "Type",
|
||||
"symbol": "Symbole",
|
||||
"currency": "Devise",
|
||||
"status": "Statut",
|
||||
|
|
@ -1632,15 +1632,14 @@
|
|||
"form": {
|
||||
"createTitle": "Nouveau compte",
|
||||
"editTitle": "Modifier le compte",
|
||||
"category": "Catégorie",
|
||||
"noCategory": "(aucune catégorie disponible)",
|
||||
"category": "Type",
|
||||
"noCategory": "(aucun type disponible)",
|
||||
"name": "Nom du compte",
|
||||
"nameRequired": "Le nom est obligatoire.",
|
||||
"symbol": "Symbole",
|
||||
"symbolPricedHint": "obligatoire pour cette catégorie cotée",
|
||||
"symbolRequiredForPriced": "Un symbole est obligatoire pour les catégories cotées.",
|
||||
"symbolPricedHint": "optionnel — requis seulement pour la récupération automatique des prix",
|
||||
"symbolPlaceholderSimple": "Optionnel",
|
||||
"symbolPlaceholderPriced": "ex. AAPL, BTC-USD",
|
||||
"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",
|
||||
|
|
@ -1648,11 +1647,11 @@
|
|||
}
|
||||
},
|
||||
"category": {
|
||||
"intro": "Les catégories seedées (CELI, REER, Encaisse, etc.) sont fournies par l'application. Vous pouvez en créer de nouvelles pour vos cas particuliers.",
|
||||
"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": "Type",
|
||||
"kind": "Mode de saisie",
|
||||
"origin": "Origine",
|
||||
"actions": "Actions"
|
||||
},
|
||||
|
|
@ -1662,26 +1661,26 @@
|
|||
},
|
||||
"origin": {
|
||||
"seeded": "Standard",
|
||||
"user": "Personnalisée"
|
||||
"user": "Personnalisé"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Nouvelle catégorie",
|
||||
"renamePrompt": "Nouveau libellé pour cette catégorie",
|
||||
"deleteConfirm": "Supprimer cette catégorie ? Cette action est irréversible.",
|
||||
"deleteSeedHint": "Les catégories standard ne peuvent pas être supprimées.",
|
||||
"deleteHasAccountsHint": "Cette catégorie a {{count}} compte(s) lié(s) — archivez ou déplacez-les d'abord."
|
||||
"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": "Nouvelle catégorie",
|
||||
"createTitle": "Nouveau type",
|
||||
"key": "Clé",
|
||||
"keyPlaceholder": "ex. ferr, rpdb",
|
||||
"label": "Libellé",
|
||||
"labelPlaceholder": "ex. FERR, RPDB",
|
||||
"kindLabel": "Type de catégorie",
|
||||
"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 sera obligatoire pour les comptes liés.",
|
||||
"simpleOnlyNotice": "Les catégories cotées (actions, crypto) seront disponibles dans une prochaine version.",
|
||||
"create": "Créer la catégorie"
|
||||
"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"
|
||||
},
|
||||
"assetType": {
|
||||
"label": "Type d'actif",
|
||||
|
|
@ -1690,12 +1689,12 @@
|
|||
"required": "Sélectionne le type d'actif"
|
||||
},
|
||||
"error": {
|
||||
"has_accounts": "Impossible de supprimer cette catégorie : {{count}} compte(s) lié(s) ({{names}}). Archivez ou déplacez-les d'abord."
|
||||
"has_accounts": "Impossible de supprimer ce type : {{count}} compte(s) lié(s) ({{names}}). Archivez ou déplacez-les d'abord."
|
||||
},
|
||||
"cash": "Encaisse",
|
||||
"cash": "Liquidités",
|
||||
"tfsa": "CELI",
|
||||
"rrsp": "REER",
|
||||
"fund": "Fonds commun",
|
||||
"fund": "Fonds / FNB",
|
||||
"other": "Autre",
|
||||
"stock": "Action",
|
||||
"crypto": "Cryptomonnaie"
|
||||
|
|
@ -1705,7 +1704,7 @@
|
|||
"newTitle": "Nouveau snapshot",
|
||||
"editTitle": "Modifier le snapshot",
|
||||
"dateLabel": "Date du snapshot",
|
||||
"dateImmutable": "La date d'un snapshot existant ne peut pas être modifiée. Pour changer la date, supprimez ce snapshot et créez-en un nouveau.",
|
||||
"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",
|
||||
|
|
@ -1745,14 +1744,15 @@
|
|||
},
|
||||
"errors": {
|
||||
"currency_unsupported": "Seul le CAD est supporté au MVP.",
|
||||
"category_seed_protected": "Les catégories standard ne peuvent pas être supprimées.",
|
||||
"category_has_accounts": "Impossible de supprimer une catégorie avec des comptes liés. Déplacez ou archivez d'abord les comptes liés.",
|
||||
"category_not_found": "Catégorie introuvable.",
|
||||
"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": "Type de catégorie invalide.",
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -80,14 +80,11 @@ export default function SnapshotEditPage() {
|
|||
const handleSave = async () => {
|
||||
try {
|
||||
await editor.save();
|
||||
// After a successful create, the URL should become `?date=...` so
|
||||
// refreshing keeps the user in edit mode.
|
||||
if (!isEditMode) {
|
||||
setSearchParams(
|
||||
{ date: state.snapshotDate },
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
// Keep the URL in sync with the saved date so a reload reopens the
|
||||
// right snapshot in edit mode. Covers both a fresh create (new → edit)
|
||||
// and an edit-mode date move (#200), where `?date=` still points at the
|
||||
// pre-move date until we update it here.
|
||||
setSearchParams({ date: state.snapshotDate }, { replace: true });
|
||||
} catch {
|
||||
// The hook surfaced the error via state.errorCode/state.error.
|
||||
}
|
||||
|
|
@ -144,27 +141,32 @@ export default function SnapshotEditPage() {
|
|||
id="snapshot-date"
|
||||
type="date"
|
||||
value={state.snapshotDate}
|
||||
disabled={isEditMode}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
editor.setDate(next);
|
||||
// Drive the route param so reloads stay coherent and an
|
||||
// existing snapshot at the chosen date flips us into 'edit'.
|
||||
if (next) {
|
||||
setSearchParams({ date: next }, { replace: true });
|
||||
} else {
|
||||
setSearchParams({}, { replace: true });
|
||||
// In 'new' mode, drive the route param so reloads stay
|
||||
// coherent and an existing snapshot at the chosen date flips
|
||||
// us into 'edit'. In 'edit' mode we deliberately DON'T sync
|
||||
// the URL: changing the date is a pending "move" applied on
|
||||
// save (#200), so reloading from the route would discard the
|
||||
// edit and reopen the original snapshot.
|
||||
if (!isEditMode) {
|
||||
if (next) {
|
||||
setSearchParams({ date: next }, { replace: true });
|
||||
} else {
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
}
|
||||
// WebKitGTK (Linux Tauri WebView) does not always dismiss the
|
||||
// native date popup after a value commit — user has to hit
|
||||
// Esc. Force-blur is a no-op on WebView2/WKWebView. See #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)] disabled:opacity-60"
|
||||
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)]"
|
||||
/>
|
||||
{isEditMode && (
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{t("balance.snapshot.page.dateImmutable")}
|
||||
{t("balance.snapshot.page.dateMovableHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -354,6 +354,33 @@ describe("createBalanceAccount", () => {
|
|||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||||
expect(params).toEqual([1, "Encaisse Wealthsimple", null, "CAD", null]);
|
||||
});
|
||||
|
||||
it("allows a priced-category account WITHOUT a symbol (Issue #199)", async () => {
|
||||
// Symbol is optional even for priced categories — manual valuation
|
||||
// (quantity × unit price) never needs it; only the price-fetch button does.
|
||||
mockSelect.mockResolvedValueOnce([
|
||||
{
|
||||
id: 3,
|
||||
key: "stock",
|
||||
i18n_key: "balance.category.stock",
|
||||
kind: "priced",
|
||||
sort_order: 50,
|
||||
is_active: 1,
|
||||
is_seed: 1,
|
||||
asset_type: "stock",
|
||||
},
|
||||
]);
|
||||
mockExecute.mockResolvedValueOnce({ lastInsertId: 9, rowsAffected: 1 });
|
||||
const id = await createBalanceAccount({
|
||||
balance_category_id: 3,
|
||||
name: "Portefeuille Wealthsimple",
|
||||
// no symbol provided
|
||||
});
|
||||
expect(id).toBe(9);
|
||||
const params = mockExecute.mock.calls[0][1] as unknown[];
|
||||
// symbol param (3rd) is null — insert succeeds, no validation thrown.
|
||||
expect(params[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateBalanceAccount", () => {
|
||||
|
|
@ -1054,6 +1081,77 @@ describe("saveSnapshotAtomic — edit mode", () => {
|
|||
"COMMIT"
|
||||
);
|
||||
});
|
||||
|
||||
it("moves the snapshot date in-txn and preserves the existing lines (Issue #200)", async () => {
|
||||
// In edit mode with moveToDate set: BEGIN → collision SELECT (free) →
|
||||
// UPDATE date → DELETE lines → INSERT line → UPDATE updated_at → COMMIT.
|
||||
mockSelect.mockResolvedValueOnce([]); // collision check → target date free
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE snapshot_date
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
|
||||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line (preserved)
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||||
|
||||
const res = await saveSnapshotAtomic({
|
||||
existingSnapshotId: 5,
|
||||
snapshot_date: "2026-04-15",
|
||||
moveToDate: "2026-05-20",
|
||||
lines: [{ account_id: 1, value: 1234 }],
|
||||
});
|
||||
|
||||
expect(res.snapshotId).toBe(5);
|
||||
// Collision SELECT excludes the moved snapshot's own id.
|
||||
const clashParams = mockSelect.mock.calls[0][1] as unknown[];
|
||||
expect(clashParams).toEqual(["2026-05-20", 5]);
|
||||
// First execute is BEGIN, then the date UPDATE happens before the lines.
|
||||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||||
expect(mockExecute.mock.calls[1][0]).toContain("SET snapshot_date = $1");
|
||||
expect(mockExecute.mock.calls[1][1]).toEqual(["2026-05-20", 5]);
|
||||
// Lines are still rewritten (preserved): DELETE then INSERT.
|
||||
expect(mockExecute.mock.calls[2][0]).toContain(
|
||||
"DELETE FROM balance_snapshot_lines"
|
||||
);
|
||||
expect(mockExecute.mock.calls[3][0]).toContain(
|
||||
"INSERT INTO balance_snapshot_lines"
|
||||
);
|
||||
// Commits, never rolls back.
|
||||
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
|
||||
"COMMIT"
|
||||
);
|
||||
expect(
|
||||
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "ROLLBACK")
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rolls back and throws snapshot_date_exists when moveToDate collides with another snapshot (Issue #200)", async () => {
|
||||
mockSelect.mockResolvedValueOnce([{ id: 42 }]); // collision: another snapshot at the target
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||
.mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK
|
||||
|
||||
await expect(
|
||||
saveSnapshotAtomic({
|
||||
existingSnapshotId: 5,
|
||||
snapshot_date: "2026-04-15",
|
||||
moveToDate: "2026-05-20",
|
||||
lines: [{ account_id: 1, value: 1234 }],
|
||||
})
|
||||
).rejects.toMatchObject({ code: "snapshot_date_exists" });
|
||||
|
||||
// BEGIN ran, then ROLLBACK — no date UPDATE, no line writes committed.
|
||||
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||||
expect(mockExecute.mock.calls[1][0]).toBe("ROLLBACK");
|
||||
expect(
|
||||
mockExecute.mock.calls.some((c: unknown[]) =>
|
||||
String(c[0]).includes("SET snapshot_date")
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT")
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export type BalanceErrorCode =
|
|||
| "asset_type_invalid"
|
||||
| "snapshot_date_required"
|
||||
| "snapshot_date_taken"
|
||||
| "snapshot_date_exists"
|
||||
| "snapshot_not_found"
|
||||
| "snapshot_value_invalid"
|
||||
| "snapshot_priced_unsupported"
|
||||
|
|
@ -935,12 +936,21 @@ export async function upsertSnapshotLines(
|
|||
* If ROLLBACK itself fails (e.g. transaction never opened), that error is
|
||||
* swallowed and the original is preserved — the caller never sees a
|
||||
* misleading rollback error.
|
||||
*
|
||||
* Edit-mode date move (Issue #200): pass `moveToDate` with the snapshot's
|
||||
* NEW date when the user changed it in edit mode. The date move + line
|
||||
* rewrite happen in the same transaction, so a collision on `moveToDate`
|
||||
* (another snapshot already there) rolls the whole save back and surfaces
|
||||
* `snapshot_date_exists`. Passing `moveToDate` in new mode is ignored — the
|
||||
* date is taken from `snapshot_date` on INSERT there.
|
||||
*/
|
||||
export async function saveSnapshotAtomic(input: {
|
||||
existingSnapshotId: number | null;
|
||||
snapshot_date: string;
|
||||
notes?: string | null;
|
||||
lines: SnapshotLineInput[];
|
||||
/** New date for an edit-mode move; omit when the date is unchanged. */
|
||||
moveToDate?: string | null;
|
||||
}): Promise<{ snapshotId: number }> {
|
||||
// Validate every line ahead of time so the transaction never opens for
|
||||
// a doomed save. Mirrors `upsertSnapshotLines` invariants.
|
||||
|
|
@ -957,6 +967,29 @@ export async function saveSnapshotAtomic(input: {
|
|||
let snapshotId: number;
|
||||
if (input.existingSnapshotId !== null) {
|
||||
snapshotId = input.existingSnapshotId;
|
||||
// Edit-mode date move (#200): if a new date was requested, re-check the
|
||||
// UNIQUE constraint in-txn and update `snapshot_date`. Done before the
|
||||
// line rewrite so a collision rolls the entire save back.
|
||||
if (input.moveToDate != null) {
|
||||
const moveTo = normalizeSnapshotDate(input.moveToDate);
|
||||
const clash = await db.select<Array<{ id: number }>>(
|
||||
`SELECT id FROM balance_snapshots
|
||||
WHERE snapshot_date = $1 AND id <> $2`,
|
||||
[moveTo, snapshotId]
|
||||
);
|
||||
if (clash.length > 0) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_date_exists",
|
||||
`Another snapshot already exists at ${moveTo}`
|
||||
);
|
||||
}
|
||||
await db.execute(
|
||||
`UPDATE balance_snapshots
|
||||
SET snapshot_date = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`,
|
||||
[moveTo, snapshotId]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const date = normalizeSnapshotDate(input.snapshot_date);
|
||||
// Date collision check inside the transaction so a concurrent
|
||||
|
|
|
|||
Loading…
Reference in a new issue