From 9d95d2e189a55775d25130b694df5e1898587620 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sun, 31 May 2026 16:38:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(balance):=20audit=20quick=20wins=20?= =?UTF-8?q?=E2=80=94=20optional=20symbol,=20movable=20snapshot=20date,=20c?= =?UTF-8?q?learer=20terminology?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 0 of the balance audit (docs/audit-bilan-2026-05.md): - #198 terminology: "category" -> "type" across the balance UI + user guide (avoids collision with transaction categories); relabel Cash/Funds-ETF; gloss "snapshot" at first use. i18n FR/EN in lockstep. - #199 make the ticker symbol optional for priced accounts (only needed for the price-fetch button; manual quantity x price never used it). - #200 allow moving an existing snapshot's date: atomic date move + line rewrite in one transaction; collision on the target date rolls back with a typed snapshot_date_exists error. No schema change. Build (tsc + vite) and balance.service tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.fr.md | 12 ++++ CHANGELOG.md | 12 ++++ docs/guide-utilisateur.md | 18 ++--- src/components/balance/AccountForm.tsx | 24 ++----- src/hooks/useSnapshotEditor.ts | 16 ++++- src/i18n/locales/en.json | 72 +++++++++---------- src/i18n/locales/fr.json | 80 ++++++++++----------- src/pages/SnapshotEditPage.tsx | 36 +++++----- src/services/balance.service.test.ts | 98 ++++++++++++++++++++++++++ src/services/balance.service.ts | 33 +++++++++ 10 files changed, 278 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index d8d0170..223b06d 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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é diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cff686..afa96ff 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/guide-utilisateur.md b/docs/guide-utilisateur.md index 5c3e9a6..04182d1 100644 --- a/docs/guide-utilisateur.md +++ b/docs/guide-utilisateur.md @@ -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 diff --git a/src/components/balance/AccountForm.tsx b/src/components/balance/AccountForm.tsx index e27d96c..128e4c4 100644 --- a/src/components/balance/AccountForm.tsx +++ b/src/components/balance/AccountForm.tsx @@ -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 && ( -

- {t("balance.account.form.symbolRequiredForPriced")} -

- )}
@@ -275,12 +266,7 @@ function AccountVariant({
diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index 5b0fd83..76e10ab 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -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); + }); }); // ----------------------------------------------------------------------------- diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index 6401ef0..1ad803e 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -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>( + `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