From cd0a2b826f1fe1f9d287e44efd5705304f904947 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 2 May 2026 11:59:45 -0400 Subject: [PATCH] feat(balance): starter accounts + opt-in modal + ADR 0012 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 — New profiles: seed 4 starter accounts in consolidated_schema.sql (Compte chèque/CELI/REER/Compte non-enregistré, currency CAD, is_active=1) right after the balance_categories seeds. Categories resolved via SELECT subquery on the seeded `key` values for robustness. Part 2 — Existing profiles: StarterAccountsModal proposes the same 4 starters at first /balance visit. Default-checked checkboxes, collision rule (case-insensitive trim name + matching category) disables matches with a "Déjà présent" tooltip. The atomic helper `proposeStarterAccounts` wraps the inserts in BEGIN/COMMIT (rolls back on error). user_preferences.balance_starter_proposed records {shown_at, accepted} so the modal never reappears, dismissed or confirmed. Part 3 — docs/adr/0012-balance-two-level-model.md (Proposed): captures the future vehicles × compositions model for reflection, no code change. Numbered 0012 because 0011 was already taken by the providers-best-effort-yahoo ADR. Linked from architecture.md ADR table and Bilan section. Tests: StarterAccountsModal.test.tsx covers STARTER_ACCOUNTS shape, getStarterCollisions (case-insensitive trim, category-scoped) and proposeStarterAccounts (insert order, COMMIT, ROLLBACK on failure). No render tests — mirrors the BalanceOnboardingCard pattern (no jsdom configured). Resolves #179 --- CHANGELOG.fr.md | 4 + CHANGELOG.md | 4 + docs/adr/0012-balance-two-level-model.md | 104 +++++++++ docs/architecture.md | 3 +- .../src/database/consolidated_schema.sql | 11 + .../balance/StarterAccountsModal.test.tsx | 119 ++++++++++ .../balance/StarterAccountsModal.tsx | 209 ++++++++++++++++++ src/i18n/locales/en.json | 16 ++ src/i18n/locales/fr.json | 16 ++ src/pages/BalancePage.tsx | 54 +++++ src/services/balance.service.ts | 132 +++++++++++ 11 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0012-balance-two-level-model.md create mode 100644 src/components/balance/StarterAccountsModal.test.tsx create mode 100644 src/components/balance/StarterAccountsModal.tsx diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 7a536b4..50d1789 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -2,6 +2,10 @@ ## [Non publié] +### Ajouté + +- Bilan : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils, plus un modal d'opt-in unique pour les profils existants à leur première visite de /balance. Cases cochées par défaut ; les comptes existants avec le même nom + catégorie désactivent la ligne correspondante avec un tooltip « Déjà présent ». Confirmation ou ignorance enregistrée dans `user_preferences.balance_starter_proposed` pour que le modal ne réapparaisse jamais. ADR 0012 (Proposed) capture le futur modèle à deux niveaux véhicule × composition (#179). + ### Modifié - Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9635c56..87a8c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Bilan: 4 starter accounts (Checking account, TFSA, RRSP, Non-registered account) are seeded for new profiles, and a one-shot opt-in modal proposes them to existing profiles on their first /balance visit. Default-checked checkboxes; existing accounts with the same name + category disable the matching row with a "Already exists" tooltip. Confirming or dismissing both write `user_preferences.balance_starter_proposed` so the modal never re-appears. ADR 0012 (Proposed) captures the future two-level vehicle × composition model (#179). + ### Changed - Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178). diff --git a/docs/adr/0012-balance-two-level-model.md b/docs/adr/0012-balance-two-level-model.md new file mode 100644 index 0000000..218a610 --- /dev/null +++ b/docs/adr/0012-balance-two-level-model.md @@ -0,0 +1,104 @@ +# ADR 0012 — Modèle à deux niveaux pour le Bilan (véhicules × compositions) + +- Status: Proposed +- Date: 2026-05-01 +- Issue: #179 + +## Contexte + +Le Bilan modélise actuellement les comptes de manière **plate** : `balance_accounts` est rattaché à exactement une `balance_categories`, qui combine implicitement la **nature fiscale du véhicule** (CELI, REER, non enregistré) et la **classe d'actif** (encaisse, action, fonds, crypto). Les sept catégories seedées par Migration v9 sont des frères/sœurs au même niveau : + +``` +cash · tfsa · rrsp · fund · other · stock · crypto +``` + +Cette structure pose une limite expressive : **un véhicule fiscal et une classe d'actif sont deux dimensions orthogonales**, pas une hiérarchie. Un utilisateur qui détient une action d'Apple à l'intérieur d'un CELI n'a aujourd'hui que des choix dégradés : + +- créer un compte `priced` rattaché à la catégorie `stock` → l'avantage fiscal CELI disparaît du modèle ; +- créer un compte `simple` rattaché à `tfsa` avec un montant agrégé → la valeur de marché et le rendement réel par titre disparaissent ; +- créer une catégorie utilisateur custom (`tfsa_stock`) → l'arbre explose en N×M permutations. + +Cette tension est visible mais reste tolérable au MVP — la plupart des utilisateurs commencent avec des comptes simples (chèque, CELI cotisations, REER cotisations) et n'investissent en titres cotés que plus tard. La question est néanmoins structurante pour la roadmap : un changement de modèle après livraison V1 nécessitera une migration de données massive et une réécriture quasi totale de `/balance`. + +L'ADR 0012 documente la réflexion **avant que le besoin devienne bloquant**, sans engager de code. + +## Proposition — Modèle à deux niveaux + +Remplacer `balance_accounts → balance_categories` par deux tables conceptuelles : + +| Table | Rôle | Exemples | +|---|---|---| +| `balance_vehicles` | Véhicule fiscal / contenant | Compte chèque, CELI, REER, FERR, RPDB, non enregistré | +| `balance_compositions` | Classe d'actif détenue dans le véhicule | Encaisse, action, fonds indiciel, obligation, crypto | + +Une **ligne de snapshot** devient un triplet `(vehicle_id, composition_id, value)` au lieu de l'actuel `(account_id, value)` : + +``` +balance_snapshot_lines +├── vehicle_id (FK balance_vehicles) +├── composition_id (FK balance_compositions) +├── quantity, unit_price (NULL pour compositions de type 'simple') +└── value +``` + +Bénéfices : +- **Expressivité** : un CELI avec 3 actions et un peu d'encaisse devient 4 lignes lisibles, additionnables, filtrables sur l'une OU l'autre dimension. +- **Rapports croisés** : "valeur totale en CELI" (somme par véhicule) ET "valeur totale en actions" (somme par composition) sont deux groupements naturels. +- **Modified Dietz par véhicule** ou **par composition** : les apports/retraits suivent le véhicule, le rendement suit la composition. + +## Alternatives considérées + +### A. Tagging multi-axes sur le modèle plat actuel + +Garder `balance_accounts` plat, ajouter une table `balance_account_tags` libre. L'utilisateur tague chaque compte avec autant d'axes que voulu (`tfsa`, `stock`, `apple`, `tech`). + +- ✅ Migration triviale (table additive). +- ❌ Aucune contrainte sur les combinaisons → la cohérence retombe sur l'utilisateur. +- ❌ Les rapports "actions dans CELI" deviennent une intersection de tags, beaucoup plus coûteuse à requêter et à expliquer. +- ❌ Risque d'arbres divergents entre profils — pas de vocabulaire partagé. + +### B. Sous-comptes sous comptes + +Introduire `balance_accounts.parent_id` (auto-référence). Un compte `Mon CELI` (catégorie `tfsa`, `simple`) pourrait avoir des enfants `Apple Inc.` (catégorie `stock`, `priced`). + +- ✅ Modèle hiérarchique familier (similaire aux catégories de transactions). +- ❌ La somme parent = somme enfants devient un invariant à maintenir → friction de saisie. +- ❌ Les snapshots doublent leur taille (ligne parent + lignes enfants) sans gain expressif réel : la nature fiscale du parent et la nature d'actif des enfants restent collées sur un seul axe. +- ❌ Profondeur d'arbre incertaine : on retombe sur le multi-axes mal déguisé. + +### C. Statu quo (modèle plat enrichi) + +Garder le modèle actuel et accepter que les utilisateurs avancés créent des catégories user-définies pour les permutations qui les intéressent (`tfsa_stock`, `rrsp_fund`). + +- ✅ Aucun coût de migration. +- ✅ Suffisant pour 80% des cas d'usage (utilisateurs avec des comptes simples). +- ❌ Friction documentée croissante au fur et à mesure que la base d'utilisateurs détient des portefeuilles diversifiés. +- ❌ La taxonomie utilisateur diverge entre profils, rendant tout futur partage ou agrégation cross-profil très coûteux. + +## Impact + +Une adoption du modèle à deux niveaux implique, au minimum : + +- **Migration v12+** : décomposer chaque `balance_accounts` existant en `(vehicle, composition)` selon une heuristique sur `category.kind` + `category.asset_type`. Migration v9 actuelle (7 catégories seedées) sera scindée en deux seeds. +- **Réécriture complète des écrans `/balance/accounts` et `/balance/snapshot`** : la grille de saisie passe d'une dimension à deux. +- **Adaptation des agrégateurs `balance.service.ts`** : `getSnapshotTotalsByDate` reste valide, mais `getSnapshotTotalsByCategoryAndDate` doit être dédoublé en `getSnapshotTotalsByVehicleAndDate` + `getSnapshotTotalsByCompositionAndDate`. +- **Adaptation du calcul Modified Dietz** : la pertinence du rendement par véhicule vs par composition doit être tranchée. +- **Adaptation des graphiques** : la pile actuelle (stacked-by-category) doit choisir un axe par défaut + offrir une bascule. + +Cet impact est massif. La proposition n'est viable qu'après stabilisation du modèle plat actuel et collecte de retours utilisateurs réels confirmant le besoin. + +## Décision + +**Status: Proposed.** L'équipe gèle la décision jusqu'à ce que les conditions de réévaluation soient réunies : + +1. La V1 du Bilan (issues #138 → #179) est livrée et utilisée en production sans régression majeure pendant au moins un cycle de release ; +2. Au moins trois retours utilisateurs distincts décrivent le cas d'usage "actions à l'intérieur d'un CELI/REER" comme bloquant ; +3. La fonctionnalité de price-fetching (Issue #143, ADR 0009) est livrée — sans elle, le modèle à deux niveaux résoudrait un problème (rendement par titre dans CELI) sans pouvoir l'exploiter. + +À la prochaine évaluation, cet ADR passera à `Accepted` (avec plan de migration v12+) ou `Rejected` (au profit du statu quo + tagging optionnel). + +## Liens + +- [ADR 0008](0008-modified-dietz-pour-rendement.md) — Modified Dietz par compte (modèle plat) +- [ADR 0010](0010-fk-restrict-balance-transfers.md) — FK RESTRICT sur transferts (contrainte préservée par les deux modèles) +- Issue #179 — Comptes de départ + cet ADR diff --git a/docs/architecture.md b/docs/architecture.md index 5c43820..6b86dc3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,7 +91,7 @@ simpl-resultat/ | `import_config_templates` | Modèles prédéfinis de config d'import | | `user_preferences` | Préférences applicatives (clé-valeur) | | `balance_categories` | Taxonomie des types d'actifs (cash, TFSA, RRSP, fund, stock, crypto, other) — `kind ∈ {simple, priced}`, 7 seedées (`is_seed = 1`) | -| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete | +| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. **Issue #179** : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils via `consolidated_schema.sql`, et proposés aux profils existants via `StarterAccountsModal` (one-shot, pref `balance_starter_proposed`). Le futur passage à un modèle véhicule × composition est décrit dans [ADR 0012](adr/0012-balance-two-level-model.md) (Proposed) | | `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer | | `balance_snapshot_lines` | Une ligne par `(snapshot, compte)`. Stockage denormalisé : pour `simple` `value` seul, pour `priced` `quantity + unit_price + value`. CHECK kind invariants côté SQL | | `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains | @@ -401,3 +401,4 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d | [0009](adr/0009-proxy-price-fetching-via-maximus-api.md) | Proxy price-fetching via maximus-api | 2025-01-01 | Accepted | | [0010](adr/0010-fk-restrict-balance-transfers.md) | FK RESTRICT sur balance_account_transfers | 2025-01-01 | Accepted | | [0011](adr/0011-providers-best-effort-yahoo.md) | Providers best-effort Yahoo | 2026-04-26 | Accepted | +| [0012](adr/0012-balance-two-level-model.md) | Modèle à deux niveaux pour le Bilan (véhicules × compositions) | 2026-05-01 | Proposed | diff --git a/src-tauri/src/database/consolidated_schema.sql b/src-tauri/src/database/consolidated_schema.sql index 6341d9d..de73462 100644 --- a/src-tauri/src/database/consolidated_schema.sql +++ b/src-tauri/src/database/consolidated_schema.sql @@ -271,6 +271,17 @@ INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_se ('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'), ('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto'); +-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so +-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on +-- balance_accounts) — once created they are indistinguishable from +-- user-created accounts and can be renamed/archived freely. Existing profiles +-- get the same 4 proposed via StarterAccountsModal on first /balance visit. +INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) VALUES + ((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1), + ((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD', 1), + ((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER', 'CAD', 1), + ((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1); + -- Default preferences (new profiles ship with the v1 IPC taxonomy) INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light'); diff --git a/src/components/balance/StarterAccountsModal.test.tsx b/src/components/balance/StarterAccountsModal.test.tsx new file mode 100644 index 0000000..81486d6 --- /dev/null +++ b/src/components/balance/StarterAccountsModal.test.tsx @@ -0,0 +1,119 @@ +// StarterAccountsModal — unit tests (issue #179) +// +// NOTE: This project does not have @testing-library/react or jsdom configured +// (matches the BalanceOnboardingCard.test.tsx pattern from #178). Tests cover +// the service-layer helpers (`getStarterCollisions`, `proposeStarterAccounts`) +// and the `STARTER_ACCOUNTS` constant — the modal itself is pure orchestration +// over those helpers. + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../services/db", () => ({ + getDb: vi.fn(), +})); + +import { getDb } from "../../services/db"; +import { + STARTER_ACCOUNTS, + getStarterCollisions, + proposeStarterAccounts, +} from "../../services/balance.service"; + +const mockSelect = vi.fn(); +const mockExecute = vi.fn(); +const mockDb = { select: mockSelect, execute: mockExecute }; + +beforeEach(() => { + vi.mocked(getDb).mockResolvedValue(mockDb as never); + mockSelect.mockReset(); + mockExecute.mockReset(); +}); + +describe("STARTER_ACCOUNTS", () => { + it("ships exactly 4 starters mapping cash/tfsa/rrsp/other", () => { + expect(STARTER_ACCOUNTS).toHaveLength(4); + expect(STARTER_ACCOUNTS.map((s) => s.key)).toEqual([ + "cash", + "tfsa", + "rrsp", + "other", + ]); + for (const s of STARTER_ACCOUNTS) { + expect(s.categoryKey).toBe(s.key); + expect(s.i18nKey).toMatch(/^balance\.starters\.items\./); + } + }); +}); + +describe("getStarterCollisions", () => { + it("returns empty set when no accounts collide", async () => { + mockSelect.mockResolvedValueOnce([]); + const result = await getStarterCollisions(); + expect(result.size).toBe(0); + }); + + it("flags exact-name collisions case-insensitive trim", async () => { + mockSelect.mockResolvedValueOnce([ + { key: "cash", account_name: " compte chèque " }, + { key: "tfsa", account_name: "Mon CELI 2024" }, // does NOT match "CELI" exactly + ]); + const result = await getStarterCollisions(); + expect(result.has("cash")).toBe(true); + expect(result.has("tfsa")).toBe(false); + expect(result.has("rrsp")).toBe(false); + expect(result.has("other")).toBe(false); + }); + + it("requires the account to live in the matching category", async () => { + // CELI-named account but in 'cash' category → not a collision for tfsa starter + mockSelect.mockResolvedValueOnce([ + { key: "cash", account_name: "CELI" }, + ]); + const result = await getStarterCollisions(); + expect(result.has("tfsa")).toBe(false); + expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque" + }); +}); + +describe("proposeStarterAccounts", () => { + it("returns [] when no keys selected without opening a transaction", async () => { + const result = await proposeStarterAccounts([]); + expect(result).toEqual([]); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("inserts selected starters atomically and returns their ids", async () => { + // BEGIN + mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); + // For each starter: SELECT id FROM balance_categories + INSERT + mockSelect + .mockResolvedValueOnce([{ id: 11 }]) // cash category + .mockResolvedValueOnce([{ id: 13 }]); // rrsp category + mockExecute + .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 100 }) // INSERT cash + .mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp + .mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT + + const result = await proposeStarterAccounts(["cash", "rrsp"]); + expect(result).toEqual([100, 101]); + + const sqls = mockExecute.mock.calls.map((c) => c[0]); + expect(sqls[0]).toBe("BEGIN"); + expect(sqls[sqls.length - 1]).toBe("COMMIT"); + expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(2); + }); + + it("rolls back on insert failure", async () => { + mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN + mockSelect.mockResolvedValueOnce([{ id: 11 }]); + mockExecute.mockRejectedValueOnce(new Error("disk full")); + mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // ROLLBACK + + await expect(proposeStarterAccounts(["cash"])).rejects.toThrow(); + + const sqls = mockExecute.mock.calls.map((c) => c[0]); + expect(sqls).toContain("BEGIN"); + expect(sqls).toContain("ROLLBACK"); + expect(sqls).not.toContain("COMMIT"); + }); +}); diff --git a/src/components/balance/StarterAccountsModal.tsx b/src/components/balance/StarterAccountsModal.tsx new file mode 100644 index 0000000..bad1220 --- /dev/null +++ b/src/components/balance/StarterAccountsModal.tsx @@ -0,0 +1,209 @@ +// StarterAccountsModal — one-shot opt-in modal proposing 4 starter accounts +// (Compte chèque, CELI, REER, Compte non-enregistré) to existing profiles +// when they first land on /balance. Issue #179. +// +// Behavior: +// - 4 checkboxes default-checked. +// - Collision rule (case-insensitive trim name + same category): the +// matching checkbox is disabled and uncheckable; tooltip explains why. +// - "Ajouter les comptes sélectionnés" → atomic BEGIN/COMMIT INSERT, then +// onClose(insertedIds). +// - "Plus tard" → no INSERT, onClose([]). +// - Parent owns isOpen state and writes user_preferences.balance_starter_proposed +// in onClose so the modal never re-appears. + +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { X, Loader2 } from "lucide-react"; +import { + STARTER_ACCOUNTS, + getStarterCollisions, + proposeStarterAccounts, +} from "../../services/balance.service"; + +export interface StarterAccountsModalProps { + /** Parent guard — modal renders only when true. */ + isOpen: boolean; + /** + * Fired in both branches (confirm + dismiss). The parent uses the returned + * ids to write `user_preferences.balance_starter_proposed` so the modal + * never re-appears, regardless of which branch was taken. + */ + onClose: (acceptedIds: number[]) => void; +} + +export default function StarterAccountsModal({ + isOpen, + onClose, +}: StarterAccountsModalProps) { + const { t } = useTranslation(); + const [collisions, setCollisions] = useState>(new Set()); + const [selected, setSelected] = useState>( + () => new Set(STARTER_ACCOUNTS.map((s) => s.key)) + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [collisionsLoaded, setCollisionsLoaded] = useState(false); + + // Load collisions once when the modal opens. We pre-uncheck colliding + // starters (and disable them) so the visible default-checked count matches + // what would actually be inserted. + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + void (async () => { + try { + const c = await getStarterCollisions(); + if (cancelled) return; + setCollisions(c); + setSelected((prev) => { + const next = new Set(prev); + for (const k of c) next.delete(k); + return next; + }); + setCollisionsLoaded(true); + } catch { + if (!cancelled) setCollisionsLoaded(true); + } + })(); + return () => { + cancelled = true; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const toggle = (key: string) => { + if (collisions.has(key)) return; + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const handleAdd = async () => { + if (submitting) return; + setError(null); + setSubmitting(true); + try { + const ids = await proposeStarterAccounts(Array.from(selected)); + setSubmitting(false); + onClose(ids); + } catch { + setSubmitting(false); + setError(t("balance.starters.errors.insert")); + } + }; + + const handleLater = () => { + if (submitting) return; + onClose([]); + }; + + return createPortal( +
+
+
+
+

+ {t("balance.starters.title")} +

+

+ {t("balance.starters.description")} +

+
+ +
+ +
    + {STARTER_ACCOUNTS.map((s) => { + const isCollision = collisions.has(s.key); + const isChecked = selected.has(s.key); + return ( +
  • + +
  • + ); + })} +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
, + document.body + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8f344e3..a751f4a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1553,6 +1553,22 @@ "disabledHint": "Create an account first to unlock this step." } }, + "starters": { + "title": "Starter accounts", + "description": "Want to add these 4 common accounts? You can rename or archive them at any time.", + "cta_add": "Add selected accounts", + "cta_later": "Later", + "collision_tooltip": "Already exists", + "items": { + "cash": "Checking account", + "tfsa": "TFSA", + "rrsp": "RRSP", + "other": "Non-registered account" + }, + "errors": { + "insert": "Could not add the accounts. Please try again." + } + }, "sidebar": "Balance sheet", "accountsPage": { "title": "Balance accounts", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 60606cd..76f08bf 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1553,6 +1553,22 @@ "disabledHint": "Créez d'abord un compte pour activer cette étape." } }, + "starters": { + "title": "Comptes de départ", + "description": "Voulez-vous ajouter ces 4 comptes courants ? Vous pourrez les renommer ou les archiver à tout moment.", + "cta_add": "Ajouter les comptes sélectionnés", + "cta_later": "Plus tard", + "collision_tooltip": "Déjà présent", + "items": { + "cash": "Compte chèque", + "tfsa": "CELI", + "rrsp": "REER", + "other": "Compte non-enregistré" + }, + "errors": { + "insert": "Impossible d'ajouter les comptes. Veuillez réessayer." + } + }, "sidebar": "Bilan", "accountsPage": { "title": "Comptes du bilan", diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx index 5c24bdb..456ea2b 100644 --- a/src/pages/BalancePage.tsx +++ b/src/pages/BalancePage.tsx @@ -31,6 +31,10 @@ import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard"; import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart"; import BalanceAccountsTable from "../components/balance/BalanceAccountsTable"; import LinkTransfersModal from "../components/balance/LinkTransfersModal"; +import StarterAccountsModal from "../components/balance/StarterAccountsModal"; +import { getPreference, setPreference } from "../services/userPreferenceService"; + +const STARTER_PREF_KEY = "balance_starter_proposed"; const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"]; @@ -52,6 +56,49 @@ export default function BalancePage() { void getAllCategories().then(setCategories).catch(() => setCategories([])); }, []); + // Issue #179 — one-shot starter-accounts modal for existing profiles. The + // pref `balance_starter_proposed` is written once (confirmed or dismissed), + // so the modal never re-appears. New profiles get the 4 starters seeded + // directly via consolidated_schema.sql and never hit this branch (the + // first /balance visit will write the pref with accepted=[] silently + // since collisions match all 4). + const [showStarterModal, setShowStarterModal] = useState(false); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const existing = await getPreference(STARTER_PREF_KEY); + if (!cancelled && existing == null) { + setShowStarterModal(true); + } + } catch { + // Pref read failure: leave modal hidden — privacy-first default. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const handleStarterModalClose = async (acceptedIds: number[]) => { + setShowStarterModal(false); + try { + await setPreference( + STARTER_PREF_KEY, + JSON.stringify({ + shown_at: new Date().toISOString(), + accepted: acceptedIds, + }) + ); + } catch { + // Best-effort: a write failure here would cause the modal to re-show + // on next visit, which is acceptable (data still consistent). + } + if (acceptedIds.length > 0) { + await reload(); + } + }; + // Refresh per-account transfer lists used by the chart markers. Keyed by // account_id → [transfers]. Used by `BalanceEvolutionChart` to plot // ReferenceLine markers (green for in, red for out). @@ -218,6 +265,13 @@ export default function BalancePage() { + { + void handleStarterModalClose(ids); + }} + /> + {linkTarget && ( { ); } +// ----------------------------------------------------------------------------- +// Starter accounts (Issue #179 / Bilan onboarding) +// ----------------------------------------------------------------------------- +// +// The 4 starter accounts proposed to existing profiles via StarterAccountsModal. +// New profiles get the same 4 directly via consolidated_schema.sql, so the +// names/categories MUST stay in sync between the two sources. + +export interface StarterDef { + /** Stable identifier used by the modal checkbox state. */ + key: "cash" | "tfsa" | "rrsp" | "other"; + /** Default account name (FR — matches consolidated_schema seed). */ + name: string; + /** i18n key for the user-facing label in the modal. */ + i18nKey: string; + /** balance_categories.key that this starter attaches to. */ + categoryKey: "cash" | "tfsa" | "rrsp" | "other"; +} + +export const STARTER_ACCOUNTS: StarterDef[] = [ + { + key: "cash", + name: "Compte chèque", + i18nKey: "balance.starters.items.cash", + categoryKey: "cash", + }, + { + key: "tfsa", + name: "CELI", + i18nKey: "balance.starters.items.tfsa", + categoryKey: "tfsa", + }, + { + key: "rrsp", + name: "REER", + i18nKey: "balance.starters.items.rrsp", + categoryKey: "rrsp", + }, + { + key: "other", + name: "Compte non-enregistré", + i18nKey: "balance.starters.items.other", + categoryKey: "other", + }, +]; + +/** + * Returns the set of starter keys whose proposed (name, category) already + * exists as an account on the active profile. Comparison is case-insensitive + * and trim-tolerant on the name. Used by StarterAccountsModal to disable the + * matching checkbox + render a "Déjà présent" tooltip. + */ +export async function getStarterCollisions(): Promise> { + const db = await getDb(); + const rows = await db.select< + { key: string; account_name: string }[] + >( + `SELECT c.key AS key, a.name AS account_name + FROM balance_accounts a + INNER JOIN balance_categories c ON c.id = a.balance_category_id + WHERE c.key IN ('cash','tfsa','rrsp','other')` + ); + const collisions = new Set(); + for (const starter of STARTER_ACCOUNTS) { + const wanted = starter.name.trim().toLowerCase(); + const hit = rows.some( + (r) => + r.key === starter.categoryKey && + r.account_name.trim().toLowerCase() === wanted + ); + if (hit) collisions.add(starter.key); + } + return collisions; +} + +/** + * Insert the selected starter accounts atomically. Resolves each starter's + * `category_id` from the seeded `balance_categories.key`. Wraps the inserts + * in BEGIN/COMMIT — on any failure ROLLBACK is issued and the original error + * is re-thrown. Returns the inserted account ids in input order. + * + * Callers MUST pre-filter `selectedKeys` against `getStarterCollisions()` so + * we never INSERT a duplicate (the table has no UNIQUE on (name, category), + * so collisions would silently create dupes if not guarded upstream). + */ +export async function proposeStarterAccounts( + selectedKeys: string[] +): Promise { + const wanted = STARTER_ACCOUNTS.filter((s) => selectedKeys.includes(s.key)); + if (wanted.length === 0) return []; + const db = await getDb(); + let inTxn = false; + const inserted: number[] = []; + try { + await db.execute("BEGIN"); + inTxn = true; + for (const starter of wanted) { + // Resolve category id by key. Seeded keys are guaranteed to exist on + // a freshly migrated profile (Migration v9), so we surface a clean + // error if somehow missing rather than letting the FK fire. + const catRows = await db.select<{ id: number }[]>( + `SELECT id FROM balance_categories WHERE key = $1`, + [starter.categoryKey] + ); + if (catRows.length === 0) { + throw new BalanceServiceError( + "category_not_found", + `Seeded category '${starter.categoryKey}' missing — expected v9 schema` + ); + } + const result = await db.execute( + `INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) + VALUES ($1, $2, 'CAD', 1)`, + [catRows[0].id, starter.name] + ); + inserted.push(result.lastInsertId as number); + } + await db.execute("COMMIT"); + inTxn = false; + return inserted; + } catch (e) { + if (inTxn) { + try { + await db.execute("ROLLBACK"); + } catch { + // Preserve original error. + } + } + throw e; + } +} + // ----------------------------------------------------------------------------- // Snapshots + lines (Issue #146 / Bilan #1b — simple kind only) // ----------------------------------------------------------------------------- -- 2.45.2