This commit is contained in:
commit
0cf13de7fe
11 changed files with 671 additions and 1 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
104
docs/adr/0012-balance-two-level-model.md
Normal file
104
docs/adr/0012-balance-two-level-model.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
119
src/components/balance/StarterAccountsModal.test.tsx
Normal file
119
src/components/balance/StarterAccountsModal.test.tsx
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
209
src/components/balance/StarterAccountsModal.tsx
Normal file
209
src/components/balance/StarterAccountsModal.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// StarterAccountsModal — one-shot opt-in modal proposing 4 starter accounts
|
||||
// (Compte chèque, CELI, REER, Compte non-enregistré) to existing profiles
|
||||
// when they first land on /balance. Issue #179.
|
||||
//
|
||||
// Behavior:
|
||||
// - 4 checkboxes default-checked.
|
||||
// - Collision rule (case-insensitive trim name + same category): the
|
||||
// matching checkbox is disabled and uncheckable; tooltip explains why.
|
||||
// - "Ajouter les comptes sélectionnés" → atomic BEGIN/COMMIT INSERT, then
|
||||
// onClose(insertedIds).
|
||||
// - "Plus tard" → no INSERT, onClose([]).
|
||||
// - Parent owns isOpen state and writes user_preferences.balance_starter_proposed
|
||||
// in onClose so the modal never re-appears.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import {
|
||||
STARTER_ACCOUNTS,
|
||||
getStarterCollisions,
|
||||
proposeStarterAccounts,
|
||||
} from "../../services/balance.service";
|
||||
|
||||
export interface StarterAccountsModalProps {
|
||||
/** Parent guard — modal renders only when true. */
|
||||
isOpen: boolean;
|
||||
/**
|
||||
* Fired in both branches (confirm + dismiss). The parent uses the returned
|
||||
* ids to write `user_preferences.balance_starter_proposed` so the modal
|
||||
* never re-appears, regardless of which branch was taken.
|
||||
*/
|
||||
onClose: (acceptedIds: number[]) => void;
|
||||
}
|
||||
|
||||
export default function StarterAccountsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: StarterAccountsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [collisions, setCollisions] = useState<Set<string>>(new Set());
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
() => new Set(STARTER_ACCOUNTS.map((s) => s.key))
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [collisionsLoaded, setCollisionsLoaded] = useState(false);
|
||||
|
||||
// Load collisions once when the modal opens. We pre-uncheck colliding
|
||||
// starters (and disable them) so the visible default-checked count matches
|
||||
// what would actually be inserted.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const c = await getStarterCollisions();
|
||||
if (cancelled) return;
|
||||
setCollisions(c);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const k of c) next.delete(k);
|
||||
return next;
|
||||
});
|
||||
setCollisionsLoaded(true);
|
||||
} catch {
|
||||
if (!cancelled) setCollisionsLoaded(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const toggle = (key: string) => {
|
||||
if (collisions.has(key)) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (submitting) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ids = await proposeStarterAccounts(Array.from(selected));
|
||||
setSubmitting(false);
|
||||
onClose(ids);
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
setError(t("balance.starters.errors.insert"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLater = () => {
|
||||
if (submitting) return;
|
||||
onClose([]);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="balance-starters-title"
|
||||
data-testid="balance-starters-modal"
|
||||
>
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full">
|
||||
<div className="flex items-start justify-between p-5 border-b border-[var(--border)]">
|
||||
<div>
|
||||
<h2
|
||||
id="balance-starters-title"
|
||||
className="text-lg font-semibold"
|
||||
>
|
||||
{t("balance.starters.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mt-1">
|
||||
{t("balance.starters.description")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLater}
|
||||
aria-label={t("common.close")}
|
||||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="p-5 space-y-2" data-testid="balance-starters-list">
|
||||
{STARTER_ACCOUNTS.map((s) => {
|
||||
const isCollision = collisions.has(s.key);
|
||||
const isChecked = selected.has(s.key);
|
||||
return (
|
||||
<li key={s.key}>
|
||||
<label
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border ${
|
||||
isCollision
|
||||
? "border-[var(--border)] opacity-60 cursor-not-allowed"
|
||||
: "border-[var(--border)] hover:bg-[var(--muted)]/30 cursor-pointer"
|
||||
}`}
|
||||
title={
|
||||
isCollision
|
||||
? t("balance.starters.collision_tooltip")
|
||||
: undefined
|
||||
}
|
||||
data-testid={`balance-starter-row-${s.key}`}
|
||||
data-collision={isCollision ? "true" : "false"}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={isCollision || submitting}
|
||||
onChange={() => toggle(s.key)}
|
||||
data-testid={`balance-starter-checkbox-${s.key}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t(s.i18nKey)}
|
||||
</span>
|
||||
{isCollision && (
|
||||
<span className="ml-auto text-xs italic text-[var(--muted-foreground)]">
|
||||
{t("balance.starters.collision_tooltip")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{error && (
|
||||
<div className="mx-5 mb-3 p-2 rounded text-sm bg-[var(--negative)]/10 text-[var(--negative)] border border-[var(--negative)]/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 p-5 border-t border-[var(--border)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLater}
|
||||
disabled={submitting}
|
||||
data-testid="balance-starters-cta-later"
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)]/30 disabled:opacity-50"
|
||||
>
|
||||
{t("balance.starters.cta_later")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={submitting || !collisionsLoaded || selected.size === 0}
|
||||
data-testid="balance-starters-cta-add"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 size={14} className="animate-spin" />}
|
||||
{t("balance.starters.cta_add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<StarterAccountsModal
|
||||
isOpen={showStarterModal}
|
||||
onClose={(ids) => {
|
||||
void handleStarterModalClose(ids);
|
||||
}}
|
||||
/>
|
||||
|
||||
{linkTarget && (
|
||||
<LinkTransfersModal
|
||||
accountId={linkTarget.account_id}
|
||||
|
|
|
|||
|
|
@ -427,6 +427,138 @@ export async function unarchiveBalanceAccount(id: number): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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<Set<string>> {
|
||||
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<string>();
|
||||
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<number[]> {
|
||||
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)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue