Compare commits
8 commits
bde47dabed
...
0cf13de7fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf13de7fe | ||
|
|
a9d1301dd2 | ||
|
|
e342a1f567 | ||
|
|
3260ea8c47 | ||
|
|
cd0a2b826f | ||
|
|
eac2a516b5 | ||
|
|
50b119121f | ||
|
|
44cc77d8f6 |
16 changed files with 1493 additions and 47 deletions
|
|
@ -2,6 +2,19 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [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).
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- Bilan : correction de l'erreur SQLite « misuse of aggregate function MIN() » au chargement de /balance avec des snapshots existants ; remplacement du pattern aggregate-in-WHERE par une window function ROW_NUMBER() dans getAccountsPeriodAnchor (#175).
|
||||||
|
- Bilan : la sauvegarde d'un snapshot utilise désormais une transaction atomique BEGIN/COMMIT et valide toutes les lignes avant toute écriture en BDD, empêchant les snapshots orphelins lorsque la validation échoue. La migration v11 nettoie les orphelins existants (#176).
|
||||||
|
|
||||||
## [0.9.0] - 2026-04-29
|
## [0.9.0] - 2026-04-29
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -2,6 +2,19 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Bilan: fix SQLite "misuse of aggregate function MIN()" error when loading /balance with existing snapshots; replaced aggregate-in-WHERE pattern with ROW_NUMBER() window function in getAccountsPeriodAnchor (#175).
|
||||||
|
- Bilan: snapshot save now uses atomic BEGIN/COMMIT and validates all lines before any DB write, preventing orphan snapshot rows when validation fails. Migration v11 cleans existing orphans (#176).
|
||||||
|
|
||||||
## [0.9.0] - 2026-04-29
|
## [0.9.0] - 2026-04-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -28,7 +28,7 @@ simpl-resultat/
|
||||||
├── src/ # Frontend React/TypeScript
|
├── src/ # Frontend React/TypeScript
|
||||||
│ ├── components/ # 58 composants organisés par domaine
|
│ ├── components/ # 58 composants organisés par domaine
|
||||||
│ │ ├── adjustments/ # 3 composants
|
│ │ ├── adjustments/ # 3 composants
|
||||||
│ │ ├── balance/ # 7 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
|
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
|
||||||
│ │ ├── budget/ # 5 composants
|
│ │ ├── budget/ # 5 composants
|
||||||
│ │ ├── categories/ # 5 composants
|
│ │ ├── categories/ # 5 composants
|
||||||
│ │ ├── dashboard/ # 2 composants
|
│ │ ├── dashboard/ # 2 composants
|
||||||
|
|
@ -91,7 +91,7 @@ simpl-resultat/
|
||||||
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
||||||
| `user_preferences` | Préférences applicatives (clé-valeur) |
|
| `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_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_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_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 |
|
| `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 |
|
| [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 |
|
| [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 |
|
| [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'),
|
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
|
||||||
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
|
('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)
|
-- 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 ('language', 'fr');
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,22 @@ pub fn run() {
|
||||||
WHERE key = 'crypto' AND is_seed = 1;",
|
WHERE key = 'crypto' AND is_seed = 1;",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
},
|
},
|
||||||
|
// Migration v11 — cleanup orphan balance snapshots (#176). Before
|
||||||
|
// useSnapshotEditor.save was made atomic via BEGIN/COMMIT, a
|
||||||
|
// priced-line validation failure could leave the snapshot row
|
||||||
|
// inserted but with no lines, blocking subsequent saves at that
|
||||||
|
// date through the snapshot_date UNIQUE constraint. This deletes
|
||||||
|
// any such orphan rows from existing profiles. New orphans are
|
||||||
|
// no longer possible thanks to saveSnapshotAtomic.
|
||||||
|
Migration {
|
||||||
|
version: 11,
|
||||||
|
description: "cleanup orphan balance snapshots",
|
||||||
|
sql: "DELETE FROM balance_snapshots \
|
||||||
|
WHERE NOT EXISTS ( \
|
||||||
|
SELECT 1 FROM balance_snapshot_lines \
|
||||||
|
WHERE snapshot_id = balance_snapshots.id);",
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
|
@ -1176,5 +1192,109 @@ mod tests {
|
||||||
"CHECK should reject asset_type values outside ('stock','crypto')"
|
"CHECK should reject asset_type values outside ('stock','crypto')"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Migration v11 — cleanup orphan balance snapshots (#176)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Validates that the v11 SQL deletes snapshot rows that have no associated
|
||||||
|
// lines (left behind by the pre-#176 race) while preserving rows that have
|
||||||
|
// at least one line. Statement-equivalent to the production migration.
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Production v11 SQL — kept in sync with the Migration { version: 11 }
|
||||||
|
/// entry above.
|
||||||
|
const V11_SQL: &str = "DELETE FROM balance_snapshots \
|
||||||
|
WHERE NOT EXISTS ( \
|
||||||
|
SELECT 1 FROM balance_snapshot_lines \
|
||||||
|
WHERE snapshot_id = balance_snapshots.id);";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v11_deletes_orphan_snapshots() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute_batch(V10_SQL).expect("apply v10");
|
||||||
|
|
||||||
|
// Seed an orphan: snapshot with NO lines.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-01-15')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let orphan_id: i64 = conn
|
||||||
|
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Seed a healthy snapshot with one line — needs an account first.
|
||||||
|
// Use the seeded `cash` simple category from v9.
|
||||||
|
let cash_cat_id: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM balance_categories WHERE key = 'cash'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_accounts (balance_category_id, name) VALUES (?1, 'Test')",
|
||||||
|
[cash_cat_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let acc_id: i64 = conn
|
||||||
|
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshots (snapshot_date) VALUES ('2026-02-15')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let healthy_id: i64 = conn
|
||||||
|
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO balance_snapshot_lines \
|
||||||
|
(snapshot_id, account_id, value, price_source) \
|
||||||
|
VALUES (?1, ?2, 100.0, 'manual')",
|
||||||
|
[healthy_id, acc_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Pre-conditions.
|
||||||
|
let pre_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(pre_count, 2);
|
||||||
|
|
||||||
|
// Apply v11.
|
||||||
|
conn.execute_batch(V11_SQL).expect("apply v11");
|
||||||
|
|
||||||
|
// Orphan gone, healthy preserved.
|
||||||
|
let post_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(post_count, 1, "v11 should delete only the orphan");
|
||||||
|
let surviving_id: i64 = conn
|
||||||
|
.query_row("SELECT id FROM balance_snapshots", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(surviving_id, healthy_id);
|
||||||
|
// And ensure the orphan id is gone.
|
||||||
|
let still_orphan: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM balance_snapshots WHERE id = ?1",
|
||||||
|
[orphan_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(still_orphan, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v11_is_idempotent_on_clean_db() {
|
||||||
|
let conn = fresh_db();
|
||||||
|
conn.execute_batch(V10_SQL).expect("apply v10");
|
||||||
|
// Empty balance_snapshots — running v11 should be a no-op.
|
||||||
|
conn.execute_batch(V11_SQL).expect("apply v11");
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM balance_snapshots", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
41
src/components/balance/BalanceOnboardingCard.test.tsx
Normal file
41
src/components/balance/BalanceOnboardingCard.test.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// BalanceOnboardingCard — unit tests (issue #178)
|
||||||
|
//
|
||||||
|
// NOTE: This project does not have @testing-library/react or jsdom configured
|
||||||
|
// (logged as MEDIUM in autopilot decisions-log). Tests cover the pure
|
||||||
|
// `deriveOnboardingSteps` helper that drives the visual state of each step.
|
||||||
|
// All React rendering is bypassed.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { deriveOnboardingSteps } from "./BalanceOnboardingCard";
|
||||||
|
|
||||||
|
describe("BalanceOnboardingCard — deriveOnboardingSteps", () => {
|
||||||
|
it("0 accounts, 0 snapshots → step1 active, step2 disabled", () => {
|
||||||
|
const r = deriveOnboardingSteps(0, 0);
|
||||||
|
expect(r.step1).toBe("active");
|
||||||
|
expect(r.step2).toBe("disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(">=1 account, 0 snapshots → step1 done, step2 active", () => {
|
||||||
|
const r = deriveOnboardingSteps(1, 0);
|
||||||
|
expect(r.step1).toBe("done");
|
||||||
|
expect(r.step2).toBe("active");
|
||||||
|
|
||||||
|
const r2 = deriveOnboardingSteps(5, 0);
|
||||||
|
expect(r2.step1).toBe("done");
|
||||||
|
expect(r2.step2).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(">=1 account, >=1 snapshot → both done (defensive — card normally hidden)", () => {
|
||||||
|
const r = deriveOnboardingSteps(2, 3);
|
||||||
|
expect(r.step1).toBe("done");
|
||||||
|
expect(r.step2).toBe("done");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guard: 0 accounts but >=1 snapshot (anomaly) → step1 active, step2 done", () => {
|
||||||
|
// This combination should not happen in practice (a snapshot requires at
|
||||||
|
// least one account), but the helper handles it conservatively.
|
||||||
|
const r = deriveOnboardingSteps(0, 1);
|
||||||
|
expect(r.step1).toBe("active");
|
||||||
|
expect(r.step2).toBe("done");
|
||||||
|
});
|
||||||
|
});
|
||||||
210
src/components/balance/BalanceOnboardingCard.tsx
Normal file
210
src/components/balance/BalanceOnboardingCard.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
// BalanceOnboardingCard — empty-state onboarding for /balance.
|
||||||
|
//
|
||||||
|
// Issue #178. Replaces the BalanceOverviewCard when the user has no accounts
|
||||||
|
// or no snapshots yet. Two vertical steps:
|
||||||
|
// 1. Create an account → /balance/accounts
|
||||||
|
// 2. Enter a snapshot → /balance/snapshot
|
||||||
|
//
|
||||||
|
// Each step has 3 states:
|
||||||
|
// - "active": primary CTA, currently the next thing to do
|
||||||
|
// - "done": marked with a checkmark, no CTA
|
||||||
|
// - "disabled": grayed out (e.g. step 2 when 0 accounts), CTA disabled
|
||||||
|
//
|
||||||
|
// The whole card is replaced by BalanceOverviewCard once at least one
|
||||||
|
// snapshot exists, so step 2 in practice is rendered as "active" or
|
||||||
|
// "disabled"; the "done" branch is supported for completeness/tests.
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
interface BalanceOnboardingCardProps {
|
||||||
|
/** Number of active (non-archived) accounts. */
|
||||||
|
accountsCount: number;
|
||||||
|
/** Number of snapshots saved (any date). */
|
||||||
|
snapshotsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StepState = "active" | "done" | "disabled";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper exposed for unit tests — derives the state of each onboarding
|
||||||
|
* step from the (accountsCount, snapshotsCount) pair.
|
||||||
|
*
|
||||||
|
* - Step 1 is "done" once at least one account exists, "active" otherwise.
|
||||||
|
* - Step 2 is "done" once any snapshot exists, "active" once at least one
|
||||||
|
* account exists, "disabled" otherwise. In practice the parent guard on
|
||||||
|
* /balance only renders this card when snapshotsCount === 0, so the
|
||||||
|
* "done" branch for step 2 is mostly defensive.
|
||||||
|
*/
|
||||||
|
export function deriveOnboardingSteps(
|
||||||
|
accountsCount: number,
|
||||||
|
snapshotsCount: number
|
||||||
|
): { step1: StepState; step2: StepState } {
|
||||||
|
const step1: StepState = accountsCount >= 1 ? "done" : "active";
|
||||||
|
const step2: StepState =
|
||||||
|
snapshotsCount >= 1
|
||||||
|
? "done"
|
||||||
|
: accountsCount >= 1
|
||||||
|
? "active"
|
||||||
|
: "disabled";
|
||||||
|
return { step1, step2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceOnboardingCard({
|
||||||
|
accountsCount,
|
||||||
|
snapshotsCount,
|
||||||
|
}: BalanceOnboardingCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { step1: step1State, step2: step2State } = deriveOnboardingSteps(
|
||||||
|
accountsCount,
|
||||||
|
snapshotsCount
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">
|
||||||
|
{t("balance.onboarding.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mb-5">
|
||||||
|
{t("balance.onboarding.subtitle")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="space-y-3">
|
||||||
|
<Step
|
||||||
|
number={1}
|
||||||
|
state={step1State}
|
||||||
|
icon={<Wallet size={18} />}
|
||||||
|
title={t("balance.onboarding.step1.title")}
|
||||||
|
description={t("balance.onboarding.step1.description")}
|
||||||
|
ctaLabel={t("balance.onboarding.step1.cta")}
|
||||||
|
ctaHref="/balance/accounts"
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
<Step
|
||||||
|
number={2}
|
||||||
|
state={step2State}
|
||||||
|
icon={<FileText size={18} />}
|
||||||
|
title={t("balance.onboarding.step2.title")}
|
||||||
|
description={t("balance.onboarding.step2.description")}
|
||||||
|
ctaLabel={t("balance.onboarding.step2.cta")}
|
||||||
|
ctaHref="/balance/snapshot"
|
||||||
|
disabledHint={t("balance.onboarding.step2.disabledHint")}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Internal — single step row
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StepProps {
|
||||||
|
number: number;
|
||||||
|
state: StepState;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ctaLabel: string;
|
||||||
|
ctaHref: string;
|
||||||
|
disabledHint?: string;
|
||||||
|
t: TFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({
|
||||||
|
number,
|
||||||
|
state,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
ctaLabel,
|
||||||
|
ctaHref,
|
||||||
|
disabledHint,
|
||||||
|
t,
|
||||||
|
}: StepProps) {
|
||||||
|
const isDone = state === "done";
|
||||||
|
const isActive = state === "active";
|
||||||
|
const isDisabled = state === "disabled";
|
||||||
|
|
||||||
|
// Number bubble: green check when done, primary bg when active, muted when disabled.
|
||||||
|
const bubbleClass = isDone
|
||||||
|
? "bg-[var(--positive)] text-white"
|
||||||
|
: isActive
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--muted)] text-[var(--muted-foreground)]";
|
||||||
|
|
||||||
|
const titleClass = isDisabled
|
||||||
|
? "text-[var(--muted-foreground)]"
|
||||||
|
: "text-[var(--foreground)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-testid={`balance-onboarding-step-${number}`}
|
||||||
|
data-state={state}
|
||||||
|
className={`flex items-start gap-4 p-4 rounded-lg border ${
|
||||||
|
isDisabled
|
||||||
|
? "border-[var(--border)] opacity-60"
|
||||||
|
: "border-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${bubbleClass}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{isDone ? <Check size={16} /> : number}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-[var(--muted-foreground)]" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<h3 className={`text-sm font-semibold ${titleClass}`}>{title}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
||||||
|
{isDisabled && disabledHint && (
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] italic mt-1">
|
||||||
|
{disabledHint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 self-center">
|
||||||
|
{isDone ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-[var(--positive)] font-medium"
|
||||||
|
data-testid={`balance-onboarding-step-${number}-done-badge`}
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
{t("balance.onboarding.doneBadge")}
|
||||||
|
</span>
|
||||||
|
) : isActive ? (
|
||||||
|
<Link
|
||||||
|
to={ctaHref}
|
||||||
|
data-testid={`balance-onboarding-step-${number}-cta`}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
data-testid={`balance-onboarding-step-${number}-cta`}
|
||||||
|
aria-disabled="true"
|
||||||
|
title={disabledHint}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] text-sm font-medium cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,10 +28,9 @@ import {
|
||||||
listBalanceAccounts,
|
listBalanceAccounts,
|
||||||
listBalanceCategories,
|
listBalanceCategories,
|
||||||
getSnapshotByDate,
|
getSnapshotByDate,
|
||||||
createSnapshot,
|
|
||||||
deleteSnapshot,
|
deleteSnapshot,
|
||||||
listLinesBySnapshot,
|
listLinesBySnapshot,
|
||||||
upsertSnapshotLines,
|
saveSnapshotAtomic,
|
||||||
getPreviousSnapshot,
|
getPreviousSnapshot,
|
||||||
BalanceServiceError,
|
BalanceServiceError,
|
||||||
} from "../services/balance.service";
|
} from "../services/balance.service";
|
||||||
|
|
@ -412,35 +411,32 @@ export function useSnapshotEditor(options: Options = {}) {
|
||||||
}, [state.previousLines, state.accounts]);
|
}, [state.previousLines, state.accounts]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist the editor state to the database.
|
* Persist the editor state to the database (#176 — atomic).
|
||||||
* - 'new' mode: create the snapshot row (UNIQUE per date), then upsert
|
*
|
||||||
* its lines. If creation fails because a snapshot was created at this
|
* Order of operations:
|
||||||
* same date concurrently (snapshot_date_taken), the page is expected
|
* 1. Build & validate `simpleLines` and `pricedLines` arrays from
|
||||||
* to redirect to edit mode.
|
* editor state. Any input parsing error throws BEFORE any DB
|
||||||
* - 'edit' mode: upsert lines on the existing snapshot.
|
* mutation happens, so an invalid form never produces an orphan
|
||||||
|
* snapshot row.
|
||||||
|
* 2. Call `saveSnapshotAtomic` which wraps `INSERT INTO
|
||||||
|
* balance_snapshots` (new mode) and the line rewrite in a single
|
||||||
|
* `BEGIN/COMMIT/ROLLBACK` transaction.
|
||||||
|
*
|
||||||
|
* Modes:
|
||||||
|
* - 'new' mode: atomic helper inserts the snapshot row and its lines.
|
||||||
|
* - 'edit' mode: only the lines get rewritten on the existing snapshot.
|
||||||
*
|
*
|
||||||
* Only accounts with a non-empty value (after trim) are persisted; empty
|
* Only accounts with a non-empty value (after trim) are persisted; empty
|
||||||
* fields mean "no entry for this account at this date" — they're cleared
|
* fields mean "no entry for this account at this date" — they're cleared
|
||||||
* by the rewrite-all strategy in `upsertSnapshotLines`.
|
* by the rewrite-all strategy in `saveSnapshotAtomic`.
|
||||||
*/
|
*/
|
||||||
const save = useCallback(async (): Promise<{ snapshotId: number }> => {
|
const save = useCallback(async (): Promise<{ snapshotId: number }> => {
|
||||||
dispatch({ type: "SET_SAVING", payload: true });
|
dispatch({ type: "SET_SAVING", payload: true });
|
||||||
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
||||||
try {
|
try {
|
||||||
let snapshotId: number;
|
// Step 1 — build & validate every line in memory. THROW HERE means
|
||||||
if (state.mode === "edit" && state.snapshot) {
|
// no DB mutation has happened yet, so no orphan snapshot can be
|
||||||
snapshotId = state.snapshot.id;
|
// left behind by a validation failure (#176).
|
||||||
} else {
|
|
||||||
snapshotId = await createSnapshot({
|
|
||||||
snapshot_date: state.snapshotDate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Index account kinds for line classification at save time.
|
|
||||||
const kindByAccountId = new Map<number, BalanceCategory["kind"]>();
|
|
||||||
for (const acc of state.accounts) {
|
|
||||||
kindByAccountId.set(acc.id, acc.category_kind);
|
|
||||||
}
|
|
||||||
// Simple-kind lines: drop empty fields, accept any finite number.
|
|
||||||
const simpleLines = Object.entries(state.values)
|
const simpleLines = Object.entries(state.values)
|
||||||
.filter(([, v]) => v !== undefined && String(v).trim().length > 0)
|
.filter(([, v]) => v !== undefined && String(v).trim().length > 0)
|
||||||
.map(([accountIdStr, raw]) => {
|
.map(([accountIdStr, raw]) => {
|
||||||
|
|
@ -459,7 +455,6 @@ export function useSnapshotEditor(options: Options = {}) {
|
||||||
account_kind: "simple" as const,
|
account_kind: "simple" as const,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// Priced-kind lines: both qty + price required, value computed.
|
|
||||||
const pricedLines = Object.entries(state.pricedValues)
|
const pricedLines = Object.entries(state.pricedValues)
|
||||||
.filter(
|
.filter(
|
||||||
([, entry]) =>
|
([, entry]) =>
|
||||||
|
|
@ -495,7 +490,16 @@ export function useSnapshotEditor(options: Options = {}) {
|
||||||
value: qty * price,
|
value: qty * price,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
await upsertSnapshotLines(snapshotId, [...simpleLines, ...pricedLines]);
|
|
||||||
|
// Step 2 — atomic write. BEGIN / INSERT snapshot (if 'new') /
|
||||||
|
// INSERT lines / COMMIT, with ROLLBACK on any failure.
|
||||||
|
const existingSnapshotId =
|
||||||
|
state.mode === "edit" && state.snapshot ? state.snapshot.id : null;
|
||||||
|
const { snapshotId } = await saveSnapshotAtomic({
|
||||||
|
existingSnapshotId,
|
||||||
|
snapshot_date: state.snapshotDate,
|
||||||
|
lines: [...simpleLines, ...pricedLines],
|
||||||
|
});
|
||||||
dispatch({ type: "CLEAR_DIRTY" });
|
dispatch({ type: "CLEAR_DIRTY" });
|
||||||
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
|
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
|
||||||
await loadForDate(state.snapshotDate);
|
await loadForDate(state.snapshotDate);
|
||||||
|
|
@ -512,7 +516,6 @@ export function useSnapshotEditor(options: Options = {}) {
|
||||||
state.snapshotDate,
|
state.snapshotDate,
|
||||||
state.values,
|
state.values,
|
||||||
state.pricedValues,
|
state.pricedValues,
|
||||||
state.accounts,
|
|
||||||
loadForDate,
|
loadForDate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1537,6 +1537,38 @@
|
||||||
"stacked": "Stacked by category"
|
"stacked": "Stacked by category"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Get started with your balance sheet",
|
||||||
|
"subtitle": "Two steps to start tracking your net worth.",
|
||||||
|
"doneBadge": "Done",
|
||||||
|
"step1": {
|
||||||
|
"title": "Create an account",
|
||||||
|
"description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.",
|
||||||
|
"cta": "Create an account"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "Enter a snapshot",
|
||||||
|
"description": "A snapshot is the picture, at a given date, of the balance in each account. Enter one a month to track changes over time.",
|
||||||
|
"cta": "Enter a snapshot",
|
||||||
|
"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",
|
"sidebar": "Balance sheet",
|
||||||
"accountsPage": {
|
"accountsPage": {
|
||||||
"title": "Balance accounts",
|
"title": "Balance accounts",
|
||||||
|
|
@ -1643,8 +1675,8 @@
|
||||||
"dateLabel": "Snapshot date",
|
"dateLabel": "Snapshot date",
|
||||||
"dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.",
|
"dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.",
|
||||||
"total": "Entered total",
|
"total": "Entered total",
|
||||||
"noAccounts": "You need to create at least one balance account first.",
|
"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": "Go to accounts",
|
"goToAccounts": "Create an account",
|
||||||
"prefill": "Prefill from previous",
|
"prefill": "Prefill from previous",
|
||||||
"prefillTooltip": "Copy values from the snapshot dated {{date}}",
|
"prefillTooltip": "Copy values from the snapshot dated {{date}}",
|
||||||
"prefillNoPrevious": "No earlier snapshot available.",
|
"prefillNoPrevious": "No earlier snapshot available.",
|
||||||
|
|
|
||||||
|
|
@ -1537,6 +1537,38 @@
|
||||||
"stacked": "Empilé par catégorie"
|
"stacked": "Empilé par catégorie"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Premiers pas avec le bilan",
|
||||||
|
"subtitle": "Deux étapes pour commencer à suivre votre valeur nette.",
|
||||||
|
"doneBadge": "Fait",
|
||||||
|
"step1": {
|
||||||
|
"title": "Créer un compte",
|
||||||
|
"description": "Un compte représente l'endroit où vous tenez votre argent : compte chèque, CELI, REER, actions, crypto, etc.",
|
||||||
|
"cta": "Créer un compte"
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"title": "Saisir un snapshot",
|
||||||
|
"description": "Un snapshot est la photo, à une date donnée, du solde de chaque compte. Saisissez-en un par mois pour suivre l'évolution.",
|
||||||
|
"cta": "Saisir un snapshot",
|
||||||
|
"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",
|
"sidebar": "Bilan",
|
||||||
"accountsPage": {
|
"accountsPage": {
|
||||||
"title": "Comptes du bilan",
|
"title": "Comptes du bilan",
|
||||||
|
|
@ -1643,8 +1675,8 @@
|
||||||
"dateLabel": "Date du 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.",
|
"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.",
|
||||||
"total": "Total saisi",
|
"total": "Total saisi",
|
||||||
"noAccounts": "Vous devez d'abord créer au moins un compte de bilan.",
|
"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": "Aller aux comptes",
|
"goToAccounts": "Créer un compte",
|
||||||
"prefill": "Pré-remplir depuis le précédent",
|
"prefill": "Pré-remplir depuis le précédent",
|
||||||
"prefillTooltip": "Copier les valeurs du snapshot du {{date}}",
|
"prefillTooltip": "Copier les valeurs du snapshot du {{date}}",
|
||||||
"prefillNoPrevious": "Aucun snapshot antérieur disponible.",
|
"prefillNoPrevious": "Aucun snapshot antérieur disponible.",
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,14 @@ import {
|
||||||
import { getAllCategories } from "../services/transactionService";
|
import { getAllCategories } from "../services/transactionService";
|
||||||
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
||||||
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||||
|
import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard";
|
||||||
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||||
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||||
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
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"];
|
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
||||||
|
|
||||||
|
|
@ -51,6 +56,49 @@ export default function BalancePage() {
|
||||||
void getAllCategories().then(setCategories).catch(() => setCategories([]));
|
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
|
// Refresh per-account transfer lists used by the chart markers. Keyed by
|
||||||
// account_id → [transfers]. Used by `BalanceEvolutionChart` to plot
|
// account_id → [transfers]. Used by `BalanceEvolutionChart` to plot
|
||||||
// ReferenceLine markers (green for in, red for out).
|
// ReferenceLine markers (green for in, red for out).
|
||||||
|
|
@ -127,7 +175,25 @@ export default function BalancePage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<BalanceOverviewCard totals={state.evolutionTotals} />
|
{(() => {
|
||||||
|
// Issue #178 — show a 2-step onboarding card while the user has no
|
||||||
|
// accounts or no snapshots yet. We probe accountsLatest for ANY
|
||||||
|
// snapshot date so the empty-state guard is independent of the
|
||||||
|
// active period filter (state.period).
|
||||||
|
const accountsCount = state.accountsLatest.length;
|
||||||
|
const hasAnySnapshot = state.accountsLatest.some(
|
||||||
|
(a) => a.latest_snapshot_date != null
|
||||||
|
);
|
||||||
|
if (accountsCount === 0 || !hasAnySnapshot) {
|
||||||
|
return (
|
||||||
|
<BalanceOnboardingCard
|
||||||
|
accountsCount={accountsCount}
|
||||||
|
snapshotsCount={hasAnySnapshot ? 1 : 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <BalanceOverviewCard totals={state.evolutionTotals} />;
|
||||||
|
})()}
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
{/* Period selector */}
|
{/* Period selector */}
|
||||||
|
|
@ -199,6 +265,13 @@ export default function BalancePage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StarterAccountsModal
|
||||||
|
isOpen={showStarterModal}
|
||||||
|
onClose={(ids) => {
|
||||||
|
void handleStarterModalClose(ids);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{linkTarget && (
|
{linkTarget && (
|
||||||
<LinkTransfersModal
|
<LinkTransfersModal
|
||||||
accountId={linkTarget.account_id}
|
accountId={linkTarget.account_id}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
deleteSnapshot,
|
deleteSnapshot,
|
||||||
listLinesBySnapshot,
|
listLinesBySnapshot,
|
||||||
upsertSnapshotLines,
|
upsertSnapshotLines,
|
||||||
|
saveSnapshotAtomic,
|
||||||
getPreviousSnapshot,
|
getPreviousSnapshot,
|
||||||
validateLineKindInvariants,
|
validateLineKindInvariants,
|
||||||
PRICED_VALUE_TOLERANCE,
|
PRICED_VALUE_TOLERANCE,
|
||||||
|
|
@ -908,6 +909,153 @@ describe("upsertSnapshotLines — priced kind", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// saveSnapshotAtomic (#176) — atomic BEGIN/COMMIT/ROLLBACK orchestration
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("saveSnapshotAtomic — new mode", () => {
|
||||||
|
it("issues BEGIN before any write and COMMIT once everything succeeds", async () => {
|
||||||
|
// Order: SELECT dup-check → INSERT snapshot → DELETE lines → INSERT line → UPDATE → COMMIT
|
||||||
|
mockSelect.mockResolvedValueOnce([]); // no duplicate
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
|
||||||
|
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||||||
|
|
||||||
|
const res = await saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: null,
|
||||||
|
snapshot_date: "2026-04-30",
|
||||||
|
lines: [{ account_id: 1, value: 1000 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.snapshotId).toBe(42);
|
||||||
|
// First execute is BEGIN
|
||||||
|
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||||||
|
// INSERT snapshot is second
|
||||||
|
expect(mockExecute.mock.calls[1][0]).toContain(
|
||||||
|
"INSERT INTO balance_snapshots"
|
||||||
|
);
|
||||||
|
// DELETE lines, INSERT line, UPDATE updated_at all happen between BEGIN and COMMIT
|
||||||
|
expect(mockExecute.mock.calls[2][0]).toContain(
|
||||||
|
"DELETE FROM balance_snapshot_lines"
|
||||||
|
);
|
||||||
|
expect(mockExecute.mock.calls[3][0]).toContain(
|
||||||
|
"INSERT INTO balance_snapshot_lines"
|
||||||
|
);
|
||||||
|
expect(mockExecute.mock.calls[4][0]).toContain("UPDATE balance_snapshots");
|
||||||
|
// Last execute is COMMIT
|
||||||
|
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
|
||||||
|
"COMMIT"
|
||||||
|
);
|
||||||
|
// No ROLLBACK on success
|
||||||
|
expect(
|
||||||
|
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "ROLLBACK")
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when a snapshot already exists at this date (snapshot_date_taken) and ROLLBACKs", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([{ id: 7 }]); // duplicate found
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: null,
|
||||||
|
snapshot_date: "2026-04-30",
|
||||||
|
lines: [{ account_id: 1, value: 1000 }],
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: "snapshot_date_taken" });
|
||||||
|
|
||||||
|
// BEGIN ran, then ROLLBACK because the duplicate threw mid-transaction.
|
||||||
|
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||||||
|
expect(mockExecute.mock.calls[1][0]).toBe("ROLLBACK");
|
||||||
|
// No INSERT INTO balance_snapshots happened.
|
||||||
|
expect(
|
||||||
|
mockExecute.mock.calls.some((c: unknown[]) =>
|
||||||
|
String(c[0]).includes("INSERT INTO balance_snapshots")
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ROLLBACKs and re-throws when a line INSERT fails (no orphan snapshot persists)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]); // no duplicate
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ lastInsertId: 42, rowsAffected: 1 }) // INSERT snapshot
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
|
||||||
|
.mockRejectedValueOnce(new Error("simulated FK violation")) // INSERT line fails
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }); // ROLLBACK
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: null,
|
||||||
|
snapshot_date: "2026-04-30",
|
||||||
|
lines: [{ account_id: 999, value: 1000 }],
|
||||||
|
})
|
||||||
|
).rejects.toThrow("simulated FK violation");
|
||||||
|
|
||||||
|
// BEGIN happened, ROLLBACK was the last call — no COMMIT.
|
||||||
|
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||||||
|
expect(
|
||||||
|
mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]
|
||||||
|
).toBe("ROLLBACK");
|
||||||
|
expect(
|
||||||
|
mockExecute.mock.calls.some((c: unknown[]) => c[0] === "COMMIT")
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects validation failures BEFORE BEGIN — no transaction is opened", async () => {
|
||||||
|
await expect(
|
||||||
|
saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: null,
|
||||||
|
snapshot_date: "2026-04-30",
|
||||||
|
// Priced line missing quantity should fail validation before any DB write.
|
||||||
|
lines: [
|
||||||
|
{ account_id: 1, value: 100, account_kind: "priced", unit_price: 10 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({ code: "snapshot_priced_quantity_required" });
|
||||||
|
// Pre-DB validation: no BEGIN, no SELECT, no execute at all.
|
||||||
|
expect(mockExecute).not.toHaveBeenCalled();
|
||||||
|
expect(mockSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveSnapshotAtomic — edit mode", () => {
|
||||||
|
it("skips INSERT INTO balance_snapshots when existingSnapshotId is provided", async () => {
|
||||||
|
mockExecute
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }) // BEGIN
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }) // DELETE lines
|
||||||
|
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // INSERT line
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 1 }) // UPDATE updated_at
|
||||||
|
.mockResolvedValueOnce({ rowsAffected: 0 }); // COMMIT
|
||||||
|
|
||||||
|
const res = await saveSnapshotAtomic({
|
||||||
|
existingSnapshotId: 5,
|
||||||
|
snapshot_date: "2026-04-30",
|
||||||
|
lines: [{ account_id: 1, value: 1000 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.snapshotId).toBe(5);
|
||||||
|
// No SELECT (no duplicate check in edit mode), no INSERT INTO balance_snapshots.
|
||||||
|
expect(mockSelect).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
mockExecute.mock.calls.some((c: unknown[]) =>
|
||||||
|
String(c[0]).includes("INSERT INTO balance_snapshots")
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
|
// BEGIN / DELETE / INSERT line / UPDATE / COMMIT
|
||||||
|
expect(mockExecute.mock.calls[0][0]).toBe("BEGIN");
|
||||||
|
expect(mockExecute.mock.calls[mockExecute.mock.calls.length - 1][0]).toBe(
|
||||||
|
"COMMIT"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Time-series aggregators (Issue #141 / Bilan #3)
|
// Time-series aggregators (Issue #141 / Bilan #3)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
@ -1057,8 +1205,14 @@ describe("getAccountsPeriodAnchor", () => {
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect(rows[0].anchor_value).toBe(1000);
|
expect(rows[0].anchor_value).toBe(1000);
|
||||||
const sql = mockSelect.mock.calls[0][0] as string;
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
expect(sql).toContain("MIN(s.snapshot_date)");
|
// Window function: ROW_NUMBER partitioned by account_id, earliest first.
|
||||||
expect(sql).toContain("GROUP BY l.account_id");
|
expect(sql).toContain("ROW_NUMBER()");
|
||||||
|
expect(sql).toContain("PARTITION BY l.account_id");
|
||||||
|
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
|
||||||
|
expect(sql).toContain("WHERE rn = 1");
|
||||||
|
// Old aggregate-in-WHERE pattern must be gone (regression guard, #175).
|
||||||
|
expect(sql).not.toContain("MIN(s.snapshot_date)");
|
||||||
|
expect(sql).not.toContain("GROUP BY l.account_id");
|
||||||
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-01-01"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1075,6 +1229,57 @@ describe("getAccountsPeriodAnchor", () => {
|
||||||
// No WHERE clause when neither bound is set.
|
// No WHERE clause when neither bound is set.
|
||||||
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns earliest snapshot per account within range", async () => {
|
||||||
|
// Multiple accounts, each with multiple snapshots in the window.
|
||||||
|
// The DB returns one row per account (the rn = 1 row), so the mocked
|
||||||
|
// result mirrors that contract.
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 },
|
||||||
|
{ account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 },
|
||||||
|
]);
|
||||||
|
const rows = await getAccountsPeriodAnchor({
|
||||||
|
from: "2026-02-01",
|
||||||
|
to: "2026-06-30",
|
||||||
|
});
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ account_id: 1, anchor_snapshot_date: "2026-02-29", anchor_value: 1500 },
|
||||||
|
{ account_id: 2, anchor_snapshot_date: "2026-03-31", anchor_value: 2700 },
|
||||||
|
]);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("ROW_NUMBER()");
|
||||||
|
expect(sql).toContain("PARTITION BY l.account_id");
|
||||||
|
expect(sql).toContain("ORDER BY s.snapshot_date ASC");
|
||||||
|
expect(sql).toContain("WHERE rn = 1");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual(["2026-02-01", "2026-06-30"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for an empty window (no snapshots in range)", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
const rows = await getAccountsPeriodAnchor({
|
||||||
|
from: "2099-01-01",
|
||||||
|
to: "2099-12-31",
|
||||||
|
});
|
||||||
|
expect(rows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: /balance load (issue #175) used to throw "misuse of aggregate
|
||||||
|
// function MIN()" because MIN was used inside the WHERE of a scalar
|
||||||
|
// subquery. With ROW_NUMBER() the query is plain SQLite — assert the
|
||||||
|
// service forwards rows from db.select without throwing.
|
||||||
|
it("regression #175: loads without SQLite aggregate misuse error", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 },
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
getAccountsPeriodAnchor({ from: "2026-01-01", to: "2026-12-31" })
|
||||||
|
).resolves.toEqual([
|
||||||
|
{ account_id: 1, anchor_snapshot_date: "2026-01-15", anchor_value: 500 },
|
||||||
|
]);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
// The exact pattern that triggered the SQLite error must not reappear.
|
||||||
|
expect(sql).not.toMatch(/=\s*MIN\(s\.snapshot_date\)/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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)
|
// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
@ -768,6 +900,124 @@ export async function upsertSnapshotLines(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic snapshot save (#176). Wraps `INSERT INTO balance_snapshots` and
|
||||||
|
* the line writes in a single explicit BEGIN/COMMIT transaction so a
|
||||||
|
* failure during line validation or insertion never leaves an orphan
|
||||||
|
* snapshot row behind (which used to wedge subsequent saves at the same
|
||||||
|
* date through the `snapshot_date_taken` UNIQUE constraint).
|
||||||
|
*
|
||||||
|
* Caller contract:
|
||||||
|
* - All `lines` MUST already be validated by the caller — this function
|
||||||
|
* does NOT translate string inputs to numbers; it expects the same
|
||||||
|
* `SnapshotLineInput` shape that `upsertSnapshotLines` accepts.
|
||||||
|
* - The caller passes `existingSnapshotId` for edit-mode (no INSERT
|
||||||
|
* happens, only the line rewrite). For new-mode pass `null` and a
|
||||||
|
* `snapshot_date`; this function handles both cases inside the same
|
||||||
|
* transaction.
|
||||||
|
*
|
||||||
|
* On any error, ROLLBACK is issued and the original error is re-thrown.
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export async function saveSnapshotAtomic(input: {
|
||||||
|
existingSnapshotId: number | null;
|
||||||
|
snapshot_date: string;
|
||||||
|
notes?: string | null;
|
||||||
|
lines: SnapshotLineInput[];
|
||||||
|
}): Promise<{ snapshotId: number }> {
|
||||||
|
// Validate every line ahead of time so the transaction never opens for
|
||||||
|
// a doomed save. Mirrors `upsertSnapshotLines` invariants.
|
||||||
|
for (const line of input.lines) {
|
||||||
|
validateLineKindInvariants(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
let inTxn = false;
|
||||||
|
try {
|
||||||
|
await db.execute("BEGIN");
|
||||||
|
inTxn = true;
|
||||||
|
|
||||||
|
let snapshotId: number;
|
||||||
|
if (input.existingSnapshotId !== null) {
|
||||||
|
snapshotId = input.existingSnapshotId;
|
||||||
|
} else {
|
||||||
|
const date = normalizeSnapshotDate(input.snapshot_date);
|
||||||
|
// Date collision check inside the transaction so a concurrent
|
||||||
|
// insert can't sneak between the SELECT and the INSERT.
|
||||||
|
const dup = await db.select<Array<{ id: number }>>(
|
||||||
|
`SELECT id FROM balance_snapshots WHERE snapshot_date = $1`,
|
||||||
|
[date]
|
||||||
|
);
|
||||||
|
if (dup.length > 0) {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"snapshot_date_taken",
|
||||||
|
`A snapshot already exists at ${date}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const insRes = await db.execute(
|
||||||
|
`INSERT INTO balance_snapshots (snapshot_date, notes)
|
||||||
|
VALUES ($1, $2)`,
|
||||||
|
[date, input.notes ? input.notes.trim() || null : null]
|
||||||
|
);
|
||||||
|
snapshotId = insRes.lastInsertId as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite-all strategy (matches `upsertSnapshotLines`): clear
|
||||||
|
// existing lines, then re-insert every line. Cheap because snapshot
|
||||||
|
// line counts are small.
|
||||||
|
await db.execute(
|
||||||
|
"DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1",
|
||||||
|
[snapshotId]
|
||||||
|
);
|
||||||
|
for (const line of input.lines) {
|
||||||
|
const kind = line.account_kind ?? "simple";
|
||||||
|
if (kind === "simple") {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO balance_snapshot_lines
|
||||||
|
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||||||
|
VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
|
||||||
|
[snapshotId, line.account_id, line.value]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO balance_snapshot_lines
|
||||||
|
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'manual')`,
|
||||||
|
[
|
||||||
|
snapshotId,
|
||||||
|
line.account_id,
|
||||||
|
line.quantity,
|
||||||
|
line.unit_price,
|
||||||
|
line.value,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.execute(
|
||||||
|
`UPDATE balance_snapshots
|
||||||
|
SET updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1`,
|
||||||
|
[snapshotId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute("COMMIT");
|
||||||
|
inTxn = false;
|
||||||
|
return { snapshotId };
|
||||||
|
} catch (e) {
|
||||||
|
if (inTxn) {
|
||||||
|
try {
|
||||||
|
await db.execute("ROLLBACK");
|
||||||
|
} catch {
|
||||||
|
// Defensive: if ROLLBACK fails we still want the caller to see
|
||||||
|
// the original error, not the rollback error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience helper used by the "Prefill from previous snapshot" button.
|
* Convenience helper used by the "Prefill from previous snapshot" button.
|
||||||
* Returns the snapshot whose `snapshot_date` is strictly earlier than
|
* Returns the snapshot whose `snapshot_date` is strictly earlier than
|
||||||
|
|
@ -984,6 +1234,12 @@ export async function getAccountsPeriodAnchor(
|
||||||
): Promise<AccountPeriodAnchor[]> {
|
): Promise<AccountPeriodAnchor[]> {
|
||||||
// For each account, find the earliest snapshot_date >= range.from (and
|
// For each account, find the earliest snapshot_date >= range.from (and
|
||||||
// <= range.to when set), then read that line's value.
|
// <= range.to when set), then read that line's value.
|
||||||
|
//
|
||||||
|
// We use a ROW_NUMBER() window function partitioned by account_id and
|
||||||
|
// ordered by snapshot_date ASC, then keep only rn = 1 per account. This
|
||||||
|
// avoids the previous "MIN(s.snapshot_date) inside a scalar subquery
|
||||||
|
// WHERE" pattern, which SQLite rejects with "misuse of aggregate function
|
||||||
|
// MIN()" (issue #175).
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
if (range.from) {
|
if (range.from) {
|
||||||
|
|
@ -997,18 +1253,22 @@ export async function getAccountsPeriodAnchor(
|
||||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
return db.select<AccountPeriodAnchor[]>(
|
return db.select<AccountPeriodAnchor[]>(
|
||||||
`SELECT l.account_id AS account_id,
|
`SELECT account_id,
|
||||||
MIN(s.snapshot_date) AS anchor_snapshot_date,
|
snapshot_date AS anchor_snapshot_date,
|
||||||
(SELECT l2.value
|
value AS anchor_value
|
||||||
FROM balance_snapshot_lines l2
|
FROM (
|
||||||
JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
|
SELECT l.account_id AS account_id,
|
||||||
WHERE l2.account_id = l.account_id
|
s.snapshot_date AS snapshot_date,
|
||||||
AND s2.snapshot_date = MIN(s.snapshot_date)
|
l.value AS value,
|
||||||
LIMIT 1) AS anchor_value
|
ROW_NUMBER() OVER (
|
||||||
FROM balance_snapshot_lines l
|
PARTITION BY l.account_id
|
||||||
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
ORDER BY s.snapshot_date ASC
|
||||||
${where}
|
) AS rn
|
||||||
GROUP BY l.account_id`,
|
FROM balance_snapshot_lines l
|
||||||
|
JOIN balance_snapshots s ON s.id = l.snapshot_id
|
||||||
|
${where}
|
||||||
|
)
|
||||||
|
WHERE rn = 1`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue