Compare commits

..

No commits in common. "main" and "v0.9.0" have entirely different histories.
main ... v0.9.0

68 changed files with 805 additions and 2961 deletions

11
.gitignore vendored
View file

@ -53,19 +53,8 @@ public/CHANGELOG.fr.md
# Tauri generated # Tauri generated
src-tauri/gen/ src-tauri/gen/
# Tauri icon CLI also generates iOS/Android assets — desktop targets only (nsis, deb, rpm)
src-tauri/icons/android/
src-tauri/icons/ios/
# Claude Code local state # Claude Code local state
.claude/settings.local.json .claude/settings.local.json
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
.claude/worktrees/ .claude/worktrees/
decisions-log.md decisions-log.md
# Autopilot scratch + daily reports
reports/
# Spec scratch (committed only when promoted to docs/archive/)
spec-decisions-*.md
spec-plan-*.md

View file

@ -2,28 +2,6 @@
## [Non publié] ## [Non publié]
## [0.9.1] - 2026-05-10
### 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é
- **Récupération de prix activée**`/v1/prices` de `maximus-api` est en production depuis le 2026-05-05. La fonctionnalité premium de récupération de prix livrée en 0.9.0 (#160) est désormais fonctionnellement disponible de bout en bout (#161).
- **Paramètres réorganisés en 3 sous-pages** — la page unique de 12 cartes est éclatée en un hub (`/settings`) qui pointe vers trois sous-pages thématiques : `/settings/users` (comptes, licences, guide d'utilisation), `/settings/data` (catégories, sauvegarde, confidentialité de la récupération de prix) et `/settings/systems` (version, mise à jour, historique des versions, journaux + commentaires). Le guide d'utilisation et l'historique des versions, qui occupaient leurs propres pages, sont maintenant intégrés dans leur sous-page parente ; les anciennes URL `/docs` et `/changelog` redirigent automatiquement pour préserver les marque-pages externes et les liens des notes de version. Le bandeau de sécurité du fallback token-store est maintenant rendu une seule fois en haut du layout des paramètres, visible depuis chaque sous-page principale (#190).
- **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut).
- 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).
- Bilan : le sélecteur de date sur `/balance/snapshot` se ferme maintenant après la sélection sur Linux (WebKitGTK) au lieu de rester ouvert jusqu'à ce que l'utilisateur appuie sur Échap. Le contournement appelle `blur()` sur le champ après chaque changement — sans effet sur Windows WebView2 / macOS WKWebView, où le sélecteur se ferme déjà automatiquement (#177).
- Mise à jour de la dépendance `postcss` (8.5.6 → 8.5.13) pour corriger l'avis de sécurité de sévérité modérée GHSA-qx2v-qp2m-jg93 (XSS via `</style>` non échappé dans le stringifier CSS). Transitive via vite, build-time uniquement — aucun impact runtime sur le binaire Tauri livré (#180).
- Contournement du sélecteur de date WebKitGTK étendu aux 7 autres champs `<input type="date">` natifs répartis sur 4 composants (barre de filtres Transactions, formulaire Ajustements, modal de Liaison de transferts, sélecteur de période). Chaque handler onChange appelle désormais `e.currentTarget.blur()` pour fermer le popup natif sur Linux Tauri WebView — sans effet sur Windows WebView2 / macOS WKWebView. Même approche que #177 (#188).
- Bilan : nettoyage post-merge des suggestions issues des reviews des PR #182-#185. Six corrections groupées : (1) `getStarterCollisions` filtre désormais `archived_at IS NULL`, donc recréer un compte starter volontairement archivé n'est plus bloqué ; (2) `proposeStarterAccounts` re-vérifie chaque collision (nom, catégorie) en transaction avant l'INSERT en défense-in-depth (saut silencieux si déjà présent, aucune contrainte UNIQUE ajoutée) ; (3) les nouveaux profils reçoivent désormais `balance_starter_proposed` pré-seedé dans `consolidated_schema.sql` pour que le StarterAccountsModal ne s'ouvre jamais brièvement avec uniquement des collisions à la première visite de /balance ; (4) `/balance` cache maintenant le sélecteur de période, le graphique d'évolution et le tableau des comptes tant que la carte d'onboarding vide est affichée (évite trois messages vides empilés) ; (5) `BalanceOnboardingCard.Step` appelle directement `useTranslation()` au lieu de recevoir `t` en prop ; (6) le bloc de doc de la formule Modified Dietz dans `return_calculator.rs` est maintenant entouré d'une fence `text` pour que `cargo test --doc` n'essaie plus de compiler la pseudo-math comme du Rust (#187).
## [0.9.0] - 2026-04-29 ## [0.9.0] - 2026-04-29
### Ajouté ### Ajouté
@ -36,7 +14,7 @@
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146) - **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138) - **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
- **Récupération de prix premium pour actions (best-effort) et crypto (exchanges directs)** — vie privée préservée via proxy maximus-api. Toggle dans les Paramètres pour révoquer le consentement (activation serveur en attente — fonctionnalité dormante jusqu'à la mise en ligne de `/v1/prices` de maximus-api). (#160) - **Récupération de prix premium pour actions (best-effort) et crypto (exchanges directs)** — vie privée préservée via proxy maximus-api. Toggle dans les Paramètres pour révoquer le consentement. (#160)
### Modifié ### Modifié
- **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être déployé en production (live à `https://api.lacompagniemaximus.com`). Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants — mais `/licenses/activate` répond désormais, donc l'activation par machine (issue #53) sera débloquée dès la sortie de cette version. La clé privée correspondante vit uniquement sur le serveur (#49) - **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être déployé en production (live à `https://api.lacompagniemaximus.com`). Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants — mais `/licenses/activate` répond désormais, donc l'activation par machine (issue #53) sera débloquée dès la sortie de cette version. La clé privée correspondante vit uniquement sur le serveur (#49)

View file

@ -2,28 +2,6 @@
## [Unreleased] ## [Unreleased]
## [0.9.1] - 2026-05-10
### 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
- **Price-fetching activated**`maximus-api` `/v1/prices` is live in production since 2026-05-05. The premium price-fetching feature shipped in 0.9.0 (#160) is now functionally available end-to-end (#161).
- **Settings reorganized into 3 sub-pages** — the single 12-card `Settings` page is split into a hub (`/settings`) that links to three thematic sub-pages: `/settings/users` (accounts, licenses, user guide), `/settings/data` (categories, backup, price-fetch privacy) and `/settings/systems` (version, update, version history, logs + feedback). The user guide and changelog, previously full-page routes, are now embedded inside their parent sub-page; the legacy `/docs` and `/changelog` URLs redirect to keep external bookmarks and release-note links working. The token-store fallback security banner is now rendered once at the top of the settings layout, visible from every main settings sub-page (#190).
- **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold).
- 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).
- Bilan: snapshot date picker on `/balance/snapshot` now closes after a date is selected on Linux (WebKitGTK), instead of staying open until the user pressed Esc. Workaround calls `blur()` on the input after each change — no-op on Windows WebView2 / macOS WKWebView, where the picker already auto-closes (#177).
- Updated `postcss` dependency (8.5.6 → 8.5.13) to address moderate severity advisory GHSA-qx2v-qp2m-jg93 (XSS via unescaped `</style>` in CSS stringifier). Transitive via vite, build-time only — no runtime impact on the shipped Tauri binary (#180).
- WebKitGTK date picker workaround extended to the remaining 7 native `<input type="date">` fields across 4 components (Transactions filter bar, Adjustments form, Link Transfers modal, Period selector). Each onChange handler now calls `e.currentTarget.blur()` to dismiss the native popup on Linux Tauri WebView — no-op on Windows WebView2 / macOS WKWebView. Same approach as #177 (#188).
- Bilan: post-merge cleanup of suggestions raised in the #182-#185 reviews. Six fixes bundled: (1) `getStarterCollisions` now filters `archived_at IS NULL` so re-creating a voluntarily archived starter is no longer blocked; (2) `proposeStarterAccounts` re-checks each (name, category) collision in-transaction before INSERT as defense-in-depth (skips silently on hit, no UNIQUE constraint added); (3) brand-new profiles now get `balance_starter_proposed` pre-seeded in `consolidated_schema.sql` so the StarterAccountsModal never briefly opens with all-collisions on first /balance visit; (4) `/balance` now hides the period selector, evolution chart and accounts table while the empty-state onboarding card is shown (avoids three stacked empty messages); (5) `BalanceOnboardingCard.Step` now calls `useTranslation()` directly instead of receiving `t` as a prop; (6) `return_calculator.rs` Modified Dietz formula doc block is wrapped in a `text` fence so `cargo test --doc` no longer fails to compile pseudo-math as Rust (#187).
## [0.9.0] - 2026-04-29 ## [0.9.0] - 2026-04-29
### Added ### Added
@ -36,7 +14,7 @@
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146) - **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138) - **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
- **Price-fetching premium for stocks (best-effort) and crypto (direct exchanges)** — privacy preserved via maximus-api proxy. Privacy toggle in Settings to revoke consent (server activation pending — feature dormant until maximus-api `/v1/prices` ships). (#160) - **Price-fetching premium for stocks (best-effort) and crypto (direct exchanges)** — privacy preserved via maximus-api proxy. Privacy toggle in Settings to revoke consent. (#160)
### Changed ### Changed
- **License Ed25519 public key** rotated to match the freshly deployed `maximus-api` license server (now live at `https://api.lacompagniemaximus.com`). No production licenses had been issued against the previous key, so this change is invisible to existing users — but `/licenses/activate` now answers, so machine activation (Issue #53) is unblocked once this release ships. The matching private key lives only on the server (#49) - **License Ed25519 public key** rotated to match the freshly deployed `maximus-api` license server (now live at `https://api.lacompagniemaximus.com`). No production licenses had been issued against the previous key, so this change is invisible to existing users — but `/licenses/activate` now answers, so machine activation (Issue #53) is unblocked once this release ships. The matching private key lives only on the server (#49)

View file

@ -1,7 +1,5 @@
# CLAUDE.md — Simpl'Résultat # CLAUDE.md — Simpl'Résultat
@STATE.md
## Contexte du projet ## Contexte du projet
**Simpl'Résultat** est une application de bureau desktop **privacy-first** pour la gestion des finances personnelles. Elle traite localement les fichiers CSV bancaires sans aucune dépendance cloud. Projet solo entrepreneurial, en développement par Max. **Simpl'Résultat** est une application de bureau desktop **privacy-first** pour la gestion des finances personnelles. Elle traite localement les fichiers CSV bancaires sans aucune dépendance cloud. Projet solo entrepreneurial, en développement par Max.

View file

@ -1,27 +0,0 @@
# STATE — Simpl'Résultat
> Derniere MAJ : 2026-05-03 (par fix-issue #187 + #188)
## Position actuelle
Phase post-Bilan : milestone complet (5 sub-features merged, ADRs 0008-0010+0012). Polish + prep release pour shipper la nouvelle pubkey Ed25519 alignee sur maximus-api LIVE (`api.lacompagniemaximus.com`). Prochains gros chantiers : activation en ligne (#53), pipeline Stripe (#50, #135-136), price-fetching premium production (#161).
## Decisions recentes
- 2026-05-03 : Bilan post-merge cleanup (S1-S5+S7) — `getStarterCollisions` filtre `archived_at IS NULL`, in-txn re-check sur `proposeStarterAccounts`, pre-seed `balance_starter_proposed` pour nouveaux profils, guard empty-state `/balance`, `useTranslation` direct dans `Step`, doctest fence `text` sur Modified Dietz (ref #187)
- 2026-05-03 : WebKitGTK date picker workaround etendu aux 7 inputs date restants dans 4 composants (TransactionFilterBar, AdjustmentForm, LinkTransfersModal, PeriodSelector) (ref #188)
- 2026-05-03 : postcss 8.5.6 -> 8.5.13, fix GHSA-qx2v-qp2m-jg93 (transitif via vite, build-time only, exposition runtime nulle) (ref #180)
- 2026-05-02 : Settings eclate en 3 sous-pages `/settings/{users,data,systems}` + redirections legacy `/docs` et `/changelog` (ref #190)
- 2026-05-02 : Doc license — placeholder Bearer JWT-like remplace par `<license-token>` (ref #181)
- 2026-05-01 : WebKitGTK date picker — `blur()` apres selection sur `/balance/snapshot` (ref #177)
- 2026-05-01 : Icon Tauri custom (calculatrice + cadenas privacy), 16 raster sizes regenerees
- 2026-04-29 : Bilan starter accounts (4 comptes seedes + modal opt-in) + ADR 0012 vehicle x composition (ref #179)
- 2026-04-29 : Bilan onboarding 2-step empty state `/balance` (ref #178)
- 2026-04-28 : Bilan snapshot save atomic BEGIN/COMMIT + migration v11 cleanup orphans (ref #176)
## Blockers actifs
- #161 — feat(prices): production wiring + smoke test + release (BLOCKED par maximus-api Phase 2)
- #135 / #136 — maximus-api Stripe webhooks license auto-generate (BLOCKED par maximus-api Phase 2)
- #53 — online activation + machine limit enforcement (status:needs-fix)
- #50 / #52 — Stripe integration + purchase page (status:ready, design en attente)

View file

@ -1,104 +0,0 @@
# 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

View file

@ -1,217 +0,0 @@
# ADR 0013 — Évaluation provider stocks : Alpha Vantage retenu comme cible de bascule (override partiel ADR 0011)
- Status: **Accepted**
- Date: 2026-05-07
- Successor of: ADR 0011 (override partiel — la pré-désignation **Tiingo Power** comme cible de bascule devient invalide ; **Alpha Vantage Premium** la remplace)
- Issue: [maximus-api#41](https://git.lacompagniemaximus.com/maximus/maximus-api/issues/41)
- Phase 1 research note: [maximus-api/docs/research/0013-stocks-providers-phase1.md](https://git.lacompagniemaximus.com/maximus/maximus-api/src/branch/issue-41-stocks-providers-research/docs/research/0013-stocks-providers-phase1.md)
## Context
L'ADR 0011 (2026-04-26) a adopté Yahoo Finance en best-effort pour les stocks, **avec un plan de bascule pré-désigné vers Tiingo Power (~10 $/mo, 1000 req/jour)** déclenché par triggers (1+ IP-ban/mois × 2 mois consécutifs, ou 30 %+ requêtes en service_degraded sur 7 jours, ou plainte légale).
Le smoke test 2026-05-04 (issue #25) a confirmé que Yahoo bloque l'IP du VPS OVH de manière stable. Un trigger ADR 0011 est de fait actif. Le feature stocks est non-fonctionnel en production.
**Décision encadrante (le présent ADR ne la modifie pas)** : on reste dans l'esprit MVP de l'ADR 0011 — **0 $ de cash burn tant que le produit n'a pas validé son marché**. La bascule vers un provider payant est repoussée jusqu'à un trigger plus net (1ère licence payée, OU 1ère plainte client active, OU saturation des plaintes "stocks cassé"). Le scope du présent ADR est de **valider empiriquement quel provider sera la cible de bascule** quand un trigger réel se déclenchera, **pas** de déclencher la bascule maintenant.
Avant de figer mécaniquement Tiingo Power comme dans 0011, l'évaluation a été élargie à 3 providers — Alpha Vantage, Tiingo, Polygon — pour potentiellement override la pré-désignation 0011 si un autre provider domine.
## Phase 1 — Recherche documentaire (sources publiques uniquement)
3 sous-agents WebSearch ont produit une synthèse 6-axes par provider. Synthèse complète dans `maximus-api/docs/research/0013-stocks-providers-phase1.md`.
Findings critiques :
| Critère | Alpha Vantage | Tiingo | Polygon |
|---|---|---|---|
| **TSX coverage** | ✅ via `.TRT` / `.TRV` | ❓ non confirmé publiquement | ❌ non couvert |
| **Plan technique min pour 1500 rpm × 10k/jour** | aucun palier public ≥ 1500 rpm (top 1200 rpm @ ~$249.99/mo) | Power suffit techniquement (~$30/mo, 100k/jour) | Starter suffit techniquement ($29/mo "unlimited") |
| **ToS proxy mutualisé pour clients tiers payants** | ⚠️ zone grise, pas de clause explicite, email business pour cas commercial | ❌ Power = "internal consumption only" → Commercial $500+/mo + redistribution license | ❌ Individuals ToS interdit explicitement → Business négocié obligatoire |
| **HTTP error model** | ⚠️ 200 OK + champ `Note`/`Information` | ⚠️ 200 OK + body non-JSON sur quota | ✅ HTTP 429 standard |
| **Profondeur historique daily** | 20+ ans | 30+ ans US | 5 ans Starter |
| **Free tier viable pour dev** | ✅ 25 req/jour, 5 req/min — OK pour valider l'intégration | ⚠️ 1000 req/jour mais ToS interdit le commercial sans Commercial plan | ⚠️ 5 req/min, EOD only, ToS interdit le commercial sans Business |
**Finding majeur** : la pré-désignation ADR 0011 « Tiingo Power ~10 $/mo » est obsolète sur le prix (réel 2026 ≈ $30/mo) ET invalide sur le ToS (Power = internal-use only). Notre cas d'usage cible (proxy multi-tenant) forcerait Tiingo en plan Commercial ($500+/mo) avec redistribution license négociée. Polygon idem (Business plan, prix non public). Alpha Vantage seul reste en zone grise sans interdiction explicite et offre un free tier exploitable pour valider l'intégration en dev.
## Phase 2 — Smoke test live Alpha Vantage (2026-05-07, free tier)
13 calls réels sur `https://www.alphavantage.co/query` avec une clé free tier. Réponses brutes archivées dans `~/.maximus-research-keys/raw-av.json` (à supprimer après validation de cet ADR).
| # | Test | Résultat | Verdict |
|---|---|---|---|
| 01 | `GLOBAL_QUOTE` AAPL | $287.44, day 2026-05-07 | Happy path ✅ |
| 02 | `TIME_SERIES_DAILY_ADJUSTED` AAPL | `Information` field — premium endpoint | ⚠️ **Adjusted close = premium $49.99/mo minimum** |
| 03 | `GLOBAL_QUOTE SHOP.TRT` | $152.57 CAD | TSX coverage confirmée ✅ |
| 04 | `GLOBAL_QUOTE RY.TRT` | $247.64 CAD | TSX big caps OK ✅ |
| 05 | **`GLOBAL_QUOTE SHOP.TO`** (Yahoo style) | **$152.57 — alias silencieux de `.TRT`** | 🎉 **Pas de mapping table requis** — drop-in Yahoo |
| 06 | `GLOBAL_QUOTE SHOP` (sans suffixe) | $111.74 — listing US différent | Suffixe nécessaire pour désambiguïsation CA vs US |
| 07 | `GLOBAL_QUOTE XYZAB123` (inconnu) | `"Global Quote": {}` (objet vide) | ⚠️ **Pas de `Error Message`** — détection = empty object |
| 08 | `GLOBAL_QUOTE SPX` | objet racine `{}` vide | ❌ Indices broad-market non couverts |
| 09 | `GLOBAL_QUOTE ^GSPC` | objet racine `{}` vide | ❌ Idem |
| 10 | `GLOBAL_QUOTE VTSAX` (mutual fund) | $176.23, day 2026-05-06 | Mutual funds OK ✅ |
| 11 | `GLOBAL_QUOTE BRK.B` (share class) | $475.08 | Format dot natif ✅ |
| 12 | `SYMBOL_SEARCH keywords=shopify` | 5 résultats incluant `SHOP.TRT` Toronto | Convention `.TRT` confirmée par AV |
| 13 | `TIME_SERIES_DAILY_ADJUSTED` AAPL outputsize=full | `Information` field — premium endpoint | ⚠️ Premium-gate global sur l'historique ajusté |
**Headers HTTP** : `Retry-After` absent sur les 13 réponses. Toutes en HTTP 200 (confirme la doc — pas de 4xx propres). Content-Type `application/json` partout.
## Decision
**Override partiel de l'ADR 0011 — uniquement le provider de bascule désigné** :
- L'ADR 0011 reste en vigueur sur tout le reste : Yahoo best-effort en prod, garde-fous (badge UX, circuit breaker, quota 200 req/jour/licence, saisie manuelle toujours active), triggers de migration inchangés.
- **Le provider de bascule désigné passe de Tiingo Power à Alpha Vantage Premium $49.99/mo (75 rpm)** quand un trigger ADR 0011 se déclenchera réellement.
- **Aucune bascule immédiate.** Yahoo best-effort reste en prod tant qu'aucun trigger réel (1ère licence payée, plainte client formelle, saturation des incidents) ne justifie le cash burn.
### Pourquoi Alpha Vantage plutôt que Tiingo (pré-désignation 0011)
1. **Tiingo Power est invalide pour notre cas** : "internal consumption only" dans le ToS. Notre proxy multi-tenant force Tiingo en plan Commercial ~$500/mo + redistribution license — ~10× plus cher que la pré-désignation 0011 ($10/mo). Le rapport coût/bénéfice supposé par 0011 ne tient plus.
2. **Alpha Vantage Premium $49.99/mo** est le palier le moins cher qui (a) couvre le besoin technique avec marge (75 rpm × 1440 = ~108k req/jour, vs cible 10k/jour), (b) inclut `TIME_SERIES_DAILY_ADJUSTED` confirmé empiriquement comme premium-gate, (c) couvre le TSX nativement.
3. **Drop-in Yahoo via `.TO` natif** — découverte Phase 2 majeure : AV accepte le suffixe Yahoo `.TO` silencieusement comme alias de `.TRT`. **Aucune mapping table à coder.** Le code `yahooProvider.ts` existant peut être copié quasi-tel-quel en `alphaVantageProvider.ts`. C'est l'argument décisif pour la bascule rapide quand elle sera déclenchée — délai d'implémentation ~quelques heures, pas quelques jours.
4. **ToS en zone grise = négociable au moment voulu** : pas d'interdiction explicite (vs Polygon Individuals qui interdit, vs Tiingo Power qui interdit, vs Yahoo qui interdit). Email à `support@alphavantage.co` peut être envoyé au moment du déclenchement, pas avant.
### Polygon écarté
Polygon est techniquement supérieur (data quality, "unlimited" rpm sur Starter $29) mais **disqualifié seul par l'absence de couverture TSX**. Une stratégie hybride Polygon US + AV CA serait plus complexe et plus chère pour un bénéfice marginal vs AV seul.
### État du free tier AV (statu quo dev)
La clé free tier obtenue pendant l'évaluation reste active pour :
- **Dev / smoke test continu** : valider l'intégration en local avant tout déploiement payant.
- **Smoke test périodique de la cible de bascule** : 1× par mois, 5-10 calls pour vérifier que la convention `.TO` fonctionne toujours, que les premium-gates n'ont pas changé, que `support@alphavantage.co` n'a pas resserré le free tier.
La clé reste hors-Coolify (jamais déployée en prod), dans `~/.maximus-research-keys/av.txt` côté machine de dev. **À ne pas confondre avec un déploiement de prod** — le free tier 25 req/jour est 400× insuffisant pour servir 50 licences réelles.
## Plan de bascule (déclenché par trigger ADR 0011, pas maintenant)
Quand un trigger réel se déclenchera :
1. **Décision business** : confirmer que la bascule est justifiée vs amender ADR 0011 (rester sur Yahoo + mitigations alternatives).
2. **Email ToS** à `support@alphavantage.co` (draft inclus en annexe ci-dessous) — à envoyer **à ce moment-là**, pas maintenant. Délai de réponse standard 3-5 j ouvrables. Use case précisé : "server-side proxy serving N paying B2B licensees".
3. **Sur réponse positive** : signup Premium $49.99/mo (75 rpm). Clé en var Coolify `ALPHAVANTAGE_API_KEY` (secret). Issue follow-up `feat(api): integrer Alpha Vantage comme provider stocks` créée à ce moment-là.
4. **Sur réponse négative ou zone grise prolongée** : amender cet ADR pour retomber sur Tiingo Commercial ($500+/mo) — financièrement justifiable seulement si l'audience est suffisamment monétisée pour absorber le coût.
5. **Implémentation variante A** (remplacement direct, switch via env var `STOCKS_PROVIDER=alphavantage`). Yahoo retiré du code dans une seconde PR séquencée pour rollback rapide.
6. **Smoke test prod** : `?symbol=AAPL` et `?symbol=SHOP.TO` doivent renvoyer 200 + prix non-stale.
7. **Bascule + monitoring 7 jours**.
8. **Cleanup** : `~/.maximus-research-keys/` supprimé.
## Garde-fous obligatoires (mirror ADR 0011, applicables à AV au moment de la bascule)
1. **Label UX inchangé** : badge "best-effort" reste sur les catégories stocks. AV est plus stable que Yahoo mais reste un provider tiers — pas de SLA contractuel pour notre cas d'usage à $49.99/mo.
2. **Circuit breaker côté maximus-api** : seuil identique 5 erreurs AV / 60 sec → breaker ouvert 15 min. Notification Telegram/email à `maxime2tremblay@protonmail.com`.
3. **Quota côté maximus-api** : 200 req/jour/licence — inchangé. Avec 75 rpm × 1440 min = 108 000 req/jour de capacité, marge ample pour 50 licences.
4. **Saisie manuelle toujours active** : aucun chemin d'erreur ne bloque la saisie d'un snapshot.
5. **Headers stripping rigoureux** : tous les headers entrants supprimés. Vers AV : UA `maximus-api/<version>` (pas besoin de UA browser-like contrairement à Yahoo). Auth via query string `?apikey=` (limitation AV — pas de header support).
6. **Logs** : URL avec `?apikey=` masquée dans les logs Coolify/Traefik via une regex de filtre côté pino logger.
## Parsing défensif (issu des findings Phase 2 — applicable au moment de l'implémentation)
Le module `alphaVantageProvider.ts` doit gérer **5 cas distincts** sur HTTP 200 :
```typescript
// 1. Happy path — body['Global Quote'] populated
if (body['Global Quote'] && Object.keys(body['Global Quote']).length > 0) { /* ok */ }
// 2. Symbol unknown — body['Global Quote'] = {} empty object
else if (body['Global Quote'] && Object.keys(body['Global Quote']).length === 0) { /* symbol_not_found */ }
// 3. Premium endpoint blocked — body['Information'] field with subscribe message
else if (body['Information']) { /* premium_required or rate_limit */ }
// 4. Error — body['Error Message'] field (param malformed)
else if (body['Error Message']) { /* invalid_request */ }
// 5. Rate limit hit — body['Note'] field (legacy free tier message)
else if (body['Note']) { /* rate_limit */ }
```
Pas de fallback sur HTTP status (toujours 200). Le code de parsing yahoo existant ne couvre pas ces cas — adaptation requise dans la PR follow-up déclenchée par la bascule.
## Consequences
### Positives
- **0 $ de cash burn maintenu** — l'esprit MVP de l'ADR 0011 est préservé. Pas de bascule prématurée à un provider payant.
- **Cible de bascule validée empiriquement**`.TO` natif, TSX confirmé, mutual funds OK, format API simple. Au moment du trigger, la bascule prendra des heures, pas des jours.
- **Override propre de la pré-désignation Tiingo** — la décision 0011 ne s'auto-déclenche pas mécaniquement vers un provider mal calibré.
- **Email ToS reporté** — pas d'effort gaspillé tant qu'il n'y a pas d'enjeu réel.
### Négatives / risques actés
- **Yahoo reste cassé en prod** — feature stocks non-fonctionnel jusqu'au déclenchement du trigger ou résolution Yahoo (improbable). Acceptable tant qu'aucun client payant ne se plaint.
- **Adjusted close = premium endpoint** chez AV : confirmé empiriquement. Au moment de la bascule, le tier Premium $49.99 sera nécessaire (pas de chemin gratuit pour `TIME_SERIES_DAILY_ADJUSTED`).
- **Indices broad-market non couverts via `GLOBAL_QUOTE`** : SPX, ^GSPC, GSPTSE retournent objet vide. Hors-scope tant que la roadmap ne demande pas d'indices ; sinon ETF proxy (`SPY`, `XIC.TO`).
- **HTTP 200 sur toutes les erreurs** : parsing fragile, code défensif obligatoire (5 cas distincts à gérer).
- **Pas de `Retry-After` natif** : exponential backoff côté client requis sur détection de `Note`/`Information`.
- **Auth `?apikey=` query string uniquement** : leak risk dans les logs Coolify/Traefik. Mitigation = regex de masking pino.
- **ToS en zone grise jusqu'à confirmation écrite future** : risque de suspension de clé sans préavis si AV détecte un pattern multi-tenant. Probabilité faible à petite échelle, à monitorer.
- **Profondeur smallcaps TSXV non validée** : risque sur ~5-10 % des positions clients (à mitiger en Phase 4 par smoke test sur échantillon représentatif des holdings réels).
- **Free tier érodé historiquement** (500 → 100 → 25 req/jour) : signal qu'AV peut resserrer aussi les paid tiers à terme. Risque budget sur 12-24 mois.
### Neutre
- Le code reste `src/services/providers/<provider>Provider.ts` parallèle, switch via env var. Pattern déjà en place pour Yahoo, extension triviale.
## Triggers de bascule (rappel ADR 0011, inchangés)
- 1+ incidents IP-ban Yahoo / mois pendant 2 mois consécutifs, OU
- 30 %+ requêtes stocks tombent en `service_degraded` sur 7 jours, OU
- Plainte légale formelle de Yahoo / Verizon Media, OU
- (nouveau) 1ère licence payée active OU 1ère plainte client formelle sur le feature stocks.
Le 4ème trigger est ajouté pour aligner la décision avec la réalité business : tant qu'il n'y a pas de licence payée ou de plainte active, le 0 $ recurring l'emporte sur le service-quality.
## Suivi
- ADR à reviewer dans 6 mois (2026-11-07) ou plus tôt si :
- Trigger de bascule déclenché (réévaluer Yahoo vs AV en fonction du contexte du moment),
- AV resserre le free tier au point que le smoke test mensuel devient impossible,
- Yahoo redevient stable (improbable mais possible).
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`, `paid_licenses_active_count`.
- Smoke test mensuel AV free tier : entrée TODO dans le calendrier ou cron `claude` skill.
## Annexe — Draft email à `support@alphavantage.co` (à envoyer au moment de la bascule, pas maintenant)
```
Subject: Commercial use authorization — server-side proxy for ~N paying B2B licensees
Hello Alpha Vantage support team,
I am evaluating Alpha Vantage Premium for a small B2B SaaS use case and would like
to confirm the licensing model in writing before subscribing.
Use case:
- Server-side proxy (single VPS, single API key) on behalf of ~N paying B2B licensees
- Delayed/EOD US equities (NYSE/NASDAQ/AMEX) and Canadian equities (TSX via .TRT/.TO)
- Mutual funds (limited)
- ~Y 000 requests/day total, ~Z req/min peak
- No client-side redistribution: only the licensee's own portfolio holdings are
fetched, and the response data is consumed by the licensee's own desktop app
(no public-facing data feed, no resale to non-licensees)
Question:
1. Is this use case authorized under the standard Premium ToS, or do we need
a custom commercial agreement / data onboarding process?
2. Which tier would you recommend for the volume above?
3. Are there any per-end-user fees or exchange data fees I should be aware of
for delayed/EOD US + Canadian equities?
I would prefer to have written confirmation before subscribing, to comply with
my own internal documentation requirements (the ToS confirmation will be filed
as part of an internal architecture decision record).
Thanks for your help,
Maxime Tremblay
maxime2tremblay@protonmail.com
```
Réponse écrite à archiver dans `simpl-resultat/docs/adr/0013-attachments/alphavantage-tos-confirmation-YYYY-MM-DD.txt` une fois reçue.
## References
- [Alpha Vantage — API Documentation](https://www.alphavantage.co/documentation/)
- [Alpha Vantage — Premium API Key (pricing)](https://www.alphavantage.co/premium/)
- [Alpha Vantage — Terms of Service](https://www.alphavantage.co/terms_of_service/)
- [Alpha Vantage — Customer Support](https://www.alphavantage.co/support/)
- [Macroption — Alpha Vantage Symbols (suffixes)](https://www.macroption.com/alpha-vantage-symbols/)
- ADR 0009 — Architecture du proxy
- ADR 0011 — Providers best-effort Yahoo (override partiel sur le provider de bascule désigné)
- `maximus-api/docs/research/0013-stocks-providers-phase1.md` — Synthèse 3-way Phase 1
- `~/.maximus-research-keys/raw-av.json` — Réponses brutes Phase 2 smoke test (à supprimer après merge)
- Issue Forgejo `maximus-api#41`

View file

@ -468,7 +468,7 @@ Décisions **tranchées en §0** (ne sont plus ouvertes) : provider, infra rate-
```http ```http
GET /v1/prices?symbol=AAPL&date=2026-04-25 HTTP/1.1 GET /v1/prices?symbol=AAPL&date=2026-04-25 HTTP/1.1
Host: api.lacompagniemaximus.com Host: api.lacompagniemaximus.com
Authorization: Bearer <license-token> Authorization: Bearer eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9...
Accept: application/json Accept: application/json
User-Agent: simpl-resultat User-Agent: simpl-resultat
``` ```

View file

@ -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/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow) │ │ ├── balance/ # 7 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, 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. **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_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete |
| `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 |
@ -346,14 +346,9 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/balance` | `BalancePage` | Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté | | `/balance` | `BalancePage` | Bilan — vue d'ensemble : carte "Aujourd'hui" + Δ% + avertissement bilan pas à jour > 60j, graphique d'évolution (toggle ligne / aire empilée par catégorie), tableau des comptes avec rendements multi-horizons (3M / 1A / depuis création — Modified Dietz) côte-à-côte avec rendement non-ajusté |
| `/balance/snapshot` | `SnapshotEditPage` | Saisie / édition d'un snapshot daté. Mode `?date=today` (création) ou `?date=YYYY-MM-DD` (édition, date immutable). Lignes groupées par catégorie : `simple` = champ valeur, `priced` = `quantity` × `unit_price` (`value` calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date | | `/balance/snapshot` | `SnapshotEditPage` | Saisie / édition d'un snapshot daté. Mode `?date=today` (création) ou `?date=YYYY-MM-DD` (édition, date immutable). Lignes groupées par catégorie : `simple` = champ valeur, `priced` = `quantity` × `unit_price` (`value` calculé read-only). Bouton "Pré-remplir depuis le snapshot précédent". Suppression à double-confirmation par re-saisie de la date |
| `/balance/accounts` | `AccountsPage` | CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (`is_seed = 1`) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT) | | `/balance/accounts` | `AccountsPage` | CRUD comptes + catégories de bilan (deux onglets). Catégories seedées (`is_seed = 1`) renommables mais non-supprimables ; refus de suppression d'une catégorie avec comptes liés (FK RESTRICT) |
| `/settings` | `SettingsLayout` (layout) + `SettingsHomePage` (index) | Hub des paramètres : 3 cards-cluster vers les sous-pages. Le layout monte `TokenStoreFallbackBanner` une seule fois, partagé par les 4 routes principales | | `/settings` | `SettingsPage` | Paramètres |
| `/settings/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) | | `/docs` | `DocsPage` | Documentation in-app |
| `/settings/data` | `DataSettingsPage` | Catégories (avec liens vers `/settings/categories/standard` et `/settings/categories/migrate`), backup chiffré et confidentialité de la récupération de prix | | `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
| `/settings/systems` | `SystemsSettingsPage` | Version, mise à jour (`UpdateCard`), historique des versions (`ChangelogContent`), journaux + commentaires (`LogViewerCard`) |
| `/settings/categories/standard` | `CategoriesStandardGuidePage` | Guide imprimable de la structure de catégories standard (route flat, hors `SettingsLayout`) |
| `/settings/categories/migrate` | `CategoriesMigrationPage` | Flux de migration v1→v2 (route flat, hors `SettingsLayout`) |
| `/docs` | `DocsPage` | Redirige vers `/settings/users` (rétrocompatibilité bookmarks) |
| `/changelog` | `ChangelogPage` | Redirige vers `/settings/systems` (rétrocompatibilité release notes) |
Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif). Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est actif).
@ -406,4 +401,3 @@ 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 |

View file

@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simpl'Résultat</title> <title>Tauri + React + Typescript</title>
</head> </head>
<body> <body>

11
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.9.1", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.9.1", "version": "0.9.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -2923,9 +2923,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2941,7 +2941,6 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",

View file

@ -1,7 +1,7 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.9.1", "version": "0.9.0",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -1,66 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Concept 04b — Calculatrice-robot avec cadenas sur la touche "="
Iteration sur 04 : la touche Entrée/= devient le porteur du symbole privacy.
Robot (yeux + antenne) + comptabilité (calculatrice) + simplicité + privacy (cadenas explicite).
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- Background squircle -->
<rect x="64" y="64" width="896" height="896" rx="200" ry="200" fill="#1E3A8A"/>
<!-- Antenna -->
<line x1="512" y1="120" x2="512" y2="200" stroke="#FCD34D" stroke-width="18" stroke-linecap="round"/>
<circle cx="512" cy="110" r="28" fill="#FCD34D"/>
<!-- Calculator body -->
<rect x="232" y="200" width="560" height="700" rx="60" ry="60" fill="#F1F5F9"/>
<!-- Screen (robot face) -->
<rect x="282" y="250" width="460" height="240" rx="20" ry="20" fill="#0F172A"/>
<!-- Screen highlight (subtle reflection) -->
<rect x="300" y="265" width="200" height="20" rx="10" fill="#1E3A8A" opacity="0.4"/>
<!-- Robot eyes on screen -->
<circle cx="402" cy="370" r="36" fill="#10B981"/>
<circle cx="622" cy="370" r="36" fill="#10B981"/>
<circle cx="412" cy="358" r="10" fill="#F1F5F9"/>
<circle cx="632" cy="358" r="10" fill="#F1F5F9"/>
<!-- Smile -->
<path d="M 432 440 Q 512 470 592 440" fill="none" stroke="#10B981" stroke-width="12" stroke-linecap="round"/>
<!-- Calculator buttons grid (3x4) -->
<g fill="#1E3A8A">
<rect x="302" y="540" width="100" height="80" rx="16"/>
<rect x="412" y="540" width="100" height="80" rx="16"/>
<rect x="522" y="540" width="100" height="80" rx="16"/>
<rect x="632" y="540" width="100" height="80" rx="16" fill="#FCD34D"/>
<rect x="302" y="630" width="100" height="80" rx="16"/>
<rect x="412" y="630" width="100" height="80" rx="16"/>
<rect x="522" y="630" width="100" height="80" rx="16"/>
<rect x="632" y="630" width="100" height="80" rx="16" fill="#FCD34D"/>
<rect x="302" y="720" width="100" height="80" rx="16"/>
<rect x="412" y="720" width="100" height="80" rx="16"/>
<rect x="522" y="720" width="100" height="80" rx="16"/>
<rect x="632" y="720" width="100" height="80" rx="16" fill="#FCD34D"/>
</g>
<!-- "=" / Enter button (wide, accent green) with lock icon -->
<rect x="302" y="810" width="430" height="80" rx="16" fill="#10B981"/>
<!-- Lock icon centered on the Enter key -->
<g transform="translate(517 850)">
<!-- Shackle (arc above body) -->
<path d="M -16 -2 L -16 -14 A 16 16 0 0 1 16 -14 L 16 -2"
fill="none" stroke="#F1F5F9" stroke-width="8" stroke-linecap="round"/>
<!-- Body -->
<rect x="-24" y="-2" width="48" height="32" rx="5" fill="#F1F5F9"/>
<!-- Keyhole circle -->
<circle cx="0" cy="11" r="4.5" fill="#10B981"/>
<!-- Keyhole stem -->
<rect x="-2.5" y="11" width="5" height="11" rx="1.5" fill="#10B981"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

6
public/tauri.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

2
src-tauri/Cargo.lock generated
View file

@ -4509,7 +4509,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "simpl-result" name = "simpl-result"
version = "0.9.1" version = "0.9.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "simpl-result" name = "simpl-result"
version = "0.9.1" version = "0.9.0"
description = "Personal finance management app" description = "Personal finance management app"
license = "GPL-3.0-only" license = "GPL-3.0-only"
authors = ["you"] authors = ["you"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 963 B

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 B

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,66 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Concept 04b — Calculatrice-robot avec cadenas sur la touche "="
Iteration sur 04 : la touche Entrée/= devient le porteur du symbole privacy.
Robot (yeux + antenne) + comptabilité (calculatrice) + simplicité + privacy (cadenas explicite).
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<!-- Background squircle -->
<rect x="64" y="64" width="896" height="896" rx="200" ry="200" fill="#1E3A8A"/>
<!-- Antenna -->
<line x1="512" y1="120" x2="512" y2="200" stroke="#FCD34D" stroke-width="18" stroke-linecap="round"/>
<circle cx="512" cy="110" r="28" fill="#FCD34D"/>
<!-- Calculator body -->
<rect x="232" y="200" width="560" height="700" rx="60" ry="60" fill="#F1F5F9"/>
<!-- Screen (robot face) -->
<rect x="282" y="250" width="460" height="240" rx="20" ry="20" fill="#0F172A"/>
<!-- Screen highlight (subtle reflection) -->
<rect x="300" y="265" width="200" height="20" rx="10" fill="#1E3A8A" opacity="0.4"/>
<!-- Robot eyes on screen -->
<circle cx="402" cy="370" r="36" fill="#10B981"/>
<circle cx="622" cy="370" r="36" fill="#10B981"/>
<circle cx="412" cy="358" r="10" fill="#F1F5F9"/>
<circle cx="632" cy="358" r="10" fill="#F1F5F9"/>
<!-- Smile -->
<path d="M 432 440 Q 512 470 592 440" fill="none" stroke="#10B981" stroke-width="12" stroke-linecap="round"/>
<!-- Calculator buttons grid (3x4) -->
<g fill="#1E3A8A">
<rect x="302" y="540" width="100" height="80" rx="16"/>
<rect x="412" y="540" width="100" height="80" rx="16"/>
<rect x="522" y="540" width="100" height="80" rx="16"/>
<rect x="632" y="540" width="100" height="80" rx="16" fill="#FCD34D"/>
<rect x="302" y="630" width="100" height="80" rx="16"/>
<rect x="412" y="630" width="100" height="80" rx="16"/>
<rect x="522" y="630" width="100" height="80" rx="16"/>
<rect x="632" y="630" width="100" height="80" rx="16" fill="#FCD34D"/>
<rect x="302" y="720" width="100" height="80" rx="16"/>
<rect x="412" y="720" width="100" height="80" rx="16"/>
<rect x="522" y="720" width="100" height="80" rx="16"/>
<rect x="632" y="720" width="100" height="80" rx="16" fill="#FCD34D"/>
</g>
<!-- "=" / Enter button (wide, accent green) with lock icon -->
<rect x="302" y="810" width="430" height="80" rx="16" fill="#10B981"/>
<!-- Lock icon centered on the Enter key -->
<g transform="translate(517 850)">
<!-- Shackle (arc above body) -->
<path d="M -16 -2 L -16 -14 A 16 16 0 0 1 16 -14 L 16 -2"
fill="none" stroke="#F1F5F9" stroke-width="8" stroke-linecap="round"/>
<!-- Body -->
<rect x="-24" y="-2" width="48" height="32" rx="5" fill="#F1F5F9"/>
<!-- Keyhole circle -->
<circle cx="0" cy="11" r="4.5" fill="#10B981"/>
<!-- Keyhole stem -->
<rect x="-2.5" y="11" width="5" height="11" rx="1.5" fill="#10B981"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -9,9 +9,7 @@
//! //!
//! Modified Dietz formula: //! Modified Dietz formula:
//! //!
//! ```text
//! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i)) //! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i))
//! ```
//! //!
//! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start //! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start
//! to flow date`. A flow on day 0 is fully invested for the whole period //! to flow date`. A flow on day 0 is fully invested for the whole period

View file

@ -271,29 +271,12 @@ 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');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1'); INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
-- Suppress StarterAccountsModal on first /balance visit for new profiles
-- (Issue #179). The 4 starter accounts are already seeded above, so the
-- modal would only show 4 collision rows with no actionable choice. Pre-
-- writing the pref skips that briefly-empty UX entirely. Suggestion S1
-- from PR #185 review (#187).
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_proposed', '{"shown_at":"seed","accepted":[]}');
-- ============================================================================ -- ============================================================================
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec -- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec

View file

@ -127,22 +127,6 @@ 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()
@ -1192,109 +1176,5 @@ 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);
}
} }

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat", "productName": "Simpl Resultat",
"version": "0.9.1", "version": "0.9.0",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -26,7 +26,6 @@
"targets": ["nsis", "deb", "rpm"], "targets": ["nsis", "deb", "rpm"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",

View file

@ -15,11 +15,7 @@ import ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage"; import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage"; import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsLayout from "./pages/settings/SettingsLayout"; import SettingsPage from "./pages/SettingsPage";
import SettingsHomePage from "./pages/settings/SettingsHomePage";
import UsersSettingsPage from "./pages/settings/UsersSettingsPage";
import DataSettingsPage from "./pages/settings/DataSettingsPage";
import SystemsSettingsPage from "./pages/settings/SystemsSettingsPage";
import AccountsPage from "./pages/AccountsPage"; import AccountsPage from "./pages/AccountsPage";
import BalancePage from "./pages/BalancePage"; import BalancePage from "./pages/BalancePage";
import SnapshotEditPage from "./pages/SnapshotEditPage"; import SnapshotEditPage from "./pages/SnapshotEditPage";
@ -120,12 +116,7 @@ export default function App() {
<Route path="/reports/compare" element={<ReportsComparePage />} /> <Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} /> <Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} /> <Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsLayout />}> <Route path="/settings" element={<SettingsPage />} />
<Route index element={<SettingsHomePage />} />
<Route path="users" element={<UsersSettingsPage />} />
<Route path="data" element={<DataSettingsPage />} />
<Route path="systems" element={<SystemsSettingsPage />} />
</Route>
<Route path="/balance" element={<BalancePage />} /> <Route path="/balance" element={<BalancePage />} />
<Route path="/balance/accounts" element={<AccountsPage />} /> <Route path="/balance/accounts" element={<AccountsPage />} />
<Route path="/balance/snapshot" element={<SnapshotEditPage />} /> <Route path="/balance/snapshot" element={<SnapshotEditPage />} />

View file

@ -71,11 +71,7 @@ export default function AdjustmentForm({
<input <input
type="date" type="date"
value={form.date} value={form.date}
onChange={(e) => { onChange={(e) => setForm({ ...form, date: e.target.value })}
setForm({ ...form, date: e.target.value });
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/> />
</div> </div>

View file

@ -1,41 +0,0 @@
// 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");
});
});

View file

@ -1,206 +0,0 @@
// 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 { 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"
/>
<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")}
/>
</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;
}
function Step({
number,
state,
icon,
title,
description,
ctaLabel,
ctaHref,
disabledHint,
}: StepProps) {
const { t } = useTranslation();
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>
);
}

View file

@ -229,11 +229,7 @@ export default function LinkTransfersModal({
<input <input
type="date" type="date"
value={from} value={from}
onChange={(e) => { onChange={(e) => setFrom(e.target.value)}
setFrom(e.target.value);
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm" className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/> />
</label> </label>
@ -244,11 +240,7 @@ export default function LinkTransfersModal({
<input <input
type="date" type="date"
value={to} value={to}
onChange={(e) => { onChange={(e) => setTo(e.target.value)}
setTo(e.target.value);
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm" className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/> />
</label> </label>

View file

@ -1,152 +0,0 @@
// 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"
});
it("excludes archived accounts via SQL filter", async () => {
mockSelect.mockResolvedValueOnce([]);
await getStarterCollisions();
const sql = mockSelect.mock.calls[0][0];
expect(sql).toMatch(/archived_at IS NULL/);
});
});
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 category id, SELECT in-txn collision check, INSERT
mockSelect
.mockResolvedValueOnce([{ id: 11 }]) // cash category lookup
.mockResolvedValueOnce([{ count: 0 }]) // S3 collision check for cash
.mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check for rrsp
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("skips silently when in-txn collision check finds an existing account (S3)", async () => {
// BEGIN
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
// First starter "cash": category lookup succeeds, collision check returns count=1 → skip
mockSelect
.mockResolvedValueOnce([{ id: 11 }]) // cash category lookup
.mockResolvedValueOnce([{ count: 1 }]) // S3 collision: cash already exists
// Second starter "rrsp": category lookup + clean collision check
.mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup
.mockResolvedValueOnce([{ count: 0 }]); // rrsp clean
mockExecute
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT
const result = await proposeStarterAccounts(["cash", "rrsp"]);
expect(result).toEqual([101]); // only rrsp inserted, cash skipped silently
const sqls = mockExecute.mock.calls.map((c) => c[0]);
expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(1);
expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow
});
it("rolls back on insert failure", async () => {
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
mockSelect
.mockResolvedValueOnce([{ id: 11 }]) // cash category
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean
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");
});
});

View file

@ -1,209 +0,0 @@
// 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
);
}

View file

@ -94,11 +94,7 @@ export default function PeriodSelector({
<input <input
type="date" type="date"
value={localFrom} value={localFrom}
onChange={(e) => { onChange={(e) => setLocalFrom(e.target.value)}
setLocalFrom(e.target.value);
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]" className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
/> />
</div> </div>
@ -109,11 +105,7 @@ export default function PeriodSelector({
<input <input
type="date" type="date"
value={localTo} value={localTo}
onChange={(e) => { onChange={(e) => setLocalTo(e.target.value)}
setLocalTo(e.target.value);
// Close native date popup on WebKitGTK (#177)
e.currentTarget.blur();
}}
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]" className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
/> />
</div> </div>

View file

@ -1,92 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
interface ChangelogEntry {
version: string;
sections: { heading: string; items: string[] }[];
}
function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: { heading: string; items: string[] } | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
export default function ChangelogContent() {
const { t, i18n } = useTranslation();
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
useEffect(() => {
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => setEntries(parseChangelog(text)))
.catch(() => setEntries([]));
}, [i18n.language]);
if (entries.length === 0) {
return (
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
);
}
return (
<div className="space-y-6">
{entries.map((entry) => (
<div
key={entry.version}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
>
<h3 className="text-lg font-semibold">{entry.version}</h3>
{entry.sections.map((section, si) => (
<div key={si} className="space-y-1.5">
<h4 className="text-sm font-semibold text-[var(--primary)]">
{section.heading}
</h4>
<ul className="space-y-1">
{section.items.map((item, ii) => (
<li
key={ii}
className="text-sm text-[var(--muted-foreground)] pl-3"
>
{"• "}
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
);
}

View file

@ -1,201 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import {
Rocket,
LayoutDashboard,
Upload,
ArrowLeftRight,
Tags,
SlidersHorizontal,
PiggyBank,
BarChart3,
Wallet,
Settings,
Lightbulb,
ListChecks,
Footprints,
Printer,
Users,
} from "lucide-react";
const SECTIONS = [
{ key: "gettingStarted", icon: Rocket },
{ key: "profiles", icon: Users },
{ key: "dashboard", icon: LayoutDashboard },
{ key: "import", icon: Upload },
{ key: "transactions", icon: ArrowLeftRight },
{ key: "categories", icon: Tags },
{ key: "adjustments", icon: SlidersHorizontal },
{ key: "budget", icon: PiggyBank },
{ key: "reports", icon: BarChart3 },
{ key: "balance", icon: Wallet },
{ key: "settings", icon: Settings },
] as const;
export default function DocsContent() {
const { t } = useTranslation();
const location = useLocation();
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
const [activeSection, setActiveSection] = useState<string>(SECTIONS[0].key);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
}
}
},
{ rootMargin: "-30% 0px -60% 0px", threshold: 0 },
);
for (const { key } of SECTIONS) {
const el = sectionRefs.current[key];
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
const hash = location.hash.replace("#", "");
if (hash && sectionRefs.current[hash]) {
requestAnimationFrame(() => {
sectionRefs.current[hash]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
});
}
}, [location.hash]);
const scrollToSection = (key: string) => {
sectionRefs.current[key]?.scrollIntoView({
behavior: "smooth",
block: "start",
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">{t("docs.title")}</h2>
<button
onClick={() => window.print()}
className="print:hidden flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
title={t("docs.print")}
>
<Printer size={16} />
{t("docs.print")}
</button>
</div>
<nav
className="print:hidden bg-[var(--card)] border border-[var(--border)] rounded-xl p-3"
aria-label={t("docs.title")}
>
<ul className="flex flex-wrap gap-1">
{SECTIONS.map(({ key, icon: Icon }) => (
<li key={key}>
<button
onClick={() => scrollToSection(key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${
activeSection === key
? "bg-[var(--primary)] text-white font-medium"
: "text-[var(--muted-foreground)] hover:bg-[var(--border)] hover:text-[var(--foreground)]"
}`}
>
<Icon size={13} />
{t(`docs.${key}.title`)}
</button>
</li>
))}
</ul>
</nav>
{SECTIONS.map(({ key, icon: Icon }) => (
<section
key={key}
id={key}
ref={(el) => {
sectionRefs.current[key] = el;
}}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4 scroll-mt-4"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<Icon size={20} />
</div>
<h3 className="text-lg font-semibold">{t(`docs.${key}.title`)}</h3>
</div>
<p className="text-[var(--muted-foreground)]">
{t(`docs.${key}.overview`)}
</p>
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<ListChecks size={14} />
{t("docs.features")}
</h4>
<ul className="space-y-1">
{(
t(`docs.${key}.features`, { returnObjects: true }) as string[]
).map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="text-[var(--primary)] mt-0.5 shrink-0">
&bull;
</span>
{item}
</li>
))}
</ul>
</div>
<div>
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<Footprints size={14} />
{key === "gettingStarted"
? t("docs.quickStart")
: t("docs.howTo")}
</h4>
<ol className="space-y-1 list-decimal list-inside">
{(
t(`docs.${key}.steps`, { returnObjects: true }) as string[]
).map((item, i) => (
<li key={i} className="text-sm">
{item}
</li>
))}
</ol>
</div>
<div className="bg-[var(--background)] rounded-lg p-4">
<h4 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<Lightbulb size={14} />
{t("docs.tipsHeader")}
</h4>
<ul className="space-y-1">
{(
t(`docs.${key}.tips`, { returnObjects: true }) as string[]
).map((item, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]"
>
<Lightbulb
size={13}
className="text-[var(--primary)] mt-0.5 shrink-0"
/>
{item}
</li>
))}
</ul>
</div>
</section>
))}
</div>
);
}

View file

@ -1,211 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Info,
RefreshCw,
Download,
CheckCircle,
AlertCircle,
RotateCcw,
Loader2,
} from "lucide-react";
import { useUpdater } from "../../hooks/useUpdater";
export default function UpdateCard() {
const { t, i18n } = useTranslation();
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
useUpdater();
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
const fetchReleaseNotes = useCallback(
(targetVersion: string) => {
const file =
i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => {
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(
`^## \\[?${escaped}\\]?.*$\\n([\\s\\S]*?)(?=^## |$(?!\\n))`,
"m",
);
const match = text.match(re);
setReleaseNotes(match ? match[1].trim() : null);
})
.catch(() => setReleaseNotes(null));
},
[i18n.language],
);
useEffect(() => {
if (state.status === "available" && state.version) {
fetchReleaseNotes(state.version);
}
}, [state.status, state.version, fetchReleaseNotes]);
const progressPercent =
state.contentLength && state.contentLength > 0
? Math.round((state.progress / state.contentLength) * 100)
: null;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Info size={18} />
{t("settings.updates.title")}
</h2>
{state.status === "idle" && (
<button
onClick={checkForUpdate}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<RefreshCw size={16} />
{t("settings.updates.checkButton")}
</button>
)}
{state.status === "checking" && (
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.checking")}
</div>
)}
{state.status === "notEntitled" && (
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.updates.notEntitled")}</p>
</div>
)}
{state.status === "upToDate" && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[var(--positive)]">
<CheckCircle size={16} />
{t("settings.updates.upToDate")}
</div>
<button
onClick={checkForUpdate}
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<RefreshCw size={14} />
</button>
</div>
)}
{state.status === "available" && (
<div className="space-y-3">
<p>
{t("settings.updates.available", { version: state.version })}
</p>
{(() => {
const notes = releaseNotes || state.body;
if (!notes) return null;
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[var(--foreground)]">
{t("settings.updates.releaseNotes")}
</h3>
<div className="max-h-48 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 text-sm text-[var(--muted-foreground)] space-y-1">
{notes.split("\n").map((line, i) => {
const trimmed = line.trim();
if (!trimmed) return <div key={i} className="h-2" />;
if (trimmed.startsWith("### "))
return (
<p
key={i}
className="font-semibold text-[var(--foreground)] mt-2"
>
{trimmed.slice(4)}
</p>
);
if (trimmed.startsWith("## "))
return (
<p
key={i}
className="font-bold text-[var(--foreground)] mt-2"
>
{trimmed.slice(3)}
</p>
);
if (trimmed.startsWith("- "))
return (
<p key={i} className="pl-3">
{"• "}
{trimmed.slice(2).replace(/\*\*(.+?)\*\*/g, "$1")}
</p>
);
return <p key={i}>{trimmed}</p>;
})}
</div>
</div>
);
})()}
<button
onClick={downloadAndInstall}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<Download size={16} />
{t("settings.updates.downloadButton")}
</button>
</div>
)}
{state.status === "downloading" && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.downloading")}
{progressPercent !== null && <span>{progressPercent}%</span>}
</div>
<div className="w-full bg-[var(--border)] rounded-full h-2">
<div
className="bg-[var(--primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercent ?? 0}%` }}
/>
</div>
</div>
)}
{state.status === "readyToInstall" && (
<div className="space-y-3">
<p className="text-[var(--positive)]">
{t("settings.updates.readyToInstall")}
</p>
<button
onClick={installAndRestart}
className="flex items-center gap-2 px-4 py-2 bg-[var(--positive)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<RotateCcw size={16} />
{t("settings.updates.installButton")}
</button>
</div>
)}
{state.status === "installing" && (
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.installing")}
</div>
)}
{state.status === "error" && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[var(--negative)]">
<AlertCircle size={16} />
{t("settings.updates.error")}
</div>
<p className="text-sm text-[var(--muted-foreground)]">{state.error}</p>
<button
onClick={checkForUpdate}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
<RotateCcw size={16} />
{t("settings.updates.retryButton")}
</button>
</div>
)}
</div>
);
}

View file

@ -167,11 +167,9 @@ export default function TransactionFilterBar({
<input <input
type="date" type="date"
value={filters.dateFrom ?? ""} value={filters.dateFrom ?? ""}
onChange={(e) => { onChange={(e) =>
onFilterChange("dateFrom", e.target.value || null); onFilterChange("dateFrom", e.target.value || null)
// Close native date popup on WebKitGTK (#177) }
e.currentTarget.blur();
}}
placeholder={t("transactions.filters.dateFrom")} placeholder={t("transactions.filters.dateFrom")}
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/> />
@ -179,11 +177,9 @@ export default function TransactionFilterBar({
<input <input
type="date" type="date"
value={filters.dateTo ?? ""} value={filters.dateTo ?? ""}
onChange={(e) => { onChange={(e) =>
onFilterChange("dateTo", e.target.value || null); onFilterChange("dateTo", e.target.value || null)
// Close native date popup on WebKitGTK (#177) }
e.currentTarget.blur();
}}
placeholder={t("transactions.filters.dateTo")} placeholder={t("transactions.filters.dateTo")}
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/> />

View file

@ -28,9 +28,10 @@ import {
listBalanceAccounts, listBalanceAccounts,
listBalanceCategories, listBalanceCategories,
getSnapshotByDate, getSnapshotByDate,
createSnapshot,
deleteSnapshot, deleteSnapshot,
listLinesBySnapshot, listLinesBySnapshot,
saveSnapshotAtomic, upsertSnapshotLines,
getPreviousSnapshot, getPreviousSnapshot,
BalanceServiceError, BalanceServiceError,
} from "../services/balance.service"; } from "../services/balance.service";
@ -411,32 +412,35 @@ export function useSnapshotEditor(options: Options = {}) {
}, [state.previousLines, state.accounts]); }, [state.previousLines, state.accounts]);
/** /**
* Persist the editor state to the database (#176 atomic). * Persist the editor state to the database.
* * - 'new' mode: create the snapshot row (UNIQUE per date), then upsert
* Order of operations: * its lines. If creation fails because a snapshot was created at this
* 1. Build & validate `simpleLines` and `pricedLines` arrays from * same date concurrently (snapshot_date_taken), the page is expected
* editor state. Any input parsing error throws BEFORE any DB * to redirect to edit mode.
* mutation happens, so an invalid form never produces an orphan * - 'edit' mode: upsert lines on the existing snapshot.
* 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 `saveSnapshotAtomic`. * by the rewrite-all strategy in `upsertSnapshotLines`.
*/ */
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 {
// Step 1 — build & validate every line in memory. THROW HERE means let snapshotId: number;
// no DB mutation has happened yet, so no orphan snapshot can be if (state.mode === "edit" && state.snapshot) {
// left behind by a validation failure (#176). snapshotId = state.snapshot.id;
} 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]) => {
@ -455,6 +459,7 @@ 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]) =>
@ -490,16 +495,7 @@ 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);
@ -516,6 +512,7 @@ export function useSnapshotEditor(options: Options = {}) {
state.snapshotDate, state.snapshotDate,
state.values, state.values,
state.pricedValues, state.pricedValues,
state.accounts,
loadForDate, loadForDate,
]); ]);

View file

@ -645,38 +645,6 @@
"revokeButton": "Revoke consent", "revokeButton": "Revoke consent",
"notPremium": "Premium licenses only" "notPremium": "Premium licenses only"
} }
},
"backToHome": "Back to settings",
"home": {
"intro": "Configure the app across three sections: users, data and system."
},
"users": {
"title": "Users",
"description": "Accounts, licenses and user guide.",
"sections": {
"accounts": "Accounts",
"licenses": "Licenses",
"userGuide": "User guide"
}
},
"data": {
"title": "Data",
"description": "Categories, backups and privacy.",
"sections": {
"categories": "Categories",
"backup": "Backup",
"priceFetch": "Price privacy"
}
},
"systems": {
"title": "System",
"description": "Version, updates, logs and history.",
"sections": {
"version": "Version",
"update": "Update",
"changelog": "Version history",
"logs": "Logs and feedback"
}
} }
}, },
"charts": { "charts": {
@ -1569,38 +1537,6 @@
"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",
@ -1707,8 +1643,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": "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.", "noAccounts": "You need to create at least one balance account first.",
"goToAccounts": "Create an account", "goToAccounts": "Go to accounts",
"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.",

View file

@ -645,38 +645,6 @@
"revokeButton": "Révoquer le consentement", "revokeButton": "Révoquer le consentement",
"notPremium": "Réservé aux licences premium" "notPremium": "Réservé aux licences premium"
} }
},
"backToHome": "Retour aux paramètres",
"home": {
"intro": "Configurez l'application en trois sections : utilisateurs, données et système."
},
"users": {
"title": "Utilisateurs",
"description": "Comptes, licences et guide d'utilisation.",
"sections": {
"accounts": "Comptes",
"licenses": "Licences",
"userGuide": "Guide d'utilisation"
}
},
"data": {
"title": "Données",
"description": "Catégories, sauvegardes et confidentialité.",
"sections": {
"categories": "Catégories",
"backup": "Sauvegarde",
"priceFetch": "Confidentialité des prix"
}
},
"systems": {
"title": "Système",
"description": "Version, mises à jour, journaux et historique.",
"sections": {
"version": "Version",
"update": "Mise à jour",
"changelog": "Historique des versions",
"logs": "Journaux et commentaires"
}
} }
}, },
"charts": { "charts": {
@ -1569,38 +1537,6 @@
"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",
@ -1707,8 +1643,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": "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.", "noAccounts": "Vous devez d'abord créer au moins un compte de bilan.",
"goToAccounts": "Créer un compte", "goToAccounts": "Aller aux comptes",
"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.",

View file

@ -27,14 +27,9 @@ 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"];
@ -56,48 +51,6 @@ 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 both the 4 starters AND
// the pref pre-seeded via consolidated_schema.sql, so they never hit this
// branch at all (S1 fix from #187).
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).
@ -173,30 +126,6 @@ export default function BalancePage() {
</div> </div>
)} )}
{/* Issue #178 empty-state guard. We probe accountsLatest for ANY
snapshot date so the guard is independent of the active period
filter (state.period). When empty, we render only the onboarding
card period selector, chart and accounts table would all show
empty states stacked under it (S2 from #187). */}
{(() => {
const accountsCount = state.accountsLatest.length;
const hasAnySnapshot = state.accountsLatest.some(
(a) => a.latest_snapshot_date != null
);
const isEmpty = accountsCount === 0 || !hasAnySnapshot;
if (isEmpty) {
return (
<div className="space-y-6">
<BalanceOnboardingCard
accountsCount={accountsCount}
snapshotsCount={hasAnySnapshot ? 1 : 0}
/>
</div>
);
}
return (
<div className="space-y-6"> <div className="space-y-6">
<BalanceOverviewCard totals={state.evolutionTotals} /> <BalanceOverviewCard totals={state.evolutionTotals} />
@ -269,15 +198,6 @@ export default function BalancePage() {
/> />
</div> </div>
</div> </div>
);
})()}
<StarterAccountsModal
isOpen={showStarterModal}
onClose={(ids) => {
void handleStarterModalClose(ids);
}}
/>
{linkTarget && ( {linkTarget && (
<LinkTransfersModal <LinkTransfersModal

View file

@ -207,7 +207,7 @@ export default function CategoriesMigrationPage() {
return ( return (
<div className="p-6 max-w-2xl mx-auto space-y-4"> <div className="p-6 max-w-2xl mx-auto space-y-4">
<Link <Link
to="/settings/data" to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
@ -229,7 +229,7 @@ export default function CategoriesMigrationPage() {
<div className="p-6 max-w-5xl mx-auto space-y-6"> <div className="p-6 max-w-5xl mx-auto space-y-6">
<div> <div>
<Link <Link
to="/settings/data" to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />
@ -527,7 +527,7 @@ function ErrorScreen({ errors, onRetry }: ErrorScreenProps) {
{t("categoriesSeed.migration.error.retry")} {t("categoriesSeed.migration.error.retry")}
</button> </button>
<Link <Link
to="/settings/data" to="/settings"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]" className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)]"
> >
{t("categoriesSeed.migration.error.backToSettings")} {t("categoriesSeed.migration.error.backToSettings")}

View file

@ -83,7 +83,7 @@ export default function CategoriesStandardGuidePage() {
{/* Back link (hidden in print) */} {/* Back link (hidden in print) */}
<div className="print:hidden"> <div className="print:hidden">
<Link <Link
to="/settings/data" to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors" className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
> >
<ArrowLeft size={16} /> <ArrowLeft size={16} />

View file

@ -1,5 +1,107 @@
import { Navigate } from "react-router-dom"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
interface ChangelogEntry {
version: string;
sections: { heading: string; items: string[] }[];
}
function parseChangelog(markdown: string): ChangelogEntry[] {
const entries: ChangelogEntry[] = [];
let current: ChangelogEntry | null = null;
let currentSection: { heading: string; items: string[] } | null = null;
for (const line of markdown.split("\n")) {
const trimmed = line.trim();
// Version heading: ## [0.6.0] or ## 0.6.0
const versionMatch = trimmed.match(/^## \[?([^\]]+)\]?/);
if (versionMatch) {
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
current = { version: versionMatch[1], sections: [] };
currentSection = null;
continue;
}
// Section heading: ### Added, ### Corrigé, etc.
const sectionMatch = trimmed.match(/^### (.+)/);
if (sectionMatch && current) {
if (currentSection) current.sections.push(currentSection);
currentSection = { heading: sectionMatch[1], items: [] };
continue;
}
// List item
if (trimmed.startsWith("- ") && currentSection) {
currentSection.items.push(trimmed.slice(2));
}
}
if (currentSection && current) current.sections.push(currentSection);
if (current) entries.push(current);
return entries;
}
export default function ChangelogPage() { export default function ChangelogPage() {
return <Navigate to="/settings/systems" replace />; const { t, i18n } = useTranslation();
const [entries, setEntries] = useState<ChangelogEntry[]>([]);
useEffect(() => {
const file = i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => setEntries(parseChangelog(text)))
.catch(() => setEntries([]));
}, [i18n.language]);
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Link
to="/settings"
className="p-1.5 rounded-lg hover:bg-[var(--muted)] transition-colors"
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("changelog.title")}</h1>
</div>
{entries.length === 0 ? (
<p className="text-[var(--muted-foreground)]">{t("changelog.empty")}</p>
) : (
<div className="space-y-6">
{entries.map((entry) => (
<div
key={entry.version}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-3"
>
<h2 className="text-lg font-semibold">{entry.version}</h2>
{entry.sections.map((section, si) => (
<div key={si} className="space-y-1.5">
<h3 className="text-sm font-semibold text-[var(--primary)]">
{section.heading}
</h3>
<ul className="space-y-1">
{section.items.map((item, ii) => (
<li
key={ii}
className="text-sm text-[var(--muted-foreground)] pl-3"
>
{"\u2022 "}
{item.replace(/\*\*(.+?)\*\*/g, "$1")}
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
)}
</div>
);
} }

View file

@ -1,5 +1,232 @@
import { Navigate } from "react-router-dom"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import {
Rocket,
LayoutDashboard,
Upload,
ArrowLeftRight,
Tags,
SlidersHorizontal,
PiggyBank,
BarChart3,
Wallet,
Settings,
ArrowLeft,
Lightbulb,
ListChecks,
Footprints,
Printer,
Users,
} from "lucide-react";
const SECTIONS = [
{ key: "gettingStarted", icon: Rocket },
{ key: "profiles", icon: Users },
{ key: "dashboard", icon: LayoutDashboard },
{ key: "import", icon: Upload },
{ key: "transactions", icon: ArrowLeftRight },
{ key: "categories", icon: Tags },
{ key: "adjustments", icon: SlidersHorizontal },
{ key: "budget", icon: PiggyBank },
{ key: "reports", icon: BarChart3 },
{ key: "balance", icon: Wallet },
{ key: "settings", icon: Settings },
] as const;
export default function DocsPage() { export default function DocsPage() {
return <Navigate to="/settings/users" replace />; const { t } = useTranslation();
const location = useLocation();
const [activeSection, setActiveSection] = useState<string>(SECTIONS[0].key);
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
const contentRef = useRef<HTMLDivElement>(null);
// Scroll spy via IntersectionObserver
useEffect(() => {
const container = contentRef.current;
if (!container) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveSection(entry.target.id);
}
}
},
{
root: container,
rootMargin: "-10% 0px -80% 0px",
threshold: 0,
}
);
for (const { key } of SECTIONS) {
const el = sectionRefs.current[key];
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, []);
// Handle initial anchor from URL
useEffect(() => {
const hash = location.hash.replace("#", "");
if (hash && sectionRefs.current[hash]) {
requestAnimationFrame(() => {
sectionRefs.current[hash]?.scrollIntoView({ behavior: "smooth" });
});
}
}, [location.hash]);
const scrollToSection = (key: string) => {
sectionRefs.current[key]?.scrollIntoView({ behavior: "smooth" });
};
return (
<div className="flex h-full overflow-hidden">
{/* Sidebar TOC */}
<nav className="w-56 shrink-0 border-r border-[var(--border)] p-4 overflow-y-auto">
<Link
to="/settings"
className="flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors mb-4"
>
<ArrowLeft size={14} />
{t("docs.backToSettings")}
</Link>
<h2 className="text-sm font-semibold text-[var(--muted-foreground)] uppercase tracking-wider mb-3">
{t("docs.title")}
</h2>
<ul className="space-y-1">
{SECTIONS.map(({ key, icon: Icon }) => (
<li key={key}>
<button
onClick={() => scrollToSection(key)}
className={`flex items-center gap-2 w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === key
? "bg-[var(--primary)] text-white font-medium"
: "text-[var(--muted-foreground)] hover:bg-[var(--border)] hover:text-[var(--foreground)]"
}`}
>
<Icon size={15} />
{t(`docs.${key}.title`)}
</button>
</li>
))}
</ul>
</nav>
{/* Scrollable content */}
<div ref={contentRef} className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("docs.title")}</h1>
<button
onClick={() => window.print()}
className="print:hidden flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
title={t("docs.print")}
>
<Printer size={16} />
{t("docs.print")}
</button>
</div>
{SECTIONS.map(({ key, icon: Icon }) => (
<section
key={key}
id={key}
ref={(el) => {
sectionRefs.current[key] = el;
}}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4"
>
{/* Section header */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<Icon size={20} />
</div>
<h2 className="text-lg font-semibold">
{t(`docs.${key}.title`)}
</h2>
</div>
{/* Overview */}
<p className="text-[var(--muted-foreground)]">
{t(`docs.${key}.overview`)}
</p>
{/* Features */}
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<ListChecks size={14} />
{t("docs.features")}
</h3>
<ul className="space-y-1">
{(
t(`docs.${key}.features`, {
returnObjects: true,
}) as string[]
).map((item, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm"
>
<span className="text-[var(--primary)] mt-0.5 shrink-0"></span>
{item}
</li>
))}
</ul>
</div>
{/* Steps */}
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<Footprints size={14} />
{key === "gettingStarted"
? t("docs.quickStart")
: t("docs.howTo")}
</h3>
<ol className="space-y-1 list-decimal list-inside">
{(
t(`docs.${key}.steps`, {
returnObjects: true,
}) as string[]
).map((item, i) => (
<li key={i} className="text-sm">
{item}
</li>
))}
</ol>
</div>
{/* Tips */}
<div className="bg-[var(--background)] rounded-lg p-4">
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)] mb-2">
<Lightbulb size={14} />
{t("docs.tipsHeader")}
</h3>
<ul className="space-y-1">
{(
t(`docs.${key}.tips`, {
returnObjects: true,
}) as string[]
).map((item, i) => (
<li
key={i}
className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]"
>
<Lightbulb size={13} className="text-[var(--primary)] mt-0.5 shrink-0" />
{item}
</li>
))}
</ul>
</div>
</section>
))}
</div>
</div>
</div>
);
} }

316
src/pages/SettingsPage.tsx Normal file
View file

@ -0,0 +1,316 @@
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
Info,
RefreshCw,
Download,
CheckCircle,
AlertCircle,
RotateCcw,
Loader2,
ShieldCheck,
BookOpen,
ChevronRight,
FileText,
} from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { useUpdater } from "../hooks/useUpdater";
import { Link } from "react-router-dom";
import { APP_NAME } from "../shared/constants";
import { PageHelp } from "../components/shared/PageHelp";
import DataManagementCard from "../components/settings/DataManagementCard";
import LicenseCard from "../components/settings/LicenseCard";
import AccountCard from "../components/settings/AccountCard";
import LogViewerCard from "../components/settings/LogViewerCard";
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
import CategoriesCard from "../components/settings/CategoriesCard";
import { PriceFetchConsentToggle } from "../components/settings/PriceFetchConsentToggle";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
const { state, checkForUpdate, downloadAndInstall, installAndRestart } =
useUpdater();
const [version, setVersion] = useState("");
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
const fetchReleaseNotes = useCallback(
(targetVersion: string) => {
const file =
i18n.language === "fr" ? "/CHANGELOG.fr.md" : "/CHANGELOG.md";
fetch(file)
.then((r) => r.text())
.then((text) => {
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(
`^## \\[?${escaped}\\]?.*$\\n([\\s\\S]*?)(?=^## |$(?!\\n))`,
"m",
);
const match = text.match(re);
setReleaseNotes(
match ? match[1].trim() : null,
);
})
.catch(() => setReleaseNotes(null));
},
[i18n.language],
);
useEffect(() => {
getVersion().then(setVersion);
}, []);
useEffect(() => {
if (state.status === "available" && state.version) {
fetchReleaseNotes(state.version);
}
}, [state.status, state.version, fetchReleaseNotes]);
const progressPercent =
state.contentLength && state.contentLength > 0
? Math.round((state.progress / state.contentLength) * 100)
: null;
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
<div className="relative flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
<PageHelp helpKey="settings" />
</div>
{/* License card */}
<LicenseCard />
{/* Account card */}
<AccountCard />
{/* Security banner renders only when OAuth tokens are in the
file fallback instead of the OS keychain */}
<TokenStoreFallbackBanner />
{/* About card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)] flex items-center justify-center text-white font-bold text-lg">
S
</div>
<div>
<h2 className="text-lg font-semibold">{APP_NAME}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.version", { version })}
</p>
</div>
</div>
</div>
{/* User guide card */}
<Link
to="/docs"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<BookOpen size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">{t("settings.userGuide.title")}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.userGuide.description")}
</p>
</div>
</div>
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
</div>
</Link>
{/* Categories card — entry to the standard categories guide */}
<CategoriesCard />
{/* Changelog card */}
<Link
to="/changelog"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<FileText size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">{t("changelog.title")}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("changelog.description")}
</p>
</div>
</div>
<ChevronRight size={18} className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors" />
</div>
</Link>
{/* Update card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Info size={18} />
{t("settings.updates.title")}
</h2>
{/* idle */}
{state.status === "idle" && (
<button
onClick={checkForUpdate}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<RefreshCw size={16} />
{t("settings.updates.checkButton")}
</button>
)}
{/* checking */}
{state.status === "checking" && (
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.checking")}
</div>
)}
{/* not entitled (free edition) */}
{state.status === "notEntitled" && (
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.updates.notEntitled")}</p>
</div>
)}
{/* up to date */}
{state.status === "upToDate" && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[var(--positive)]">
<CheckCircle size={16} />
{t("settings.updates.upToDate")}
</div>
<button
onClick={checkForUpdate}
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<RefreshCw size={14} />
</button>
</div>
)}
{/* available */}
{state.status === "available" && (
<div className="space-y-3">
<p>
{t("settings.updates.available", { version: state.version })}
</p>
{(() => {
const notes = releaseNotes || state.body;
if (!notes) return null;
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-[var(--foreground)]">
{t("settings.updates.releaseNotes")}
</h3>
<div className="max-h-48 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 text-sm text-[var(--muted-foreground)] space-y-1">
{notes.split("\n").map((line, i) => {
const trimmed = line.trim();
if (!trimmed) return <div key={i} className="h-2" />;
if (trimmed.startsWith("### "))
return <p key={i} className="font-semibold text-[var(--foreground)] mt-2">{trimmed.slice(4)}</p>;
if (trimmed.startsWith("## "))
return <p key={i} className="font-bold text-[var(--foreground)] mt-2">{trimmed.slice(3)}</p>;
if (trimmed.startsWith("- "))
return <p key={i} className="pl-3">{"\u2022 "}{trimmed.slice(2).replace(/\*\*(.+?)\*\*/g, "$1")}</p>;
return <p key={i}>{trimmed}</p>;
})}
</div>
</div>
);
})()}
<button
onClick={downloadAndInstall}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<Download size={16} />
{t("settings.updates.downloadButton")}
</button>
</div>
)}
{/* downloading */}
{state.status === "downloading" && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.downloading")}
{progressPercent !== null && <span>{progressPercent}%</span>}
</div>
<div className="w-full bg-[var(--border)] rounded-full h-2">
<div
className="bg-[var(--primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercent ?? 0}%` }}
/>
</div>
</div>
)}
{/* ready to install */}
{state.status === "readyToInstall" && (
<div className="space-y-3">
<p className="text-[var(--positive)]">
{t("settings.updates.readyToInstall")}
</p>
<button
onClick={installAndRestart}
className="flex items-center gap-2 px-4 py-2 bg-[var(--positive)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<RotateCcw size={16} />
{t("settings.updates.installButton")}
</button>
</div>
)}
{/* installing */}
{state.status === "installing" && (
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.installing")}
</div>
)}
{/* error */}
{state.status === "error" && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-[var(--negative)]">
<AlertCircle size={16} />
{t("settings.updates.error")}
</div>
<p className="text-sm text-[var(--muted-foreground)]">{state.error}</p>
<button
onClick={checkForUpdate}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
<RotateCcw size={16} />
{t("settings.updates.retryButton")}
</button>
</div>
)}
</div>
{/* Logs */}
<LogViewerCard />
{/* Data management */}
<DataManagementCard />
{/* Privacy — price fetching consent (premium only) */}
<PriceFetchConsentToggle />
{/* Data safety notice */}
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.dataSafeNotice")}</p>
</div>
</div>
);
}

View file

@ -155,10 +155,6 @@ export default function SnapshotEditPage() {
} else { } else {
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
} }
// WebKitGTK (Linux Tauri WebView) does not always dismiss the
// native date popup after a value commit — user has to hit
// Esc. Force-blur is a no-op on WebView2/WKWebView. See #177.
e.currentTarget.blur();
}} }}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60" className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60"
/> />

View file

@ -1,45 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import CategoriesCard from "../../components/settings/CategoriesCard";
import DataManagementCard from "../../components/settings/DataManagementCard";
import { PriceFetchConsentToggle } from "../../components/settings/PriceFetchConsentToggle";
export default function DataSettingsPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("settings.backToHome")}
</Link>
<h1 className="text-2xl font-bold">{t("settings.data.title")}</h1>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.data.sections.categories")}
</h2>
<CategoriesCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.data.sections.backup")}
</h2>
<DataManagementCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.data.sections.priceFetch")}
</h2>
<PriceFetchConsentToggle />
</section>
</div>
);
}

View file

@ -1,106 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Users, Database, Cpu, ChevronRight } from "lucide-react";
import { PageHelp } from "../../components/shared/PageHelp";
interface SectionCard {
to: string;
titleKey: string;
descriptionKey: string;
Icon: React.ComponentType<{ size?: number }>;
itemKeys: string[];
}
const SECTIONS: SectionCard[] = [
{
to: "/settings/users",
titleKey: "settings.users.title",
descriptionKey: "settings.users.description",
Icon: Users,
itemKeys: [
"settings.users.sections.accounts",
"settings.users.sections.licenses",
"settings.users.sections.userGuide",
],
},
{
to: "/settings/data",
titleKey: "settings.data.title",
descriptionKey: "settings.data.description",
Icon: Database,
itemKeys: [
"settings.data.sections.categories",
"settings.data.sections.backup",
"settings.data.sections.priceFetch",
],
},
{
to: "/settings/systems",
titleKey: "settings.systems.title",
descriptionKey: "settings.systems.description",
Icon: Cpu,
itemKeys: [
"settings.systems.sections.version",
"settings.systems.sections.update",
"settings.systems.sections.changelog",
"settings.systems.sections.logs",
],
},
];
export default function SettingsHomePage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="relative flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("settings.title")}</h1>
<PageHelp helpKey="settings" />
</div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.home.intro")}
</p>
<div className="space-y-4">
{SECTIONS.map(({ to, titleKey, descriptionKey, Icon, itemKeys }) => (
<Link
key={to}
to={to}
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)] shrink-0">
<Icon size={22} />
</div>
<div className="flex-1 space-y-2">
<div>
<h2 className="text-lg font-semibold">{t(titleKey)}</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t(descriptionKey)}
</p>
</div>
<ul className="flex flex-wrap gap-2 text-xs text-[var(--muted-foreground)]">
{itemKeys.map((key) => (
<li
key={key}
className="px-2 py-0.5 rounded-full bg-[var(--background)] border border-[var(--border)]"
>
{t(key)}
</li>
))}
</ul>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors shrink-0"
/>
</div>
</Link>
))}
</div>
</div>
);
}

View file

@ -1,11 +0,0 @@
import { Outlet } from "react-router-dom";
import TokenStoreFallbackBanner from "../../components/settings/TokenStoreFallbackBanner";
export default function SettingsLayout() {
return (
<div className="p-6 max-w-3xl mx-auto space-y-6">
<TokenStoreFallbackBanner />
<Outlet />
</div>
);
}

View file

@ -1,77 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft, ShieldCheck } from "lucide-react";
import { getVersion } from "@tauri-apps/api/app";
import { APP_NAME } from "../../shared/constants";
import UpdateCard from "../../components/settings/UpdateCard";
import ChangelogContent from "../../components/settings/ChangelogContent";
import LogViewerCard from "../../components/settings/LogViewerCard";
export default function SystemsSettingsPage() {
const { t } = useTranslation();
const [version, setVersion] = useState("");
useEffect(() => {
getVersion().then(setVersion);
}, []);
return (
<div className="space-y-6">
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("settings.backToHome")}
</Link>
<h1 className="text-2xl font-bold">{t("settings.systems.title")}</h1>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.version")}
</h2>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)] flex items-center justify-center text-white font-bold text-lg">
S
</div>
<div>
<h3 className="text-lg font-semibold">{APP_NAME}</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.version", { version })}
</p>
</div>
</div>
</div>
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.update")}
</h2>
<UpdateCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.changelog")}
</h2>
<ChangelogContent />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.systems.sections.logs")}
</h2>
<LogViewerCard />
</section>
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.dataSafeNotice")}</p>
</div>
</div>
);
}

View file

@ -1,45 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import AccountCard from "../../components/settings/AccountCard";
import LicenseCard from "../../components/settings/LicenseCard";
import DocsContent from "../../components/settings/DocsContent";
export default function UsersSettingsPage() {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Link
to="/settings"
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft size={16} />
{t("settings.backToHome")}
</Link>
<h1 className="text-2xl font-bold">{t("settings.users.title")}</h1>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.accounts")}
</h2>
<AccountCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.licenses")}
</h2>
<LicenseCard />
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{t("settings.users.sections.userGuide")}
</h2>
<DocsContent />
</section>
</div>
);
}

View file

@ -32,7 +32,6 @@ import {
deleteSnapshot, deleteSnapshot,
listLinesBySnapshot, listLinesBySnapshot,
upsertSnapshotLines, upsertSnapshotLines,
saveSnapshotAtomic,
getPreviousSnapshot, getPreviousSnapshot,
validateLineKindInvariants, validateLineKindInvariants,
PRICED_VALUE_TOLERANCE, PRICED_VALUE_TOLERANCE,
@ -909,153 +908,6 @@ 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)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -1205,14 +1057,8 @@ 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;
// Window function: ROW_NUMBER partitioned by account_id, earliest first. expect(sql).toContain("MIN(s.snapshot_date)");
expect(sql).toContain("ROW_NUMBER()"); expect(sql).toContain("GROUP BY l.account_id");
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"]);
}); });
@ -1229,57 +1075,6 @@ 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\)/);
});
}); });
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View file

@ -427,153 +427,6 @@ 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')
AND a.archived_at IS NULL`
);
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 SHOULD pre-filter `selectedKeys` against `getStarterCollisions()`
* to keep the UI honest, but each iteration ALSO re-checks for an existing
* (name, category) account inside the transaction and skips silently on a
* hit a defense-in-depth guard since the table has no UNIQUE constraint
* on (name, balance_category_id). Returned ids exclude any skipped starter.
*/
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`
);
}
// Defense-in-depth: re-check collision in-txn before INSERT so we
// never create a silent duplicate even if the upstream pre-filter
// raced or was bypassed (S3 from PR #185 review).
const existing = await db.select<{ count: number }[]>(
`SELECT COUNT(*) AS count FROM balance_accounts
WHERE name = $1 AND balance_category_id = $2
AND archived_at IS NULL`,
[starter.name, catRows[0].id]
);
if ((existing[0]?.count ?? 0) > 0) {
continue;
}
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)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -915,124 +768,6 @@ 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
@ -1249,12 +984,6 @@ 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) {
@ -1268,22 +997,18 @@ 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 account_id, `SELECT l.account_id AS account_id,
snapshot_date AS anchor_snapshot_date, MIN(s.snapshot_date) AS anchor_snapshot_date,
value AS anchor_value (SELECT l2.value
FROM ( FROM balance_snapshot_lines l2
SELECT l.account_id AS account_id, JOIN balance_snapshots s2 ON s2.id = l2.snapshot_id
s.snapshot_date AS snapshot_date, WHERE l2.account_id = l.account_id
l.value AS value, AND s2.snapshot_date = MIN(s.snapshot_date)
ROW_NUMBER() OVER ( LIMIT 1) AS anchor_value
PARTITION BY l.account_id
ORDER BY s.snapshot_date ASC
) AS rn
FROM balance_snapshot_lines l FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id JOIN balance_snapshots s ON s.id = l.snapshot_id
${where} ${where}
) GROUP BY l.account_id`,
WHERE rn = 1`,
params params
); );
} }