Compare commits

..

1 commit

Author SHA1 Message Date
d4625d9f46 Add previous year annual total column to budget table (#16)
Fetch previous year budget entries in parallel and display as a
read-only reference column between Category and Annual columns.
Parent/subtotal rows aggregate children's previous year values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:10:25 -04:00
262 changed files with 3563 additions and 42254 deletions

View file

@ -1,6 +0,0 @@
---
paths: ["src/**", "src-tauri/**"]
---
Tout changement affectant le comportement utilisateur doit avoir une entree sous `## [Unreleased]` dans `CHANGELOG.md`.
Categories : Added, Changed, Fixed, Removed. Format Keep a Changelog.
Le contenu est extrait par le CI pour les release notes et affiche dans l'app.

View file

@ -1,7 +0,0 @@
---
paths: ["**/*.tsx", "**/*.ts"]
---
Toute chaine visible par l'utilisateur doit passer par i18n (react-i18next).
Fichiers : `src/i18n/locales/fr.json` et `src/i18n/locales/en.json`.
Jamais de texte en dur dans les composants React.
Toujours ajouter la cle dans les DEUX langues.

View file

@ -1,7 +0,0 @@
---
paths: ["**/migrations/**", "**/*.sql", "**/lib.rs"]
---
Ne JAMAIS modifier une migration SQL existante. Toujours creer une nouvelle migration.
Les checksums sont verifies au demarrage (SHA-384 dans `_sqlx_migrations`).
Les migrations sont inline dans `lib.rs` via `tauri_plugin_sql::Migration`.
Le schema consolide (`consolidated_schema.sql`) sert uniquement pour les nouveaux profils.

View file

@ -1,50 +0,0 @@
---
name: release
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
user-invocable: true
updated: 2026-04-19
---
# /release — Release Simpl-Resultat
## Context injection
1. Lire version dans `src-tauri/Cargo.toml` et `package.json`
2. Lister les derniers tags : `git tag --sort=-v:refname | head -10`
3. Lire `CHANGELOG.md` et `CHANGELOG.fr.md` (dernieres entrees)
## Workflow
1. Determiner la nouvelle version (argument utilisateur ou demander)
2. Bump version dans les 5 fichiers :
- `src-tauri/Cargo.toml` (ligne `version = "..."`)
- `src-tauri/Cargo.lock` (bloc `[[package]] name = "simpl-result"` + sa ligne `version = "..."` ; ne PAS regenerer avec cargo)
- `src-tauri/tauri.conf.json` (champ `"version"`)
- `package.json` (champ `"version"`)
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
- `CHANGELOG.md` (EN)
- `CHANGELOG.fr.md` (FR)
- Pattern de migration : transformer `## [Unreleased]` en `## [X.Y.Z] - YYYY-MM-DD`, puis **recreer une section `## [Unreleased]` vide au-dessus** pour accueillir les prochaines entrees. Ne pas deplacer le contenu — les sections sont laissees en place.
4. Si changement d'architecture : mettre a jour `docs/architecture.md`
5. Commit : `chore: release vX.Y.Z` (ajouter les 7 fichiers : 5 bumps + 2 changelogs)
6. Tag annote (permet une release notes par tag, lisible via `git show vX.Y.Z`) :
```
git tag -a vX.Y.Z -m "Release X.Y.Z
- <bullet highlights>"
```
7. Push : `git push origin main && git push origin vX.Y.Z`
8. Forgejo CI build automatique (Windows + Linux) via `release.yml` sur `on: push: tags: v*`
## Regles
- **JAMAIS `git push --tags`** — toujours push le tag individuellement
- Toujours mettre a jour les **2 changelogs** (EN + FR)
- Format Keep a Changelog : `## [X.Y.Z] - YYYY-MM-DD`
- Les changelogs sont bundles dans `public/` pour l'affichage in-app
- Tag **annote** (`-a`), pas lightweight : les artefacts CI reference le tag pour les release notes
## Changelog
- 2026-04-19 — Added Cargo.lock + package-lock.json to bump list, `npm install --package-lock-only` fallback when lockfile stale, explicit `[Unreleased]` migration pattern, annotated tags (#102/#112 release cycle)

View file

@ -1,115 +0,0 @@
name: PR Check
# Validates Rust + frontend on every PR opened against main.
# Goal: catch compile errors, type errors, and failing tests BEFORE merge,
# instead of waiting for the release tag (which is when release.yml runs).
#
# Trigger is `pull_request` only — the previous `push` trigger duplicated
# every run when a branch was pushed and immediately opened as a PR (#171).
# Trade-off: branches pushed without an open PR don't get CI feedback. Open
# a draft PR if you want feedback before requesting review.
on:
pull_request:
branches:
- main
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
runs-on: ubuntu
container: ubuntu:22.04
env:
PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CARGO_TERM_COLOR: always
steps:
- name: Install system dependencies, Node.js and Rust
run: |
apt-get update
apt-get install -y --no-install-recommends \
curl wget git ca-certificates build-essential pkg-config \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \
libdbus-1-dev
# Node.js is required by actions/checkout and actions/cache (they
# are JavaScript actions and need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
node --version
rustc --version
cargo --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Cache cargo registry and git
uses: https://github.com/actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Cache cargo build target
uses: https://github.com/actions/cache@v4
with:
path: src-tauri/target
key: ${{ runner.os }}-cargo-target-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-target-
- name: cargo check
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
- name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
# Informational audit of transitive dependencies. Failure does not
# block the CI (advisories can appear on unrelated crates and stall
# unrelated work); surface them in the job log so we see them on
# every PR run and can react in a follow-up.
- name: cargo audit
continue-on-error: true
run: |
cargo install --locked cargo-audit || true
cargo audit --file src-tauri/Cargo.lock || true
frontend:
runs-on: ubuntu
container: ubuntu:22.04
steps:
- name: Install Node.js 20
run: |
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates git
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
node --version
npm --version
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Cache npm cache
uses: https://github.com/actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci
- name: Build (tsc + vite)
run: npm run build
- name: Tests (vitest)
run: npm test

View file

@ -31,7 +31,7 @@ jobs:
- name: Install Linux dependencies - name: Install Linux dependencies
run: | run: |
apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils libdbus-1-dev apt-get install -y build-essential libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf jq libssl-dev xdg-utils
- name: Install Windows cross-compile dependencies - name: Install Windows cross-compile dependencies
run: | run: |

View file

@ -1,73 +0,0 @@
name: PR Check
# Mirror of .forgejo/workflows/check.yml using GitHub-native runners.
# Forgejo is the primary host; this file keeps the GitHub mirror functional.
#
# Trigger is `pull_request` only — kept in sync with the Forgejo workflow
# after #171 dropped the redundant `push` trigger that duplicated every run.
on:
pull_request:
branches:
- main
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
runs-on: ubuntu-latest
env:
CARGO_TERM_COLOR: always
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev
- name: Install Rust toolchain (stable)
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: cargo check
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
- name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build (tsc + vite)
run: npm run build
- name: Tests (vitest)
run: npm test

22
.gitignore vendored
View file

@ -13,7 +13,6 @@ target/
# User data # User data
data/ data/
!src/data/
*.db *.db
*.db-journal *.db-journal
*.db-wal *.db-wal
@ -46,26 +45,5 @@ imports/*.csv
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Auto-generated changelogs (synced from root by vite.config.ts)
public/CHANGELOG.md
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/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees/
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,187 +2,8 @@
## [Non publié] ## [Non publié]
## [0.9.1] - 2026-05-10
### Ajouté ### Ajouté
- Budget : colonne du total annuel de l'année précédente comme base de référence (#16)
- 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
### Ajouté
- **Bilan — colonne `asset_type` sur les catégories cotées** (route `/balance/accounts`) : les catégories cotées portent maintenant un `asset_type` explicite (`stock` ou `crypto`) qui pilote le routage de PriceFetchControl vers le bon fournisseur, sans heuristique sur le symbole (ex : ETH = Ethan Allen NYSE *et* Ethereum crypto, deux symboles homonymes). La migration v10 ajoute une colonne nullable et backfille les deux catégories cotées seedées (`stock`, `crypto`) avec leur valeur respective ; les lignes cotées custom existantes restent NULL en attendant un futur écran d'édition pour qu'on les renseigne. Le formulaire de création de catégorie (onglet Catégories) affiche désormais un sélecteur de type d'actif quand `kind = priced` et refuse l'enregistrement tant qu'aucune valeur n'est choisie. L'éditeur de snapshot masque le bouton de récupération de prix sur les lignes cotées dont l'`asset_type` est encore NULL — la saisie manuelle reste l'unique chemin sur ces lignes legacy. (#169)
- **Bilan — documentation et ADRs** (`docs/`) : finalise le milestone Bilan avec la passe documentaire. `docs/architecture.md` répertorie désormais les 5 nouvelles tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`), les 7 nouveaux index, les invariants CHECK et FK (CAD seulement, invariants de type, `RESTRICT` sur `transaction_id` pour la reproductibilité Modified Dietz), le découpage 4 sections de `balance.service.ts` (CRUD / snapshots+lignes / rendements+transferts / prix), les 3 hooks scoped par page (`useBalanceAccounts`, `useSnapshotEditor`, `useBalanceOverview`), la commande Tauri `compute_account_return` (avec mention de la future commande `fetch_price` Phase 5), et les 3 nouvelles routes `/balance*`. Trois nouveaux ADRs accompagnent : **0008 — Modified Dietz** (justifie le choix vs ROI / TWR / IRR avec référence à `return_calculator.rs`) ; **0009 — Proxy price-fetching via maximus-api** (architecture documentée maintenant, implémentation BLOQUÉE en attendant la Phase 2 de maximus-api — couvre les considérations privacy comme le strip de headers, l'absence de corrélation `(symbole, licence)` dans les logs et le User-Agent fixe `simpl-resultat`, l'abstraction adapter Yahoo + CoinGecko, la stratégie d'auth Bearer, le rate-limiting client + serveur et le double gating premium UI + serveur) ; **0010 — FK RESTRICT sur `balance_account_transfers.transaction_id`** (justifie l'arbitrage intégrité vs friction pour la reproductibilité Modified Dietz). Le guide utilisateur gagne une nouvelle section *Bilan* qui détaille la saisie de snapshot (simple + coté), la liaison de transferts, la lecture des rendements multi-horizons (3M / 1A / depuis création avec colonne non-ajustée côte à côte), avec la mention « à venir Phase 5 » pour le price-fetching premium. Clés i18n `docs.balance.*` (FR + EN) ajoutées pour que le guide in-app reflète la nouvelle section (#145)
- **Bilan — suite de tests d'intégration cross-cutting** (infrastructure de tests) : clôt la feature *Bilan* avec une couche de tests d'intégration qui exerce toute la surface TypeScript en un seul flux de bout en bout (compte → catégorie cotée → snapshot coté → transfert lié → rendement) et des assertions dédiées sur le verrou de devise (CAD seulement au MVP, refusé à la fois côté service et côté CHECK SQL), la sécurité de tolérance pour le type coté (un mauvais enregistrement ne doit PAS supprimer les lignes existantes), le câblage de `computeAccountReturn` (résolution du profil actif, transmission des dates ISO, conservation telle quelle d'une réponse de période partielle). Trois nouveaux tests Rust d'intégration appliquent la migration v9 par-dessus un schéma v1 seedé contenant déjà des transactions pour vérifier (1) aucune perte ni mutation de données, (2) le round-trip lier / délier sur de vraies `transaction_id`, (3) la chaîne FK RESTRICT (suppression d'une transaction liée bloquée, autorisée après détachement), (4) la cohabitation indépendante des espaces d'identifiants `categories.id` (v1) et `balance_categories.id` (v9). Un test de non-régression au niveau source sur `TransactionTable.tsx` verrouille le contrat de l'icône de transfert inlinée : prop optionnelle, court-circuit en chaînage optionnel, clés i18n, aria-label, layout partagé de la cellule description — pour que la page reste rendue à l'identique en l'absence de transferts liés. (#144)
- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin V_début ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T t_i) / T`, et annualisation `(1 + R)^(365/T) 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
- **Bilan — page `/balance` avec graphique d'évolution et entrée sidebar** (route `/balance`) : quatrième tranche de la feature *Bilan*, qui la rend enfin accessible depuis la navigation. La nouvelle page compose (1) une carte d'aperçu avec la valeur nette agrégée du dernier snapshot, le Δ% par rapport au snapshot chronologiquement précédent (affiché « — » quand il n'existe qu'un seul snapshot), un avertissement de fraîcheur quand le dernier snapshot date de plus de 60 jours, et un CTA *Nouveau snapshot* qui pointe vers `/balance/snapshot` ; (2) un sélecteur de période (3 mois / 6 mois / 1 an / 3 ans / Tout) qui recharge toutes les séries en parallèle ; (3) un graphique d'évolution avec deux modes — *Ligne* (une seule série `SUM(value) GROUP BY snapshot_date`) et *Empilé par catégorie* (une `<Area stackId>` Recharts par `balance_categories.key`) ; (4) un tableau des comptes listant chaque compte actif avec sa dernière valeur snapshot, le Δ% par compte sur la période active (valeur la plus récente vs valeur du premier snapshot dans la fenêtre — null si pas d'ancrage, affiché « — »), et un menu d'actions (Détail désactivé en attendant la #142, Archiver). Les colonnes de rendement (3M / 1A / depuis création / non ajusté) sont réservées pour une version ultérieure avec un commentaire `TODO`. La sidebar expose désormais l'entrée *Bilan* (icône `Wallet`) entre *Rapports* et *Paramètres*. Le service gagne trois helpers de série temporelle : `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` ainsi qu'un calcul d'ancrage par compte `getAccountsPeriodAnchor(range)` — tous couverts par des tests unitaires. Nouveau hook `useBalanceOverview` (`useReducer` scoped) qui pilote l'état de la page. Nouvelles clés i18n sous `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
- **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)
- **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)
### 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)
### Corrigé
- **Rapport Zoom catégorie** (`/reports/category`) : la liste déroulante du combobox des catégories affiche désormais la liste complète dans un ordre hiérarchique DFS correct — chaque racine est émise avant ses descendants, et les frères et sœurs sont triés par `sort_order` puis nom affiché. Auparavant la liste était triée globalement par `sort_order` (via un `ORDER BY sort_order, name` SQL), ce qui entrelaçait des parents et enfants de sous-arbres différents partageant le même `sort_order`, d'où l'indentation incohérente et l'impression d'arbre cassé. La recherche filtrée (insensible aux accents) conserve le même comportement (#126)
## [0.8.4] - 2026-04-21
### Ajouté
- **Bannière (90 jours) et entrée permanente *Rétablir une sauvegarde* pour annuler une migration de catégories à partir de la sauvegarde automatique** (Paramètres → *Catégories*) : après une migration v2→v1, une bannière fermable (icône `ShieldCheck`) s'affiche désormais en haut de la carte Catégories pendant 90 jours et pointe vers la sauvegarde SREF automatique écrite par `categoryBackupService`. Une entrée dédiée *Rétablir une sauvegarde* reste accessible sous le lien de migration tant qu'une migration est enregistrée — même après les 90 jours — afin que le rétablissement ne soit jamais perdu. La fenêtre de confirmation lit le journal `last_categories_migration` pour récupérer son horodatage et son chemin de sauvegarde, impose une confirmation en deux étapes avec un bouton rouge *Rétablir*, bascule sur un sélecteur de fichier lorsque le chemin enregistré n'est plus sur disque, demande le NIP du profil lorsque le fichier SREF est chiffré, puis en cas de succès remet `categories_schema_version=v2`, inscrit `reverted_at` dans le journal et recharge l'application. La bannière se masque d'elle-même une fois la migration rétablie. Ajout de la commande Tauri `file_exists` pour la vérification préalable, nouveau service `categoryRestoreService` qui emballe `read_import_file` + `importTransactionsWithCategories` avec des codes d'erreur stables (#122)
- **Page de migration des catégories en 3 étapes** (route `/settings/categories/migrate`, Paramètres → *Migrer vers la structure standard*) : les profils v2 peuvent désormais choisir de migrer vers la nouvelle taxonomie v1 IPC via un parcours guidé — *Découvrir* (arbre en lecture seule, réutilisé de la page Guide), *Simuler* (table 3 colonnes en dry-run avec badges de confiance haute / moyenne / basse / à réviser, panneau latéral cliquable montrant les 50 premières transactions impactées par ligne, sélecteur de cible en ligne pour les lignes non résolues, bouton *Suivant* bloqué tant qu'une ligne n'est pas résolue) et *Consentir* (case à cocher + champ NIP pour les profils protégés + loader 4 étapes). Au clic de confirmation, la page crée une sauvegarde SREF vérifiée via `categoryBackupService` (obligatoire, abort sur échec sans écriture BD) puis lance une transaction SQL atomique via le nouveau service `categoryMigrationService.applyMigration(plan, backup)` — BEGIN → INSERT de la taxonomie v1 → UPDATE des transactions / budgets / budget_templates / keywords / suppliers vers les nouveaux id v1 → replacement des catégories personnalisées sous un nouveau parent *Catégories personnalisées (migration)* → désactivation des catégories v2 → pose de `categories_schema_version='v1'` et journalisation dans `user_preferences.last_categories_migration` → COMMIT. Toute erreur déclenche un ROLLBACK, laissant le profil dans son état pré-migration. Les écrans de succès et d'échec affichent le chemin de la sauvegarde et, pour le succès, le nombre de lignes insérées / transactions, mots-clés et budgets migrés (#121)
- **Bannière du tableau de bord invitant les profils v2 à découvrir la nouvelle taxonomie v1 IPC** : les profils existants (marqués `categories_schema_version='v2'`) voient désormais une bannière fermable en haut du tableau de bord qui pointe vers la nouvelle page Guide des catégories standard. La bannière est non destructive (CTA en lecture seule, aucun changement de catégories), ne s'affiche qu'aux profils v2 (les nouveaux profils semés en v1 ne la voient jamais), et sa fermeture est persistée dans `user_preferences` sous la clé `categories_v1_banner_dismissed` pour ne plus réapparaître (#118)
- **Page Guide des catégories standard** (Paramètres → *Structure standard des catégories*, route `/settings/categories/standard`) : nouvelle page en lecture seule qui expose la taxonomie v1 IPC complète sous forme d'arbre navigable avec repli/expansion par racine, un compteur global en direct (racines · sous-catégories · feuilles · total), une recherche plein texte insensible aux accents sur les noms traduits, des info-bulles au survol affichant la clé `i18n_key`, le type et l'identifiant de chaque nœud, et un bouton *Exporter en PDF* qui ouvre la boîte d'impression du navigateur. Une règle `@media print` dédiée force l'affichage complet de toutes les branches à l'impression, peu importe l'état de repli à l'écran. Tous les libellés passent par `categoriesSeed.*` avec `name` en repli pour les futures lignes personnalisées. Aucune écriture en base, aucune action destructive (#117)
- **Seed de catégories IPC pour les nouveaux profils** : les nouveaux profils sont désormais créés avec la taxonomie v1 IPC (Indice des prix à la consommation) — une hiérarchie alignée sur les catégories de Statistique Canada. Les noms des catégories du seed sont traduits dynamiquement depuis la clé i18n `categoriesSeed.*` (FR/EN), donc affichés dans la langue de l'utilisateur. Les profils existants gardent l'ancien seed v2, marqués via une nouvelle préférence `categories_schema_version` (une page de migration ultérieure offrira le passage v2→v1). Côté interne : colonne `categories.i18n_key` (nullable) ajoutée par la migration v8 (strictement additive), `src/data/categoryTaxonomyV1.json` livré comme source de vérité côté TS, les renderers `CategoryTree` et `CategoryCombobox` utilisent `name` en repli quand aucune clé de traduction n'est présente (catégories créées par l'utilisateur) (#115)
## [0.8.3] - 2026-04-19
### Ajouté
- **Rapport Cartes — Toggle Mensuel / Cumul annuel (YTD)** (`/reports/cartes`) : nouveau toggle segmenté à côté du sélecteur de mois de référence bascule les quatre cartes KPI (revenus, dépenses, solde net, taux d'épargne) entre la valeur du mois de référence (défaut inchangé) et une vue cumul annuel. En mode YTD, la valeur courante somme janvier → mois de référence, le delta MoM la compare à la fenêtre Jan → (mois 1) de la même année (null en janvier), le delta YoY la compare à Jan → mois de référence de l'année précédente, et le taux d'épargne utilise les revenus/dépenses YTD. La sparkline 13 mois, les top mouvements, la saisonnalité et l'adhésion budgétaire restent mensuels peu importe le toggle. L'info-bulle du taux d'épargne reflète maintenant le mode actif. Choix persisté dans `localStorage` (`reports-cartes-period-mode`) (#102)
- **Guide utilisateur — Section Cartes** : nouvelle section dédiée documentant les formules des quatre KPI, le toggle Mensuel/YTD, la sparkline, les top mouvements, les règles de saisonnalité et d'adhésion budgétaire, ainsi que le cas limite du taux d'épargne (« — » quand les revenus sont à zéro) (#102)
- **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101)
- **Rapport Tendances — Par catégorie** (`/reports/trends`) : nouveau toggle segmenté pour basculer le graphique d'évolution par catégorie entre les barres empilées (par défaut, inchangé) et une vue surface empilée Recharts (`<AreaChart stackId="1">`) qui montre la composition totale dans le temps. Les deux modes partagent la même palette de catégories et les mêmes patterns SVG en niveaux de gris. Le type choisi est mémorisé dans `localStorage` (`reports-trends-category-charttype`) (#105)
### Modifié
- **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `<select>` natif (#103)
- **Rapport Comparables — Réel vs réel** (`/reports/compare`) : le tableau reprend maintenant la structure riche à 8 colonnes du tableau Réel vs budget, en scindant chaque comparaison en un bloc *Mensuel* (mois de référence vs mois de comparaison) et un bloc *Cumulatif YTD* (progression jusqu'au mois de référence vs progression jusqu'à la fenêtre précédente). En mode MoM, le cumulatif précédent couvre janvier → fin du mois précédent de la même année ; en mode YoY, il couvre janvier → même mois de l'année précédente. Le graphique reste uniquement mensuel (#104)
- **Rapport Faits saillants** (`/reports/highlights`) : les tuiles mensuelles (solde du mois courant, top mouvements vs mois précédent) s'ouvrent désormais sur le **mois précédent** au lieu du mois courant, en cohérence avec les rapports Cartes et Comparables. La tuile Cumul annuel reste ancrée au 1er janvier de l'année civile en cours. Un nouveau sélecteur de mois de référence permet de faire pivoter le solde mensuel et la comparaison des top mouvements vers n'importe quel mois passé ; le choix est mémorisé dans l'URL via `?refY=YYYY&refM=MM` pour que la vue soit bookmarkable. Le panneau de faits saillants du hub suit la même valeur par défaut (#106)
### Corrigé
- **Rapport Cartes** : retrait du sélecteur de période non fonctionnel — le rapport Cartes est un instantané « mois X vs X-1 vs X-12 », seul le sélecteur de mois de référence est nécessaire (#101)
- **Rapport Cartes** : le KPI taux d'épargne affiche maintenant « — » au lieu de « 0 % » lorsque le mois de référence n'a aucun revenu (une division par zéro est indéfinie, pas zéro) (#101)
- **Rapport Cartes — adhésion budgétaire** : la carte affichait systématiquement « aucune catégorie avec budget ce mois-ci » même lorsque des budgets étaient définis sur les catégories de dépenses. Cause racine : les budgets de dépenses sont stockés signés négatifs et le filtre/la comparaison utilisaient les valeurs brutes au lieu des absolus. Le nombre de catégories, les catégories dans la cible et les montants de dépassement sont maintenant tous calculés sur les valeurs absolues (#112)
## [0.8.2] - 2026-04-17
### Ajouté
- **Widget Feedback Hub** (Paramètres → Journaux) : un bouton *Envoyer un feedback* dans la carte Journaux ouvre un dialogue pour soumettre suggestions, commentaires ou rapports de bogue vers le Feedback Hub central. Un dialogue de consentement (affiché une seule fois) explique que l'envoi atteint `feedback.lacompagniemaximus.com` — une exception explicite au fonctionnement 100 % local de l'app. Trois cases à cocher opt-in (toutes décochées par défaut) : inclure le contexte de navigation (page, thème, écran, version, OS), inclure les derniers logs d'erreur, m'identifier avec mon compte Maximus. L'envoi passe par une commande Rust côté backend, donc rien ne quitte la machine tant que l'utilisateur n'a pas cliqué *Envoyer* (#67)
- **Rapport Cartes** (`/reports/cartes`) : nouveau sous-rapport de type tableau de bord dans le hub Rapports. Combine quatre cartes KPI (Revenus, Dépenses, Solde net, Taux d'épargne) affichant les deltas MoM et YoY simultanément avec une sparkline 13 mois dont le mois de référence est mis en évidence, un graphique overlay revenus vs dépenses sur 12 mois (barres + ligne de solde net), le top 5 des catégories en hausse et en baisse par rapport au mois précédent, une carte d'adhérence au budget (N/M dans la cible plus les 3 pires dépassements avec barres de progression) et une carte de saisonnalité qui compare le mois de référence à la moyenne du même mois sur les deux années précédentes. Toutes les données proviennent d'un seul appel `getCartesSnapshot()` qui exécute ses requêtes en parallèle (#97)
### Modifié
- **Rapport Comparables** (`/reports/compare`) : passage de trois onglets (MoM / YoY / Budget) à deux modes (Réel vs réel / Réel vs budget). La vue « Réel vs réel » affiche désormais un sélecteur de mois de référence en en-tête (défaut : mois précédent), un sous-toggle MoM ↔ YoY, et un graphique en barres groupées côte-à-côte (deux barres par catégorie : période de référence vs période comparée). Le `PeriodSelector` d'URL reste synchronisé avec le sélecteur de mois (#96)
## [0.8.0] - 2026-04-14
### Ajouté
- **Hub des rapports** : `/reports` devient un hub affichant un panneau de faits saillants (solde mois courant + cumul annuel avec sparklines, top mouvements vs mois précédent, plus grosses transactions récentes) et quatre cartes de navigation vers des sous-rapports dédiés (#69#76)
- **Rapport Faits saillants** (`/reports/highlights`) : tuiles de solde avec sparklines 12 mois, tableau triable des top mouvements, graphique en barres divergentes, liste des grosses transactions avec fenêtre 30/60/90 jours (#71)
- **Rapport Tendances** (`/reports/trends`) : bascule interne entre flux global (revenus vs dépenses) et évolution par catégorie, toggle graphique/tableau sur les deux (#72)
- **Rapport Comparables** (`/reports/compare`) : barre d'onglets pour Mois vs Mois précédent, Année vs Année précédente, et Réel vs Budget ; graphique en barres divergentes centré sur zéro pour les deux premiers modes (#73)
- **Zoom catégorie** (`/reports/category`) : analyse ciblée avec donut chart de la répartition par sous-catégorie, graphique d'évolution mensuelle en aires, et tableau filtrable des transactions (#74)
- **Édition contextuelle des mots-clés** : clic droit sur n'importe quelle ligne de transaction pour ajouter sa description comme mot-clé de catégorisation ; un dialog de prévisualisation montre toutes les transactions qui seraient recatégorisées (limitées à 50, avec checkbox explicite pour les suivantes) avant validation. Disponible sur le zoom catégorie, la liste des faits saillants, et la page Transactions principale (#74, #75)
- **Période bookmarkable** : la période des rapports vit maintenant dans l'URL (`?from=YYYY-MM-DD&to=YYYY-MM-DD`), vous pouvez copier, coller et partager le lien en conservant l'état (#70)
- **Préférence chart/table** mémorisée dans `localStorage` par section de rapport
### Modifié
- Le hook monolithique `useReports` a été splitté en hooks par domaine (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) pour que chaque sous-rapport ne possède que l'état qu'il utilise (#70)
- Le menu contextuel (clic droit) des rapports est désormais un composant générique `ContextMenu` réutilisé par le menu des graphiques existant et le nouveau dialog d'ajout de mot-clé (#69)
### Supprimé
- Le tableau croisé dynamique a été retiré. Plus de 90 % de son usage réel consistait à zoomer sur une catégorie, ce que le nouveau rapport Zoom catégorie traite mieux. L'historique git préserve l'ancienne implémentation si jamais elle doit revenir (#69)
### Sécurité
- Le nouveau `AddKeywordDialog` impose une longueur de 2 à 64 caractères sur les mots-clés utilisateurs pour empêcher les attaques ReDoS sur de grands ensembles de transactions (CWE-1333), utilise des requêtes `LIKE` paramétrées pour la prévisualisation (CWE-89), encapsule l'INSERT + les UPDATE par transaction dans une transaction SQL BEGIN/COMMIT/ROLLBACK explicite (CWE-662), affiche toutes les descriptions non-sûres via rendu React enfants (CWE-79), et ne recatégorise que les lignes explicitement cochées par l'utilisateur — jamais rétroactivement. Le remplacement d'un mot-clé existant sur une autre catégorie nécessite une confirmation explicite (#74)
- `getCategoryZoom` parcourt l'arbre des catégories via une CTE récursive **bornée** (`WHERE depth < 5`), protégeant contre les cycles `parent_id` malformés (CWE-835) (#74)
## [0.7.4] - 2026-04-14
### Modifié
- Les tokens OAuth sont maintenant stockés dans le trousseau du système d'exploitation (Credential Manager sous Windows, Secret Service sous Linux) au lieu d'un fichier JSON en clair. Les utilisateurs existants sont migrés de façon transparente au prochain rafraîchissement de session ; l'ancien fichier est écrasé avec des zéros puis supprimé. Une bannière « tokens en stockage local » apparaît dans les Paramètres si le trousseau est indisponible (#66, #78, #79, #81)
- Le cache d'informations de compte est désormais signé par HMAC avec une clé stockée dans le trousseau : modifier manuellement le champ `subscription_status` dans `account.json` ne permet plus de contourner le gating Premium (#80)
- Hachage du PIN migré de SHA-256 vers Argon2id pour résistance au brute-force (CWE-916). Les PINs SHA-256 existants sont vérifiés de façon transparente et rehachés au prochain déverrouillage réussi ; les nouveaux PINs utilisent Argon2id (#54)
### Sécurité
- Correction de CWE-312 (stockage en clair des tokens OAuth), CWE-345 (absence de vérification d'intégrité du cache d'abonnement) et CWE-916 (hachage faible du PIN). Les anciens fichiers `tokens.json` et les caches `account.json` non signés sont rejetés par le chemin de gating jusqu'à ce que le prochain rafraîchissement rétablisse un anchor de confiance dans le trousseau (#66, #54)
## [0.7.3] - 2026-04-13
### Corrigé
- Connexion Compte Maximus : le callback deep-link utilise maintenant l'API canonique Tauri v2 `on_open_url`, donc le code d'autorisation parvient bien à l'app en cours d'exécution au lieu de laisser l'interface bloquée en « chargement » (#51, #65)
- Les callbacks OAuth contenant un paramètre `error` remontent maintenant l'erreur à l'interface au lieu d'être ignorés silencieusement (#51)
## [0.7.2] - 2026-04-13
### Modifié
- Les mises à jour automatiques sont temporairement ouvertes à l'édition Gratuite en attendant que le serveur de licences (issue #49) soit en ligne. Le gating sera restauré une fois l'activation payante fonctionnelle de bout en bout (#48)
## [0.7.1] - 2026-04-13
### Corrigé
- Connexion Compte Maximus : le callback OAuth2 revient maintenant correctement dans l'instance en cours au lieu de lancer une deuxième instance et de laisser l'app d'origine bloquée en « chargement » (#51, #65)
## [0.7.0] - 2026-04-11
### Ajouté
- CI : nouveau workflow `check.yml` qui exécute `cargo check`/`cargo test` et le build frontend sur chaque push de branche et PR, détectant les erreurs avant le merge plutôt qu'au moment de la release (#60)
- Carte de licence dans les Paramètres : affiche l'édition actuelle (Gratuite/Base/Premium), accepte une clé de licence et redirige vers la page d'achat (#47)
- Carte Compte Maximus dans les Paramètres : connexion optionnelle via OAuth2 PKCE pour les fonctionnalités Premium (#51)
- Activation de machines : activer/désactiver des machines via le serveur de licences, voir les machines activées dans la carte licence (#53)
- Vérification quotidienne de l'abonnement : rafraîchit automatiquement les infos du compte une fois par jour au lancement (#51)
### Modifié
- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48)
- La détection d'édition prend maintenant en compte l'abonnement Compte Maximus : Premium remplace Base quand l'abonnement est actif (#51)
## [0.6.7] - 2026-03-29
### Modifié
- Rapport Catégorie dans le temps : suppression du filtre codé en dur sur les dépenses, ajout d'un sélecteur de type avec dépense par défaut (#41)
- Rapport Catégorie dans le temps : ajout d'un filtre par type (dépense/revenu/transfert) dans le panneau de filtre à droite (#41)
### Corrigé
- Mise à jour de la dépendance picomatch (4.0.3 → 4.0.4) pour corriger des vulnérabilités de sévérité HIGH (#43)
## [0.6.6]
### Modifié
- Tableau de budget : la colonne année précédente affiche maintenant le réel (transactions) au lieu du budget planifié (#34)
- Refactorisation de `buildPrevYearTotalMap` en inline et simplification des tests (#39)
### Corrigé
- Fichiers changelog synchronisés automatiquement via plugin Vite, copies obsolètes dans public/ supprimées (#37)
## [0.6.5]
### Ajouté
- Tableau de bord : menu déroulant de sélection du mois pour la section Budget vs Réel avec le dernier mois complété par défaut (#31)
### Modifié
- Rapports et tableau de bord : police réduite dans le menu déroulant de mois pour un meilleur équilibre visuel (#31)
## [0.6.4]
### Ajouté
- Tableau de budget : colonne du total de l'année précédente affichée comme première colonne de données pour servir de référence (#16)
### Corrigé
- Tableau de bord : les catégories de niveau 4+ apparaissent maintenant sous leur parent au lieu du bas de la section (#23)
- Tableau de bord : la hiérarchie de catégories supporte maintenant une profondeur de niveaux arbitraire (#23)
### Modifié
- Tableau de bord : le graphique circulaire prend 1/3 de la largeur au lieu de 1/2, donnant plus d'espace au tableau budget (#23)
- Tableau de bord : les étiquettes du graphique circulaire s'affichent uniquement au survol via le tooltip (#23)
- Budget vs Réel : la colonne des catégories reste désormais fixe lors du défilement horizontal (#29)
- Budget vs Réel : titre changé pour « Budget vs Réel pour le mois de [mois] » avec un menu déroulant pour sélectionner le mois (#29)
- Budget vs Réel : le mois par défaut est maintenant le dernier mois complété au lieu du mois courant (#29)
## [0.6.3] ## [0.6.3]

View file

@ -2,187 +2,8 @@
## [Unreleased] ## [Unreleased]
## [0.9.1] - 2026-05-10
### Added ### Added
- Budget: previous year annual total column as baseline reference (#16)
- 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
### Added
- **Balance sheet — `asset_type` column on priced categories** (route `/balance/accounts`): priced balance categories now carry an explicit `asset_type` (`stock` or `crypto`) that drives PriceFetchControl provider routing without relying on symbol heuristics (e.g. ETH = Ethan Allen NYSE *and* Ethereum crypto are no longer ambiguous). Migration v10 adds a nullable column and backfills the two seeded priced categories (`stock`, `crypto`) with their matching values; legacy custom priced rows stay NULL until a future edit-category UI lets the user fill them in. The category creation form (Categories tab) now shows an asset-type selector when `kind = priced` and rejects submission until a value is picked. The snapshot editor hides the price-fetch button on priced rows whose `asset_type` is still NULL — manual entry remains the only path on those legacy rows. (#169)
- **Balance sheet — documentation and ADRs** (`docs/`): closes the Bilan milestone with the documentation pass. `docs/architecture.md` now lists the 5 new tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`), the 7 new indexes, the SQL CHECK and FK invariants (CAD-only, kind invariants, `RESTRICT` on `transaction_id` for Modified Dietz reproducibility), the `balance.service.ts` 4-section layout (CRUD / snapshots+lines / returns+transfers / prices), the 3 page-scoped hooks (`useBalanceAccounts`, `useSnapshotEditor`, `useBalanceOverview`), the `compute_account_return` Tauri command (with the `fetch_price` future-Phase-5 mention), and the 3 new `/balance*` routes. Three new ADRs land alongside: **0008 — Modified Dietz** (justifies the choice vs. ROI / TWR / IRR with reference to `return_calculator.rs`); **0009 — Proxy price-fetching via maximus-api** (architecture documented now, implementation stays BLOCKED by maximus-api Phase 2 — covers privacy considerations like header stripping, no `(symbol, license)` log correlation and the fixed `simpl-resultat` UA, the Yahoo + CoinGecko provider abstraction, the Bearer auth strategy, the client + server rate limiting and the dual-side premium gating); **0010 — FK RESTRICT on `balance_account_transfers.transaction_id`** (justifies the integrity over friction trade-off for Modified Dietz reproducibility). The user guide gains a new *Balance sheet* section walking through snapshot entry (simple + priced), transfer linking, multi-horizon return reading (3M / 1Y / since inception with the side-by-side unadjusted column), with the price-fetching premium flagged "coming in Phase 5". `docs.balance.*` i18n keys (FR + EN) ship so the in-app guide reflects the new section (#145)
- **Balance sheet — cross-cutting integration test suite** (test infrastructure): closes out the *Bilan* feature with a layer of integration tests that exercise the whole TypeScript surface in a single happy-path flow (account → priced category → priced snapshot → linked transfer → return) plus dedicated assertions for currency lock (CAD-only at the MVP, rejected at both the service layer and SQL CHECK), priced-kind tolerance safety (a bad save must NOT clear pre-existing lines), `computeAccountReturn` wiring (active-profile resolution, ISO date forwarding, partial-period payload pass-through). Three new Rust integration tests apply migration v9 on top of a seeded v1 schema with pre-existing transactions to verify (1) no row loss / data mutation, (2) link / unlink transfer round-trip on real transaction ids, (3) the FK RESTRICT chain (linked transaction deletion blocked, unblocked after unlink), (4) the v1 `categories.id` and v9 `balance_categories.id` namespaces coexist independently. A non-regression source-level test on `TransactionTable.tsx` locks down the inlined transfer icon contract: optional prop, optional-chaining short-circuit, i18n keys, aria-label, shared description-cell layout — so the page renders identically when no transfers are linked. (#144)
- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end V_start ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T t_i) / T`, plus `(1 + R)^(365/T) 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)
- **Balance sheet — `/balance` overview page, evolution chart and sidebar entry** (route `/balance`): fourth slice of the *Bilan* feature finally surfaces it in the navigation. The new page composes (1) an overview card with the latest aggregate net worth, the Δ% versus the previous chronological snapshot (rendered as "—" when only one snapshot exists), a 60-day staleness warning when the latest snapshot is older than that threshold, and a *New snapshot* CTA pointing at `/balance/snapshot`; (2) a period selector (3 months / 6 months / 1 year / 3 years / All) that re-fetches every series in parallel; (3) an evolution chart with two modes — *Line* (single series of `SUM(value) GROUP BY snapshot_date`) and *Stacked by category* (one Recharts `<Area stackId>` per `balance_categories.key`); (4) an accounts table listing every active account with its latest snapshot value, the per-account Δ% over the active period (latest value vs the value at the earliest snapshot inside the window — null when no anchor exists, rendered as "—"), and an actions menu (Details placeholder, Archive). Return-metric columns (3M / 1Y / since-creation / unadjusted) are reserved for a later release with a `TODO` marker. The sidebar now exposes the *Balance sheet* entry (`Wallet` icon) between *Reports* and *Settings*. The service grows three time-series helpers: `getSnapshotTotalsByDate(range?)`, `getSnapshotTotalsByCategoryAndDate(range?)`, `getAccountsLatestSnapshot()` and a per-account anchor query `getAccountsPeriodAnchor(range)` — all guarded by unit tests. New `useBalanceOverview` hook (scoped `useReducer`) drives the page state. New i18n keys under `balance.overview.*`, `balance.period.*`, `balance.chart.*` plus `nav.balance` (FR + EN) (#141)
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
- **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)
- **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)
### 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)
### Fixed
- **Category zoom report** (`/reports/category`): the category combobox dropdown now renders the full list in proper hierarchical DFS order — each root is emitted before its descendants, with siblings sorted by `sort_order` then display name. Previously the list was ordered by `sort_order` globally (from a SQL `ORDER BY sort_order, name`), which interleaved parents and children from different sub-trees that shared the same `sort_order`, producing scrambled indentation and a mis-leading tree. Filtering (accent-insensitive search) still behaves identically (#126)
## [0.8.4] - 2026-04-21
### Added
- **Settings banner (90-day) and permanent Restore action to roll back a category migration from the automatic pre-migration backup** (Settings → *Categories*): after a v2→v1 migration, a dismissable banner (`ShieldCheck` icon) now appears at the top of the Categories card for 90 days, pointing at the automatic SREF backup written by `categoryBackupService`. A dedicated *Restore a backup* entry stays available below the migrate link as long as a migration is recorded — even past the 90-day window — so the rollback is never lost. The confirm modal reads the `last_categories_migration` journal for its timestamp and backup path, enforces a two-step confirmation with a red *Restore* button, falls back to a file picker when the recorded path is missing on disk, prompts for the profile PIN when the SREF file is encrypted, and on success resets `categories_schema_version=v2` and stamps `reverted_at` on the journal before reloading the app. The banner hides automatically once the migration has been reverted. New Tauri command `file_exists` for the pre-flight presence check, new `categoryRestoreService` wrapping `read_import_file` + `importTransactionsWithCategories` with stable error codes (#122)
- **3-step category migration page** (route `/settings/categories/migrate`, Settings → *Migrate to the standard structure*): legacy v2 profiles can now opt in to migrate to the new v1 IPC taxonomy through a guided flow — *Discover* (read-only tree reused from the guide page), *Simulate* (3-column dry-run table with high / medium / low / needs-review confidence badges, a clickable side panel showing the first 50 affected transactions per row, inline target picker for unresolved rows, next button blocked until every row is resolved), and *Consent* (checklist + optional PIN field for protected profiles + 4-step loader). On confirm, the page creates a verified SREF backup via `categoryBackupService` (mandatory, abort on failure with no DB write) and then runs an atomic SQL transaction via the new `categoryMigrationService.applyMigration(plan, backup)` — BEGIN → INSERT v1 taxonomy → UPDATE transactions / budgets / budget_templates / keywords / suppliers to the new v1 category ids → reparent custom categories under a new *Custom categories (migration)* parent → soft-deactivate the v2 seed categories → bump `categories_schema_version='v1'` and journal the run in `user_preferences.last_categories_migration` → COMMIT. Any thrown error triggers ROLLBACK so the profile stays in its pre-migration state. Success and error screens surface the backup path and (for success) the counts of rows inserted / transactions, keywords and budgets migrated (#121)
- **Dashboard banner inviting v2 profiles to discover the new v1 IPC category taxonomy**: legacy profiles (tagged `categories_schema_version='v2'`) now see a dismissable banner at the top of the Dashboard pointing to the new standard categories guide page. The banner is non-destructive (read-only CTA, no category changes), only shown to v2 profiles (new v1-seeded profiles never see it), and its dismissal is persisted in `user_preferences` under `categories_v1_banner_dismissed` so it never reappears once closed (#118)
- **Standard categories guide page** (Settings → *Standard category structure*, route `/settings/categories/standard`): new read-only page that exposes the full v1 IPC taxonomy as a navigable tree with expand/collapse per root, a live category counter (roots · subcategories · leaves · total), accent-insensitive full-text search over translated names, hover tooltips showing the `i18n_key` / type / ID of each node, and a *Export as PDF* button that triggers the browser print dialog. A dedicated `@media print` rule forces every branch to render fully expanded regardless of the on-screen collapse state. All labels resolve via `categoriesSeed.*` with `name` as fallback for future custom rows. No database writes, no destructive actions (#117)
- **IPC-aligned categories seed for new profiles**: brand-new profiles are now seeded with the v1 IPC (Indice des prix à la consommation) taxonomy — a structured hierarchy aligned with Statistics Canada consumer price index categories. Category labels are now translated dynamically from the `categoriesSeed.*` i18n namespace (FR/EN), so seed categories display in the user's current language. Existing profiles remain on the legacy v2 seed, marked via a new `categories_schema_version` user preference (a later migration wizard will offer the v2→v1 transition). Internally: nullable `categories.i18n_key` column added in migration v8 (additive only), `src/data/categoryTaxonomyV1.json` bundled as the TS-side source of truth, `CategoryTree` and `CategoryCombobox` renderers fall back to the raw `name` when no translation key is present (user-created rows) (#115)
## [0.8.3] - 2026-04-19
### Added
- **Cartes report — Monthly / YTD toggle** (`/reports/cartes`): new segmented toggle next to the reference-month picker flips the four KPI cards (income, expenses, net balance, savings rate) between the reference-month value (unchanged default) and a Year-to-Date cumulative view. In YTD mode, the "current" value sums January → reference month, MoM delta compares it to the same-year Jan → (refMonth 1) window (null for January), YoY delta compares it to Jan → refMonth of the previous year, and the savings rate uses the YTD income/expenses. The 13-month sparkline, top movers, seasonality and budget adherence cards remain monthly regardless of the toggle. The savings-rate tooltip now reflects the active mode. Choice persisted in `localStorage` (`reports-cartes-period-mode`) (#102)
- **User guide — Cartes section**: new dedicated section documenting the four KPI formulas, the Monthly/YTD toggle, the sparkline, top movers, seasonality and budget adherence rules, along with the savings-rate edge case ("—" when income is zero) (#102)
- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income expenses) ÷ income × 100`, computed on the reference month (#101)
- **Trends report — by category** (`/reports/trends`): new segmented toggle to switch the category-evolution chart between stacked bars (default, unchanged) and a Recharts stacked-area view (`<AreaChart stackId="1">`) that shows total composition over time. Both modes share the same category palette and SVG grayscale patterns. The chosen type is persisted in `localStorage` (`reports-trends-category-charttype`) (#105)
### Changed
- **Category zoom report** (`/reports/category`): the category picker is now a typeable, searchable combobox with accent-insensitive matching, keyboard navigation (↑/↓/Enter/Esc) and hierarchy indentation, replacing the native `<select>` (#103)
- **Compare report — Actual vs. actual** (`/reports/compare`): the table now mirrors the rich 8-column structure of the Actual vs. budget table, splitting each comparison into a *Monthly* block (reference month vs. comparison month) and a *Cumulative YTD* block (progress through the reference month vs. progress through the previous window). MoM cumulative-previous uses Jan → end-of-previous-month of the same year; YoY cumulative-previous uses Jan → same-month of the previous year. The chart remains a monthly-only view (#104)
- **Highlights report** (`/reports/highlights`): the monthly tiles (current-month balance, top movers vs. previous month) now default to the **previous calendar month** instead of the current one, matching the Cartes and Compare reports. The YTD tile stays pinned to Jan 1st of the current civil year. A new reference-month picker lets you pivot both the monthly balance and the top-movers comparison to any past month; the selection is persisted in the URL via `?refY=YYYY&refM=MM` so the view is bookmarkable. The hub highlights panel follows the same default (#106)
### Fixed
- **Cartes report**: removed the non-functional period selector — the Cartes report is a "month X vs X-1 vs X-12" snapshot, so only the reference-month picker is needed (#101)
- **Cartes report**: savings-rate KPI now shows "—" instead of "0 %" when the reference month has no income (division by zero is undefined, not zero) (#101)
- **Cartes report — budget adherence**: the card was always saying "no budgeted categories this month" even when budgets were defined on expense categories. Root cause: expense budgets are stored signed-negative, and the filter/comparison used raw values instead of absolutes. Categories, in-target counts, and worst-overrun amounts are now all computed on absolute values (#112)
## [0.8.2] - 2026-04-17
### Added
- **Feedback Hub widget** (Settings → Logs): a *Send feedback* button in the Logs card opens a dialog to submit suggestions, comments, or bug reports to the central Feedback Hub. A one-time consent prompt explains that submission reaches `feedback.lacompagniemaximus.com` — an explicit exception to the app's 100% local operation. Three opt-in checkboxes (all unchecked by default): include navigation context (page, theme, viewport, app version, OS), include recent error logs, identify with your Maximus account. Routed through a Rust-side command so nothing is sent unless you press *Send* (#67)
- **Cartes report** (`/reports/cartes`): new dashboard-style sub-report in the Reports hub. Combines four KPI cards (income, expenses, net balance, savings rate) showing MoM and YoY deltas simultaneously with a 13-month sparkline highlighting the reference month, a 12-month income vs. expenses overlay chart (bars + net balance line), top 5 category increases and top 5 decreases vs. the previous month, a budget-adherence card (N/M on-target plus the three worst overruns with progress bars), and a seasonality card that compares the reference month against the same calendar month from the two previous years. All data comes from a single `getCartesSnapshot()` service call that runs its queries concurrently (#97)
### Changed
- **Compare report** (`/reports/compare`): reduced from three tabs (MoM / YoY / Budget) to two modes (Actual vs. actual / Actual vs. budget). The actual-vs-actual view now has an explicit reference-month dropdown in the header (defaults to the previous month), a MoM ↔ YoY sub-toggle, and a grouped side-by-side bar chart (two bars per category: reference period vs. comparison period). The URL `PeriodSelector` stays in sync with the reference month picker (#96)
## [0.8.0] - 2026-04-14
### Added
- **Reports hub**: `/reports` is now a hub surfacing a live highlights panel (current month + YTD net balance with sparklines, top movers vs. last month, top recent transactions) and four navigation cards to dedicated sub-reports (#69#76)
- **Highlights report** (`/reports/highlights`): balance tiles with 12-month sparklines, sortable top movers table, diverging bar chart, recent transactions list with 30/60/90 day window toggle (#71)
- **Trends report** (`/reports/trends`): internal sub-view toggle between global flow (income vs. expenses) and by-category evolution, chart/table toggle on both (#72)
- **Compare report** (`/reports/compare`): tab bar for Month vs. Previous Month, Year vs. Previous Year, and Actual vs. Budget; diverging bar chart centered on zero for the first two modes (#73)
- **Category zoom** (`/reports/category`): single-category drill-down with donut chart of subcategory breakdown, monthly evolution area chart, and filterable transactions table (#74)
- **Contextual keyword editing**: right-click any transaction row to add its description as a categorization keyword; a preview dialog shows every transaction that would be recategorized (capped at 50, with an opt-in checkbox for N+) before you confirm. Available on the category zoom, the highlights list, and the main transactions page (#74, #75)
- **Bookmarkable period**: the reports period now lives in the URL (`?from=YYYY-MM-DD&to=YYYY-MM-DD`), so you can copy, paste, and share the link and keep the same state (#70)
- **View mode preference** (chart vs. table) is now persisted in `localStorage` per report section
### Changed
- The legacy monolithic `useReports` hook has been split into per-domain hooks (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) so every sub-report owns only the state it needs (#70)
- Context menu on reports (right-click) is now a generic `ContextMenu` shell reused by the existing chart menu and the new keyword dialog (#69)
### Removed
- The dynamic pivot table report was removed. Over 90% of its real usage was zooming into a single category, which is better served by the new Category Zoom report. Git history preserves the old implementation if it ever needs to come back (#69)
### Security
- New `AddKeywordDialog` enforces a 264 character length bound on user keywords to prevent ReDoS on large transaction sets (CWE-1333), uses parameterized `LIKE` queries for the preview (CWE-89), wraps its INSERT + per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK transaction (CWE-662), renders all untrusted descriptions as React children (CWE-79), and recategorizes only the rows the user explicitly checked — never retroactively. Keyword reassignment across categories requires an explicit confirmation step (#74)
- `getCategoryZoom` walks the category tree through a **bounded** recursive CTE (`WHERE depth < 5`), protecting against malformed `parent_id` cycles (CWE-835) (#74)
## [0.7.4] - 2026-04-14
### Changed
- OAuth tokens are now stored in the OS keychain (Credential Manager on Windows, Secret Service on Linux) instead of a plaintext JSON file. Existing users are migrated transparently on the next sign-in refresh; the old file is zeroed and removed. A "tokens stored in plaintext fallback" banner appears in Settings if the keychain is unavailable (#66, #78, #79, #81)
- Cached account info is now HMAC-signed with a keychain-stored key: writing `subscription_status` to `account.json` manually can no longer bypass the Premium gate (#80)
- PIN hashing migrated from SHA-256 to Argon2id for brute-force resistance (CWE-916). Existing SHA-256 PINs are verified transparently and rehashed on next successful unlock; new PINs use Argon2id (#54)
### Security
- Closed CWE-312 (cleartext storage of OAuth tokens), CWE-345 (missing integrity check on the subscription cache), and CWE-916 (weak PIN hashing). Legacy `tokens.json` and legacy unsigned `account.json` caches are rejected by the gating path until the next token refresh re-establishes a keychain-anchored trust (#66, #54)
## [0.7.3] - 2026-04-13
### Fixed
- Maximus Account sign-in: the deep-link callback now uses the canonical Tauri v2 `on_open_url` API, so the auth code is properly received by the running app instead of leaving the UI stuck in "loading" (#51, #65)
- OAuth callbacks containing an `error` parameter now surface the error to the UI instead of being silently ignored (#51)
## [0.7.2] - 2026-04-13
### Changed
- Auto-updates are temporarily open to the Free edition until the license server (issue #49) is live. Gating will be restored once paid activation works end-to-end (#48)
## [0.7.1] - 2026-04-13
### Fixed
- Maximus Account sign-in: the OAuth2 callback now correctly returns to the running app instead of launching a second instance and leaving the original one stuck in "loading" (#51, #65)
## [0.7.0] - 2026-04-11
### Added
- CI: new `check.yml` workflow runs `cargo check`/`cargo test` and the frontend build on every branch push and PR, catching errors before merge instead of waiting for the release tag (#60)
- License card in Settings page: shows the current edition (Free/Base/Premium), accepts a license key, and links to the purchase page (#47)
- Maximus Account card in Settings: optional sign-in via OAuth2 PKCE for Premium features (#51)
- Machine activation: activate/deactivate machines against the license server, view activated machines in the license card (#53)
- Daily subscription status check: automatically refreshes account info once per day at launch (#51)
### Changed
- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48)
- Edition detection now considers Maximus Account subscription: Premium overrides Base when subscription is active (#51)
## [0.6.7] - 2026-03-29
### Changed
- Category Over Time report: removed hard-coded expense-only filter, added type selector defaulting to expense (#41)
- Category Over Time report: added type filter (expense/income/transfer) in the right filter panel (#41)
### Fixed
- Updated picomatch dependency (4.0.3 → 4.0.4) to fix HIGH severity vulnerabilities (#43)
## [0.6.6]
### Changed
- Budget table: previous year column now shows actual transactions instead of planned budget (#34)
- Refactored `buildPrevYearTotalMap` inline and simplified tests (#39)
### Fixed
- Changelog files synced automatically via Vite plugin, removed stale public/ copies (#37)
## [0.6.5]
### Added
- Dashboard: month dropdown selector for the Budget vs Actual section with last completed month as default (#31)
### Changed
- Reports & Dashboard: reduced font size of month dropdown for better visual balance (#31)
## [0.6.4]
### Added
- Budget table: previous year total column displayed as first data column for baseline reference (#16)
### Fixed
- Dashboard: level 4+ categories now appear under their parent instead of at the bottom of the section (#23)
- Dashboard: category hierarchy now supports arbitrary nesting depth (#23)
### Changed
- Dashboard: pie chart takes 1/3 width instead of 1/2, giving more space to the budget table (#23)
- Dashboard: pie chart labels now shown only on hover via tooltip instead of permanent legend (#23)
- Budget vs Actual: category column now stays fixed when scrolling horizontally (#29)
- Budget vs Actual: title changed to "Budget vs Réel pour le mois de [month]" with a dropdown month selector (#29)
- Budget vs Actual: default month is now the last completed month instead of current month (#29)
## [0.6.3] ## [0.6.3]

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.
@ -51,7 +49,7 @@ src/
│ ├── shared/ # Composants réutilisables │ ├── shared/ # Composants réutilisables
│ └── transactions/ # Transactions │ └── transactions/ # Transactions
├── contexts/ # ProfileContext (état global profil) ├── contexts/ # ProfileContext (état global profil)
├── hooks/ # 13 hooks custom (useReducer) ├── hooks/ # 12 hooks custom (useReducer)
├── pages/ # 11 pages ├── pages/ # 11 pages
├── services/ # 14 services métier ├── services/ # 14 services métier
├── shared/ # Types et constantes partagés ├── shared/ # Types et constantes partagés
@ -143,7 +141,7 @@ La documentation technique est centralisée dans `docs/` :
- `CHANGELOG.fr.md` (français) — traduction - `CHANGELOG.fr.md` (français) — traduction
- Catégories : Added/Ajouté, Changed/Modifié, Fixed/Corrigé, Removed/Supprimé - Catégories : Added/Ajouté, Changed/Modifié, Fixed/Corrigé, Removed/Supprimé
- Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes et affiché dans l'app selon la langue de l'utilisateur. - Format [Keep a Changelog](https://keepachangelog.com/). Le contenu est extrait automatiquement par le CI pour les release notes et affiché dans l'app selon la langue de l'utilisateur.
- The `public/` copies are synced automatically: Vite copies them on `dev`/`build` start via `syncChangelogs()` in `vite.config.ts`. No manual sync needed. - Les deux fichiers sont copiés dans `public/` par le CI (`release.yml`). En dev, synchroniser manuellement : `cp CHANGELOG*.md public/`
--- ---
@ -159,8 +157,9 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
## CI/CD ## CI/CD
- **`check.yml`** (Forgejo Actions + miroir GitHub) — déclenché sur chaque push de branche (sauf `main`) et chaque PR vers `main`. Lance `cargo check`, `cargo test`, `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. - GitHub Actions (`release.yml`) déclenché par tags `v*`
- **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques. - Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`)
- Signature des binaires + JSON d'updater pour mises à jour automatiques
--- ---

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,127 +0,0 @@
# ADR-0006 : Stockage des tokens OAuth dans le trousseau OS
- **Date** : 2026-04-14
- **Statut** : Accepté
## Contexte
Depuis la v0.7.0, Simpl'Résultat utilise OAuth2 Authorization Code + PKCE pour authentifier les utilisateurs auprès de Logto (Compte Maximus). Les tokens résultants (`access_token`, `refresh_token`, `id_token`) étaient persistés dans `<app_data>/auth/tokens.json`, protégés uniquement par les permissions fichier (`0600` sous Unix, aucune ACL sous Windows).
Le refresh token donne une session longue durée. Le laisser en clair expose l'utilisateur à plusieurs classes d'attaques :
- Malware local tournant sous le même UID (lecture du home directory).
- Backups automatiques (home sync, backup tools) qui copient le fichier sans distinction.
- Shell non-root obtenu par l'attaquant.
- Sous Windows, absence de protection ACL rendait le fichier lisible par n'importe quel process utilisateur.
De plus, avant cette ADR, `account.json` était également en clair et son champ `subscription_status` servait au gating licence (Premium). Écrire manuellement `{"subscription_status": "active"}` dans ce fichier contournait le paywall.
## Options considérées
### Option 1 — Trousseau OS via `keyring` crate (RETENUE)
Librairie Rust `keyring` (v3.6) qui expose une API unifiée au-dessus des trousseaux natifs :
- **Windows** : Credential Manager (Win32 API, toujours présent)
- **Linux** : Secret Service via D-Bus (gnome-keyring, kwallet, keepassxc)
- **macOS** : Keychain Services (hors cible actuelle)
**Avantages** :
- Le système d'exploitation gère la clé maître (session utilisateur).
- Pas de mot de passe supplémentaire à demander à l'utilisateur.
- Support multi-plateforme natif.
**Inconvénients** :
- Sur Linux, requiert un service Secret Service actif (D-Bus + keyring daemon). Sur une session headless ou un CI sans D-Bus, il faut un fallback.
- Dépendance de build supplémentaire (`libdbus-1-dev`).
### Option 2 — `tauri-plugin-stronghold`
Chiffrement au repos avec une master password déverrouillée au démarrage.
**Rejeté** parce que :
- Casse l'UX de connexion silencieuse (refresh automatique au démarrage).
- Ajoute une saisie de passphrase à chaque lancement.
- Surface de UX plus large qu'un simple fallback keychain.
### Option 3 — Chiffrement AES-256-GCM custom avec clé dérivée du PIN
**Rejeté** parce que :
- Seulement applicable aux profils protégés par PIN (minorité).
- Les tokens OAuth doivent être lus sans interaction (refresh silencieux), donc aucune clé à demander.
- Simplement déplace le problème de la clé maître.
## Décision
**Trousseau OS via `keyring` crate, avec fallback fichier supervisé.**
### Architecture
Nouveau module `src-tauri/src/commands/token_store.rs` qui centralise le stockage :
- `save(app, &StoredTokens)` — tente le keychain, retombe sur `tokens.json` chiffré par permissions (`0600` Unix) si keychain indisponible.
- `load(app) -> Option<StoredTokens>` — lit depuis le keychain, migre un `tokens.json` résiduel à la volée.
- `delete(app)` — efface les deux stores (idempotent).
- `store_mode(app) -> Option<StoreMode>` — exposé au frontend pour afficher une bannière quand le fallback est actif.
### Choix du backend `keyring`
Le crate `keyring` v3 demande la sélection explicite des backends :
- **Linux** : `sync-secret-service` + `crypto-rust` → passe par le crate `dbus-secret-service` → crate `dbus` → lib C `libdbus-1`. Requiert `libdbus-1-dev` au build time et `libdbus-1-3` au runtime (ce dernier est universellement présent sur les distributions desktop Linux).
- **Windows** : `windows-native` → bind direct sur `windows-sys`, pas de dépendance externe.
L'option `async-secret-service` (via `zbus`, pur Rust) a été envisagée pour éviter la dépendance `libdbus-1-dev`, mais elle force une API asynchrone sur toutes les plateformes, ce qui ne matche pas le design sync de `license_commands::current_edition` (appelé depuis `check_entitlement`, sync). Le compromis accepté : un paquet apt de plus dans la CI, une API sync partout.
### Identité du trousseau
`service = "com.simpl.resultat"` (identifiant canonique de l'app dans `tauri.conf.json`), `user = "oauth-tokens"`. Ce choix aligne l'entrée keychain avec l'identité installée de l'app pour que les outils de management de credentials du système puissent la scoper correctement.
### Garde anti-downgrade
Un flag persistant `store_mode` (valeurs `keychain` ou `file`) est écrit dans `<app_data>/auth/store_mode` après chaque opération. Une fois qu'un `store_mode = keychain` a été enregistré, toute tentative ultérieure de sauvegarde qui échoue sur le keychain retourne une erreur au lieu de silenter-dégrader vers le fichier. Cela empêche un attaquant local de forcer la dégradation en bloquant temporairement D-Bus pour capturer les tokens en clair au prochain refresh.
### Migration transparente
Au premier `load()` après upgrade depuis v0.7.x, le module détecte `tokens.json` résiduel, copie son contenu dans le keychain, puis **overwrite le fichier avec des zéros + `fsync()` avant `remove_file()`**. C'est un mitigation best-effort contre la récupération des bits sur unallocated sectors (CWE-212). Pas un substitut à un disk encryption : les backups antérieurs à la migration conservent évidemment le vieux fichier. Documenté dans le CHANGELOG comme recommandation de rotation de session post-upgrade pour les utilisateurs inquiets.
### Scope : `tokens.json` migré, `account.json` signé
`account.json` **n'est pas** migré dans le keychain pour limiter le blast radius du changement et garder `write_restricted()` en place pour les fichiers non-sensibles. Toutefois, le champ `subscription_status` de ce cache servait au gating de licence Premium, ce qui créait un trou de tampering : un malware local pouvait écrire `"active"` dans le cache pour bypass le paywall sans jamais toucher le keychain.
**Corrigé via un second module `account_cache.rs`** : le cache est désormais encapsulé dans un wrapper `{"data": {...}, "sig": "<HMAC-SHA256>"}`. La clé HMAC 32 bytes est stockée dans le keychain (`user = "account-hmac-key"`), parallèlement aux tokens. Le chemin de gating (`license_commands::check_account_edition`) appelle `account_cache::load_verified` qui refuse tout payload non-signé ou avec signature invalide, et **fail-closed** (retourne None → Premium reste gated) si la clé HMAC est inaccessible.
Le chemin d'affichage UI (`get_account_info` → `load_unverified`) accepte encore les anciens payloads non-signés pour que les utilisateurs upgradés voient leur profil immédiatement. La distinction display/verified est explicite dans l'API.
## Conséquences
### Positives
- **Protection cryptographique native** : sous Windows, les tokens sont maintenant protégés par Credential Manager (DPAPI sous le capot). Sous Linux, par le keyring daemon avec une master password de session.
- **Anti-tampering du gating licence** : écrire `account.json` ne débloque plus Premium.
- **Fail-closed par défaut** : tous les chemins qui échouent sur le keychain retournent des erreurs au lieu de dégrader silencieusement.
- **Migration transparente** : zéro action utilisateur pour les upgrades depuis v0.7.x.
- **Anti-downgrade** : un attaquant ne peut pas forcer la dégradation vers le fichier pour capturer les tokens au prochain refresh.
### Négatives
- **Dépendance Linux** : `libdbus-1-dev` est requis au build time (ajouté à `check.yml` et `release.yml`). Au runtime, `libdbus-1-3` est déjà présent sur toutes les distros desktop, mais une session headless sans D-Bus déclenche le fallback fichier (signalé à l'utilisateur par la bannière Settings).
- **Surface d'attaque supply-chain accrue** : `keyring` + `dbus-secret-service` + `zbus` + `dbus` représentent une chaîne transitive nouvelle. Mitigé par un step `cargo audit` non-bloquant dans `check.yml`.
- **Logs plus verbeux** : chaque fallback imprime un warning sur stderr pour qu'un dev puisse diagnostiquer. Pas de télémétrie.
- **Sous Linux, la première utilisation peut demander un déverrouillage** : GNOME Keyring peut prompt l'utilisateur pour déverrouiller sa session keyring si elle ne l'est pas déjà. Ce comportement est natif du trousseau, pas de Simpl'Résultat.
### Tests
- Tests unitaires : 9 nouveaux tests (serde round-trip de `StoredTokens`, parse/encode de `StoreMode`, zéroïfication, HMAC sign/verify, tamper detection, wrong key, envelope serde).
- Tests manuels : matrice de 5 scénarios sur pop-os (Linux) et Windows, documentée dans l'issue #82.
- Pas de mock du keychain en CI : la matrice manuelle couvre les chemins où une lib externe est requise.
## Références
- Issue parente : maximus/simpl-resultat#66
- PRs : #83 (core), #84 (CI/packaging), #85 (subscription HMAC), #86 (UI banner), #87 (wrap-up)
- CWE-212 : Improper Removal of Sensitive Information Before Storage or Transfer
- CWE-312 : Cleartext Storage of Sensitive Information
- CWE-345 : Insufficient Verification of Data Authenticity
- CWE-757 : Selection of Less-Secure Algorithm During Negotiation
- [keyring crate v3.6 documentation](https://docs.rs/keyring/3.6.3/keyring/)
- [Secret Service API specification](https://specifications.freedesktop.org/secret-service-spec/latest/)

View file

@ -1,95 +0,0 @@
# ADR 0007 — Reports hub refactor
- Status: Accepted
- Date: 2026-04-14
- Milestone: `spec-refonte-rapports`
## Context
The original `/reports` page exposed five tabs (`trends`, `byCategory`, `overTime`, `budgetVsActual`, `dynamic`) as independent analytic views backed by a single monolithic `useReports` hook. Three problems built up over time:
1. **No narrative.** None of the tabs answered "what's important to know about my finances this month?". Users had to navigate several tabs and reconstruct the story themselves.
2. **Oversized pivot.** The dynamic pivot table (`DynamicReport*`) was powerful but complex. In practice ~90 % of its actual usage boiled down to zooming into a single category. It added visual and cognitive debt without proportional value.
3. **Disconnected classification.** Keywords could only be edited from `/categories`. Spotting a mis-classified transaction in a report meant leaving the report, editing a rule, and navigating back — a context break that discouraged hygiene.
## Decision
Refactor `/reports` into a **hub + four dedicated sub-routes**, wired to a shared bookmarkable period and per-domain hooks, with contextual keyword editing via right-click.
### Routing
```
/reports → hub (highlights panel + nav cards)
/reports/highlights → detailed highlights
/reports/trends → global flow + by-category evolution
/reports/compare → month vs month / year vs year / actual vs budget
/reports/category → single-category zoom with rollup
```
All pages share the reporting period through the URL query string (`?from=YYYY-MM-DD&to=YYYY-MM-DD&period=...`), resolved by a pure `resolveReportsPeriod()` helper. Default: current civil year. The query string approach is deliberately **not** a React context — it keeps the URL bookmarkable and stays consistent with the rest of the project, which does not use global React contexts for UI state.
### Per-domain hooks
The monolithic `useReports` was split into:
| Hook | Responsibility |
|------|----------------|
| `useReportsPeriod` | Read/write period via `useSearchParams` |
| `useHighlights` | Fetch highlights snapshot + window-days state |
| `useTrends` | Fetch global or by-category trends depending on sub-view |
| `useCompare` | Fetch MoM / YoY; budget mode delegates to `BudgetVsActualTable` |
| `useCategoryZoom` | Fetch zoom data with rollup toggle |
Each page mounts only the hook it needs; no hook carries state for reports the user is not currently viewing.
### Dynamic pivot removal
Removed outright rather than hidden behind a feature flag. A runtime flag would leave `getDynamicReportData` and its dynamic `FIELD_SQL` in the shipped bundle as a dead-but-live attack surface (OWASP A05:2021). Git history preserves the previous implementation if it ever needs to come back.
### Contextual keyword editing
Right-clicking a transaction row anywhere transaction-level (category zoom, highlights top transactions, main transactions page) opens an `AddKeywordDialog` that:
1. Validates the keyword is 264 characters after trim (anti-ReDoS, CWE-1333).
2. Previews matching transactions via a parameterised `LIKE $1` query, then filters in memory with the existing `buildKeywordRegex` helper (anti-SQL-injection, CWE-89).
3. Caps the visible preview at 50 rows; an explicit opt-in checkbox lets the user extend the apply to N50 non-displayed matches.
4. Runs INSERT (or UPDATE-reassign) + per-transaction UPDATEs inside a single SQL transaction (`BEGIN`/`COMMIT`/`ROLLBACK`), so a crash mid-apply can never leave a keyword orphaned from its transactions (CWE-662).
5. Renders transaction descriptions as React children — never `dangerouslySetInnerHTML` — with CSS-only truncation (CWE-79).
6. Recategorises only the rows the user explicitly checked; never retroactive on the entire history.
Reassigning an existing keyword across categories requires an explicit confirmation step and leaves the existing keyword's historical matches alone.
### Category zoom cycle guard
`getCategoryZoom` aggregates via a **bounded** recursive CTE (`WITH RECURSIVE ... WHERE ct.depth < 5`) so a corrupted `parent_id` loop (A B A) can never spin forever (CWE-835). A unit test with a canned cyclic fixture asserts termination.
## Consequences
### Positive
- Reports now tell a story ("what moved") before offering analytic depth.
- Each sub-route is independently code-splittable and testable.
- Period state is bookmarkable and shareable (copy URL → same view).
- Keyword hygiene happens inside the report, with a preview that's impossible in the old flow.
- The dialog's security guarantees are covered by 13 vitest cases (validation boundaries, parameterised LIKE, regex word-boundary filter, BEGIN/COMMIT wrap, ROLLBACK on failure, reassignment policy).
- The cycle guard is covered by its own test with the depth assertion.
### Negative / trade-offs
- Adds five new hooks and ~10 new components. Cognitive surface goes up but each piece is smaller and single-purpose.
- Aggregate tables in the compare and highlights sections intentionally skip the right-click menu (the row represents a category/month, not a transaction, so "add as keyword" is meaningless there). Users looking for consistency may be briefly confused.
- Right-clicking inside the main transactions page now offers two ways to add a keyword: the existing inline Tag button (no preview) and the new contextual dialog (with preview). Documented as complementary — the inline path is for quick manual classification, the dialog for preview-backed rule authoring.
## Alternatives considered
- **Keep the five-tab layout and only improve the pivot.** Rejected — it doesn't fix the "no narrative" issue and leaves the oversized pivot problem.
- **Hide the pivot behind a feature flag.** Rejected — the code stays in the bundle, runtime flag cannot be tree-shaken, and the i18n `reports.pivot.*` keys would have to linger indefinitely. Outright removal with git as the escape hatch was cheaper and cleaner.
- **React context for the shared period.** Rejected — the project does not use global React contexts for UI state. Query-string persistence is simpler, bookmarkable, and consistent with the rest of the codebase.
- **A single `ContextMenu` implementation shared across reports and charts.** Chose to generalise the existing `ChartContextMenu` into a `ContextMenu` shell; `ChartContextMenu` now composes the shared shell. Avoids duplicating click-outside + Escape handling.
## References
- Spec: `spec-refonte-rapports.md`
- Issues: #69 (foundation), #70 (hooks), #71 (highlights + hub), #72 (trends), #73 (compare), #74 (category zoom + AddKeywordDialog), #75 (right-click propagation), #76 (polish)
- OWASP A03:2021 (injection), A05:2021 (security misconfiguration)
- CWE-79 (XSS), CWE-89 (SQL injection), CWE-662 (improper synchronization), CWE-835 (infinite loop), CWE-1333 (ReDoS)

View file

@ -1,100 +0,0 @@
# ADR 0008 — Modified Dietz pour le calcul du rendement par compte
- Status: Accepted
- Date: 2026-04-25
- Milestone: `overnight-2026-04-26-bilan` (Issues #138#145)
## Context
La feature Bilan introduit une vue patrimoniale (snapshots datés) avec calcul du rendement par compte. Le rendement réel d'un compte d'investissement n'est PAS `(V_fin V_début) / V_début` : cette formule confond les **gains réels** avec les **apports/retraits**.
> Exemple : compte CELI à 10 000 $, on dépose 5 000 $, le compte vaut 16 000 $ à la fin. La formule naïve donne 60 % (16/10), mais la moitié du gain est juste l'apport. Le vrai rendement est 6 % : `(16 000 5 000 10 000) / 10 000`.
C'est exactement la raison pour laquelle l'utilisateur tague des transferts (table `balance_account_transfers`) : pour les exclure du calcul.
Quatre formules candidates ont été comparées dans le spike (`~/claude-code/.spikes/bilan/code/rendement.md`) :
| Méthode | Pondère le timing des flux ? | Nécessite des valeurs intermédiaires ? | Standard d'industrie ? |
|---------|---|---|---|
| ROI ajusté simple | ❌ | ❌ | ❌ |
| **Modified Dietz** | ✅ (approximation linéaire) | ❌ | ✅ (GIPS-compliant en première approximation) |
| Time-Weighted Return (TWR) | ✅ (exact) | ✅ (à chaque flux) | ✅ |
| Money-Weighted Return / IRR | ✅ (exact, itératif) | ❌ | ✅ |
Contraintes du contexte Simpl'Résultat :
- Les snapshots sont saisis librement (mensuels, trimestriels, ad-hoc) — il n'y a **pas** de valeur du compte aux dates de flux.
- Pas de solveur numérique embarqué côté client (pas de Newton-Raphson en Rust pour l'IRR).
- L'utilisateur doit pouvoir comprendre le résultat sans formation financière.
## Decision
**Adopter Modified Dietz** comme méthode unique de calcul du rendement par compte au MVP, implémentée côté Rust dans le module privé `src-tauri/src/commands/return_calculator.rs`.
```
R = (V_fin V_début C_net) / (V_début + Σ(C_i × W_i))
```
où :
- `C_i` = chaque flux (signé : + apport, retrait)
- `W_i = (T t_i) / T` = poids temporel (1 si début de période, 0 si fin)
- `T` = durée totale de la période en jours, `t_i` = position du flux
### Architecture
- **Logique pure** : `commands/return_calculator.rs` (module privé, pas exposé comme commande). `pub(crate) fn modified_dietz(...) -> AccountReturn`.
- **Commande Tauri** : `commands/balance_commands.rs::compute_account_return(account_id, period_start, period_end, db_filename)` ouvre une connexion `rusqlite` courte sur la DB du profil actif, lit le snapshot ≤ start, le snapshot ≥ end et les cash flows liés, puis délègue le calcul.
- **Dépendance Cargo** : `chrono = "0.4"` ajoutée pour l'arithmétique de dates (poids temporels en jours).
- **Tests TDD co-localisés** : `#[cfg(test)] mod tests` dans le même fichier — 7 cas (nominal, pas de snapshot début, partial-end, compte créé en cours, compte vidé, aucun transfert, annualisation).
### Output
```rust
struct AccountReturn {
value_start: Option<f64>,
value_end: f64,
net_contributions: f64,
return_pct: Option<f64>, // None si dénominateur ≈ 0
annualized_pct: Option<f64>, // (1 + R)^(365/days) - 1, si days > 30
is_partial: bool, // true si snapshot manquant après fin
has_no_transfers_warning: bool, // true si aucun transfert lié
}
```
### Affichage côté UI (`BalanceAccountsTable`)
- 3 colonnes Modified Dietz : 3M / 1A / depuis création
- 1 colonne **rendement non-ajusté** (`(V_fin V_début) / V_début`) côte-à-côte — pédagogique : montre l'effet des apports vs gains réels
- Warnings visibles (`is_partial`, `has_no_transfers_warning`) avec tooltip i18n
## Consequences
### Positive
- **Pas besoin de valeurs intermédiaires** : le calcul ne nécessite que les snapshots existants + les transferts taggés. C'est exactement ce que l'utilisateur saisit déjà.
- **Standard d'industrie** : Modified Dietz est GIPS-compliant en première approximation. Le résultat est défendable.
- **Pédagogique** : afficher le rendement non-ajusté à côté du Modified Dietz éduque l'utilisateur sur la différence entre "valeur du compte" et "vraie performance".
- **Implémentation simple** : ~50 lignes de logique pure en Rust + 7 tests. Pas de solveur numérique.
- **Reproductibilité** : combinée avec la FK `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id` (voir [ADR 0010](0010-fk-restrict-balance-transfers.md)), une période déjà calculée ne peut pas changer rétroactivement.
### Negative / trade-offs
- **Approximation** : Modified Dietz suppose une distribution linéaire des flux dans le temps. Si plusieurs flux concentrés tombent juste avant un mouvement de marché significatif, l'erreur s'accumule. Acceptable pour un usage personnel ; un investisseur professionnel utiliserait TWR exact.
- **Cas dégénéré "compte vidé puis rechargé"** : le dénominateur `V_début + Σ(C_i × W_i)` peut tendre vers zéro et faire exploser le ratio. Mitigé par un warning UI "Performance non significative" basé sur `has_no_transfers_warning` ou un seuil sur le dénominateur.
- **Pas de TWR au MVP** : si l'utilisateur veut la vraie performance gestionnaire (indépendante du timing des flux), il devra attendre une v2 qui demandera de saisir des valeurs intermédiaires aux dates de flux.
- **Pas de Money-Weighted Return / IRR** : formule plus précise mais nécessite Newton-Raphson. Coût/bénéfice défavorable au MVP.
## Alternatives considered
- **ROI ajusté simple** (`(V_fin V_début C_net) / V_début`). Rejeté : ignore *quand* l'apport est arrivé. Un dépôt de 10 000 $ le 1er janvier vs le 31 décembre donne le même résultat — incorrect.
- **TWR (Time-Weighted Return)**. Rejeté pour le MVP : nécessite des valeurs du compte aux dates de flux, qu'on ne stocke pas. Possible v2 si l'utilisateur accepte de saisir des valeurs intermédiaires.
- **IRR (Money-Weighted Return)**. Rejeté : nécessite un solveur Newton-Raphson, complexité disproportionnée pour un usage personnel.
- **Calcul côté TypeScript (sans commande Rust)**. Rejeté : l'arithmétique de dates en JavaScript (`Date.UTC(...) / 86400000`) est correcte mais le pattern projet (logique financière côté Rust avec tests `cargo`) est plus robuste. Cohérent avec `aes-gcm`, `argon2`, etc.
## References
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md), [`spec-plan-bilan.md`](../../spec-plan-bilan.md)
- Spike : `~/claude-code/.spikes/bilan/code/rendement.md` (comparaison ROI / Modified Dietz / TWR / IRR)
- Implémentation : `src-tauri/src/commands/return_calculator.rs`, `src-tauri/src/commands/balance_commands.rs`
- Tests TDD : `#[cfg(test)] mod tests` dans `return_calculator.rs` (7 cas)
- ADR liée : [0010 — FK RESTRICT sur `balance_account_transfers.transaction_id`](0010-fk-restrict-balance-transfers.md)
- GIPS standards (Global Investment Performance Standards) — Modified Dietz est listé comme méthode acceptable d'approximation pour des périodes < 1 an.

View file

@ -1,158 +0,0 @@
# ADR 0009 — Price-fetching premium via proxy maximus-api
- Status: Accepted (architecture documentée — implémentation reportée à l'Issue #143, BLOCKED par maximus-api Phase 2)
- Date: 2026-04-25
- Milestone: `overnight-2026-04-26-bilan` (architecture spec)
## Context
La feature Bilan supporte des comptes "priced" (actions, crypto) où chaque ligne de snapshot stocke `(quantity, unit_price, value)`. La saisie manuelle de `unit_price` reste toujours possible mais devient pénible dès qu'on a plusieurs titres ou qu'on rétro-saisit un historique.
L'objectif est de proposer un bouton "récupérer le prix au [date]" qui interroge un fournisseur de données (Yahoo Finance, CoinGecko, etc.) sans **trahir le principe privacy-first NON NÉGOCIABLE** du projet :
> Zéro donnée envoyée vers un serveur tiers. Tout le traitement CSV et toutes les données financières restent en local. Aucune télémétrie, aucun analytics cloud.
Or interroger Yahoo ou CoinGecko, c'est par définition envoyer une requête sortante depuis l'IP de l'utilisateur. Quelles informations fuiteraient ?
- **L'IP de l'utilisateur** : géolocalisation grossière, profilage de session
- **L'User-Agent par défaut** de `reqwest` : `reqwest/0.12 ...`, identifie le client comme une app Tauri (silhouette technique reconnaissable)
- **Le symbole + date** : "AAPL au 2026-03-15" n'est pas identifiant en soi mais corrélé à l'IP, le provider peut reconstruire le portefeuille
- **Headers résiduels** : `Accept-Language` peut révéler la locale système
Trois architectures candidates :
| Option | Privacy | Complexité serveur | Coût d'API |
|--------|---------|--------------------|------------|
| Appel direct client → provider | ❌ IP exposée, fingerprint headers | aucune | par user (rate limits triggered fast) |
| Appel direct + Tor / VPN intégré | ⚠ partiel, latence dégradée | aucune | par user |
| **Proxy via maximus-api auto-hébergé** | ✅ IP cachée, headers strippés, cache mutualisé | Endpoint `/v1/prices` à maintenir | mutualisé (cache mutualisé entre users premium) |
## Decision
**Implémenter le price-fetching comme fonctionnalité premium-only servie par `maximus-api` agissant comme proxy**, avec consentement explicite et hygiène de headers stricte des deux côtés du fil.
### Architecture
```
[App Tauri]
│ GET /v1/prices?symbol=AAPL&date=2026-03-15
│ Headers: Authorization: Bearer <activation_token>
│ Accept: application/json
│ User-Agent: simpl-resultat
[maximus-api] ← VPS Max (Coolify)
│ 1. Strip TOUS headers entrants identifiants
│ 2. Validation tier premium (403 si non-premium)
│ 3. Cache SQLite (symbol, date) → price (TTL infini sur dates passées)
│ 4. Cache miss → adapter (Yahoo / CoinGecko)
[Provider tiers] ← voit l'IP du VPS, pas du client
```
### Choix de providers : abstraction adapter
Côté maximus-api, un module `price-fetcher` expose une interface unique et délègue à des adapters :
| Provider | Stocks | Crypto | Coût | Adapter |
|----------|--------|--------|------|---------|
| **Yahoo Finance** (unofficial) | ✅ | ⚠ | gratuit | `YahooAdapter` (HTTP direct) |
| **CoinGecko** | ❌ | ✅ excellent | gratuit (free tier 30 req/min) | `CoinGeckoAdapter` |
| Alpha Vantage (fallback) | ✅ | ⚠ | freemium | optionnel si Yahoo casse |
**Stocks → Yahoo** ; **Crypto → CoinGecko**. L'abstraction permet de swap si un provider casse, sans changer le contrat client.
### Stratégie d'authentification
- **`Authorization: Bearer <activation_token>` uniquement.** Le token est lu côté client depuis `activation_path` (le fichier déjà utilisé par `license_commands.rs` pour persister le token d'activation). **Jamais stocké dans `user_preferences`** (la table SQL de l'app n'a pas vocation à versionner les credentials).
- **Jamais en query string.** Un token-in-URL leakerait dans :
- Les logs Traefik / nginx du VPS (URL complète loguée par défaut)
- Le header `Referer` si maximus-api redirige
- Les écrans de partage (le header `Authorization` est masqué par les outils de capture, pas l'URL)
### Hygiène des headers — privacy en profondeur
**Côté client (Rust / `reqwest`)** :
- `reqwest::Client::builder().user_agent("simpl-resultat").build()` — UA fixe, pas le default `reqwest/0.12 ...`
- Headers envoyés UNIQUEMENT : `Authorization: Bearer <token>` + `Accept: application/json`
- **Pas** de `Accept-Language` (révèle la locale)
- **Pas** d'autres headers identifiants
**Côté serveur (maximus-api)** :
- Strip TOUS les headers entrants avant de proxyer vers le provider tiers (`X-Forwarded-For`, `User-Agent` client, `Accept-Language`, etc.)
- **Ne JAMAIS logger `(symbol, license_id)` ensemble.** Soit séparer les logs (un journal pour la facturation/quota par licence sans symbole, un journal pour les hits cache/provider sans license), soit hasher le `license_id` côté serveur avec un sel rotatif court avant log
- Validation premium **AVANT** cache et provider — un client non-premium reçoit 403 sans qu'aucun appel sortant ne soit déclenché
### Rate limiting
**Côté client** :
- Max 1 fetch / 2 secondes (timer simple)
- Dedup in-flight par `(symbol, date)` (deux clics rapides = 1 seule requête réseau)
- Backoff exponentiel sur 5xx / network : 2s, 4s, 8s — max 3 retries
- Plafond hard : 100 fetches par session snapshot (anti-loop)
**Côté serveur** :
- Quota par licence (proposition initiale : 1000 req/jour, le cache absorbe l'essentiel)
- Le cache `(symbol, date)` est immuable pour les dates passées (TTL infini), 5 min pour `today` (le marché peut bouger)
### Premium gating — défense en profondeur
- **UI client** : si `entitlements.check_entitlement("price-fetching")` retourne `false`, le bouton ↻ affiche un tooltip "Disponible avec abonnement" et est désactivé. Pas de tentative de fetch.
- **Server-side** : `maximus-api /v1/prices` valide le tier premium AVANT cache/provider. Un client modifié qui bypass la UI reçoit 403.
La double vérification est délibérée : le client est compromettable (l'app Tauri est ouverte au reverse-engineering), seul le serveur peut faire foi.
### Consentement explicite (per-profile)
- Stockage : `user_preferences.price_fetching_consent = {consented_at: <ISO>, version: 1}`
- **NE PAS seeder la clé.** Absence = jamais demandé. Le default doit être "non-décidé", pas "false".
- Premier clic sur le bouton ↻ → modal de consentement → écriture de la clé après acceptation
- **Permanence** : pas de re-consent automatique. Révocation explicite via toggle Settings (supprime la clé)
- Stockage **per-profile** (table `user_preferences` est par-profil), pas global au système
### Mode offline / fallback
L'app **ne doit jamais bloquer la saisie d'un snapshot** parce que le price-fetching a échoué. La saisie manuelle de `unit_price` reste TOUJOURS disponible :
| Erreur serveur | Comportement |
|----------------|--------------|
| 401 license expirée | Toast "Renouvelez votre abonnement" + champ manuel dispo |
| 403 non-premium | Toast "Disponible avec abonnement Premium" + champ manuel dispo |
| 404 symbole | Toast "Symbole introuvable — vérifiez l'orthographe" + champ manuel |
| 429 rate limit | Toast "Limite atteinte — réessayez plus tard" + champ manuel |
| Network error / 5xx | Toast "Service temporairement indisponible" + champ manuel |
## Consequences
### Positive
- **L'IP de l'utilisateur n'est JAMAIS exposée à Yahoo / CoinGecko.** Le provider voit l'IP du VPS de Max — privacy-first préservée.
- **Aucun symbole ne révèle de données personnelles.** "AAPL" ou "BTC" ne sont pas identifiants en soi ; corrélés à une license_id ils le redeviennent, c'est pourquoi le serveur ne logue jamais les deux ensemble.
- **Cache mutualisé.** Si 500 utilisateurs premium demandent AAPL au 2026-03-15, c'est UN seul appel sortant côté maximus-api. Économise les rate limits ET réduit la surface d'exposition.
- **Mode offline préservé.** L'app continue de fonctionner sans price-fetching — la saisie manuelle reste le chemin de secours.
- **Justification commerciale.** Le price-fetching premium aligne le coût d'API tiers sur la révenue récurrente, sans dégrader l'expérience free-tier (qui reste 100 % local).
- **Adapter pattern.** Si Yahoo casse (API non officielle), swap pour Alpha Vantage côté serveur sans changer le contrat client.
### Negative / trade-offs
- **Dépendance opérationnelle au VPS.** Si maximus-api est down, le price-fetching ne fonctionne pas — atténué par le fallback manuel toujours dispo.
- **Surface serveur à maintenir.** Endpoint `/v1/prices` + cache + adapters + auth + rate limiting + observabilité (sans corrélation log).
- **Charge financière sur Max.** Les tier free n'ont pas accès, donc les coûts d'API tiers sont absorbés par les abonnements premium ; le cache aide significativement.
- **Implémentation BLOQUÉE.** L'Issue #143 ne peut shipper tant que `maximus-api` Phase 2 n'expose pas `/v1/prices` (dépendance externe : issues maximus-api `#49` license server core et `#136` Stripe webhooks).
## Alternatives considered
- **Appel direct client → provider.** Rejeté : viole le principe privacy-first (IP exposée + fingerprint headers).
- **Tor / I2P intégré.** Rejeté : latence prohibitive (5-10 secondes par fetch), maintenance d'un client Tor embarqué dans Tauri, et certains providers bloquent les exits Tor.
- **VPN tiers (Mullvad, etc.) configuré par l'utilisateur.** Rejeté : ne supprime pas le fingerprint headers, et "exiger l'utilisateur à configurer un VPN" est une régression UX inacceptable.
- **Cache local sans serveur (chaque user a son propre cache).** Rejeté : pas de mutualisation, chaque user paie son propre rate limit, et le client doit toujours faire l'appel sortant initial (donc IP exposée).
- **Saisie manuelle uniquement, pas de price-fetching du tout.** C'est le mode free-tier — fonctionnel mais friction élevée pour les utilisateurs avec un portefeuille actions/crypto significatif. Le proxy premium est le compromis qui justifie l'abonnement sans dégrader le free-tier.
- **Endpoint `/v1/symbols/search` côté maximus-api** pour autocomplete. Reporté à v2 : l'autocomplete double la surface d'API et n'est pas critique. La saisie texte simple suffit au MVP.
## References
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md), [`spec-plan-bilan.md`](../../spec-plan-bilan.md) (Issue #5 — Phase 5)
- Spike : `~/claude-code/.spikes/bilan/code/price-fetching.md` (architecture, choix providers, consent flow)
- Issue client (BLOCKED) : maximus/simpl-resultat #143
- Issues maximus-api (externes, prerequisites) : `maximus-api#49` (license server core), `maximus-api#136` (Stripe webhooks)
- Pattern auth : `src-tauri/src/commands/license_commands.rs` (`activation_path` + `activate_machine` — le token Bearer existe déjà)
- Privacy frame : ce que `maximus-api` voit jamais ensemble = `(IP, license_id, symbol)`. Le proxy garantit que (IP) est cachée du provider et que (license_id, symbol) ne se retrouvent pas dans le même log.

View file

@ -1,85 +0,0 @@
# ADR 0010 — `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id`
- Status: Accepted
- Date: 2026-04-25
- Milestone: `overnight-2026-04-26-bilan` (Issues #138#145)
## Context
La table `balance_account_transfers` lie une `transaction` existante à un `balance_account` avec une direction (`'in'` = capital ajouté au compte, `'out'` = capital retiré). Cette table est l'input du calcul Modified Dietz (cf. [ADR 0008](0008-modified-dietz-pour-rendement.md)) qui sépare les **apports** des **gains réels** pour calculer la performance d'un compte d'investissement.
La question structurante : que se passe-t-il si l'utilisateur supprime une transaction qui est liée à un transfert de bilan ?
Trois politiques de FK sont possibles côté SQL :
| Politique | Comportement | Intégrité historique | Friction utilisateur |
|-----------|--------------|----------------------|----------------------|
| `ON DELETE CASCADE` | Suppression de la transaction supprime aussi le transfert | ❌ Le rendement Modified Dietz d'une période passée change rétroactivement | ✅ Aucune friction : tout disparaît silencieusement |
| `ON DELETE SET NULL` | Le transfert reste mais perd son `transaction_id` | ⚠ Le transfert devient "orphelin" : direction connue mais montant introuvable (les montants vivent dans `transactions.amount`) | ⚠ État partiellement valide |
| **`ON DELETE RESTRICT`** | La suppression est bloquée par SQLite tant que des transferts pointent vers la transaction | ✅ Préservée : un rendement déjà calculé reste reproductible | ⚠ L'utilisateur doit délier explicitement avant suppression |
Contraintes du contexte :
- Modified Dietz produit un rendement **R** sur une période **[t1, t2]** à partir de `(V_début, V_fin, [(date, montant)])`. Si une `transaction` liée disparaît silencieusement (CASCADE), la fonction reste pure mais ses inputs changent — `R` calculé hier ≠ `R` calculé aujourd'hui sur la même période. C'est exactement l'antithèse de la reproductibilité financière.
- Le calcul est déclenché à la demande (chargement de `BalanceAccountsTable`), il n'y a pas de cache server-side. Donc l'historique de "ce que le user a vu hier" n'existe pas : si les inputs bougent, le résultat affiché change sans que l'utilisateur sache pourquoi.
- L'usage attendu de la suppression de transactions est rare et lié à des erreurs d'import (doublons, mauvaise source). Bloquer ce cas avec un message clair est acceptable.
## Decision
**Adopter `ON DELETE RESTRICT` sur `balance_account_transfers.transaction_id`** :
```sql
CREATE TABLE balance_account_transfers (
...
transaction_id INTEGER NOT NULL,
...
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT
);
```
### UX correspondante
La couche service `transactionService.ts` détecte l'erreur SQLite `FOREIGN KEY constraint failed` et la transforme en `TransactionLinkedToBalanceError` typée, qui porte la liste des comptes liés. La UI affiche alors :
> **Cette transaction est liée au compte de bilan _<nom du compte>_.**
> Pour la supprimer, déliez-la d'abord : ouvrez le compte → Lier transferts → décochez cette transaction.
Avec un lien direct vers la `LinkTransfersModal` du compte concerné. L'utilisateur ne peut pas se retrouver bloqué : le chemin de déliaison est toujours dispo, à un clic du message d'erreur.
Pour les chemins bulk (`deleteImportWithTransactions`, `deleteAllImportsWithTransactions`), une pré-vérification SELECT (`LIMIT 50`) liste les premiers transferts liés AVANT de tenter la suppression — l'utilisateur voit un message agrégé "X transactions de cet import sont liées à des comptes de bilan" plutôt qu'un raw FK error toast.
### Direction CASCADE conservée pour `account_id`
À noter : la même table a une autre FK, `account_id`, configurée en `ON DELETE CASCADE`. Si l'utilisateur supprime un compte de bilan, ses transferts disparaissent — c'est cohérent puisque les rendements de ce compte n'ont plus lieu d'être.
L'asymétrie est délibérée :
- `account_id` ON DELETE CASCADE : le compte de bilan est l'objet "principal" du domaine Bilan, sa suppression nettoie ses dépendances internes
- `transaction_id` ON DELETE RESTRICT : la transaction est externe au domaine Bilan, sa suppression ne doit pas casser silencieusement les calculs
## Consequences
### Positive
- **Reproductibilité Modified Dietz garantie.** Un rendement calculé sur une période passée ne peut pas changer à cause d'une suppression invisible côté `transactions`.
- **Audit trail préservé.** L'utilisateur qui consulte un compte de bilan voit toujours les mêmes flux pour les mêmes périodes, peu importe quand il consulte.
- **Erreur visible et actionnable.** L'utilisateur reçoit un message concret avec un chemin clair pour résoudre, plutôt qu'une suppression silencieuse qui invaliderait l'historique financier.
- **Aligné avec la convention SQL existante du projet.** D'autres FK utilisent déjà `RESTRICT` quand l'intégrité est critique (cf. `balance_accounts.balance_category_id`, `balance_snapshot_lines.account_id`).
### Negative / trade-offs
- **Friction utilisateur** : forcer l'unlink explicite avant suppression ajoute 2 clics (ouvrir le compte → ouvrir LinkTransfersModal → décocher → revenir → supprimer). Acceptable car le cas est rare et le coût d'un rendement faux est élevé.
- **Couplage UI ↔ erreur SQL** : `transactionService.ts` doit détecter le format d'erreur SQLite (`FOREIGN KEY constraint failed`) et le mapper sur `TransactionLinkedToBalanceError`. Si tauri-plugin-sql change le format du message d'erreur, le mapping casse silencieusement (mitigé par les tests d'intégration co-localisés dans `transactionService.test.ts`).
- **Pré-vérification bulk a un coût** : un `SELECT ... LIMIT 50` sur `balance_account_transfers` à chaque suppression d'import. Négligeable en pratique (la table reste petite), mais à surveiller si un utilisateur a des dizaines de milliers de transferts.
## Alternatives considered
- **`ON DELETE CASCADE`.** Rejeté : trahit la promesse de reproductibilité du calcul Modified Dietz. Un rendement vu hier peut changer sans signal vers l'utilisateur.
- **`ON DELETE SET NULL` + transferts orphelins.** Rejeté : laisse la base dans un état "valide mais incohérent". Le transfert sait sa direction mais a perdu son montant (qui vit dans `transactions.amount`). Le code Modified Dietz devrait alors filtrer les orphelins, et l'utilisateur ne saurait plus pourquoi son rendement a changé. Pire que CASCADE, qui au moins est explicite.
- **Pas de FK du tout, juste un INTEGER orphelin possible.** Rejeté : retire toute garantie d'intégrité référentielle, et les calculs de rendement deviendraient une chasse aux pointeurs cassés.
- **Soft-delete des transactions (`deleted_at` au lieu de DELETE)** pour préserver les données liées tout en cachant la transaction de l'UI. Rejeté pour l'instant : les transactions n'ont pas de soft-delete dans le schéma actuel et l'introduire ouvrirait un chantier transversal (toutes les requêtes de transactions devraient filtrer `WHERE deleted_at IS NULL`). À reconsidérer si plusieurs domaines en font la demande.
## References
- Implémentation : `src-tauri/src/database/balance_schema.sql` (FK definition), `src/services/transactionService.ts` (`TransactionLinkedToBalanceError` mapping)
- Tests : `src/services/transactionService.test.ts` (mapping FK error → typed error), `src/__integration__/balance-flow.test.ts` (lien + tentative de suppression bloquée)
- Spec : [`spec-decisions-bilan.md`](../../spec-decisions-bilan.md) — décision "FK `balance_account_transfers.transaction_id` : `ON DELETE RESTRICT` + UI force unlink avec message clair"
- ADR liée : [0008 — Modified Dietz pour le calcul du rendement](0008-modified-dietz-pour-rendement.md)

View file

@ -1,89 +0,0 @@
# ADR 0011 — Providers de prix : exchanges directs (crypto) + Yahoo Finance best-effort (stocks)
- Status: Accepted
- Date: 2026-04-26
- Successor of: ADR 0009 (architecture proxy) — précise les providers concrets
- Milestone: `spec-price-fetching` + `prices-proxy` (maximus-api)
## Context
ADR 0009 a établi qu'un proxy `maximus-api` mutualisé sert le price-fetching premium pour préserver la privacy (IP cachée, headers strippés). La revue spec du contrat `/v1/prices` (2026-04-26) a soulevé deux risques critiques :
1. **Yahoo Finance n'a pas d'API publique officielle.** Les endpoints `query1/query2.finance.yahoo.com` sont non documentés, leur ToS interdit l'usage commercial et la redistribution. Un IP-ban du VPS coupe le feature pour 100% des premium en même temps.
2. **CoinGecko free tier interdit le proxy commercial.** Seul le plan Demo/Pro payant (~129 $/mo Analyst) le permet contractuellement.
Quatre options ont été considérées (cf. revue inline `docs/api-contract-prices.md` §0) :
| Option | Coût/mois | Légalité commercial | Stabilité | Couverture |
|--------|-----------|---------------------|-----------|------------|
| Polygon.io Starter | 29 $ | ✅ contractuelle | ✅ haute | Stocks NYSE/NASDAQ + crypto |
| Tiingo Power + exchanges directs (crypto) | 10 $ | ✅ Tiingo, ✅ exchanges (public market data OSS-légal) | ✅ haute | Stocks + crypto |
| **Exchanges directs (crypto) + Yahoo best-effort (stocks)** | **0 $** | ⚠️ Yahoo ToS risqué (data publique mais redistribution interdite) ; ✅ exchanges | ⚠️ Yahoo fragile, exchanges stables | Stocks + crypto |
| Polygon Stocks + CoinGecko Pro | 158 $ | ✅ | ✅ | Best-of-both |
## Decision
**Adopter l'option « tout-OSS / best-effort »** pour le MVP :
- **Crypto** : interrogation directe des exchanges majeurs via la lib `ccxt` (MIT). Les données de marché publiques (ticker, OHLC) sont gratuites et explicitement autorisées en commercial par les ToS de Kraken, Coinbase, Binance, etc. Implémentation initiale : Kraken d'abord, Coinbase en fallback si Kraken 404.
- **Stocks** : interrogation de Yahoo Finance via `query1.finance.yahoo.com/v7/finance/quote` (et v8 chart pour historique) avec un User-Agent navigateur. **Best-effort assumé** : peut échouer ou changer sans préavis.
Le client paie pour **l'infrastructure d'anonymisation**, pas pour la donnée. Cette distinction est centrale au modèle économique : la valeur premium = privacy (proxy mutualisé) + commodité (auto-fill), pas la donnée elle-même.
### Garde-fous obligatoires
1. **Label UX explicite** : sur les catégories de bilan en stocks, le bouton fetch affiche un badge « best-effort » + warning au premier usage. Sur crypto : pas de warning.
2. **Circuit breaker côté maximus-api** : seuil `5 erreurs Yahoo / 60 sec → breaker ouvert pour 15 min`. Notification automatique Telegram/email à `maxime2tremblay@protonmail.com`.
3. **Quota baissé** : 200 req/jour/licence (vs 2000 initialement). Suffit pour ~50 actifs × snapshot mensuel. Réduit l'incitation à abuser.
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 avant call sortant. Vers Yahoo : UA browser-like (`Mozilla/5.0 ...`). Vers exchanges : UA `maximus-api/<version>`.
6. **Logs séparés** : pas de log conjoint `(license_id, symbol)`. Implémentation via wrapper logger injectable (`src/logger.ts` pino).
### Plan de migration si Yahoo devient inutilisable
Triggers de migration vers un provider payant :
- Plus de 1 incident IP-ban / mois pendant 2 mois consécutifs, OU
- Plus de 30% des requêtes stocks tombent en circuit-breaker `service_degraded` sur 7 jours, OU
- Plainte légale formelle de Yahoo / Verizon Media.
Provider de bascule prioritaire : **Tiingo plan Power** (~10 $/mo, 1000 req/jour, ToS-clean).
- Implémentation : ajouter un module `providers/tiingo.ts` parallèle à `providers/yahoo.ts`. Switch via env var `STOCKS_PROVIDER=yahoo|tiingo`.
- Délai de bascule : ≤ 30 jours après déclenchement d'un trigger.
- Communication : entrée CHANGELOG explicite + email aux licences premium actives.
Si l'audience grandit (>500 licences premium actives), bascule vers Polygon Starter (~29 $/mo) considérée.
## Consequences
### Positives
- **0 $ de coût récurrent au MVP** — pas de cash burn avant que le produit ait validé son marché.
- **Crypto 100% OSS-légal** — voie pérenne, ne nécessitera jamais de migration.
- **Justification premium cohérente** — privacy comme valeur, pas la donnée. Aligne avec les principes du projet.
- **Plan de bascule pré-engagé** — pas pris au dépourvu si Yahoo devient hostile.
### Négatives / Risques actés
- **ToS Yahoo en zone grise** — le proxy commercial de leur data publique n'est pas formellement autorisé. Yahoo a déjà émis des cease-and-desist contre yfinance (lib Python). Risque légal théorique mais peu probable à petite échelle.
- **IP-ban probable à un moment ou l'autre** — Yahoo bloque les UA non browser et les patterns de requête trop réguliers. Le circuit breaker absorbe l'événement, mais le feature devient temporairement HS pour tous les premium.
- **Pas de garantie de stabilité de schéma** — Yahoo peut renommer un champ JSON sans préavis. Tests d'intégration `nock` ne capturent pas ça (mock = donnée figée).
- **Charge ops accrue** — il faudra surveiller le taux d'erreur Yahoo et réagir vite si dégradation.
### Neutre
- Première implémentation un peu plus complexe côté serveur (deux providers + circuit breaker), mais le code reste contained dans `src/providers/` et est testable.
## Suivi
- ADR à reviewer dans 6 mois (2026-10-26) ou plus tôt si trigger de migration déclenché.
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`.
- Issue de suivi : créer une issue `ops` dans `maximus-api` pour le monitoring continu une fois deployé.
## References
- [Yahoo Finance ToS](https://legal.yahoo.com/us/en/yahoo/terms/index.html) — sec. 7-8 sur l'usage commercial
- [CoinGecko API ToS](https://www.coingecko.com/en/api/terms) — restrictions free tier
- [Kraken API public market data](https://docs.kraken.com/rest/) — explicite : free public tier, commercial OK pour data publique
- [CCXT (MIT)](https://github.com/ccxt/ccxt) — abstraction multi-exchange, lib OSS
- ADR 0009 — Architecture du proxy
- `docs/api-contract-prices.md` — Contrat figé `/v1/prices`

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

@ -1,598 +0,0 @@
# Contrat API — `GET /v1/prices`
> **Statut** : Draft v2 (2026-04-26) — décisions de revue intégrées, prêt pour gel après création des issues
> **Producteurs** : `maximus-api` (serveur Hono/Node.js sur VPS OVH)
> **Consommateurs** : `simpl-resultat` (client desktop Tauri)
> **Fichiers de référence (mirror)** :
> - `simpl-resultat/docs/api-contract-prices.md` (ce fichier — source de vérité)
> - `maximus-api/docs/api-contract-prices.md` (copie identique, à synchroniser à chaque modification)
Ce document fige la surface d'API entre le client desktop premium et le proxy de récupération de prix de `maximus-api`. Le but : permettre au client et au serveur d'être développés et testés en parallèle, sans dépendance temporelle, contre des mocks conformes.
## 0. Décisions de revue (2026-04-26)
Synthèse des décisions issues de la revue multi-expert (cf. issue #143 et ce fichier annoté). Ces décisions sont **gelées** et impactent toutes les sections ci-dessous.
| Décision | Choix | Section impactée |
|----------|-------|------------------|
| Providers de prix | **Crypto via exchanges directs (Kraken, Coinbase via CCXT, OSS-légal, gratuit) + Stocks via Yahoo Finance en best-effort assumé** (gratuit, ToS risque acté dans ADR 0011) | §8 |
| Coût mensuel | 0 $ initial. Migration vers Tiingo (~10 $/mo) ou Polygon (~29 $/mo) si Yahoo devient inutilisable (cf. ADR 0011) | §8, §11 |
| Rate-limit infra | **Postgres token-bucket atomique** (`INSERT ... ON CONFLICT DO UPDATE` + `pg_advisory_xact_lock`). Pas de Redis. | §6.1 |
| Versioning + enveloppe | **Migration globale vers `/v1/` + enveloppe nestée `{error: {code, message, retry_after}}`**. `/licenses/*` deviennent aliases deprecated 30 jours pointant sur `/v1/licenses/*`. | §2, §5, §11 |
| Quota par licence | **30/min, 200/jour** (revu à la baisse depuis 2000/jour : Yahoo est gratuit mais fragile, et 200 suffit pour ~50 actifs × snapshot mensuel) | §6.1 |
| Justification du gating premium | Le client premium paie le **proxy d'anonymisation et l'infrastructure**, pas la donnée (qui est gratuite/best-effort). Cohérent avec privacy-first. | §1, ADR 0011 |
| `product` claim binding | Middleware valide `claims.product === 'simpl-resultat'`. | §7.1 |
| Lib mocks | Client TS : `vi.fn()` sur `fetch`. Serveur outbound : `nock`. Serveur inbound : `app.request()` natif Hono. | §12 |
## 1. Objectif fonctionnel
Permettre au client desktop d'obtenir le prix d'un actif coté (action ou crypto) à une date donnée, sans exposer l'identité ni l'IP de l'utilisateur aux fournisseurs de données. Le proxy `maximus-api` fait écran et mutualise les appels.
**Le client premium paie pour l'infrastructure d'anonymisation et de proxy, pas pour la donnée elle-même.** La donnée est gratuite (crypto via exchanges publics, stocks via Yahoo en best-effort). Cette distinction est centrale au modèle économique et préserve la cohérence privacy-first.
**UX explicite — feature stocks en best-effort** : pour les catégories de bilan en stocks (actions cotées), le bouton de fetch affiche un label « best-effort » + warning au premier usage : « Source non garantie, peut être indisponible. La saisie manuelle reste prioritaire et toujours active. » Pas de warning pour crypto (provider stable).
**Ce qui n'est PAS dans cet endpoint** :
- Recherche / autocomplete de symboles (hors scope MVP)
- Historique sur intervalle (un seul couple `(symbol, date)` par requête)
- Conversion de devise
## 2. Endpoint
```
GET /v1/prices?symbol=<symbol>&date=<YYYY-MM-DD>
```
- **Méthode** : `GET` (idempotent, cacheable au sens HTTP — mais voir §10 pour les contraintes côté client)
- **Base URL prod** : `https://api.lacompagniemaximus.com`
- **Base URL dev** : configurable côté client (`MAXIMUS_API_URL` ou équivalent)
- **Versioning** : préfixe `/v1/`. Toute modification non rétrocompatible passe par `/v2/`. Au sein de `/v1/`, seuls les ajouts de champs sont autorisés.
> **🔴 ARCHITECTURE** — Asymétrie de versioning `/v1/prices` vs `/licenses/*`.
> Introduire `/v1/` sur prices alors que `/licenses/*` reste non-versionné crée une surface mixte. La politique §11 ne s'applique alors qu'à un endpoint.
> **Resolution :** Trancher avant gel : soit renommer en `/v1/licenses/*` (avec alias durant deprecation window), soit retirer le préfixe `/v1` sur prices. À acter dans l'ADR 0009.
### 2.1 Paramètres de requête
| Param | Type | Format | Validation | Obligatoire |
|-------|------|--------|------------|-------------|
| `symbol` | string | alphanum + `.` + `-`, 1-20 chars, case-insensitive (normalisé en MAJUSCULES côté serveur) | regex `^[A-Za-z0-9.\-]{1,20}$` | oui |
| `date` | string | ISO 8601 `YYYY-MM-DD` | doit être ≥ `1970-01-01` et ≤ aujourd'hui (UTC) | oui |
Toute autre query string est ignorée silencieusement (le serveur ne se base que sur ces deux paramètres).
> **🔴 ARCHITECTURE** — Binding manquant à la claim `product` du JWT.
> Tous les endpoints existants requièrent `product` (schéma multi-produit) et le JWT activation porte une claim `product`. `/v1/prices` ne la valide pas — un futur 2e produit pourrait hitter prices avec son propre token.
> **Resolution :** Valider `claims.product === 'simpl-resultat'` dans le middleware d'auth (extraction JWT, pas en query). Documenter en §7.1 (étape 5.5).
## 3. Headers de requête
### 3.1 Headers requis (client → maximus-api)
| Header | Valeur | Notes |
|--------|--------|-------|
| `Authorization` | `Bearer <activation_token>` | Token opaque côté client. Le serveur valide la signature Ed25519 + l'état de la licence en DB. |
| `Accept` | `application/json` | |
| `User-Agent` | `simpl-resultat` | **FIXE**, sans version, sans OS, sans architecture. |
> **🟢 SECURITE+TECHNIQUE** — User-Agent fixe empêche la dépréciation d'urgence des clients.
> Aucun moyen de refuser un build vulnérable connu (ex. version qui leak l'`activation_token` dans les logs). Pas de gate de version minimale possible.
> **Resolution :** Envoyer un header séparé `X-Client-Major: 0.x` (major+minor uniquement, pas patch/OS/arch) — préserve la k-anonymity et active les gates de dépréciation. Documenter le tradeoff de privacy.
### 3.2 Headers interdits (client → maximus-api)
Le client **ne doit jamais envoyer** :
- `Accept-Language`
- `X-Forwarded-For`, `X-Real-IP`, ou tout header `X-Forwarded-*`
- Cookies
- Aucun header personnalisé identifiant la machine
Cette règle est testée par un test unitaire côté client (« privacy headers test »). Le serveur tolère leur présence (ne les rejette pas par 400) mais les supprime avant tout traitement.
### 3.3 Comportement du serveur sur les headers entrants
Avant tout appel sortant vers Yahoo / CoinGecko, `maximus-api` strippe **tous** les headers entrants à l'exception de ceux qu'il génère lui-même. Garanti par contrat — vérifié par tests d'intégration côté serveur.
> **🟡 TECHNIQUE** — Headers injectés par l'infra (CF-*, Coolify, Traefik) non couverts par le test §12.2.
> Le proxy Traefik / Coolify ajoute des headers (`X-Forwarded-*`, `X-Real-IP`, etc.) entre le client et l'app — si l'app les propage par accident vers Yahoo/CoinGecko, la promesse §3.3 est cassée.
> **Resolution :** Utiliser un client HTTP nu (`fetch` natif Node, sans propagation d'headers) pour les appels sortants. Test : asserter que la liste de headers reçue par le mock provider == exactement `['user-agent', 'accept', 'host']`.
## 4. Réponse de succès (200 OK)
```json
{
"symbol": "AAPL",
"date": "2026-04-25",
"actual_date": "2026-04-24",
"price": 173.45,
"currency": "USD",
"source": "yahoo",
"fetched_at": "2026-04-25T14:32:11Z",
"cached": true
}
```
### 4.1 Sémantique des champs
| Champ | Type | Description |
|-------|------|-------------|
| `symbol` | string | Symbole tel que normalisé par le serveur (MAJUSCULES) |
| `date` | string | Date demandée (echo du paramètre) |
| `actual_date` | string \| null | Si la date demandée n'a pas de cotation (week-end, jour férié pour les actions), date effective de la cotation retournée. `null` si `actual_date == date`. |
| `price` | number | Prix de clôture pour les actions, prix instantané pour les crypto. Précision : 4 décimales pour les actions, 8 pour les crypto. |
| `currency` | string | ISO 4217 (3 lettres). Exemples : `USD`, `CAD`, `EUR`. |
| `source` | string | `yahoo` ou `coingecko`. Indicatif uniquement — le client ne doit pas faire de logique conditionnelle dessus. |
| `fetched_at` | string | ISO 8601 UTC du moment où le prix a été récupéré du provider (différent de `now` si servi depuis cache). |
| `cached` | boolean | `true` si servi depuis le cache serveur. Indicatif uniquement — n'affecte pas la fraîcheur garantie (cf. §10). |
### 4.2 Headers de réponse (succès)
| Header | Toujours présent | Description |
|--------|------------------|-------------|
| `Content-Type` | oui | `application/json; charset=utf-8` |
| `X-RateLimit-Limit` | oui | Quota total sur la fenêtre courante (entier) |
| `X-RateLimit-Remaining` | oui | Quota restant (entier) |
| `X-RateLimit-Reset` | oui | Unix timestamp (secondes) du reset de quota |
| `Cache-Control` | oui | `private, max-age=0` — le client **ne doit pas** mettre en cache HTTP |
## 5. Réponses d'erreur
### 5.1 Format d'enveloppe (toutes les 4xx/5xx)
```json
{
"error": {
"code": "premium_required",
"message": "Premium license required for price fetching",
"retry_after": 30
}
}
```
| Champ | Type | Description |
|-------|------|-------------|
| `error.code` | string | Code stable, lisible-machine. Snake_case. Liste fermée (cf. §5.2). |
| `error.message` | string | Message lisible-humain en anglais. **Ne pas afficher tel quel à l'utilisateur** — le client doit traduire en FR/EN via i18n à partir du `code`. |
| `error.retry_after` | number \| absent | Présent uniquement sur 429 et 503. Secondes à attendre avant retry. |
> **🔴 ARCHITECTURE** — Enveloppe d'erreur incohérente avec `/licenses/*`.
> Les routes existantes `/licenses/*` retournent un format plat `{ error: "string" }` (parfois avec `details` ou `machines`). Cette spec propose `{ error: { code, message, retry_after } }` — deux shapes dans la même app Hono force les clients à brancher par route.
> **Resolution :** Trancher : soit migrer `/licenses/*` vers la nouvelle enveloppe (versionner en `/v1/licenses/*`), soit aligner `/v1/prices` sur `{ error, code, retry_after }` plat. Documenter le choix dans le README maximus-api.
### 5.2 Codes d'erreur par status HTTP
| Status | `error.code` | Cause | Retry possible ? |
|--------|--------------|-------|------------------|
| **400 Bad Request** | `invalid_symbol` | `symbol` ne matche pas la regex | non |
| 400 | `invalid_date` | `date` mal formée, future, ou pré-1970 | non |
| 400 | `missing_param` | `symbol` ou `date` absent | non |
| **401 Unauthorized** | `missing_token` | Header `Authorization` absent | non |
| 401 | `invalid_token` | Signature Ed25519 invalide | non |
| 401 | `expired_token` | Token expiré | non — re-activation requise |
| **403 Forbidden** | `premium_required` | Licence valide mais `edition != 'premium'` | non — abonnement requis |
| 403 | `license_revoked` | Licence révoquée | non — contact support |
| **404 Not Found** | `symbol_not_found` | Le symbole est inconnu de tous les providers consultés | non |
| **429 Too Many Requests** | `rate_limit_exceeded` | Quota dépassé pour cette licence | oui — après `retry_after` secondes |
| **502 Bad Gateway** | `provider_unavailable` | Yahoo / CoinGecko a échoué (timeout, 5xx) | oui — backoff exponentiel |
| **503 Service Unavailable** | `service_degraded` | Maintenance ou panne interne | oui — après `retry_after` secondes |
| **500 Internal Server Error** | `internal_error` | Bug serveur. Loggé côté server. | oui — backoff |
Le serveur **ne doit jamais** retourner un code HTTP avec un body qui ne respecte pas l'enveloppe `{error: {code, message}}`. Aucune fuite de stack trace, aucun message d'erreur de provider non sanitisé.
## 6. Rate-limiting
### 6.1 Côté serveur
Le quota est appliqué **par licence** (clé = `hash(license_id)`).
| Tier | Fenêtre | Quota |
|------|---------|-------|
| premium | 1 minute glissante | 30 requêtes |
| premium | 1 jour glissant | 200 requêtes |
Le quota est partagé entre toutes les machines activées sur la même licence.
**Implémentation** : table Postgres `rate_limit_buckets(license_id PK, window_start TIMESTAMPTZ, count INT)` avec atomicité via `INSERT ... ON CONFLICT (license_id) DO UPDATE SET count = count + 1, window_start = CASE WHEN now() - window_start > '1 day' THEN now() ELSE window_start END RETURNING count`. Pour la fenêtre minute, idem en table séparée. Pas de Redis (cf. décision §0).
> **🔴 SECURITE+ARCHITECTURE** — Rate-limit in-memory ne peut pas garantir un quota par-licence.
> `maximus-api/src/middleware/rateLimit.ts` keye par IP via `Map` global (5 req/min, hardcoded). Le quota promis (30/min, 2000/jour, partagé entre machines) requiert un store atomique partagé : sur restart Coolify les compteurs se réinitialisent (refill gratuit), et toute scaling horizontale crée une race TOCTOU exploitable.
> **Resolution :** Avant que §6 ne quitte le draft : ajouter Redis OU token-bucket Postgres avec row-level lock. Généraliser `rateLimit.ts` en `{ keyFn, windows: [{ms,max}] }` pour réutiliser une même middleware avec deux configs (IP pour licenses, license-id pour prices). Documenter le backend.
> *Ref : OWASP API4:2023 — Unrestricted Resource Consumption*
> **🔴 TECHNIQUE** — Quota 2000/jour × N licences incompatible avec free tier CoinGecko (30/min).
> Le free tier CoinGecko Demo plafonne à 30 calls/min ; promettre 2000 req/j × N premium dépasse ce plafond dès quelques utilisateurs simultanés. Le quota promis n'est pas adossé à une capacité provider.
> **Resolution :** Soit prévoir CoinGecko Analyst payant (~$129/mois, 500 calls/min) et le mentionner dans la section coûts, soit baisser le quota par licence (ex. 200/j) et le justifier. Test interne que le quota provider est respecté en interne.
### 6.2 Côté client
Le client implémente **en plus** un rate-limit local pour éviter de gaspiller le quota serveur :
- Max 1 requête sortante toutes les 2 secondes
- Déduplication des requêtes en vol identiques (mêmes `symbol` + `date` → une seule requête réseau, plusieurs awaiters)
- Plafond hard : 100 requêtes par session de saisie de snapshot (anti-loop)
Ces limites sont des défenses en profondeur — le contrat ne dépend pas de leur valeur exacte.
> **🟡 TECHNIQUE** — Test "1 req / 2s" requiert fake timers + emplacement du rate-limiter non figé.
> Le test `it("respecte le rate-limit local 1 req / 2s")` est temporel et flaky sans `vi.useFakeTimers()`. Le contrat ne dit pas où implémenter le rate-limiter (hook ? service ?), ce qui laisse une décision d'archi ouverte.
> **Resolution :** Préciser §6.2 : « rate-limit implémenté dans `src/services/priceService.ts` (ou dans `balance.service.ts` section prices). Tests vitest avec `vi.useFakeTimers()`. » Ajouter le service au CLAUDE.md avant gel.
## 7. Authentification et autorisation — détail
### 7.1 Validation côté serveur (ordre)
Implémentée comme middleware partagée `src/middleware/licenseAuth.ts` (analogue à `adminAuth.ts`), réutilisable pour `/v1/quota` et autres endpoints premium futurs.
1. Header `Authorization` présent → sinon 401 `missing_token`
2. Format `Bearer <token>` correct → sinon 401 `invalid_token`
3. Signature Ed25519 valide (clé publique embarquée côté serveur) → sinon 401 `invalid_token`
4. Token non expiré (`exp` claim) → sinon 401 `expired_token`
5. **Claim `product` du JWT = `'simpl-resultat'`** → sinon 401 `invalid_token` (un futur 2e produit aura sa propre clé ou son propre claim ; pas de cross-product)
6. Licence en DB existe et `is_revoked = false` → sinon 403 `license_revoked`
7. Licence `edition = 'premium'` → sinon 403 `premium_required`
8. **Aucun appel provider tant que ces 7 étapes n'ont pas réussi.** Cette règle est testée côté serveur.
La middleware populate `c.set('license', { id, edition, product })` pour les handlers downstream.
> **🟡 SECURITE** — `activation_token` sans `jti`, durée 2 ans → fenêtre de replay non bornée.
> Tokens signés Ed25519 avec `exp` ~2 ans, sans `jti` (vérifié dans `licenseService.ts`). Un token leaké (compromission machine, slip de log, exfil malware) donne un accès premium pendant ~2 ans sans path de révocation hors révocation de la licence entière. `/v1/prices` envoie ce token à chaque appel — multiplie la surface d'exfil.
> **Resolution :** (a) ajouter `jti` + revocation list Redis-backed checkée à chaque `/v1/prices`, OU (b) raccourcir le TTL à 7-14 jours avec refresh-token silencieux, OU (c) bind du token au canal TLS via DPoP. Choix à acter en §7.
> *Ref : CWE-294 (Authentication Bypass by Capture-Replay), OWASP API2:2023*
> **🟡 ARCHITECTURE** — Auth + premium check doit être une middleware partagée.
> Étapes 1-6 = concern auth/authz autonome. Inliner dans le handler prices = duplication quand `/v1/quota` ou autres endpoints premium arriveront.
> **Resolution :** Ajouter `src/middleware/licenseAuth.ts` (analogue à `adminAuth.ts`) qui populate `c.set('license', ...)`. Référencer cette middleware par nom dans §7.1.
### 7.2 Le champ `edition` côté licence
Le serveur expose **déjà** `edition` dans la réponse de `POST /v1/licenses/verify` (depuis maximus-api Phase 1, cf. `licenseService.ts:216`). Valeurs possibles : `"base"` | `"premium"`. Aucun travail backend additionnel sur ce champ. Le client lit ce champ pour afficher / cacher conditionnellement le bouton de price-fetching dans l'UI — mais cette vérif n'est qu'**ergonomique**, jamais un substitut au check serveur.
> **🟡 ARCHITECTURE** — `edition` est déjà exposé par `/licenses/verify` (depuis Phase 1).
> La référence à une « issue maximus-api dédiée à ajouter » est obsolète : `licenseService.ts:216` retourne déjà `edition` dans la réponse verify.
> **Resolution :** Mettre à jour §7.2 : « `edition` est déjà exposé par `/licenses/verify` depuis maximus-api Phase 1. Aucun travail backend nécessaire pour ce champ. »
## 8. Comportement de proxying (côté serveur)
### 8.1 Routage par type de symbole
`maximus-api` détermine le provider en interne :
- **Crypto** : symbole matche le catalogue crypto connu (BTC, ETH, SOL, ADA, DOT, etc. ou suffixe `-USD`/`-USDT`) → exchanges directs via la lib `ccxt`. Tente Kraken d'abord, fallback Coinbase si Kraken 404. Données de marché publiques, ToS-clean, gratuit.
- **Stocks** : tout le reste → Yahoo Finance en best-effort assumé (cf. ADR 0011). Endpoint `query1.finance.yahoo.com/v7/finance/quote` ou `v8/finance/chart`. **Best-effort** : peut échouer / bouger sans préavis.
Si crypto 404 sur Kraken ET Coinbase, retourne `404 symbol_not_found`. Si Yahoo 404 ou indisponible, retourne `404 symbol_not_found` ou `503 service_degraded` (cf. circuit breaker §8.4). Le client doit utiliser la saisie manuelle dans ces cas.
**Pas de fallback cross-asset** : un symbole inconnu de la liste crypto n'est pas réessayé sur Yahoo (et vice-versa). Le client est explicite.
> **🔴 SECURITE+TECHNIQUE** — Yahoo Finance n'a pas d'API publique stable et son ToS interdit la redistribution.
> Endpoints `query1/query2.finance.yahoo.com` non documentés, sujets à blocage IP/CAPTCHA. ToS interdit l'usage commercial et la redistribution. Proxying tous les premium via une IP VPS = kill-switch unique pour le feature payant + risque légal.
> **Resolution :** Soit (a) souscrire à un fournisseur licencié payant (Polygon, Alpha Vantage, Twelve Data, Finnhub) avec droit contractuel de proxy, soit (b) flagger explicitement `source: 'yahoo'` comme best-effort + circuit breaker + fallback provider documenté. Acter dans un ADR avant ship en payant.
> *Ref : Yahoo Finance ToS sec. 7-8 (no commercial reuse)*
> **🔴 SECURITE** — Free tier CoinGecko interdit l'usage commercial / proxying.
> CoinGecko Demo gratuit interdit explicitement le commercial use et le proxy/redistribution ; seul le plan Demo/Pro payant avec API key le permet. Feature premium-payant sur free tier = breach ToS + risque de cutoff soudain.
> **Resolution :** Souscrire à CoinGecko Demo/Pro (API key en env), documenter le tier contractuel en §8, ajouter le header API-key serveur. Refléter le coût dans le pricing model.
> *Ref : CoinGecko ToS — Free Plan restrictions*
### 8.2 Headers sortants vers le provider
**Vers les exchanges crypto (Kraken, Coinbase via CCXT)** :
- `User-Agent: maximus-api/<version>` (version interne, jamais transmise au client)
- `Accept: application/json`
**Vers Yahoo Finance (stocks, best-effort)** : Yahoo bloque les requêtes sans User-Agent navigateur. Le serveur envoie un UA browser-like (`Mozilla/5.0 (X11; Linux x86_64) ... Chrome/...`) — c'est une exception explicite à la règle « UA fixe maximus-api ». Documenté dans ADR 0011.
**Garanties communes** : aucun header issu de la requête entrante n'est répercuté vers les providers. Implémentation via un client HTTP nu (`fetch` natif Node sans propagation), validée par le test §12.2.
### 8.3 IP source du provider
L'IP source vue par les providers est celle du VPS Maximus, mutualisée pour tous les utilisateurs premium. Cette propriété est garantie par la topologie réseau (pas de NAT transparent, pas de proxy SSL inverse vers les providers).
### 8.4 Circuit breaker (Yahoo best-effort)
Yahoo Finance étant un provider best-effort sans API officielle, un circuit breaker est obligatoire :
- **Compteur d'erreurs** : sur les 60 dernières secondes, si `count(5xx | 403 | timeout) >= 5`, le breaker s'ouvre.
- **État ouvert** : pendant 15 minutes, toutes les requêtes stocks retournent immédiatement `503 service_degraded` avec `retry_after: <secondes restantes>`. Aucun appel sortant Yahoo.
- **Half-open** : après 15 min, une seule requête tentée. Si succès, breaker fermé ; si échec, ouvert pour 15 min de plus.
- **Notification** : à l'ouverture du breaker, log structuré `level=warn` + (optionnel) webhook Telegram / email à `maxime2tremblay@protonmail.com`.
Crypto via CCXT n'a pas de circuit breaker dédié — les exchanges sont stables, leurs erreurs sont rares et déterministes.
> **🔴 SECURITE** — Single point of failure : un IP block VPS coupe le feature pour TOUS les premium.
> Mutualiser l'IP VPS est la promesse privacy, mais une seule sanction de Yahoo (qui voit du trafic commercial) bloque l'IP — kill-switch global qui touche 100% des utilisateurs payants en même temps.
> **Resolution :** Documenter dans un ADR le risque + budget pour un fallback provider rotatif. Ajouter un circuit breaker côté maximus-api : sur 5xx ou 403 répétés du provider, marquer automatiquement le service degraded et notifier (Telegram/email).
## 9. Garanties de logging et de privacy
Le serveur garantit par contrat :
1. **Pas de log conjoint** `(IP utilisateur, symbol)`. Les logs d'accès Traefik conservent les IP, mais le log applicatif des prix utilise `hash(license_id, salt_serveur)` à la place de toute info utilisateur.
2. **Pas de log de l'`activation_token` complet**. Seulement le `license_id` extrait du payload après validation de signature.
3. **Le cache prix** ne stocke aucune référence utilisateur — clé = `(symbol, date)`, valeur = `(price, currency, source, fetched_at)`.
4. **Aucun analytics, aucune télémétrie** sur `/v1/prices`. Seuls les logs minimaux d'observabilité.
> **🟡 SECURITE** — Logs Traefik (IP) + log applicatif (license_hash, symbol) → corrélation par timestamp casse §9.1.
> §9.1 promet « pas de log conjoint (IP, symbol) », mais Traefik enregistre `(IP, ts, path?querystring)` et l'app enregistre `(license_hash, symbol, ts)`. Quiconque a accès aux deux logs (ou un backup co-localisé) corrèle par timestamp et reconstitue `(IP, symbol)`. La garantie privacy est structurellement plus faible qu'annoncée.
> **Resolution :** (a) configurer Traefik pour stripper la querystring sur `/v1/prices`, OU (b) ne pas logger `/v1/prices` du tout côté Traefik (rely sur log app + stats privacy-preserving). Mettre à jour §9.1 pour refléter la garantie réelle.
> *Ref : CWE-532 (Insertion of Sensitive Info into Log)*
## 10. Sémantique de cache
### 10.1 Cache serveur
| Type de date | TTL |
|--------------|-----|
| Date passée (< aujourd'hui UTC) | 90 jours (les prix passés sont immuables, mais TTL fini = défense LRU) |
| Aujourd'hui (UTC) | 5 minutes |
| Réponse 404 `symbol_not_found` | 1 heure (TTL court séparé pour éviter pollution) |
**Implémentation** : table Drizzle `pricesCache` dans la même DB Postgres que les licenses (cf. décision §0).
```typescript
// src/db/schema.ts
export const pricesCache = pgTable("prices_cache", {
symbol: text("symbol").notNull(),
date: text("date").notNull(), // YYYY-MM-DD
price: numeric("price", { precision: 20, scale: 8 }), // null si 404
currency: text("currency"),
source: text("source").notNull(), // 'yahoo' | 'kraken' | 'coinbase'
fetchedAt: timestamp("fetched_at", { withTimezone: true }).notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
}, (t) => [primaryKey({ columns: [t.symbol, t.date] })]);
```
Pas de FK vers `licenses` (privacy). Job nightly cleanup `DELETE FROM prices_cache WHERE expires_at < now()`.
L'invalidation manuelle du cache n'est **pas** exposée par l'API.
**Défenses anti-pollution** :
- Cap LRU implicite via TTL fini (90 jours max sur les passées)
- 404s en TTL court (1h) sur table séparée pour éviter qu'un attaquant fill l'espace clé avec des symboles inexistants
- Idéalement (optimisation v1.1) : allowlist de symboles connus refresh quotidiennement (catalogue Yahoo + crypto exchanges) — reject les inconnus en 404 avant tout appel provider
> **🟡 ARCHITECTURE+TECHNIQUE** — Emplacement du cache non spécifié + maximus-api utilise Postgres (pas SQLite).
> §10 dit « cache local SQLite » dans l'ADR 0009 mais maximus-api est sur Postgres+Drizzle, pas SQLite. Pas de Redis non plus. Sans précision, soit on stocke en mémoire (perdu au restart Coolify), soit on persiste sans plan.
> **Resolution :** Ajouter §10.3 : « Cache implémenté via table Drizzle `prices_cache(symbol, date PK, price, currency, source, fetched_at, expires_at)` dans la même DB Postgres. Pas de FK à licenses (privacy). » Migration Drizzle nouvelle. Test d'intégration `it('persiste à travers un restart')`.
> **🟡 SECURITE** — Cache illimité avec TTL infini + regex symbole permissive → pollution de l'espace clé.
> TTL « infini » sur dates passées + regex `^[A-Za-z0-9.\-]{1,20}$` sans allowlist. Une licence premium compromise (ou plusieurs en parallèle) peut itérer ~36^20 symboles pour saturer le cache OU brûler le quota provider. Plusieurs machines par licence partagent le quota — abus coordonné dans le quota légal possible.
> **Resolution :** Cap LRU sur la taille du cache. 404s cachés en TTL court (1h max) avec LRU séparé. Idéalement : allowlist de symboles connus (catalogue Yahoo + CoinGecko refresh quotidien) — reject les inconnus en 404 avant tout appel provider.
> *Ref : CWE-770 (Allocation of Resources Without Limits)*
### 10.2 Cache client
Le client **ne doit pas** mettre en cache les réponses au-delà de la session courante :
- Pas de stockage SQLite des prix retournés
- Pas de `localStorage` ni `IndexedDB`
- Le `value` calculé (`quantity × unit_price`) **est** stocké dans `balance_snapshot_lines` — c'est une valeur dérivée denormalisée, pas un cache de l'API
- En mémoire de la session : OK pour la déduplication in-flight (cf. §6.2)
Cette contrainte protège contre l'extraction d'historique de consultation en cas de compromission de la machine cliente.
## 11. Versioning et évolutions
### 11.1 Changements rétrocompatibles autorisés en `/v1/`
- Ajout d'un champ optionnel dans la réponse de succès
- Ajout d'un nouveau code d'erreur (le client doit avoir un fallback sur les codes inconnus)
- Ajout d'un header optionnel
- Élargissement du quota
### 11.2 Changements non rétrocompatibles → `/v2/`
- Renommage / suppression d'un champ
- Changement du format d'enveloppe d'erreur
- Changement du format d'auth
- Restriction des paramètres acceptés
### 11.3 Coexistence
`maximus-api` peut servir simultanément `/v1/` et `/v2/` pendant une période de transition. Le client signale sa version par le path, pas par un header.
### 11.5 Migration `/licenses/*``/v1/licenses/*` (en parallèle de cette spec)
Pour cohérence avec `/v1/prices`, les endpoints existants `/licenses/*` migrent vers `/v1/licenses/*` :
- **Phase 1** (avec ce milestone) : nouveau préfixe `/v1/licenses/*` exposé. Anciens `/licenses/*` deviennent aliases qui renvoient `308 Permanent Redirect` vers `/v1/licenses/*`. Le client desktop continue à fonctionner sans modification.
- **Phase 2** (release simpl-resultat suivante, +30 jours) : le client desktop migre ses appels vers `/v1/licenses/*` directement. Header de réponse `Deprecation` (RFC 8594) sur les anciens paths.
- **Phase 3** (+60 jours) : les anciens paths retournent `410 Gone`. Suppression du code legacy.
L'enveloppe d'erreur de `/v1/licenses/*` est aussi migrée vers `{error: {code, message}}` nesté pour cohérence. Mapping des erreurs existantes vers les codes nouveaux est documenté dans le README maximus-api.
### 11.4 Rotation de clé Ed25519 (cross-cutting)
> **🟡 SECURITE** — Pas de header `kid` → la prochaine rotation Ed25519 force un redeploy complet.
> Le client Rust embed un PEM unique en constante (`license_commands.rs:28`) et le serveur signe sans `kid` dans le header JWT (`crypto/ed25519.ts setProtectedHeader`). La rotation 2026-04-25 (#49) a marché parce qu'aucune licence active n'existait ; la prochaine cassera toutes les licences actives jusqu'à update client. Pas de fenêtre d'overlap.
> **Resolution :** Ajouter `kid` au header JWT protégé. Ship le client Rust avec une map `kid → PEM`. Documenter une procédure de rotation avec fenêtre overlap 30 jours dans un nouvel ADR.
> *Ref : RFC 7515 §4.1.4 (kid header)*
## 12. Tests de conformité (références)
### 12.1 Côté client (`simpl-resultat`)
Tests unitaires obligatoires (`vitest` + `vi.fn()` sur `fetch` natif — pas de lib de mock externe ajoutée) :
- `it("envoie uniquement Authorization, Accept, User-Agent fixe")` — privacy headers
- `it("retourne le price sur 200")`
- `it("traduit chaque error.code en clé i18n")`
- `it("respecte Retry-After sur 429 et 503")`
- `it("ne réessaye pas sur 401, 403, 404, 400")`
- `it("dédoublonne les requêtes in-flight identiques")`
- `it("respecte le rate-limit local 1 req / 2s")` — utilise `vi.useFakeTimers()`
- `it("respecte le plafond hard 100 req / session")`
Le rate-limiter client + la dedup vivent dans `src/services/balance.service.ts` (section `prices`), conformément à la convention « 1 service par domaine » du projet.
> **🔴 TECHNIQUE** — `mockito-rs` n'existe pas comme crate.
> (a) Le crate Rust s'appelle `mockito` (sans suffixe `-rs`). (b) Le client de prix sera en TypeScript (fetch via Tauri vers maximus-api), donc Rust n'est pas le bon endroit pour ces tests. `Cargo.toml` de simpl-resultat ne contient aucun mock HTTP actuellement.
> **Resolution :** Préciser : tests en `vitest` côté TS avec `msw` ou `vi.fn()` sur `fetch`. Si volet Rust nécessaire (ex. tests Tauri command), utiliser `mockito` (sans `-rs`) ou `wiremock` ajouté à `[dev-dependencies]` du Cargo.toml.
### 12.2 Côté serveur (`maximus-api`)
Tests d'intégration obligatoires (`vitest` + `nock` pour mock outbound + `app.request()` natif Hono pour inbound) :
- `it("rejette toute requête sans Authorization → 401 missing_token")`
- `it("rejette une signature invalide → 401 invalid_token")`
- `it("rejette `claims.product !== 'simpl-resultat'` → 401 invalid_token")`
- `it("rejette une licence non-premium → 403 premium_required AVANT tout appel provider")`
- `it("appel sortant Yahoo n'inclut que User-Agent browser-like + Accept + Host")` — assertion sur `nock` interceptor
- `it("appel sortant exchanges (Kraken/Coinbase) n'inclut que User-Agent maximus-api + Accept + Host")`
- `it("logger.spy n'a jamais été appelé avec un payload contenant license_id ET symbol simultanément")` — via wrapper `src/logger.ts` (pino) + `vi.spyOn(logger, 'info')`
- `it("retourne 404 symbol_not_found sans fallback cross-asset")` — crypto inconnu ≠ tenter Yahoo
- `it("sert depuis le cache si disponible — vérifié par 0 appel sortant nock")`
- `it("circuit breaker : ouvre après 5 erreurs Yahoo / minute → 503 service_degraded en réponse")`
- `it("token-bucket Postgres : 31e requête en 1 minute → 429 rate_limit_exceeded")`
- `it("retourne le bon shape d'erreur pour chaque status code")`
> **🟡 TECHNIQUE** — Test « jamais log (license_id, symbol) » non testable simplement.
> Requiert une infra d'inspection de logs (capture stdout, parsing fichier) que maximus-api n'a pas — pas de logger structuré (`pino`, `winston`) dans `package.json`.
> **Resolution :** Reformuler en assertion sur logger injectable : `expect(loggerSpy).not.toHaveBeenCalledWith(stringContaining(licenseId) && stringContaining(symbol))`. Introduire `src/logger.ts` (pino) avant l'implémentation et tester contre lui.
> **🟢 TECHNIQUE** — Aucune lib HTTP mock prévue côté serveur.
> Les tests d'intégration mentionnent « supertest ou équivalent » mais aucune lib n'est dans `devDependencies` actuellement. Pour mocker Yahoo/CoinGecko il faut aussi `nock` ou `msw/node`.
> **Resolution :** Ajouter à `maximus-api/package.json` : `nock` (mock HTTP outbound) + `@hono/testing` ou `supertest` (test inbound). Préciser §12.2 : « `nock` pour les fournisseurs externes, `app.request()` de Hono pour l'API maximus. »
## 13. Décisions ouvertes (à trancher avant gel)
> Cette section disparaît une fois le contrat marqué `Statut: Stable`.
1. **Crypto pricing : prix instantané ou close UTC ?** Pour les snapshots de bilan datés, il faut un prix « représentatif » de la date. Décision proposée : **close UTC 00:00** via les endpoints OHLC des exchanges (Kraken `OHLC` interval=1440, Coinbase `candles` granularity=86400) ; pour `date == today`, prix instantané toléré.
2. **`actual_date` est-il utile ou source de bugs ?** Décision : **garder** car plus explicite que `is_approximation: bool`.
3. **Quota nuit / WE plus permissif ?** Décision : **non**, garder les seuils plats. Simple à expliquer.
4. **Endpoint d'introspection du quota** (`GET /v1/quota`) ? Décision : **out** du MVP. Ajouter en v1.x si demande utilisateur.
Décisions **tranchées en §0** (ne sont plus ouvertes) : provider, infra rate-limit, versioning, enveloppe d'erreur, lib mocks, `product` claim binding.
## Annexe A — Exemples complets
### A.1 Succès
**Requête** :
```http
GET /v1/prices?symbol=AAPL&date=2026-04-25 HTTP/1.1
Host: api.lacompagniemaximus.com
Authorization: Bearer <license-token>
Accept: application/json
User-Agent: simpl-resultat
```
**Réponse** :
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 28
X-RateLimit-Reset: 1714061520
Cache-Control: private, max-age=0
{
"symbol": "AAPL",
"date": "2026-04-25",
"actual_date": null,
"price": 173.45,
"currency": "USD",
"source": "yahoo",
"fetched_at": "2026-04-25T14:32:11Z",
"cached": false
}
```
### A.2 Licence non-premium
**Réponse** :
```http
HTTP/1.1 403 Forbidden
Content-Type: application/json; charset=utf-8
{
"error": {
"code": "premium_required",
"message": "Premium license required for price fetching"
}
}
```
### A.3 Rate-limit
**Réponse** :
```http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json; charset=utf-8
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714061580
Retry-After: 42
{
"error": {
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded for this license",
"retry_after": 42
}
}
```
## Annexe B — Mapping des codes d'erreur vers les clés i18n du client
À implémenter dans `src/i18n/locales/{fr,en}.json` sous `balance.priceFetching.errors.*` :
| `error.code` | Clé i18n | FR | EN |
|--------------|----------|----|----|
| `invalid_symbol` | `errors.invalidSymbol` | « Symbole invalide » | "Invalid symbol" |
| `invalid_date` | `errors.invalidDate` | « Date invalide » | "Invalid date" |
| `missing_param` | `errors.missingParam` | « Paramètre manquant » | "Missing parameter" |
| `missing_token` / `invalid_token` / `expired_token` | `errors.authFailed` | « Activation requise » | "Activation required" |
| `premium_required` | `errors.premiumRequired` | « Abonnement premium requis » | "Premium subscription required" |
| `license_revoked` | `errors.licenseRevoked` | « Licence révoquée — contactez le support » | "License revoked — contact support" |
| `symbol_not_found` | `errors.symbolNotFound` | « Symbole inconnu — saisissez le prix manuellement » | "Symbol not found — enter price manually" |
| `rate_limit_exceeded` | `errors.rateLimit` | « Trop de requêtes — réessayez dans {{seconds}}s » | "Too many requests — retry in {{seconds}}s" |
| `provider_unavailable` / `service_degraded` / `internal_error` | `errors.serverUnavailable` | « Service indisponible — saisissez le prix manuellement » | "Service unavailable — enter price manually" |
| `service_degraded` (circuit breaker Yahoo) | `errors.bestEffortDegraded` | « Source de prix temporairement indisponible — réessayez dans {{minutes}} min ou saisissez manuellement » | "Price source temporarily unavailable — retry in {{minutes}} min or enter manually" |
**Règle générale côté UI** : sur n'importe quelle erreur, le champ de saisie manuelle reste actif. Jamais bloquer la saisie d'un snapshot.
---
## Revision — Synthese
> Date : 2026-04-26 | Experts : Securite, Architecture, Technique
### Verdict
🔴 **CRITIQUES A CORRIGER** — La privacy posture et la défense en profondeur sont saines, mais le contrat repose sur des prémisses provider (Yahoo, CoinGecko free) en violation de ToS et sur une infra rate-limit/cache non encore en place. À débloquer avant le gel.
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|----|----|----|-------------|
| Securite | 3 | 4 | 1 | ToS providers, rate-limit infra, replay token, log correlation, kid rotation |
| Architecture | 3 | 4 | 1 | Asymétrie versioning, enveloppe d'erreur, claim `product`, middleware partagée, cache storage |
| Technique | 3 | 3 | 1 | `mockito-rs` inexistant, quota CoinGecko vs free tier, log inspection infra, headers infra |
### Actions requises
**🔴 Critiques — bloquantes pour le ship**
1. **Provider de prix légitime** — souscrire à un fournisseur licencié (Polygon / Alpha Vantage / Twelve Data / CoinGecko Pro) avec droit contractuel de proxy, OU acter un fallback documenté. Couvre Yahoo §8.1+§8.3 et CoinGecko §8.1+§6.1.
2. **Rate-limit partagé persistant** — Redis ou token-bucket Postgres (atomique) avant que §6 ne quitte le draft. Généraliser `rateLimit.ts` pour accepter `{ keyFn, windows }`.
3. **Enveloppe d'erreur cohérente** — trancher entre flat (`/licenses/*` actuel) ou nesté (proposé). Migrer un côté avant de figer.
4. **Versioning cohérent** — décider du préfixe `/v1/` global (renommer `/licenses/*` en `/v1/licenses/*`) ou local au prices uniquement.
5. **Binding `product`** — middleware vérifie `claims.product === 'simpl-resultat'`. Documenter en §7.1.
6. **Lib de tests réelle** — remplacer `mockito-rs` (inexistant) par `vitest + msw` côté TS, ou `mockito`/`wiremock` côté Rust si nécessaire.
7. **Quota client réaliste** — réduire 2000/j ou souscrire au tier provider qui le supporte. Cohérence quota promis ↔ capacité provider.
**🟡 Améliorations recommandées**
8. `activation_token` : ajouter `jti` + revocation list, OU réduire TTL à 7-14j avec refresh-token.
9. Cache serveur borné (LRU + cap), 404 TTL court, idéalement allowlist symboles.
10. Stripper la querystring de `/v1/prices` dans Traefik (ou ne pas logger), pour tenir §9.1.
11. `kid` dans le header JWT + map kid→PEM côté client (préparer la prochaine rotation Ed25519).
12. Mettre à jour §7.2 — `edition` est déjà exposé par `/licenses/verify`.
13. Middleware partagée `licenseAuth.ts` (étapes 1-6) au lieu d'inline dans le handler.
14. Cache : table Drizzle `prices_cache` dans le Postgres existant (pas SQLite, pas en mémoire).
15. Logger structuré (pino) injectable pour rendre testable « jamais log conjoint ».
16. Préciser fake-timers + emplacement du rate-limiter client (`priceService.ts`).
17. Client HTTP nu côté serveur pour les appels providers (éviter propagation des headers infra).
**🟢 Suggestions**
18. `X-Client-Major: 0.x` pour permettre la dépréciation d'urgence sans casser k-anonymity.
19. Architecture globale saine — convergence essentiellement sur l'alignement avec le code existant.
20. Ajouter `nock` + `@hono/testing` aux devDependencies de maximus-api.

View file

@ -1,6 +1,6 @@
# Architecture technique — Simpl'Résultat # Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-04-25 — Version 0.8.x (Bilan) > Document mis à jour le 2026-03-07 — Version 0.6.3
## Stack technique ## Stack technique
@ -26,22 +26,21 @@
``` ```
simpl-resultat/ simpl-resultat/
├── src/ # Frontend React/TypeScript ├── src/ # Frontend React/TypeScript
│ ├── components/ # 58 composants organisés par domaine │ ├── components/ # 55 composants organisés par domaine
│ │ ├── adjustments/ # 3 composants │ │ ├── adjustments/ # 3 composants
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
│ │ ├── budget/ # 5 composants │ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants │ │ ├── categories/ # 5 composants
│ │ ├── dashboard/ # 2 composants │ │ ├── dashboard/ # 2 composants
│ │ ├── import/ # 13 composants (wizard d'import) │ │ ├── import/ # 13 composants (wizard d'import)
│ │ ├── layout/ # AppShell, Sidebar │ │ ├── layout/ # AppShell, Sidebar
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher) │ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie) │ │ ├── reports/ # 10 composants (graphiques + rapports tabulaires + rapport dynamique)
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard) │ │ ├── settings/ # 3 composants (+ LogViewerCard)
│ │ ├── shared/ # 6 composants réutilisables │ │ ├── shared/ # 6 composants réutilisables
│ │ └── transactions/ # 5 composants │ │ └── transactions/ # 5 composants
│ ├── contexts/ # ProfileContext (état global profil) │ ├── contexts/ # ProfileContext (état global profil)
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine) │ ├── hooks/ # 12 hooks custom (useReducer)
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports) │ ├── pages/ # 10 pages
│ ├── services/ # 14 services métier │ ├── services/ # 14 services métier
│ ├── shared/ # Types et constantes partagés │ ├── shared/ # Types et constantes partagés
│ ├── utils/ # 4 utilitaires (parsing, CSV, charts) │ ├── utils/ # 4 utilitaires (parsing, CSV, charts)
@ -50,13 +49,10 @@ simpl-resultat/
│ └── main.tsx # Point d'entrée │ └── main.tsx # Point d'entrée
├── src-tauri/ # Backend Rust ├── src-tauri/ # Backend Rust
│ ├── src/ │ ├── src/
│ │ ├── commands/ # 6 modules de commandes Tauri │ │ ├── commands/ # 3 modules de commandes Tauri
│ │ │ ├── fs_commands.rs │ │ │ ├── fs_commands.rs
│ │ │ ├── export_import_commands.rs │ │ │ ├── export_import_commands.rs
│ │ │ ├── profile_commands.rs │ │ │ └── profile_commands.rs
│ │ │ ├── license_commands.rs
│ │ │ ├── auth_commands.rs
│ │ │ └── entitlements.rs
│ │ ├── database/ # Schémas SQL et migrations │ │ ├── database/ # Schémas SQL et migrations
│ │ │ ├── schema.sql │ │ │ ├── schema.sql
│ │ │ ├── seed_categories.sql │ │ │ ├── seed_categories.sql
@ -73,7 +69,7 @@ simpl-resultat/
## Base de données ## Base de données
### Tables (18) ### Tables (13)
| Table | Description | | Table | Description |
|-------|-------------| |-------|-------------|
@ -90,36 +86,10 @@ simpl-resultat/
| `budget_template_entries` | Catégories et montants dans les modèles | | `budget_template_entries` | Catégories et montants dans les modèles |
| `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_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. **Issue #179** : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils via `consolidated_schema.sql`, et proposés aux profils existants via `StarterAccountsModal` (one-shot, pref `balance_starter_proposed`). Le futur passage à un modèle véhicule × composition est décrit dans [ADR 0012](adr/0012-balance-two-level-model.md) (Proposed) |
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
| `balance_snapshot_lines` | Une ligne par `(snapshot, compte)`. Stockage denormalisé : pour `simple` `value` seul, pour `priced` `quantity + unit_price + value`. CHECK kind invariants côté SQL |
| `balance_account_transfers` | Liaison `transactions ↔ balance_accounts` avec `direction ∈ {in, out}`. Utilisée par le calcul Modified Dietz pour séparer apports et gains |
### Index (16) ### Index (9)
Index existants (9) : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source). Index sur : `transactions` (date, category, supplier, source, file, parent), `categories` (parent, type), `suppliers` (category, normalized_name), `keywords` (category, keyword), `budget_entries` (year, month), `adjustment_entries` (adjustment_id), `imported_files` (source).
Index Bilan (7, ajoutés en migration v9) :
- `idx_balance_accounts_category` (FK lookup catégorie → comptes)
- `idx_balance_accounts_active` partiel `WHERE is_active = 1` (filtre liste active)
- `idx_balance_snapshot_lines_snapshot` (chargement d'un snapshot)
- `idx_balance_snapshot_lines_account` (historique par compte)
- `idx_balance_account_transfers_account` (cash flows Modified Dietz par compte)
- `idx_balance_account_transfers_transaction` (lookup icône d'attribution dans `TransactionTable`)
- `idx_balance_snapshots_date` (sélecteur de période + agrégation chronologique)
### Invariants Bilan (CHECK + FK)
- `balance_categories.kind``('simple','priced')`
- `balance_accounts.currency = 'CAD'` (verrou MVP — v2 lèvera ce CHECK avec table de taux)
- `balance_snapshot_lines` : `(quantity, unit_price)` doivent être tous deux NULL (kind simple) OU tous deux NOT NULL (kind priced)
- `balance_account_transfers.direction``('in','out')` ; UNIQUE `(transaction_id, account_id)` (une transaction ne peut pas être liée deux fois au même compte)
- FK `balance_accounts.balance_category_id``balance_categories(id)` `ON DELETE RESTRICT` (empêche suppression de catégorie avec comptes liés)
- FK `balance_snapshot_lines.snapshot_id``balance_snapshots(id)` `ON DELETE CASCADE` (supprimer un snapshot supprime ses lignes)
- FK `balance_snapshot_lines.account_id``balance_accounts(id)` `ON DELETE RESTRICT` (préserve l'historique)
- FK `balance_account_transfers.account_id``balance_accounts(id)` `ON DELETE CASCADE`
- FK `balance_account_transfers.transaction_id``transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md)
## Système de migrations ## Système de migrations
@ -134,46 +104,30 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
| 5 | v5 | Création de `import_config_templates` | | 5 | v5 | Création de `import_config_templates` |
| 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) | | 6 | v6 | Changement contrainte unique `imported_files` (hash → filename) |
| 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) | | 7 | v7 | Ajout sous-catégories d'assurance (niveau 3) |
| 8 | v8 | Migration de catégories (cf. release 0.8.x) |
| 9 | v9 | Schéma Bilan : 5 tables + 7 index + seed des 7 catégories standard (cash, TFSA, RRSP, fund, stock, crypto, other) |
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations). Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
## Services TypeScript (18) ## Services TypeScript (15)
| Service | Responsabilité | | Service | Responsabilité |
|---------|---------------| |---------|---------------|
| `db.ts` | Wrapper de connexion (tauri-plugin-sql) | | `db.ts` | Wrapper de connexion (tauri-plugin-sql) |
| `profileService.ts` | Gestion des profils | | `profileService.ts` | Gestion des profils |
| `categoryService.ts` | CRUD catégories hiérarchiques | | `categoryService.ts` | CRUD catégories hiérarchiques |
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) | | `transactionService.ts` | CRUD et filtrage des transactions |
| `importSourceService.ts` | Configuration des sources d'import | | `importSourceService.ts` | Configuration des sources d'import |
| `importedFileService.ts` | Suivi des fichiers importés | | `importedFileService.ts` | Suivi des fichiers importés |
| `importConfigTemplateService.ts` | Modèles de configuration d'import | | `importConfigTemplateService.ts` | Modèles de configuration d'import |
| `categorizationService.ts` | Catégorisation automatique + helpers édition de mot-clé (`validateKeyword`, `previewKeywordMatches`, `applyKeywordWithReassignment`) | | `categorizationService.ts` | Catégorisation automatique |
| `adjustmentService.ts` | Gestion des ajustements | | `adjustmentService.ts` | Gestion des ajustements |
| `budgetService.ts` | Gestion budgétaire | | `budgetService.ts` | Gestion budgétaire |
| `dashboardService.ts` | Agrégation données tableau de bord | | `dashboardService.ts` | Agrégation données tableau de bord |
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) | | `reportService.ts` | Génération de rapports et analytique |
| `dataExportService.ts` | Export de données (chiffré) | | `dataExportService.ts` | Export de données (chiffré) |
| `userPreferenceService.ts` | Stockage préférences utilisateur | | `userPreferenceService.ts` | Stockage préférences utilisateur |
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) | | `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
### Service Bilan — `balance.service.ts` ## Hooks (12)
Un seul service par convention projet (1 service par domaine, splitter seulement > ~400 lignes). Quatre sections logiques distinctes :
1. **CRUD catégories + comptes**`listBalanceCategories`, `createBalanceCategory`, `updateBalanceCategory`, `archiveBalanceCategory` (refus si comptes liés via FK RESTRICT, refus si `is_seed = 1`), `listBalanceAccounts`, `createBalanceAccount`, `updateBalanceAccount`, `archiveBalanceAccount`. Le service garde une `BalanceServiceError` typée (`BalanceErrorCode`) pour permettre à la UI d'afficher des messages i18n distincts (`currency_unsupported`, `category_seed_protected`, `category_has_accounts`, etc.).
2. **Snapshots + lines**`listBalanceSnapshots`, `getBalanceSnapshotByDate`, `upsertSnapshot` (création + édition par date), `upsertSnapshotLines` (rewrite-all : DELETE WHERE snapshot_id puis INSERT par ligne — choix simple pour < 20 comptes/snapshot), `deleteSnapshot`, helper `validateLineKindInvariants` exporté pour les tests (kind invariants TS en complément du CHECK SQL ; tolérance `PRICED_VALUE_TOLERANCE = 0.01` pour le match `value ≈ quantity × unit_price`).
3. **Returns + transfers**`linkTransfer`, `unlinkTransfer`, `listAccountTransfers`, `listAllLinkedTransfersForTooltip` (un coup pour la `Map.has(txId)` consommée par l'icône d'attribution dans `TransactionTable`), `computeAccountReturn` (wrapper sur la commande Tauri `compute_account_return` qui lit `db_filename` du profil actif via `loadProfiles()`).
4. **Prices***(Phase 5, livraison reportée à l'Issue #143)*. La forme prévue : `fetchPrice(symbol, date)` invoquant `fetch_price` (Tauri), avec rate-limit client (1/2s), backoff exponentiel et dedup in-flight. Voir [ADR 0009](adr/0009-proxy-price-fetching-via-maximus-api.md) pour l'architecture proxy.
Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`).
## Hooks (17+)
Chaque hook encapsule la logique d'état via `useReducer` : Chaque hook encapsule la logique d'état via `useReducer` :
@ -187,22 +141,12 @@ Chaque hook encapsule la logique d'état via `useReducer` :
| `useAdjustments` | Ajustements | | `useAdjustments` | Ajustements |
| `useBudget` | Budget | | `useBudget` | Budget |
| `useDashboard` | Métriques du tableau de bord | | `useDashboard` | Métriques du tableau de bord |
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) | | `useReports` | Données analytiques |
| `useHighlights` | Panneau de faits saillants du hub rapports |
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
| `useCompare` | Rapport Comparables (mode `actual`/`budget`, sous-toggle MoM ↔ YoY, mois de référence explicite avec wrap-around janvier) |
| `useCategoryZoom` | Rapport Zoom catégorie avec rollup sous-catégories |
| `useCartes` | Rapport Cartes (snapshot KPI + sparklines + top movers + budget + saisonnalité via `getCartesSnapshot`) |
| `useBalanceAccounts` | Bilan — état de la page `/balance/accounts` : CRUD comptes ET catégories (un seul hook pour les deux onglets, aligné sur la convention "1 hook par page") |
| `useSnapshotEditor` | Bilan — cycle de vie d'un snapshot unique (`/balance/snapshot`) : valeurs simple (string) + valeurs priced (`{quantity, unit_price}` strings), prefill depuis snapshot précédent, save (rewrite-all), delete avec double-confirmation par re-saisie de la date |
| `useBalanceOverview` | Bilan — page `/balance` : sélecteur de période (`3M / 6M / 1A / 3A / Tout`), série temporelle agrégée, mode chart (`line` / `stacked`), tableau des comptes avec valeurs courantes et Δ% sur la période. Les rendements multi-horizons sont chargés *lazily* dans `BalanceAccountsTable` (un appel `compute_account_return` par cellule) |
| `useDataExport` | Export de données | | `useDataExport` | Export de données |
| `useTheme` | Thème clair/sombre | | `useTheme` | Thème clair/sombre |
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) | | `useUpdater` | Mise à jour de l'application |
| `useLicense` | État de la licence et entitlements |
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
## Commandes Tauri (36) ## Commandes Tauri (18)
### `fs_commands.rs` — Système de fichiers (6) ### `fs_commands.rs` — Système de fichiers (6)
@ -227,95 +171,10 @@ Chaque hook encapsule la logique d'état via `useReducer` :
- `save_profiles` — Sauvegarde de la configuration - `save_profiles` — Sauvegarde de la configuration
- `delete_profile_db` — Suppression du fichier de base de données - `delete_profile_db` — Suppression du fichier de base de données
- `get_new_profile_init_sql` — Récupération du schéma consolidé - `get_new_profile_init_sql` — Récupération du schéma consolidé
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`) - `hash_pin` — Hachage Argon2 du PIN
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité) - `verify_pin` — Vérification du PIN
- `repair_migrations` — Réparation des checksums de migration (rusqlite) - `repair_migrations` — Réparation des checksums de migration (rusqlite)
### `license_commands.rs` — Licence et activation machine (10)
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
- `store_license` — Stockage de la clé dans le répertoire app data
- `store_activation_token` — Stockage du token d'activation
- `read_license` — Lecture de la licence stockée
- `get_edition` — Détection de l'édition active (free/base/premium)
- `get_machine_id` — Génération d'un identifiant machine unique
- `activate_machine` — Activation en ligne (appel API serveur de licences, issue #49)
- `deactivate_machine` — Désactivation d'une machine enregistrée
- `list_activated_machines` — Liste des machines activées pour la licence
- `get_activation_status` — État d'activation de la machine courante
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
- `start_oauth` — Génère un code verifier PKCE et retourne l'URL d'authentification Logto
- `refresh_auth_token` — Rafraîchit l'access token via le refresh token
- `get_account_info` — Lecture du cache d'affichage (via `account_cache::load_unverified`, accepte les payloads legacy)
- `check_subscription_status` — Vérifie l'abonnement (max 1×/jour, fallback cache gracieux). Déclenche aussi la migration `tokens.json` → keychain via `token_store::load`
- `logout` — Efface tokens (`token_store`) + cache signé (`account_cache`) + clé HMAC du keychain
Note : `handle_auth_callback` n'est PAS exposée comme commande — elle est appelée depuis le handler deep-link `on_open_url` dans `lib.rs`. Voir section "OAuth2 et deep-link" plus bas.
### `token_store.rs` — Stockage des tokens OAuth (1)
- `get_token_store_mode` — Retourne `"keychain"`, `"file"` ou `null`. Utilisé par la bannière de sécurité `TokenStoreFallbackBanner` dans Settings pour alerter l'utilisateur quand les tokens sont dans le fallback fichier.
Module non-command : `save`, `load`, `delete`, `store_mode` — toute la logique de persistance passe par ce module, `auth_commands.rs` ne touche jamais directement `tokens.json`. Voir l'ADR 0006 pour la conception complète.
### `account_cache.rs` — Cache d'abonnement signé (aucune commande)
Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`. Expose :
- `save(app, &AccountInfo)` — écrit l'enveloppe signée `{data, sig}` dans `account.json`, avec clé HMAC-SHA256 stockée dans le keychain.
- `load_unverified(app)` — lecture pour affichage UI (accepte legacy et signé).
- `load_verified(app)` — lecture pour gating licence (refuse legacy, tampering, absence de clé). Utilisé par `license_commands::check_account_edition`.
- `delete(app)` — efface le fichier et la clé HMAC du keychain.
### `entitlements.rs` — Entitlements (1)
- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live
### `balance_commands.rs` — Bilan (1)
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif, lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation).
**À venir Phase 5** (Issue #143, BLOCKED par maximus-api Phase 2) : commande `fetch_price(symbol, date)` pour le price-fetching premium via proxy maximus-api. L'architecture est documentée dans l'ADR 0009 ; la livraison est différée jusqu'à ce que le serveur de licences (`maximus-api`) expose l'endpoint `GET /v1/prices`.
## Plugins Tauri
Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ordre) :
| Plugin | Rôle | Contrainte |
|--------|------|-----------|
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
| `tauri-plugin-process` | Relaunch après mise à jour | — |
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` |
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
## OAuth2 et deep-link (Compte Maximus)
Flow complet (v0.7.3+) :
1. Frontend appelle `start_oauth` → génère un code verifier PKCE (64 chars), le stocke dans `OAuthState` (Mutex en mémoire du processus), retourne l'URL Logto
2. Frontend ouvre l'URL via `tauri-plugin-opener` → le navigateur système affiche la page Logto
3. L'utilisateur s'authentifie (ou Logto auto-consent si session existante) → redirection 303 vers `simpl-resultat://auth/callback?code=...`
4. L'OS route le custom scheme vers une nouvelle instance de l'app → `tauri-plugin-single-instance` (feature `deep-link`) détecte l'instance existante, **ne démarre PAS un nouveau processus**, et forwarde l'URL à l'instance vivante
5. Le callback `app.deep_link().on_open_url(...)` enregistré via `DeepLinkExt` reçoit les URLs. Pour chaque URL :
- Si un param `code` est présent → appelle `handle_auth_callback` (token exchange vers `/oidc/token`, fetch `/oidc/me`, écriture des tokens via `token_store::save` (keychain OS, fallback fichier 0600) + cache signé via `account_cache::save` (HMAC-SHA256), émission de l'event `auth-callback-success`)
- Si un param `error` est présent → émission de l'event `auth-callback-error` avec `error: error_description`
6. Le hook `useAuth` (frontend) écoute `auth-callback-success` / `auth-callback-error` et met à jour l'état
Pourquoi cet enchaînement est critique :
- **Sans `tauri-plugin-single-instance`** : une nouvelle instance démarre à chaque callback, le `OAuthState` est vide (pas de verifier), le token exchange échoue
- **Sans `on_open_url`** : l'ancien listener `app.listen("deep-link://new-url", ...)` ne recevait pas les URLs forwardées par single-instance. L'API canonique v2 via `DeepLinkExt` est nécessaire
- **Sans gestion des erreurs** : un callback `?error=...` laissait l'UI bloquée en état "loading" infini
Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_commands.rs` (PKCE + token exchange), `src-tauri/src/commands/token_store.rs` (persistance keychain + fallback), `src-tauri/src/commands/account_cache.rs` (cache signé HMAC), `src/hooks/useAuth.ts` (frontend), `src/components/settings/TokenStoreFallbackBanner.tsx` (UI de l'état dégradé).
## Pages et routing ## Pages et routing
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate). Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
@ -337,23 +196,10 @@ Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `App
| `/categories` | `CategoriesPage` | Gestion hiérarchique | | `/categories` | `CategoriesPage` | Gestion hiérarchique |
| `/adjustments` | `AdjustmentsPage` | Ajustements manuels | | `/adjustments` | `AdjustmentsPage` | Ajustements manuels |
| `/budget` | `BudgetPage` | Planification budgétaire | | `/budget` | `BudgetPage` | Planification budgétaire |
| `/reports` | `ReportsPage` | Hub des rapports : panneau faits saillants + 4 cartes de navigation | | `/reports` | `ReportsPage` | Analytique et rapports |
| `/reports/highlights` | `ReportsHighlightsPage` | Faits saillants détaillés (soldes, top mouvements, top transactions) | | `/settings` | `SettingsPage` | Paramètres |
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) | | `/docs` | `DocsPage` | Documentation in-app |
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) | | `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
| `/reports/category` | `ReportsCategoryPage` | Zoom catégorie avec rollup + édition contextuelle de mots-clés |
| `/reports/cartes` | `ReportsCartesPage` | Tableau de bord KPI avec sparklines, top movers, budget et saisonnalité |
| `/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/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/users` | `UsersSettingsPage` | Comptes (Maximus), licences et guide d'utilisation (rendu inline depuis `DocsContent`) |
| `/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 |
| `/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).
@ -367,43 +213,12 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
## CI/CD ## CI/CD
Deux workflows Forgejo Actions (avec miroir GitHub) dans `.forgejo/workflows/` : Workflow GitHub Actions (`release.yml`) déclenché par les tags `v*` :
### `check.yml` — Vérifications sur branches et PR
Déclenché sur chaque push de branche (sauf `main`) et chaque PR vers `main`. Lance en parallèle :
- `cargo check` + `cargo test` (Rust)
- `npm run build` (tsc + vite)
- `npm test` (vitest)
Doit être vert avant tout merge. Évite de découvrir des régressions au moment du tag de release.
### `release.yml` — Build et publication
Déclenché par les tags `v*`. Deux jobs :
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS) 1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
2. **build-linux** (ubuntu-22.04) → `.deb` + `.rpm` 2. **build-linux** (ubuntu-22.04) → `.deb` + `.AppImage`
Fonctionnalités : Fonctionnalités :
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY) - Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` - JSON d'updater pour mises à jour automatiques
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md - Release GitHub automatique avec notes d'installation
## Architecture Decision Records (ADRs)
Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `docs/adr/`.
| # | Titre | Date | Statut |
|---|-------|------|--------|
| [0001](adr/0001-tauri-v2.md) | Choix de Tauri v2 comme framework desktop | 2024-01-01 | Accepted |
| [0002](adr/0002-useReducer-vs-redux.md) | useReducer plutôt que Redux | 2024-01-01 | Accepted |
| [0003](adr/0003-sqlx-migrations.md) | Migrations SQL inline via tauri-plugin-sql | 2024-01-01 | Accepted |
| [0004](adr/0004-aes-256-gcm-encryption.md) | Chiffrement AES-256-GCM pour l'export | 2024-01-01 | Accepted |
| [0005](adr/0005-multi-profile-db.md) | Multi-profils avec bases SQLite séparées | 2024-01-01 | Accepted |
| [0006](adr/0006-oauth-tokens-keychain.md) | Stockage des tokens OAuth via keychain | 2024-01-01 | Accepted |
| [0007](adr/0007-reports-hub-refactor.md) | Refactorisation du hub de rapports | 2024-01-01 | Accepted |
| [0008](adr/0008-modified-dietz-pour-rendement.md) | Modified Dietz pour le calcul de rendement | 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 |
| [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

@ -246,183 +246,55 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
## 9. Rapports ## 9. Rapports
`/reports` est un **hub** qui répond à quatre questions : *qu'est-ce qui a bougé ce mois ?*, *où je vais sur 12 mois ?*, *comment je me situe vs période précédente ou vs budget ?*, *que se passe-t-il dans cette catégorie ?* Visualisez vos données financières avec des graphiques interactifs et comparez votre plan budgétaire au réel.
### Le hub
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, quatre cartes mènent aux quatre sous-rapports dédiés.
Le sélecteur de période en haut à droite est **partagé** entre toutes les pages via l'URL : `?from=YYYY-MM-DD&to=YYYY-MM-DD`. Copiez l'URL pour revenir plus tard au même état ou la partager.
### Rapport Faits saillants (`/reports/highlights`)
- Tuiles de soldes mois courant + YTD avec sparklines 12 mois
- Tableau triable des **top mouvements** (catégories avec la plus forte variation vs mois précédent), ou graphique en barres divergentes centré sur zéro (toggle graphique/tableau)
- Liste des **plus grosses transactions récentes** avec fenêtre configurable 30 / 60 / 90 jours
### Rapport Cartes (`/reports/cartes`)
Un tableau de bord condensé qui résume un mois de référence en quatre KPIs, une sparkline 13 mois, les plus gros mouvements de catégories, l'adhésion budgétaire et la saisonnalité. Deux contrôles en en-tête : le **sélecteur de mois de référence** et le **toggle Mensuel / Cumul annuel (YTD)**.
#### Les 4 cartes KPI et leurs formules
- **Revenus** = somme des transactions positives
- **Dépenses** = valeur absolue de la somme des transactions négatives
- **Solde net** = `revenus dépenses`
- **Taux d'épargne** = `(revenus dépenses) ÷ revenus × 100`. Affiché comme « — » quand les revenus sont à zéro (évite la division par zéro et le « 0 % » trompeur)
Chaque carte affiche la valeur courante, deux deltas (vs mois précédent, vs l'an dernier) et une sparkline 13 mois. Le delta est vert si la variation est favorable au KPI (hausse pour Revenus, baisse pour Dépenses), rouge dans le cas contraire.
#### Toggle Mensuel / Cumul annuel (YTD)
Placé à côté du sélecteur de mois, il bascule les 4 cartes entre deux vues :
- **Mensuel** (par défaut) — la valeur courante est celle du mois de référence. Les deltas comparent ce mois à son précédent (MoM) et au même mois de l'an dernier (YoY)
- **Cumul annuel (YTD)** — la valeur courante est la somme depuis le 1er janvier de l'année de référence jusqu'à la fin du mois de référence inclus. Les deltas deviennent :
- **MoM YTD** = cumul actuel (Jan→mois de réf) vs cumul précédent (Jan→mois de réf1) de la même année. **Null en janvier** (pas de fenêtre antérieure dans la même année) — affiché comme « — »
- **YoY YTD** = cumul actuel vs le même cumul (Jan→mois de réf) de l'année précédente
- **Taux d'épargne YTD** = `(revenus YTD dépenses YTD) ÷ revenus YTD × 100`, null si les revenus YTD sont à zéro
Le choix du mode est **persisté** (clé locale `reports-cartes-period-mode`) et restauré au redémarrage. La sparkline 13 mois, elle, reste toujours mensuelle — elle donne le contexte temporel indépendamment du toggle.
#### Sparkline 13 mois
Chaque KPI inclut une mini-courbe des 13 derniers mois (mois de référence + 12 précédents). Les mois sans données comptent comme zéro pour que la courbe reste continue. Non affectée par le toggle Mensuel / YTD.
#### Top mouvements (MoM)
Deux listes : les 5 catégories avec la plus forte **hausse** de dépenses vs mois précédent et les 5 avec la plus forte **baisse**. Triées par variation absolue en dollars, avec la variation en pourcentage à droite. Toujours mensuelles, indépendamment du toggle.
#### Saisonnalité
Compare les dépenses du mois de référence à la **moyenne du même mois calendaire sur les 2 années précédentes**.
- Écart en pourcentage : `(dépenses du mois moyenne historique) ÷ moyenne historique × 100`
- Affiché comme « pas assez d'historique pour ce mois » s'il n'y a aucune donnée historique ou si la moyenne est à zéro
Toujours basée sur le mois de référence, indépendamment du toggle Mensuel / YTD.
#### Adhésion budgétaire
Score `N/M` des catégories dont les dépenses restent sous le budget mensuel (comparaison en valeur absolue pour gérer correctement les budgets de dépenses stockés signés négatifs).
- Seules les catégories de type **dépense** avec un budget non nul sont comptées, feuilles uniquement (les catégories parentes sont ignorées pour éviter le double comptage)
- Suivi des **3 pires dépassements** avec le montant et le pourcentage de dépassement
Toujours mensuelle, indépendamment du toggle.
#### À savoir
- Le sélecteur de période générique (utilisé par les autres rapports) est volontairement absent ici : la Cartes pivote autour d'un mois unique avec comparaisons automatiques, donc seul le sélecteur de mois de référence est exposé
- Le taux d'épargne affiche « — » (pas « 0 % ») quand les revenus sont à zéro, pour distinguer « pas de revenus » de « revenus = dépenses »
### Rapport Tendances (`/reports/trends`)
- **Flux global** : revenus vs dépenses vs solde net sur la période, en graphique d'aires ou tableau
- **Par catégorie** : évolution de chaque catégorie, en lignes ou tableau pivot
### Rapport Comparables (`/reports/compare`)
Trois modes accessibles via un tab bar :
- **Mois vs mois précédent** — tableau catégories × 2 colonnes + écart $ et %
- **Année vs année précédente** — même logique sur 12 mois vs 12 mois
- **Réel vs budget** — reprend la vue Budget vs Réel avec ses totaux mensuels et cumul annuel
### Rapport Zoom catégorie (`/reports/category`)
Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez :
- Un **donut chart** de la répartition par sous-catégorie avec le total au centre
- Un graphique d'évolution mensuelle de la catégorie
- Un tableau triable des transactions
### Édition contextuelle des mots-clés
**Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite)
2. Un sélecteur de catégorie cible
3. Un bouton **Appliquer et recatégoriser**
L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées.
### Astuces
- Le toggle **graphique / tableau** est mémorisé par sous-rapport (vos préférences restent même après redémarrage)
- Les mots-clés doivent faire entre 2 et 64 caractères (protection contre les regex explosives)
- Le zoom catégorie est **protégé contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
---
## 10. Bilan
Le **Bilan** est une vue patrimoniale : vous saisissez périodiquement un *snapshot* daté de l'ensemble de vos comptes (encaisse, REER, CELI, fonds, actions, crypto, autres), vous suivez leur évolution dans le temps, et vous calculez le **vrai rendement** de chaque compte d'investissement en liant les transferts (apports / retraits) aux comptes correspondants.
Trois pages composent le module Bilan :
- `/balance` — vue d'ensemble (graphique + tableau des comptes)
- `/balance/snapshot` — saisie / édition d'un snapshot daté
- `/balance/accounts` — CRUD des comptes et catégories
L'entrée **Bilan** dans la barre latérale (icône portefeuille) donne accès à `/balance` ; les deux autres pages s'ouvrent depuis là.
### Fonctionnalités ### Fonctionnalités
- 7 catégories standard pré-installées : Encaisse, CELI, REER, Fonds, Actions, Crypto, Autres — renommables, non-supprimables - Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
- Création de catégories personnalisées (ex. FERR, RPDB) avec choix `simple` (montant direct) ou `priced` (quantité × prix unitaire) - Dépenses par catégorie : répartition des dépenses (graphique circulaire)
- Comptes par catégorie : nom, symbole optionnel, devise (CAD au MVP), notes - Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en ligne)
- Snapshots datés avec contrainte UNIQUE par date — éditer = revenir sur la même date, jamais dupliquer - Budget vs Réel : tableau comparatif mensuel et cumul annuel
- Saisie groupée par catégorie ; pour les catégories `priced`, le `value` est calculé automatiquement (`quantity × unit_price`) - Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
- Bouton **Pré-remplir depuis le snapshot précédent** : copie les valeurs simples + les quantités priced (vous remplissez juste les nouveaux prix) - Motifs SVG (lignes, points, hachures) pour distinguer les catégories
- Liaison de transactions existantes à un compte de bilan (modal avec filtres par période / catégorie / recherche, sens auto-proposé selon le signe) - Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
- Icône d'attribution dans la page Transactions pour les transactions liées à un transfert - Détail des transactions par catégorie avec tri par colonne (date, description, montant)
- Graphique d'évolution du bilan (mode courbe simple ou aire empilée par catégorie) avec marqueurs verticaux pour les transferts taggés (vert = in, rouge = out) - Toggle pour afficher ou masquer les montants dans le détail des transactions
- Tableau des comptes avec **3 colonnes de rendement Modified Dietz** (3 mois / 1 an / depuis création) + colonne rendement non-ajusté côte-à-côte
- Avertissement si le dernier snapshot remonte à plus de 60 jours
- Soft-delete des comptes (`Archiver`) : masqués des nouveaux snapshots, conservés dans l'historique
- Suppression d'un snapshot avec double-confirmation (re-saisie de la date)
- Privacy-first : tout est local, aucun appel sortant au MVP
### Comment faire ### Comment faire
1. Allez dans `/balance/accounts` → onglet Catégories pour créer si besoin une catégorie supplémentaire (ex. "FERR" en `simple`, ou "Stocks Wealthsimple" en `priced`) 1. Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel
2. Allez dans l'onglet Comptes pour créer chaque compte (ex. "TFSA Tangerine" rattaché à CELI, "BTC Ledger" rattaché à Crypto avec symbole `BTC`) 2. Ajustez la période avec le sélecteur de période
3. Cliquez **+ Nouveau snapshot** depuis `/balance` pour ouvrir `/balance/snapshot` à la date du jour 3. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
4. Remplissez les valeurs par compte (groupées par catégorie). Pour les comptes priced, saisissez la quantité et le prix unitaire — la valeur est calculée 4. Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher
5. Enregistrez. Le graphique sur `/balance` s'actualise immédiatement 5. Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel
6. Pour calculer le rendement réel d'un compte d'investissement, ouvrez le menu actions du compte → **Lier transferts** → cochez les transactions qui correspondent à des apports / retraits (un dépôt CELI, un achat d'actions, etc.). Le sens (in/out) est proposé automatiquement selon le signe de la transaction 6. Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions
7. Le tableau des comptes affiche maintenant les rendements Modified Dietz sur 3M / 1A / depuis création. Le rendement non-ajusté à droite vous permet de comparer "valeur du compte" et "vraie performance" 7. Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants
8. Pour éditer un snapshot existant, cliquez sur son point dans le graphique ou utilisez le sélecteur de date — la page s'ouvre en mode édition (la date est immutable)
9. Pour supprimer un snapshot, cliquez **Supprimer** dans son éditeur et re-saisissez la date pour confirmer
### Lecture des rendements multi-horizons
- **3 mois** : performance courte, sensible aux mouvements récents
- **1 an** : horizon de référence pour la plupart des décisions d'allocation
- **Depuis création** : performance totale du compte depuis le premier snapshot
- **Non-ajusté (côte-à-côte)** : `(V_fin V_début) / V_début` brut, sans soustraction des apports — utile pour voir la croissance totale (gains + apports). La différence entre les deux colonnes vous montre la part qui vient des apports plutôt que de la performance
Avertissements affichés :
- *Période partielle* — un snapshot manque au début ou à la fin de la période
- *Aucun transfert lié* — le rendement est calculé sans apports identifiés (équivaut au non-ajusté)
- *Performance non significative* — le compte a été vidé puis rechargé, le calcul Modified Dietz produit un résultat instable
### Que faire si je supprime une transaction liée ?
C'est intentionnellement bloqué : si vous tentez de supprimer une transaction qui est liée à un compte de bilan, vous voyez le message **"Cette transaction est liée au compte de bilan _<nom>_"** avec un lien direct vers le compte. Ouvrez le compte → Lier transferts → décochez la transaction → revenez la supprimer. Cette friction préserve la reproductibilité de vos rendements passés (un rendement calculé hier ne peut pas changer aujourd'hui à cause d'une suppression silencieuse).
### Astuces ### Astuces
- Saisissez vos snapshots à un rythme régulier (mensuel ou trimestriel) — la qualité des rendements dépend directement de la régularité - Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser
- Utilisez le bouton **Pré-remplir** : ça copie tout, vous mettez juste à jour ce qui a changé - Le sélecteur de période s'applique à tous les onglets de graphiques simultanément
- Le mode **graphique empilé par catégorie** raconte une histoire différente du mode ligne : il montre la composition de votre patrimoine, pas seulement son total - Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie
- Les marqueurs verticaux du graphique (transferts taggés) aident à lire les sauts de valeur — un saut suivi d'un marqueur vert n'est pas une "performance", c'est juste un dépôt - Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques
- L'avertissement "bilan pas à jour" apparaît si votre dernier snapshot remonte à plus de 60 jours — c'est le signe qu'il est temps d'en saisir un nouveau
- (À venir Phase 5) **Récupération automatique des prix** pour les comptes Actions / Crypto via un proxy privé (premium-only). Le service interroge un serveur Maximus dédié qui anonymise votre requête (votre IP n'est jamais exposée à Yahoo / CoinGecko). La saisie manuelle reste toujours disponible. ### Rapport dynamique
Le rapport dynamique fonctionne comme un tableau croisé dynamique (pivot table). Vous composez votre propre rapport en assignant des dimensions et des mesures.
**Dimensions disponibles :** Année, Mois, Type (dépense/revenu/transfert), Catégorie Niveau 1 (parent), Catégorie Niveau 2 (enfant).
**Mesures :** Montant périodique (somme), Cumul annuel (YTD).
1. Cliquez sur un champ disponible dans le panneau de droite
2. Choisissez où le placer : Lignes, Colonnes, Filtres ou Valeurs
3. Le tableau et/ou le graphique se mettent à jour automatiquement
4. Utilisez les filtres pour restreindre les données (ex : Type = dépense uniquement)
5. Basculez entre les vues Tableau, Graphique ou Les deux
6. Cliquez sur le X pour retirer un champ d'une zone
--- ---
## 11. Paramètres ## 10. Paramètres
Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import. Configurez les préférences de l'application, vérifiez les mises à jour, accédez au guide utilisateur et gérez vos données avec les outils d'export/import.
@ -432,7 +304,6 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Guide d'utilisation complet accessible directement depuis les paramètres - Guide d'utilisation complet accessible directement depuis les paramètres
- Vérification automatique des mises à jour avec installation en un clic - Vérification automatique des mises à jour avec installation en un clic
- Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement - Journaux de l'application (logs) consultables avec filtres par niveau, copie et effacement
- Envoi de feedback optionnel vers `feedback.lacompagniemaximus.com` (exception explicite au fonctionnement 100 % local — déclenche une demande de consentement avant le premier envoi)
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV - Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
- Import des données depuis un fichier exporté précédemment - Import des données depuis un fichier exporté précédemment
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés - Chiffrement AES-256-GCM optionnel pour les fichiers exportés
@ -442,10 +313,9 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète 1. Cliquez sur Guide d'utilisation pour accéder à la documentation complète
2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible 2. Cliquez sur Vérifier les mises à jour pour voir si une nouvelle version est disponible
3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez 3. Consultez la section Journaux pour voir les logs de l'application — filtrez par niveau (Tout, Error, Warn, Info), copiez ou effacez
4. Pour partager une suggestion ou signaler un problème, cliquez sur Envoyer un feedback dans la carte Journaux ; les cases d'identification et d'ajout du contexte/logs sont décochées par défaut 4. Utilisez la section Gestion des données pour exporter ou importer vos données
5. Utilisez la section Gestion des données pour exporter ou importer vos données 5. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement 6. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
### Astuces ### Astuces
@ -454,5 +324,4 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
- Exportez régulièrement pour garder une sauvegarde de vos données - Exportez régulièrement pour garder une sauvegarde de vos données
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer - Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page - Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique - En cas de problème, copiez les journaux et joignez-les à votre signalement
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement

View file

@ -1,141 +0,0 @@
# QA — Refonte seed catégories IPC
Checklist manuelle pour valider le flow complet de migration v2→v1 (spec `spec-refonte-seed-categories-ipc`). À dérouler avant chaque release touchant au module catégories.
## Prérequis
- [ ] Profil de test v2 avec au moins : 10+ catégories seedées, 2-3 catégories custom, 50+ transactions réparties, quelques suppliers et keywords, un budget actif.
- [ ] Profil de test v1 (nouveau profil créé après #115) pour les tests de régression.
- [ ] Variante profil v2 **avec PIN activé** (profil chiffré).
---
## 1. Pré-migration (découverte)
- [ ] Ouvrir l'app sur un profil v2 → bannière dashboard "Découvrez la nouvelle structure" (#118) visible en haut.
- [ ] Cliquer CTA bannière → redirige vers `/settings/categories/standard` (#117).
- [ ] Dismiss la bannière → disparaît immédiatement, reste dismiss après redémarrage de l'app.
- [ ] Créer un nouveau profil v1 (ou charger le profil v1 de test) → bannière **pas** affichée.
## 2. Page Guide des catégories standard (#117)
- [ ] Arbre affiche les 3 niveaux avec les noms traduits (FR et EN).
- [ ] Switcher EN → les noms basculent, l'arbre conserve son état d'expansion.
- [ ] Tooltip au hover d'un nœud affiche `i18n_key`, `type`, `id`.
- [ ] Compteur "N catégories" en haut correspond au nombre total de leaves.
- [ ] Recherche full-text filtre les noms correctement (accent-insensitive).
- [ ] Bouton Print → impression/PDF avec arbre entièrement déplié et toolbar masquée.
- [ ] Lien "Voir les catégories standard" dans Settings > Catégories → même page.
## 3. Page de migration — Étape 1 (Discover)
- [ ] Depuis Settings > Catégories sur un profil v2, cliquer "Migrer vers la nouvelle structure" → `/settings/categories/migrate`.
- [ ] Étape 1 affiche l'arbre v1 (read-only, comme la page Guide).
- [ ] Bouton Next passe à l'étape 2.
## 4. Page de migration — Étape 2 (Simulate)
- [ ] Table 3 colonnes : v2 source | confidence badge | cible v1 + action.
- [ ] Badges corrects : 🟢 haute (keyword), 🔵 moyenne (supplier), 🟠 basse (default), 🔴 aucune (review).
- [ ] Stats summary en haut : total / high / medium / low / none.
- [ ] Section "Catégories personnalisées préservées" liste les custom du profil (si présentes).
- [ ] Cliquer une ligne 🟠 ou 🔴 → panneau latéral "Transactions impactées" affiche jusqu'à 50 transactions liées.
- [ ] Pour une ligne unresolved, ouvrir le picker de cible → sélectionner un leaf v1 → badge passe à ✅ résolu.
- [ ] Bouton Next **disabled** tant qu'il reste des lignes unresolved.
- [ ] Résoudre toutes les unresolved → Next devient actif.
## 5. Page de migration — Étape 3 (Consent)
- [ ] Checklist obligatoire : "J'ai compris…", "Je consens…", "Je sauvegarde avant…".
- [ ] Bouton Apply disabled tant que la checklist n'est pas complète.
- [ ] Sur profil avec PIN, champ PIN demandé.
- [ ] Apply déclenche : loader 4 sous-étapes (1. Sauvegarde, 2. Insertion v1, 3. Remap transactions/budgets, 4. Cleanup v2).
## 6. Migration — cas nominal
- [ ] Après Apply, écran de succès affiche :
- [ ] Chemin du backup SREF (ex: `~/Documents/Simpl-Resultat/backups/MonProfil_avant-migration-2026-04-21T12-34-56.sref`).
- [ ] Récap : nb v1 insérées, nb transactions updatées, nb budgets updatées, nb keywords updatés, nb v2 désactivées, nb custom préservées.
- [ ] Liens vers Dashboard / Voir les catégories.
- [ ] Retour au Dashboard → bannière dashboard #118 disparue.
- [ ] Settings > Catégories → bannière "Sauvegarde disponible" (#122) visible (90 jours).
- [ ] Page Catégories → affiche la structure v1.
## 7. Migration — échec backup
Simuler : rendre le dossier `~/Documents/Simpl-Resultat/backups/` non-writable (Linux : `chmod -w`, Windows : permissions lecture seule) **avant** de lancer Apply.
- [ ] Apply échoue à l'étape 1 (Sauvegarde).
- [ ] Message d'erreur clair : "Impossible de créer la sauvegarde. Aucune modification n'a été effectuée."
- [ ] **Aucune écriture BDD** : via outil SQLite, vérifier que `categories`, `transactions`, `budgets`, `keywords` sont strictement identiques avant/après.
- [ ] Flag `categories_schema_version` reste à `v2`.
- [ ] Bouton retry dispo ; rétablir les droits d'écriture, relancer → succès.
## 8. Migration — échec SQL (rollback)
Plus difficile à reproduire ; le chemin testé unitairement est le `ROLLBACK` déclenché si un `UPDATE` casse une contrainte FK/UNIQUE. Tester en injectant artificiellement une contrainte (ex: DB corrompue) si un outil le permet, sinon revue de code suffit. La checklist minimale :
- [ ] Si un échec est simulé, écran d'erreur affiche "La migration a échoué. Vos données n'ont pas été modifiées. Votre sauvegarde est disponible à <path>."
- [ ] BDD intacte (même check qu'au point 7).
- [ ] Backup SREF créé (toujours disponible sur le disque).
## 9. Bannière post-migration (#122) — 90 jours
- [ ] Migration récente → bannière Settings > Catégories visible.
- [ ] Date d'expiration affichée = timestamp migration + 90 jours.
- [ ] Cliquer "Fermer" → bannière disparaît, flag `categories_migration_banner_dismissed=1`, ne réapparaît plus après redémarrage.
- [ ] Après 90 jours (avancer l'horloge système ou manipuler `last_categories_migration.timestamp` dans la DB) → bannière **plus** affichée, mais entrée permanente "Rétablir une sauvegarde" reste dispo dans Settings.
## 10. Rétablir la sauvegarde (#122)
- [ ] Cliquer "Rétablir la sauvegarde" depuis bannière ou depuis l'entrée permanente.
- [ ] Modale s'ouvre : chemin backup affiché, texte de warning, boutons Annuler / Rétablir (rouge).
- [ ] Sur profil chiffré (PIN), champ PIN demandé.
- [ ] Simuler fichier manquant : renommer/déplacer le `.sref` avant de cliquer Rétablir → erreur claire + file picker de secours.
- [ ] Choisir le fichier via le picker → la restauration procède.
- [ ] Après succès :
- [ ] Toast / message succès.
- [ ] L'app recharge, catégories v2 à nouveau actives.
- [ ] `categories_schema_version` revient à `v2`.
- [ ] `last_categories_migration.reverted_at` renseigné (ISO string).
- [ ] Transactions, budgets, keywords correspondent à l'état pré-migration.
- [ ] Annuler la modale → aucune modification.
## 11. Profil avec catégories custom
- [ ] Migration préserve les 3 custom sous un parent "Catégories personnalisées (migration)" (id 2000).
- [ ] Les transactions liées aux custom conservent leur `category_id`.
- [ ] Les keywords liés aux custom conservent leur `category_id`.
## 12. Profil sans catégories custom
- [ ] Migration **ne crée pas** de parent "Catégories personnalisées (migration)" (section `preserved` vide → skip).
## 13. Régression — fonctionnalités auxiliaires
Tester sur profil v2 ET profil v1 pour vérifier qu'aucune fonctionnalité n'a cassé :
- [ ] **Auto-catégorisation CSV** : importer un CSV de test → catégorisation par keyword/supplier fonctionne identiquement.
- [ ] **Budget vs Actuel** : agrégation parent/enfant cohérente, montants corrects.
- [ ] **Splits** : transactions multi-catégories préservent leurs ratios.
- [ ] **Export/Import SREF** : export d'un profil v1 → re-import dans un nouveau profil → structure identique.
- [ ] **UI CategoryTree et CategoryCombobox** : rendent correctement l'arbre v1 et v2, pas d'affichage cassé ou mélangé.
- [ ] **Rapports** : aucune régression sur les graphiques catégorie (tendances, répartition).
## 14. i18n
- [ ] Toutes les nouvelles pages (Guide, Migration, bannières, modale) supportent FR et EN.
- [ ] Aucune chaîne en dur visible à l'écran.
- [ ] Bascule live FR↔EN ne casse pas l'état local des pages.
---
## Tests automatisés équivalents
Pour référence, les chemins de tests automatisés qui couvrent partiellement cette checklist :
- Unitaires : `src/services/categoryMappingService.test.ts` (100 tests), `src/services/categoryBackupService.test.ts` (23), `src/services/categoryMigrationService.test.ts` (16), `src/services/categoryTaxonomyService.test.ts` (15), `src/services/categoryRestoreService.test.ts` (12), `src/hooks/useCategoryMigration.test.ts` (13).
- Intégration : `src/__integration__/category-migration.test.ts` (flow + échecs + rollback).
- Régression : `src/__integration__/regression-v2-v1.test.ts` (auto-catégorisation, budget agrégation, splits paramétrés v2/v1).
Cette QA manuelle couvre les dimensions UX, erreurs système (permissions, fichiers manquants), profil chiffré et régression visuelle qui ne sont pas automatisables actuellement.

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>

425
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.9.1", "version": "0.5.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.5.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",
@ -35,8 +35,7 @@
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.4.1", "vite": "^6.4.1"
"vitest": "^4.0.18"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -1431,66 +1430,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@ -1824,17 +1763,6 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@ -1889,13 +1817,6 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1963,127 +1884,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.0.18",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@ -2146,16 +1946,6 @@
} }
] ]
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -2348,13 +2138,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.44.0", "version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
@ -2410,31 +2193,11 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/eventemitter3": { "node_modules/eventemitter3": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -2880,29 +2643,11 @@
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true "dev": true
}, },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/papaparse": { "node_modules/papaparse": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==" "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="
}, },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2910,11 +2655,10 @@
"dev": true "dev": true
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2923,9 +2667,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 +2685,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",
@ -3177,13 +2920,6 @@
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3193,20 +2929,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@ -3231,23 +2953,6 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -3264,16 +2969,6 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -3358,11 +3053,10 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.2", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@ -3432,84 +3126,6 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
"@vitest/pretty-format": "4.0.18",
"@vitest/runner": "4.0.18",
"@vitest/snapshot": "4.0.18",
"@vitest/spy": "4.0.18",
"@vitest/utils": "4.0.18",
"es-module-lexer": "^1.7.0",
"expect-type": "^1.2.2",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.0.18",
"@vitest/browser-preview": "4.0.18",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/ui": "4.0.18",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/void-elements": { "node_modules/void-elements": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
@ -3518,23 +3134,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -1,16 +1,14 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.9.1", "version": "0.6.3",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri"
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -39,7 +37,6 @@
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.4.1", "vite": "^6.4.1"
"vitest": "^4.0.18"
} }
} }

233
public/CHANGELOG.fr.md Normal file
View file

@ -0,0 +1,233 @@
# Journal des modifications
## [Non publié]
## [0.6.3]
### Ajouté
- Tableau de bord : histogramme empilé des dépenses par catégorie et par mois (#15)
- Tableau de bord : tableau budget vs réel du mois courant avec écart en $ et % (#15)
- Tableau de budget et rapport Budget vs Réel : formatage des sous-totaux de section avec poids visuel croissant (#14)
### Modifié
- Tableau de bord : période par défaut changée de « mois » à « année à ce jour » (#15)
- Tableau de bord : section des transactions récentes supprimée (#15)
- Tous les rapports tabulaires : les lignes de grand total utilisent maintenant une police plus grande (text-sm), gras et bordure supérieure plus épaisse pour une meilleure hiérarchie visuelle (#14)
### Corrigé
- Rapport catégories dans le temps : toutes les catégories sont maintenant affichées (limite passée de 8 à 50) (#13)
- Graphique par catégorie : les noms sur l'axe Y utilisent maintenant la couleur du texte principal au lieu du gris (#13)
- Graphique catégories dans le temps : le texte de la légende utilise maintenant la couleur du texte principal au lieu d'hériter la couleur de la catégorie (#13)
## [0.6.2]
### Ajouté
- Tableau de budget : sous-totaux par section (dépenses, revenus, transferts) affichés après chaque groupe (#11)
- Rapport Budget vs Réel : sous-totaux par section avec réel, prévu, écart ($) et écart (%) par type (#11)
### Corrigé
- Page catégories : le panneau de détail reste maintenant visible lors du défilement d'une longue liste de catégories (#12)
## [0.6.1]
### Ajouté
- Page historique des versions : historique complet accessible depuis les Paramètres à tout moment
- Changelog bilingue : les notes de version s'affichent dans la langue choisie par l'utilisateur (FR/EN)
### Corrigé
- Visibilité des labels de graphiques : les montants sur les barres empilées utilisent maintenant du texte noir avec contour blanc pour un meilleur contraste (#8)
- Tableau de budget : les cellules éditables affichent maintenant un fond au survol, un curseur pointeur et une info-bulle pour clarifier l'interaction (#9)
## [0.6.0]
### Ajouté
- Rapports : bascule entre vue tableau et graphique pour les onglets Tendances, Par catégorie et Évolution
- Rapports : option « Afficher les montants » pour afficher les valeurs directement sur les barres et courbes
- Rapports : panneau de filtres avec cases à cocher par catégorie (recherche, tout sélectionner/désélectionner) et filtre par source
- Rapports : le filtre source s'applique au niveau SQL pour des totaux filtrés précis
- Rapports : en-têtes de tableau fixes sur tous les tableaux de rapports (Rapport dynamique, Budget vs Réel)
- Rapports : survol interactif — barres non survolées estompées, info-bulle filtrée sur la catégorie survolée
- Rapports : le survol de la légende met en évidence la catégorie sur tous les mois (graphique Évolution)
### Corrigé
- Tableau des transactions : l'icône de commentaire devient orange (comme l'icône de ventilation) quand une note est présente (#7)
## [0.5.0]
### Ajouté
- Gestion d'erreurs : intercepte les plantages React et affiche une page d'erreur au lieu d'un écran blanc
- Délai de démarrage (10 s) sur la connexion à la base de données — affiche une page d'erreur au lieu d'un indicateur de chargement infini
- Page d'erreur avec « Rafraîchir », « Vérifier les mises à jour » et liens de contact
- Visionneuse de journaux dans les paramètres — capture la sortie console, filtrable par niveau, copiable, persiste entre les rafraîchissements
- Licence GPL-3.0 — le projet est maintenant open source
### Modifié
- Modale détail de rapport : colonnes triables — cliquez sur les en-têtes pour trier par date, description ou montant (#1)
- Modale détail de rapport : bascule pour afficher/masquer la colonne des montants (#3)
- Tableau de budget : les en-têtes de colonnes restent fixes lors du défilement vertical (#2)
### Corrigé
- Mise à jour automatique sur Linux : le champ version de `latest.json` n'a plus le préfixe `v`, téléversement au registre de paquets plus robuste
- Nouvelle tentative au démarrage : la connexion BD réessaie jusqu'à 3 fois avant d'afficher la page d'erreur (corrige l'échec au premier lancement sur Windows)
- Somme de contrôle de migration : répare automatiquement la somme de contrôle obsolète de la migration 1 au démarrage
## [0.4.4]
### Corrigé
- Le binaire Linux est maintenant compatible avec glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — le CI compile dans un conteneur Ubuntu 22.04
## [0.4.3]
### Corrigé
- Le point de terminaison de mise à jour automatique utilise maintenant le registre de paquets Forgejo pour une URL stable
- Les signatures Linux (.AppImage.sig) sont maintenant correctement collectées dans le CI
- Toutes les signatures de plateforme (.deb.sig, .rpm.sig) sont maintenant incluses dans les assets de la release
## [0.4.2]
### Modifié
- La mise à jour automatique pointe maintenant vers l'instance Forgejo auto-hébergée
- Les builds Windows sont maintenant compilés en croisé via cargo-xwin
## [0.4.1]
### Corrigé
- Application bloquée sur un indicateur de chargement infini après mise à jour depuis v0.3.x (somme de contrôle de migration incompatible sur seed_categories.sql)
- Les erreurs de connexion BD sont maintenant journalisées dans la console au lieu d'échouer silencieusement
## [0.4.0]
### Ajouté
- Catégories : support de 3 niveaux de hiérarchie (ex : Dépenses récurrentes → Assurances → Assurance-auto)
- Rapport dynamique : nouveau champ « Catégorie (Niveau 3) »
- Budget : sous-totaux intermédiaires et indentation 3 niveaux pour les catégories imbriquées
- Catégories : gestion automatique de `is_inputable` à la création/suppression de sous-catégories
- Catégories : la validation de profondeur empêche la création d'un 4e niveau
- Données initiales : Assurances divisées en Assurance-auto, Assurance-habitation, Assurance-vie
### Corrigé
- Auto-catégorisation : les mots-clés commençant/finissant par des caractères spéciaux (`[`, `]`, `(`, `)`, `-`, etc.) sont maintenant reconnus
- Auto-catégorisation : pré-compilation des regex pour de meilleures performances en lot
## [0.3.11]
### Ajouté
- Rapport dynamique : support de plusieurs dimensions en colonnes (clés composites)
### Corrigé
- Rapport dynamique : n'est plus affecté par les filtres de date globaux — utilise uniquement ses propres filtres du panneau
## [0.3.10]
### Ajouté
- Rapport dynamique : les champs peuvent maintenant être utilisés dans plusieurs zones simultanément (lignes + filtres, colonnes + filtres)
- Rapport dynamique : clic-droit sur une valeur de filtre pour l'exclure (affiché barré en rouge)
- Option de période « Cette année » dans les rapports et le tableau de bord (du 1er janvier à aujourd'hui)
## [0.3.9]
### Ajouté
- Rapport dynamique (tableau croisé) : composez des rapports personnalisés en assignant des dimensions aux lignes, colonnes, filtres et mesures aux valeurs
- Suppression de mots-clés depuis la vue « Tous les mots-clés »
## [0.3.8]
### Ajouté
- Sélecteur de plage de dates personnalisée pour les rapports et le tableau de bord
- Bascule pour positionner les sous-totaux au-dessus ou en dessous des lignes de détail
- Affichage des notes de version du CHANGELOG dans les releases et le système de mise à jour
## [0.3.7]
### Corrigé
- Suppression du bundle MSI pour éviter le conflit de chemin d'installation du système de mise à jour
- Changement du mode d'installation Windows à basicUi
- Amélioration de la visibilité de l'indicateur de ventilation et de la mise en page des ajustements
## 0.3.2
### Nouvelles fonctionnalités
- **Support Linux** : ajout des builds Linux (`.deb`, `.rpm`, `.AppImage`) au workflow de release
- **Ventilations sur la page Ajustements** : visualisez les ajustements de ventilation des transactions dans une section dédiée
### Corrigé
- Correction de cas limites de détection automatique CSV
- Suppression de l'accent dans productName pour la compatibilité `.deb` Linux
## 0.3.1
### Corrigé
- Toujours afficher le sélecteur de profil dans la barre latérale (#2)
## 0.3.0
### Nouvelles fonctionnalités
- **Profils multiples** : créez plusieurs profils avec des bases de données séparées, des noms et couleurs personnalisés
- **Protection par NIP** : protégez les profils avec un NIP numérique optionnel
- **Sélecteur de profil** : changement rapide de profil depuis la barre latérale
- **Glisser-déposer les catégories** : réordonnez les catégories ou changez le parent par glisser-déposer dans l'arborescence
- **Ventilation des transactions** : ventilez une transaction sur plusieurs catégories avec des montants ajustables
## 0.2.10
### Nouvelles fonctionnalités
- **Sélection rapide de période** : boutons de filtre rapide (Ce mois, Mois dernier, etc.) sur la page Transactions
- **Rapport Budget vs Réel** : tableau comparatif mensuel et cumulatif annuel dans les Rapports
- **Sous-totaux de catégories parentes** : la page Budget affiche les sous-totaux agrégés pour les catégories parentes
- **Guide utilisateur** : documentation complète accessible depuis les Paramètres, imprimable en PDF
### Améliorations
- Persistance de la sélection de modèle et ajout du bouton Mettre à jour le modèle
- Ne plus pré-sélectionner les fichiers déjà importés à l'entrée de la configuration source
- Rendre les imports de données de paramètres visibles dans l'historique d'import
- Remplacer les boutons de suppression par modèle par un seul bouton sur la sélection
- Remplacer l'icône de rafraîchissement par une icône de sauvegarde sur le bouton de mise à jour du modèle
- Ajout de la convention de signe à la page budget
## 0.2.9
### Corrigé
- Permettre les fichiers à contenu identique avec des noms différents (#1)
## 0.2.8
### Nouvelles fonctionnalités
- **Export/import de données** : exportez et importez vos données (transactions, catégories, ou les deux) avec chiffrement AES-256-GCM optionnel (#3)
### Corrigé
- Détection de doublons inter-fichiers et suivi d'import par fichier
## 0.2.5
### Nouvelles fonctionnalités
- **Modèles de configuration d'import** : sauvegardez et chargez les configurations de source d'import comme modèles réutilisables
- **Grille de budget 12 mois** : vue budgétaire annuelle complète avec cellules mensuelles et totaux annuels
### Corrigé
- Corrections du budget et des catégories
- Problème de somme de contrôle de migration (schema.sql ne doit pas être modifié après la release initiale)
## 0.2.3
### Nouvelles fonctionnalités
- **Motifs de graphiques** : ajout de motifs de remplissage SVG (lignes diagonales, points, hachures, etc.) pour différencier les catégories dans les graphiques au-delà de la couleur
- **Menu contextuel des graphiques** : clic-droit sur une catégorie dans un graphique pour la masquer ou voir ses transactions dans une fenêtre de détail
- **Catégories masquées** : les catégories masquées apparaissent comme des puces au-dessus des graphiques avec un bouton « Tout afficher »
- **Modale de détail des transactions** : visualisez toutes les transactions composant le total d'une catégorie directement depuis n'importe quel graphique
- **Aperçu d'import en popup** : l'aperçu des données est maintenant une modale popup au lieu d'une étape séparée de l'assistant
- **Vérification directe des doublons** : nouveau bouton « Vérifier les doublons » sur la page de configuration d'import
### Améliorations
- Flux de l'assistant d'import simplifié : configuration source → vérification des doublons (l'aperçu est optionnel via popup)
- Le bouton retour de la vérification des doublons retourne maintenant à la configuration source
## 0.2.2
- Mise à jour de version
## 0.2.1
- Ajout de la vue « Tous les mots-clés » sur la page Catégories
- Ajout du mode sombre avec palette de gris chauds
- Correction des catégories orphelines, persistance de has_header pour les imports, ajout de la réinitialisation
- Ajout des pages Budget et Ajustements

234
public/CHANGELOG.md Normal file
View file

@ -0,0 +1,234 @@
# Changelog
## [Unreleased]
## [0.6.3]
### Added
- Dashboard: expenses over time stacked bar chart by category and month (#15)
- Dashboard: budget vs actual table for current month with variance in $ and % (#15)
- Budget table and Budget vs Actual report: section subtotal formatting with increasing visual weight (#14)
### Changed
- Dashboard: default period changed from "month" to "year to date" (#15)
- Dashboard: removed recent transactions section (#15)
- All report tables: grand total rows now use larger font (text-sm), bold weight, and thicker top border for better visual hierarchy (#14)
### Fixed
- Category over time report: all categories now displayed (limit increased from 8 to 50) (#13)
- Category bar chart: Y-axis labels now use foreground color instead of muted gray (#13)
- Category over time chart: legend text now uses foreground color instead of inheriting category color (#13)
## [0.6.2]
### Added
- Budget table: section subtotals for expenses, income, and transfers displayed after each group (#11)
- Budget vs Actual report: section subtotals with actual, planned, variation ($) and variation (%) per type (#11)
### Fixed
- Category page: detail panel now stays visible when scrolling through a long category list (#12)
## [0.6.1]
### Added
- Changelog page: full version history accessible from Settings at any time
- Bilingual changelog: release notes displayed in the user's selected language (EN/FR)
### Fixed
- Chart label visibility: amount labels on stacked bar charts now use black text with white outline for better contrast (#8)
- Budget table: editable cells now show hover background, pointer cursor, and tooltip hint for clearer affordance (#9)
## [0.6.0]
### Added
- Reports: toggle between table and chart view for Trends, By Category, and Over Time tabs
- Reports: "Show amounts" toggle displays values directly on chart bars and area curves
- Reports: filter panel with category checkboxes (search, select all/none) and source dropdown
- Reports: source filter applies at SQL level for accurate filtered totals
- Reports: sticky table headers on all report tables (Dynamic Report, Budget vs Actual)
- Reports: interactive hover — dimmed non-hovered bars, tooltip filtered to hovered category
- Reports: legend hover highlights category across all months (Over Time chart)
### Fixed
- Transaction table: comment icon now turns orange (like split icon) when a note is present (#7)
## [0.5.0]
### Added
- Error boundary catches React crashes and displays an error page instead of a white screen
- Startup timeout (10s) on database connection — shows error page instead of infinite spinner
- Error page with "Refresh", "Check for updates", and contact/issue links
- Log viewer in settings page — captures console output, filterable by level, copyable, persists across refresh
- GPL-3.0 license — project is now open source
### Changed
- Report detail modal: sortable columns — click headers to sort by date, description, or amount (#1)
- Report detail modal: toggle to show/hide amounts column (#3)
- Budget table: column headers stay fixed when scrolling vertically (#2)
### Fixed
- Auto-updater on Linux: `latest.json` version field no longer has `v` prefix, package registry upload is more robust
- Startup retry: DB connection retries up to 3 times before showing error page (fixes first-launch failure on Windows)
- Migration checksum mismatch: automatically repairs stale migration 1 checksum on startup
## [0.4.4]
### Fixed
- Linux binary now compatible with glibc 2.35+ (Ubuntu 22.04 / Pop!_OS) — CI builds in Ubuntu 22.04 container
## [0.4.3]
### Fixed
- Auto-updater endpoint now uses Forgejo package registry for stable URL
- Linux updater signatures (.AppImage.sig) now correctly collected in CI
- All platform signatures (.deb.sig, .rpm.sig) now included in release assets
## [0.4.2]
### Changed
- Auto-updater now points to self-hosted Forgejo instance
- Windows builds now cross-compiled via cargo-xwin
## [0.4.1]
### Fixed
- App stuck on infinite spinner after updating from v0.3.x (migration checksum mismatch on seed_categories.sql)
- DB connection errors now logged to console instead of silently failing
## [0.4.0]
### Added
- Categories: support for 3 levels of hierarchy (e.g., Dépenses récurrentes → Assurances → Assurance-auto)
- Dynamic Report: new "Category (Level 3)" pivot field
- Budget: intermediate subtotals and 3-level indentation for nested categories
- Categories: automatic `is_inputable` management when creating/deleting subcategories
- Categories: depth validation prevents creating a 4th level
- Seed data: Assurances split into Assurance-auto, Assurance-habitation, Assurance-vie
### Fixed
- Auto-categorization: keywords starting/ending with special characters (`[`, `]`, `(`, `)`, `-`, etc.) now match correctly
- Auto-categorization: pre-compile regex patterns for better batch performance
## [0.3.11]
### Added
- Dynamic Report: support multiple column dimensions (composite column keys)
### Fixed
- Dynamic Report: no longer affected by global page date filters — uses only its own panel filters
## [0.3.10]
### Added
- Dynamic Report: fields can now be used in multiple zones simultaneously (rows + filters, columns + filters)
- Dynamic Report: right-click on a filter value to exclude it (shown with strikethrough in red)
- "This year" period option in reports and dashboard (Jan 1 to today)
## [0.3.9]
### Added
- Dynamic Report (pivot table): compose custom reports by assigning dimensions to rows, columns, filters and measures to values
- Delete keywords from the "All Keywords" view
## [0.3.8]
### Added
- Custom date range picker for reports and dashboard
- Toggle to position subtotals above or below detail rows
- Display release notes from CHANGELOG in GitHub releases and in-app updater
## [0.3.7]
### Fixes
- Remove MSI bundle to prevent updater install path conflict
- Change Windows updater installMode to basicUi
- Improve split indicator visibility and adjustments layout
## 0.3.2
### New Features
- **Linux support**: Add Linux build (`.deb`, `.rpm`, `.AppImage`) to release workflow
- **Transaction splits on Adjustments page**: View transaction split adjustments in a dedicated section on the Adjustments page
### Fixes
- Fix CSV auto-detect edge cases
- Remove accent from productName for Linux `.deb` compatibility
## 0.3.1
### Fixes
- Always show profile switcher in sidebar (#2)
## 0.3.0
### New Features
- **Multiple profiles**: Create multiple profiles with separate databases, custom names, and colors
- **PIN protection**: Protect profiles with an optional numeric PIN
- **Profile switcher**: Quick profile switching from the sidebar
- **Drag-and-drop categories**: Reorder categories or change parent via drag-and-drop in the category tree
- **Transaction splits**: Split a transaction across multiple categories with adjustable amounts
## 0.2.10
### New Features
- **Period quick-select**: Add quick period filter buttons (This month, Last month, etc.) on the Transactions page
- **Budget vs Actual report**: Monthly and year-to-date comparison table in Reports
- **Parent category subtotals**: Budget page shows aggregated subtotals for parent categories
- **User guide**: Complete documentation page accessible from Settings, printable to PDF
### Improvements
- Persist template selection and add Update template button
- Don't pre-select already-imported files when entering source config
- Make settings data imports visible in Import History
- Replace per-template delete buttons with single delete on selection
- Replace refresh icon with save icon on update template button
- Add sign convention to budget page
## 0.2.9
### Fixes
- Allow duplicate-content files with different names (#1)
## 0.2.8
### New Features
- **Data export/import**: Export and import your data (transactions, categories, or both) with optional AES-256-GCM encryption (#3)
### Fixes
- Cross-file duplicate detection and per-file import tracking
## 0.2.5
### New Features
- **Import config templates**: Save and load import source configurations as reusable templates
- **12-month budget grid**: Full year budget view with monthly cells and annual totals
### Fixes
- Budget and category fixes
- Migration checksum issue (schema.sql must not be modified after initial release)
## 0.2.3
### New Features
- **Chart patterns**: Added SVG fill patterns (diagonal lines, dots, crosshatch, etc.) to differentiate categories in bar charts, pie chart, and stacked bar charts beyond just color
- **Chart context menu**: Right-click any category in a chart to hide it or view its transactions in a detail popup
- **Hidden categories**: Hidden categories appear as dismissible chips above charts with a "Show all" button to restore them
- **Transaction detail modal**: View all transactions composing a category's total directly from any chart
- **Import preview popup**: The data preview is now a popup modal instead of a separate wizard step, allowing quick inspection without leaving the configuration page
- **Direct duplicate check**: New "Check Duplicates" button on the import configuration page skips directly to duplicate validation without requiring a preview first
### Improvements
- Import wizard flow simplified: source-config → duplicate-check (preview is optional via popup)
- Duplicate-check back button now returns to source configuration instead of the removed preview step
- Added `categoryIds` map to `CategoryOverTimeData` for proper category resolution in the over-time chart
## 0.2.2
- Bump version
## 0.2.1
- Add "All Keywords" view on Categories page
- Add dark mode with warm gray palette
- Fix orphan categories, persist has_header for imports, add re-initialize
- Add Budget and Adjustments pages

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

View file

@ -1,81 +0,0 @@
# Spec Decisions — Refonte du seed de catégories vers IPC Statistique Canada
> Date: 2026-04-19
> Projet: simpl-resultat
> Statut: Draft
> Slug: refonte-seed-categories-ipc
## Contexte
Le seed actuel (migration v2, `src-tauri/src/database/seed_categories.sql`) comprend 42 catégories sur 2 niveaux, structurées autour d'un axe cosmétique "récurrentes vs ponctuelles" sans logique métier derrière. Cette organisation :
- Ne reflète aucun référentiel comptable standard (pas d'alignement possible avec Statistique Canada, CPA, ou benchmarks internationaux).
- Exploite seulement 2 des 3 niveaux de hiérarchie supportés par le code (`src/services/categoryService.ts:55-60`).
- Contient des catégories "fourre-tout" (Amazon, Projets) qui cassent la cohérence comptable.
- Propose des mots-clés limités aux fournisseurs rencontrés dans un jeu de données historique personnel.
La refonte vise à s'aligner sur la **classification IPC de Statistique Canada (panier 2024)** qui regroupe les dépenses des ménages en 8 composantes principales officielles, étendues de granularité fine inspirée de Monarch Money (~60 catégories standard, 3 niveaux).
Le spike `seed-standard` (archivé sous `~/claude-code/.spikes/archived/seed-standard/`) a livré 4 documents préparatoires :
- `NOTES.md` — synthèse et décisions
- `code/seed-proposal-v1.sql` — taxonomie v1 (139 catégories, 150+ keywords)
- `code/mapping-old-to-new.md` — table de correspondance v2→v1
- `code/preview-page-mockup.md` — wireframes 3-étapes
**Bénéficiaires** : tous les utilisateurs actuels et futurs de Simpl'Résultat — en particulier les ménages québécois/canadiens qui pourront éventuellement comparer leurs dépenses aux moyennes Statistique Canada.
## Objectif
Remplacer le seed de catégories par une taxonomie alignée IPC/Monarch (3 niveaux, 9 racines IPC + Revenus/Finances/Transferts, ~140 catégories, 150+ keywords fournisseurs canadiens), livrer une page de prévisualisation read-only (Livraison 1), puis une page de migration interactive à 3 étapes avec backup obligatoire via SREF (Livraison 2). Les profils existants migrent en opt-in conscient ; les nouveaux profils reçoivent v1 automatiquement.
## Scope
### IN
- Nouveau fichier `consolidated_schema.sql` avec seed v1 IPC appliqué aux **nouveaux profils** automatiquement.
- Livraison 1 (Option E) : page read-only `/paramètres/catégories/standard` — arbre navigable de la taxonomie v1 avec descriptions, compteurs, export PDF. Aucune modification de données.
- Livraison 2 (Option B) : page de migration 3 étapes (Découvrir / Simuler / Consentir) pour les profils existants.
- Étape Simuler : dry-run complet, calcul des mappings automatiques avec badges de confiance, choix manuel pour cas ambigus.
- Étape Consentir : backup SREF obligatoire (format AES-256-GCM existant) + vérification d'intégrité AVANT toute écriture destructive + migration SQL atomique.
- Algorithme de ventillage en 4 passes (keyword → supplier → défaut → revue utilisateur) pour catégories splittées (Transport commun → Bus/Train, Assurances → 4 branches).
- Préservation des catégories custom utilisateur sous un parent "Catégories personnalisées (migration)" créé automatiquement.
- Bannière dashboard one-shot post-MAJ + entrée permanente dans Paramètres pour inviter à découvrir E et potentiellement B.
- Bouton "Rétablir la sauvegarde" accessible pendant 90 jours post-migration.
- i18n des noms de catégories seed via clés techniques (ex: `seed.categories.alimentation.epicerie`) avec fallback au nom brut pour les catégories custom.
- Couverture de tests complète : unitaires (algo mapping), intégration (flow backup→migrate→rollback), régression (transactions/budgets/keywords post-migration), QA manuelle.
### OUT (explicitement exclu)
- Attribut `transactions.frequency` (recurring/one_shot/unknown) et détection auto → issue séparée future.
- Attribut `categories.essentiality` (essential/discretionary/savings) et rapport 50/30/20 → issue séparée future.
- Peuplement de la colonne `categories.icon` (reste NULL, décision reportée à une future issue UI).
- Suppression de la colonne `categories.icon` (non nécessaire, hors scope).
- Comparaisons avec les moyennes Statistique Canada (infrastructure nécessaire pour télécharger des datasets IPC, analyser, afficher — hors scope v1).
- Migration automatique silencieuse des profils existants (principe : consentement explicite obligatoire).
- Suppression physique du fichier SREF backup (privacy-first : l'app ne gère pas les fichiers de l'utilisateur au-delà de la création).
- Traduction EN des catégories custom créées par l'utilisateur (restent dans la langue saisie).
- Version mobile / Simpl'Résultat Web (hors stack du projet aujourd'hui).
## Decisions prises
| Question | Décision | Raison |
|----------|----------|--------|
| Ordre de livraison (E read-only, B migration, ou fusion) | **E puis B en deux livraisons** | Permet de collecter du feedback sur la taxonomie via E avant d'investir dans l'UX migration B. Deux PRs testables indépendamment. |
| i18n des noms de catégories | **Clés i18n pour seed + noms libres pour custom** | Préserve l'expérience bilingue sans table de traduction BDD. Renderer : clé i18n si elle existe, sinon nom brut. |
| Nouveaux profils après MAJ | **v1 automatique** | Pas de friction pour un nouvel utilisateur sans historique. v1 devient le nouveau standard. |
| Rétention bannière "Rétablir la sauvegarde" | **90 jours** | Laisse le temps à plusieurs cycles mensuels pour détecter un problème. Le fichier SREF lui-même n'est jamais supprimé par l'app. |
| Granularité L3 (~90 feuilles) | **Toutes actives par défaut** | Simplicité, prévisibilité. L'utilisateur peut désactiver via `is_active=0` depuis les paramètres. Overhead négligeable (tree virtualisable). |
| Invitation des profils existants | **Bannière dashboard one-shot + entrée permanente dans Paramètres** | Équilibre entre découvrabilité et respect de l'utilisateur. Ni silent (adoption lente), ni modale bloquante (intrusif). |
| Catégories custom pendant la migration | **Préservées sous parent "Catégories personnalisées (migration)"** | Aucune perte de données ou de règles custom. L'utilisateur déplace à son rythme. Friction minimale à la migration. |
| Colonne `categories.icon` | **Garder NULL, décision reportée** | Ne pas élargir le scope. Issue UI séparée décidera du renderer (emojis / lucide-react / SVG). |
| Tests | **Unitaires + intégration + régression + QA manuelle** | Feature destructive sur données utilisateur → couverture complète justifiée. Algo ventillage = unitaires, migration end-to-end = intégration, non-régression = régression. |
## References
| Source | Pertinence |
|--------|------------|
| [IPC Statistique Canada — panier 2024](https://www150.statcan.gc.ca/n1/pub/62f0014m/62f0014m2025003-fra.htm) | Les 8 composantes principales officielles structurent les racines L1 v1. Référence "quasi-PCGR" pour les dépenses des ménages au Canada. |
| [Statistique Canada — Enquête sur les dépenses des ménages (EDM)](https://www23.statcan.gc.ca/imdb/p2SV_f.pl?Function=getSurvey&SDDS=3508) | Fournit la granularité L2/L3 avec >650 codes de classification détaillés. |
| [Monarch Money — Default Categories](https://help.monarch.com/hc/en-us/articles/360048883851-Default-Categories) | Benchmark 3 niveaux, ~60 catégories par défaut. Inspiration pour la granularité fine (ex: Food & Dining → Groceries, Restaurants, Coffee). |
| Spike `seed-standard` (archivé) | Analyse complète du modèle de données, taxonomie v1 draftée, mapping v2→v1 à 88% automatisable, wireframes 3-étapes. Input direct pour Phase 3b. |
| `src-tauri/src/commands/export_import_commands.rs:8-49` | Format SREF v0.1 + AES-256-GCM + Argon2id réutilisable tel quel pour le backup pre-migration. |
| `src/services/dataExportService.ts:7-10, 199` | Mode `transactions_with_categories` + `importCategoriesOnly()` pour le flow backup/restore. |
| `src/services/categoryService.ts:55-60, 68-74, 175-186` | Règles `is_inputable` auto-gérées + limite 3 niveaux enforced. Le nouveau seed doit respecter ces invariants. |

View file

@ -1,348 +0,0 @@
# Spec — Migration des tokens OAuth vers le keychain OS (#66)
## Contexte
Simpl'Résultat utilise depuis la v0.7.0 un flux OAuth2 Authorization Code + PKCE pour authentifier les utilisateurs auprès de Logto (Compte Maximus). Les tokens résultants (`access_token`, `refresh_token`, `id_token`) sont actuellement persistés dans le filesystem utilisateur :
- **Chemin** : `<app_data_dir>/auth/tokens.json`
- **Protection Unix** : permissions `0600` (owner-only) via `OpenOptionsExt::mode(0o600)`
- **Protection Windows** : aucune (le fichier est écrit avec `fs::write` sans ACL particulière)
- **Commentaire de code source** (auth_commands.rs:7-11) : la solution actuelle est explicitement documentée comme transitoire, en attendant une migration vers le keychain OS.
**Objectif de la présente spec** : migrer le stockage des tokens OAuth de fichier plat vers le keychain OS natif (Credential Manager sur Windows, Secret Service sur Linux), avec migration transparente pour les utilisateurs existants et fallback gracieux si le keychain est indisponible.
**Référence** : CWE-312 (Cleartext Storage of Sensitive Information), issue Forgejo #66, liée à #51 (OAuth2 PKCE initial).
---
## Problème
### Ce qui est stocké en clair aujourd'hui
Le fichier `auth_commands.rs` écrit et lit 2 fichiers sensibles via la fonction `write_restricted()` (l.77-97) :
| Fichier | Contenu | Sensibilité | Usage |
|---|---|---|---|
| `tokens.json` | `access_token`, `refresh_token`, `id_token`, `expires_at` | **HAUTE** | Tous les appels API authentifiés, rotation via refresh |
| `account.json` | email, nom, picture, `subscription_status` | Moyenne | Affichage UI du compte, gating des features payantes |
| `last_check` | timestamp unix (dernier check abonnement) | Aucune | Throttling du polling Logto (24h) |
### Trou principal : Windows
La fonction `write_restricted()` utilise `OpenOptionsExt::mode(0o600)` uniquement sous `#[cfg(unix)]`. Sur Windows, le fallback est un `fs::write` brut — aucune ACL appliquée. Or Windows est une plateforme cible supportée. Un autre processus utilisateur peut lire le fichier.
### Trou secondaire : tous les OS
Même avec `0600` sur Linux, le contenu reste :
- Accessible à n'importe quel process tournant sous le même UID (malware utilisateur, extensions de navigateur local exfiltrant `$HOME`, outils de debug mal configurés, backups non chiffrés synchronisés sur le cloud)
- Inclus dans les backups de dossier home par défaut
- Lisible si l'attaquant obtient un shell non-root sur la machine
Le refresh token en particulier donne une session longue durée (rotation mais pas d'expiration courte) et représente le pire-cas d'exposition.
### Pourquoi c'est le bon moment
1. **Avant la monétisation** : la session Logto gatera l'accès aux features payantes (#53 machine activation, #50 Stripe). Un refresh token volé = contournement du gating de licence.
2. **La dette est connue** : le code lui-même documente l'intention de migrer (auth_commands.rs:7-8).
3. **Changement bien scopé** : le flux OAuth est stabilisé depuis v0.7.3, l'API de stockage est centralisée dans un seul fichier.
---
## Solution proposée
### Crate `keyring` (v3)
> **🟡 SECURITE** — Pas de pinning de version ni de revue supply-chain. `keyring` v3 tire `secret-service` qui pulls `zbus` et un large graphe D-Bus — surface d'attaque non négligeable pour une app privacy-first.
> **Resolution :** Pinner `keyring = "3.x"` explicitement dans Cargo.toml, ajouter `cargo audit` à check.yml après l'ajout de la dep, et documenter la chaîne de deps transitives dans l'ADR.
> *Ref : OWASP A06:2021*
Wrapper Rust maintenu au-dessus des keychains natifs :
| OS | Backend | Installé par défaut |
|---|---|---|
| Windows | Credential Manager (Win32 API) | Oui |
| Linux | Secret Service API via D-Bus (GNOME Keyring / KWallet) | **Non** — nécessite `libsecret-1-0` |
| macOS | Keychain Services (hors cible) | Oui |
Alternatives considérées :
- **`tauri-plugin-stronghold`** : chiffrement au repos avec une master password. Rejeté car demande à l'utilisateur de saisir une passphrase supplémentaire, ce qui casse l'UX de connexion silencieuse (refresh automatique au démarrage).
- **`tauri-plugin-store` + chiffrement custom** : il faudrait gérer une clé maître quelque part — on déplace le problème.
- **AES-256-GCM avec clé dérivée du PIN** : seulement viable pour les profils avec PIN, pas pour les tokens OAuth qui doivent être lus sans interaction.
`keyring` est le bon compromis : le système d'exploitation gère déjà la clé maître (session utilisateur).
### Scope
> **🟡 SECURITE** — L'exclusion de `account.json` ignore le tampering de `subscription_status`. Un malware local peut écrire `"active"` dedans pour bypass le gating de licence sans toucher au keychain.
> **Resolution :** Re-valider `subscription_status` depuis l'id_token/userinfo à chaque décision de gating, OU signer la valeur en cache, OU inclure account.json dans la migration keychain.
> *Ref : CWE-345*
> **🟢 ARCHITECTURE** — Scope limité aux tokens est le bon compromis : blast radius minimal, `write_restricted()` réutilisé tel quel, valeur sécurité ≈ coût du refactor. Garder tel quel et justifier l'asymétrie dans l'ADR.
**Migration uniquement de `tokens.json`**. Raisons :
- `account.json` contient de l'info d'affichage non-sensible (email déjà visible dans le menu, picture URL HTTP publique). Pas un secret opérationnel.
- `last_check` est un timestamp.
- Limiter le blast radius du changement, garder `write_restricted()` pour le reste.
- Permet un rollback ciblé si le keychain pose problème en production.
### Architecture
Nouveau module `src-tauri/src/auth/token_store.rs` exposant 3 fonctions :
```rust
pub struct StoredTokens { /* identique à aujourd'hui */ }
pub fn save(app: &AppHandle, tokens: &StoredTokens) -> Result<(), String>;
pub fn load(app: &AppHandle) -> Result<Option<StoredTokens>, String>;
pub fn delete(app: &AppHandle) -> Result<(), String>;
```
> **🔴 SECURITE + ARCHITECTURE + TECHNIQUE** — Service keychain ne correspond pas à l'identifiant bundle réel (`com.simpl.resultat` dans tauri.conf.json vs `com.lacompagniemaximus.simpl-resultat` dans la spec).
> **Resolution :** Utiliser `com.simpl.resultat` (l'identifiant canonique de l'app) dans la constante, les commandes `secret-tool` de test et l'ADR. Aligner sinon tauri.conf.json en premier.
> *Ref : CWE-1270*
> **🟡 ARCHITECTURE + TECHNIQUE** — Nouveau top-level module `auth/` casse la convention `commands/`.
> **Resolution :** Placer le module à `src-tauri/src/commands/token_store.rs` et l'enregistrer dans `commands/mod.rs` comme les autres. Pas de nouveau répertoire à créer.
**Conventions :**
- Une seule entrée keychain : `service = "com.lacompagniemaximus.simpl-resultat"`, `user = "oauth-tokens"`
- `value = serde_json::to_string(&StoredTokens)` (JSON compact, pas pretty)
- Le module `auth_commands.rs` ne touche plus jamais `tokens.json` directement — tous les accès passent par `token_store`.
### Implémentation — save()
> **🔴 SECURITE + ARCHITECTURE** — Fallback silencieux annule l'objectif de sécurité. Sur Windows le fallback n'applique aucune ACL, et un user qui installe le `.deb` sans libsecret continuera à stocker ses tokens en clair sans aucun signal visible.
> **Resolution :** Sur Windows, appliquer une DACL restrictive via `SetNamedSecurityInfoW` lors du fallback (ou fail-closed et forcer reauth). Exposer l'état `store_mode: keychain|file` à la frontend pour afficher une bannière de sécurité si le fallback est actif.
> *Ref : CWE-276*
```
1. Essayer keyring::Entry::new(SERVICE, USER).set_password(&json)
2. Si OK : supprimer tokens.json résiduel (migration nettoyage)
3. Si KO : fallback write_restricted() sur tokens.json + log warning
```
### Implémentation — load()
> **🔴 SECURITE** — Migration laisse des tokens en clair récupérables. `fs::remove_file` ne zéroifie pas les blocs disque, et le refresh token (long-lived) reste récupérable via unallocated sectors ou backups existants — précisément le threat model cité dans la spec.
> **Resolution :** Avant `remove_file`, overwrite le contenu avec des zéros et `fsync()`, puis delete. Documenter dans le changelog la recommandation de rotation de session post-migration pour les utilisateurs inquiets des backups passés.
> *Ref : CWE-212*
```
1. Essayer keyring::Entry::new(...).get_password()
2. Si OK et valeur présente : désérialiser et retourner Some(tokens)
3. Si KO ou vide : tenter de lire tokens.json
3a. Si tokens.json présent : migrer (keyring write + file delete) et retourner Some(tokens)
3b. Si tokens.json absent : retourner Ok(None)
```
C'est dans cette fonction que la **migration transparente** se fait : à la première lecture après upgrade, les tokens passent du fichier au keychain.
### Implémentation — delete()
```
1. Supprimer keychain entry (ignorer les erreurs "no entry")
2. Supprimer tokens.json (ignorer si absent)
```
Double-delete pour éviter les états "fantôme" où un reliquat traîne dans l'un des deux stores.
### Refactor d'`auth_commands.rs`
Tous les call sites qui manipulent actuellement `TOKENS_FILE` passent par `token_store` :
| Ligne actuelle | Opération | Nouveau code |
|---|---|---|
| 202 (handle_auth_callback) | write après exchange | `token_store::save(&app, &tokens)` |
| 219-227 (refresh_auth_token) | read | `token_store::load(&app)?` |
| 277 (refresh_auth_token) | write après rotation | `token_store::save(&app, &new_tokens)` |
| 251 (refresh_auth_token) | delete sur échec refresh | `token_store::delete(&app)` |
| 305 (logout) | delete | `token_store::delete(&app)` |
| 320 (check_subscription_status) | exists check | `token_store::load(&app)?.is_some()` |
La constante `TOKENS_FILE` est supprimée du fichier (reste `ACCOUNT_FILE` et `LAST_CHECK_FILE` gérés par `write_restricted` comme avant).
### Fallback gracieux
> **🟡 SECURITE** — Aucune distinction entre "keychain n'a jamais marché" et "keychain marchait hier et échoue aujourd'hui". Un process hostile local pourrait forcer la dégradation.
> **Resolution :** Persister un flag `store_mode` dans `app_data_dir/auth/`. Si le keychain a déjà fonctionné, un échec ultérieur doit refuser le plaintext et forcer reauth au lieu de dégrader silencieusement.
> *Ref : CWE-757*
Le fallback fichier s'active quand :
- L'appel `keyring::Entry::new()` retourne une erreur (keyring crate indisponible)
- `set_password()` / `get_password()` retourne `PlatformFailure` ou équivalent
- Spécifique Linux : D-Bus non démarré, libsecret absent, session sans keyring déverrouillé
Comportement attendu :
- `save()` et `load()` logguent un warning une seule fois par session (pas de spam)
- L'app reste fonctionnelle, juste avec le filet de sécurité `0600` (Unix) ou rien (Windows sans keychain — très improbable puisque Credential Manager est toujours présent)
- Aucune fenêtre d'erreur ne s'affiche à l'utilisateur
### Migration des utilisateurs existants
> **🟡 TECHNIQUE** — Timing incertain. `check_subscription_status` a un throttle 24h et un early-return sur `last_check` récent — un user qui relance souvent l'app peut voir `tokens.json` traîner indéfiniment.
> **Resolution :** Remplacer le check `dir.join(TOKENS_FILE).exists()` à auth_commands.rs:320 par `token_store::load(&app)?.is_some()` — ça force la migration dès la première lecture, sans attendre le throttle.
À la prochaine lecture (`refresh_auth_token` au démarrage via `check_subscription_status`), `token_store::load()` détecte `tokens.json` résiduel, le copie dans le keychain, et supprime le fichier. **Pas de reconnexion forcée, pas de notification.**
Si le keychain est indisponible, le fichier reste en place avec ses permissions `0600` — comportement équivalent à aujourd'hui.
---
## Impact CI/CD et packaging
### Linux — dépendance libsecret
> **🔴 SECURITE** — Cible AppImage oubliée. `tauri.conf.json` inclut `appimage` dans `bundle.targets` — ces builds n'héritent pas des deps apt et ne bundlent pas libsecret par défaut. Chaque user AppImage retombe silencieusement dans le fallback plaintext.
> **Resolution :** Soit bundler libsecret via `linuxdeploy` dans le build AppImage, soit retirer AppImage du scope v0.8, soit documenter `libsecret-1-0` comme pré-requis système dans les release notes AppImage.
> **🔴 TECHNIQUE** — `.forgejo/workflows/release.yml` n'est pas mentionné. Le build Linux de release échouera au linking si libsecret-1-dev n'est pas installé là aussi.
> **Resolution :** Ajouter `libsecret-1-dev` aux steps d'install système Linux de `release.yml` en plus de `check.yml`. Lister explicitement les deux workflows dans la spec.
Le crate `keyring` sous Linux utilise `libsecret` via D-Bus. Il faut :
1. **Dev** : `libsecret-1-dev` installé sur les machines de build (pop-os du dev + workers Forgejo Actions). À vérifier si déjà présent.
2. **Build .deb** : ajouter `libsecret-1-0` aux `depends` dans `tauri.conf.json``bundle.linux.deb.depends`.
3. **Build .rpm** : ajouter `libsecret` aux `depends` dans `bundle.linux.rpm.depends`.
4. **CI `check.yml`** : installer `libsecret-1-dev` avant `cargo check` / `cargo test`.
Sans ces ajouts, l'app **compile** mais au runtime le keychain échoue → fallback fichier → warning log. L'app reste fonctionnelle, mais la valeur de sécurité du changement est annulée pour les utilisateurs qui installent via `.deb` sans la dépendance.
### Windows
Credential Manager est un service Windows built-in, toujours disponible. Aucune dépendance à déclarer.
### Contrainte taille Credential Manager
> **🟢 SECURITE** — Plan B "access_token en RAM only" crée une race au cold-start offline : chaque démarrage doit hit Logto avant toute call authentifiée, cassant la promesse offline-first pour 24h.
> **Resolution :** Si la limite 2.5 KB est atteinte, stocker `access_token` et `refresh_token` dans **deux entrées keychain distinctes** (user=access, user=refresh) plutôt que sortir l'access token du store.
La limite théorique d'un credential Windows est ~2.5 KB (valeur stockée dans le `CRED_BLOB`). Un `StoredTokens` sérialisé pèse typiquement 1-1.8 KB (3 JWT + timestamp). À mesurer avec un vrai payload Logto avant le merge. Si on approche la limite, fallback possible : stocker uniquement le refresh token dans le keychain, garder l'access token en mémoire (il expire en 1h de toute façon).
---
## Tests
### Tests unitaires (impossibles pour la vraie partie keychain)
> **🟡 ARCHITECTURE + TECHNIQUE** — L'injection de trait `Backend` est YAGNI et techniquement infaisable : `keyring` v3 expose une struct `Entry` concrète sans trait public à swapper. Ajouter un wrapper trait juste pour les tests = abstraction sans contrepartie.
> **Resolution :** Drop le trait injection. Tester uniquement le round-trip serde sur `StoredTokens` et le chemin fallback fichier (qui ne touche pas keyring). Marquer `#[ignore]` tous les tests qui nécessitent un vrai keychain.
Le crate `keyring` ne fournit pas de mock officiel. On teste :
- La sérialisation / désérialisation `StoredTokens` (déjà couvert implicitement)
- La logique de migration via un fake backend (trait `Backend` injecté pour les tests)
### Tests manuels obligatoires avant merge
Sur **pop-os** (dev) :
1. **Fresh install** : supprimer `<app_data>/auth/`, lancer l'app, se connecter, vérifier qu'aucun fichier `tokens.json` n'est créé, vérifier via `secret-tool lookup service com.lacompagniemaximus.simpl-resultat user oauth-tokens`
2. **Migration** : créer un `tokens.json` artificiel (en repartant d'une ancienne version), lancer la nouvelle version, vérifier que le fichier est supprimé et le secret présent dans le keychain après le premier refresh
3. **Logout** : vérifier que le keychain entry ET le fichier résiduel sont effacés
4. **Fallback** : masquer D-Bus (`DBUS_SESSION_BUS_ADDRESS=/dev/null`), vérifier que l'app fonctionne et que le fallback fichier s'active
Sur **Windows** (VM ou machine dédiée) :
1. Fresh install + login → vérifier présence dans Credential Manager (`rundll32.exe keymgr.dll,KRShowKeyMgr`)
2. Migration depuis un `tokens.json` artificiel
3. Logout
### Tests CI
> **🔴 TECHNIQUE** — `check.yml` a deux jobs distincts (`rust` et `frontend`, conteneur ubuntu:22.04). La spec ne le précise pas et parle d'"installer avant cargo check" comme si c'était une seule étape.
> **Resolution :** Éditer uniquement le step **"Install system dependencies"** du job `rust` — append `libsecret-1-dev` à la liste `apt-get install` existante. Ne pas toucher le job frontend.
`check.yml` doit :
- Installer `libsecret-1-dev` avant `cargo check` / `cargo test`
- `cargo test` passe (les tests qui nécessitent un vrai keychain sont marqués `#[ignore]`, exécutés manuellement)
---
## Documentation à mettre à jour
> **🟡 ARCHITECTURE + TECHNIQUE** — Format de numérotation ADR incorrect. Les ADRs existants (0001-0005) utilisent un préfixe 4 chiffres sans `adr-`. De plus, `Security` n'est pas une catégorie du CHANGELOG du projet (voir `.claude/rules/changelog.md`).
> **Resolution :** Nommer le fichier `docs/adr/0006-oauth-tokens-keychain.md`. Classer l'entrée changelog sous `Changed` (ou `Fixed` si framed comme correction de vulnérabilité), pas `Security`.
- `docs/architecture.md` — section "Stockage" et "Commandes Tauri" : mentionner `token_store`, mettre à jour le diagramme de stockage auth
- `docs/adr/` — nouvel ADR `adr-006-oauth-tokens-keychain.md` décrivant la décision (contexte, options considérées, fallback)
- `CHANGELOG.md` / `CHANGELOG.fr.md` — section `Security` :
- EN : `Migrated OAuth tokens storage from plaintext JSON file to OS keychain (Credential Manager on Windows, Secret Service on Linux). Existing users are migrated transparently on first token refresh.`
- FR : `Migration du stockage des tokens OAuth d'un fichier JSON en clair vers le keychain du système (Credential Manager sous Windows, Secret Service sous Linux). Les utilisateurs existants sont migrés de façon transparente au premier rafraîchissement du token.`
---
## Critères d'acceptance (issue #66)
- [x] Tokens stockés dans le keychain OS → via `keyring` crate
- [x] Fallback gracieux si keychain indisponible → fallback `write_restricted()` avec warning logué
- [x] Migration automatique des fichiers existants → dans `token_store::load()` au premier appel
- [ ] Linux packaging : `libsecret-1-0` ajouté aux dépendances `.deb` / `.rpm`
- [ ] CI `check.yml` : `libsecret-1-dev` installé avant les tests
- [ ] ADR rédigé et mergé dans `docs/adr/`
- [ ] Tests manuels passés sur pop-os + Windows (3 scénarios chacun)
---
## Estimation
> **🟢 TECHNIQUE** — Estimation optimiste. N'inclut pas les debug cycles CI, ni les surprises de linking pkg-config dans le conteneur ubuntu:22.04 du job `rust`, ni le first-run libsecret de validation.
> **Resolution :** Monter à **4-5h** et prévoir un premier push CI dédié à valider que libsecret compile avant d'écrire les tests.
- Module `token_store` + Cargo.toml : 45 min
- Refactor `auth_commands.rs` : 20 min
- Mise à jour packaging (tauri.conf.json + check.yml) : 15 min
- Tests manuels pop-os : 30 min
- Tests manuels Windows (si VM dispo) : 30 min
- ADR + changelog + PR : 20 min
**Total : ~2h30 à 3h**
---
## Risques identifiés
| Risque | Probabilité | Sévérité | Mitigation |
|---|---|---|---|
| `libsecret` absent sur install `.deb` minimale → fallback silencieux annule le bénéfice | Moyenne | Moyenne | Ajouter aux deps `.deb`, documenter dans changelog |
| Credential Manager dépasse la limite 2.5 KB | Basse | Haute | Mesurer avant merge, plan B : refresh token only au keychain |
| Migration échoue silencieusement (fichier gardé) | Basse | Basse | Double-write acceptable, logué en warning |
| Régression du flux OAuth existant (utilisateurs v0.7.x) | Basse | Haute | Tests manuels exhaustifs des 3 scénarios sur 2 OS |
| Keyring Linux demande un déverrouillage GNOME Keyring au démarrage | Moyenne | Basse | C'est le comportement attendu — documenter dans le changelog |
---
## Questions ouvertes
1. **Scope account.json** — on laisse hors scope comme proposé, ou on migre aussi ? Recommandation : hors scope.
2. **libsecret dans `.deb`** — ajout immédiat ou follow-up ? Recommandation : immédiat, sinon la migration n'a aucune valeur pour la majorité des utilisateurs Linux.
3. **ADR format** — réutiliser le gabarit existant de `docs/adr/` (à vérifier s'il y en a un).
---
## Revision — Synthese
> Date: 2026-04-13 | Experts: Securite, Architecture, Technique
### Verdict
🔴 **CRITIQUES A CORRIGER** — La spec est solide sur les principes, mais 6 trous critiques sont à colmater avant implémentation : identifiant bundle incohérent, fallbacks silencieux qui annulent le gain de sécurité, plaintext récupérable post-migration, AppImage et release.yml oubliés, structure CI mal comprise.
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|-----|-----|-----|-------------|
| Securite | 3 | 3 | 1 | Fallback silencieux, plaintext résiduel, AppImage, subscription tampering, supply chain |
| Architecture | 1 | 3 | 1 | Bundle id, module path, YAGNI trait, ADR numbering, scope OK |
| Technique | 3 | 2 | 1 | release.yml oublié, check.yml mal lu, migration timing, estimation optimiste |
### Actions requises
1. 🔴 **Bundle identifier** — aligner service keychain sur `com.simpl.resultat` (tauri.conf.json)
2. 🔴 **Fallback save() non-silencieux** — DACL Windows OU fail-closed, exposer `store_mode` au frontend
3. 🔴 **Zéroification avant delete** — overwrite + fsync avant `fs::remove_file` des tokens migrés
4. 🔴 **AppImage libsecret** — bundler via linuxdeploy, retirer du scope, ou documenter le pré-requis
5. 🔴 **release.yml libsecret-1-dev** — ajouter aux steps Linux sinon le build release casse
6. 🔴 **check.yml job rust uniquement** — append à "Install system dependencies", pas de nouveau step
7. 🟡 **Module path**`src-tauri/src/commands/token_store.rs`, pas de nouveau top-level `auth/`
8. 🟡 **Fallback integrity** — flag `store_mode` persisté, refus du downgrade si keychain a déjà marché
9. 🟡 **Migration timing** — remplacer `TOKENS_FILE.exists()` ligne 320 par `token_store::load()?.is_some()`
10. 🟡 **Pin keyring + cargo audit**`keyring = "3.x"` explicite, `cargo audit` dans CI
11. 🟡 **subscription_status integrity** — re-valider ou signer la valeur en cache
12. 🟡 **Drop trait Backend** — tester uniquement round-trip serde + fallback fichier
13. 🟡 **ADR format**`0006-oauth-tokens-keychain.md`, changelog sous `Changed` (pas `Security`)

View file

@ -1,588 +0,0 @@
# Spec — Monétisation Simpl'Résultat
> Date: 2026-04-08
> Projet: simpl-resultat
> Statut: En cours (Phase 1 complétée)
> Dépendances: spec-simpl-resultat-web.md (version web/SaaS), Logto IdP (Compte Maximus)
## Contexte
Simpl'Résultat est une app desktop Tauri v2 open-source (GPL-3.0) pour la gestion des finances personnelles, actuellement gratuite. Le moment de monétiser approche. L'objectif est de mettre en place l'infrastructure complète pour vendre le logiciel desktop (achat unique) et offrir une version web SaaS (abonnement mensuel), tout en préservant le principe privacy-first.
**Modèle retenu : Open Core + Hybride**
- Le code desktop reste GPL-3.0 sur Forgejo (open core)
- Le code serveur (API licence, paiement, web/SaaS) est propriétaire
- Achat unique pour l'édition Base desktop
- Abonnement mensuel pour l'édition Premium (version web + sync + features avancées)
## Objectif
Implémenter les 3 piliers de la monétisation : (1) un système de licence offline pour l'app desktop, (2) l'intégration du Compte Maximus optionnel pour les features premium, et (3) l'infrastructure de paiement (achat unique + abonnement). La version web/SaaS est couverte par le spec existant `spec-simpl-resultat-web.md`.
## Scope
### IN
- Système de clé de licence offline (Ed25519, vérification côté Rust)
- API serveur de licences (génération, validation, révocation)
- Connexion optionnelle au Compte Maximus (Logto) dans l'app desktop
- Page d'achat sur lacompagniemaximus.com (processeur de paiement)
- Gestion des abonnements (Stripe Billing ou alternative)
- UI licence et compte dans les paramètres de l'app desktop
- Webhook paiement → génération de licence automatique
- Gestion TPS/TVQ pour les ventes au Canada
### OUT (explicitement exclu)
- Version web/SaaS (couvert par `spec-simpl-resultat-web.md`)
- Sync desktop ↔ web (couvert par `spec-simpl-resultat-web.md`, Issue 5)
- DRM agressif ou protection anti-compilation (incompatible GPL)
- App mobile
- Programme de revente / affiliés
- Facturation papier / comptabilité (usage externe, ex: Wave/QuickBooks)
## Design
### Modèle de tarification
| Tier | Prix | Contenu | Licence |
|------|------|---------|---------|
| **Gratuit** | 0$ | App desktop complète, sans clé, mises à jour manuelles uniquement | Aucune |
| **Base** | ~29-49$ CAD (unique) | App desktop + mises à jour automatiques + support email | Clé offline Ed25519 |
| **Premium** | ~7-12$ CAD/mois | Base + version web + sync desktop↔web + features avancées futures | Compte Maximus (abonnement actif) |
> **Note :** Les prix exacts seront déterminés avant le lancement. Les fourchettes ci-dessus sont des recommandations basées sur le marché des apps de finances personnelles.
### Architecture de licence
#### Clé de licence offline (Édition Base)
Format : JWT signé Ed25519, encodé Base64, vérifiable sans serveur.
```
SR-BASE-<base64url(JWT)>
```
**Payload JWT :**
```json
{
"sub": "user@email.com",
"iss": "lacompagniemaximus.com",
"iat": 1712534400,
"edition": "base",
"features": ["auto-update"],
"machine_limit": 3
}
```
> **🔴 SECURITE** — JWT sans claim `exp` : une licence signée est valide à jamais, même après révocation serveur, car la vérification offline ne peut pas checker le statut de révocation.
> **Résolution :** Ajouter un claim `exp` obligatoire (ex: 2 ans). L'app doit demander une re-validation en ligne avant expiration pour obtenir une clé rafraîchie.
> *Ref : CWE-613 (Insufficient Session Expiration)*
**Flux de validation :**
1. L'utilisateur entre sa clé dans Paramètres > Licence
2. Le backend Rust décode le JWT et vérifie la signature Ed25519 avec la clé publique embarquée
3. Si valide : stocke la clé dans un fichier `license.key` dans le répertoire app data
4. L'app vérifie la clé au démarrage (lecture locale, aucun appel réseau)
5. Features débloquées : mises à jour automatiques (l'updater vérifie la présence d'une licence valide)
**Sécurité :**
- La clé privée Ed25519 réside UNIQUEMENT sur le serveur de licences (VPS)
- La clé publique est embarquée dans le binaire Rust (hardcoded)
- Opérations crypto dans Rust uniquement (jamais dans le WebView)
- `machine_limit` vérifié lors de l'activation en ligne (premier lancement)
> **🔴 SECURITE** — `license.key` en clair est copiable entre machines, contournant `machine_limit`. La vérification offline ne peut pas détecter la copie car il n'y a pas de binding machine dans la signature JWT.
> **Résolution :** Stocker un token d'activation séparé (signé par le serveur avec le `machine_id` inclus). Vérifier à la fois le JWT licence + le token d'activation au démarrage.
> **🔴 TECHNIQUE** — Le spec propose `ed25519-dalek` mais c'est `jsonwebtoken` (avec feature EdDSA) qui décode les JWT. `ed25519-dalek` seul ne gère pas les headers/claims JWT.
> **Résolution :** Clarifier dans l'Issue 1 que `jsonwebtoken` est la dépendance primaire pour la validation JWT. `ed25519-dalek` peut ne pas être nécessaire si `jsonwebtoken` supporte EdDSA nativement.
#### Compte Maximus (Édition Premium)
L'intégration Logto dans l'app desktop utilise le flow OAuth2 PKCE :
1. L'utilisateur clique "Se connecter" dans Paramètres
2. Tauri ouvre le navigateur système vers Logto (OAuth2 Authorization Code + PKCE)
3. Après auth, callback vers `simpl-resultat://auth/callback`
4. L'app échange le code pour un access token + refresh token
5. Les tokens sont stockés de façon sécurisée (fichier chiffré dans app data)
> **🔴 SECURITE** — Le chiffrement des tokens avec une clé dérivée du machine ID est faible. Les machine IDs sont à faible entropie, déterministes et lisibles publiquement (ex: `/etc/machine-id` sur Linux). Équivaut à chiffrer avec une clé connue.
> **Résolution :** Utiliser le stockage natif de l'OS (keyring/credential manager via le crate `keyring` ou `tauri-plugin-store` avec OS keychain). Si basé fichier, dériver la clé du machine ID + un secret aléatoire par installation stocké séparément.
> *Ref : CWE-321 (Hard Coded Cryptographic Key)*
> **🟡 TECHNIQUE** — Le flow OAuth2 PKCE via `simpl-resultat://auth/callback` nécessite une config plateforme spécifique (registre Windows, etc.) non documentée dans le spec. `tauri-plugin-deep-link` v2 requiert des entrées explicites dans `tauri.conf.json` et les manifestes plateforme.
> **Résolution :** Ajouter une sous-tâche à l'Issue 6 pour la registration deep-link par plateforme. Référencer la doc `tauri-plugin-deep-link` v2.
6. L'access token JWT contient les claims : `{ "apps": {"simpl-resultat": "premium"}, "subscription_status": "active" }`
7. Vérification périodique du statut d'abonnement (1x/jour, graceful si offline)
**Dégradation gracieuse :**
- Si le token expire et le refresh échoue → affiche un avertissement mais ne bloque pas l'usage Base
- Si l'abonnement expire → l'app repasse en mode Base (clé offline toujours valide)
- Grace period de 7 jours après expiration de l'abonnement
### Comparaison des processeurs de paiement
| Critère | Stripe | Square | Paddle | LemonSqueezy | FastSpring |
|---------|--------|--------|--------|--------------|------------|
| **Frais** | 2.9% + 0.30$ | 2.9% + 0.30$ | 5% + 0.50$ | 5% | ~8.9% |
| **Merchant of Record** | Non (tu gères les taxes) | Non | Oui | Oui | Oui |
| **Gestion TPS/TVQ** | Via Stripe Tax (add-on) | Manuel | Inclus | Inclus | Inclus |
| **Abonnements** | Stripe Billing (excellent) | Square Subscriptions | Natif | Natif | Natif |
| **Clés de licence** | Non (API custom) | Non | Non | Intégré | Intégré |
| **API/Webhooks** | Excellent | Bon | Bon | Bon | Bon |
| **Desktop software** | Bon (généraliste) | Faible (focus commerce) | Bon | Bon | Excellent (spécialisé) |
| **Canada** | Oui (HQ Montréal inaccessible mais opère au Canada) | Oui | Oui | Oui (Stripe subsidiary) | Oui |
| **Paiement CAD** | Oui | Oui | Oui | Oui | Oui |
**Recommandation : Stripe** pour les raisons suivantes :
- Frais les plus bas (2.9% + 0.30$)
- API la plus robuste et documentée
- Stripe Billing pour les abonnements récurrents
- Stripe Tax pour la collecte TPS/TVQ automatique (0.5% add-on)
- Stripe Checkout pour une page de paiement hébergée (conforme PCI sans effort)
- Webhooks fiables pour automatiser la génération de licences
- LemonSqueezy est maintenant une filiale Stripe — si tu veux le MoR plus tard, migration facile
**Alternative viable : Paddle/LemonSqueezy** si tu veux zéro gestion fiscale (MoR). Plus cher (~5%) mais ils gèrent TPS/TVQ/TVA mondialement. Recommandé si tu vends internationalement dès le départ.
**Square : Non recommandé** pour la vente de logiciel. Orienté commerce de détail/point de vente physique. API d'abonnement moins mature que Stripe. Pas de Merchant of Record. Pas d'intégration licence.
### UX / Interface
#### Paramètres > Licence (nouveau card)
```
┌─────────────────────────────────────────────┐
│ 🔑 Licence │
│ │
│ Édition : Gratuite │
│ │
│ [Entrer une clé de licence] │
│ │
│ ─────────────────────────────────────────── │
│ │
│ Acheter Simpl'Résultat Base — 39$ CAD │
│ → Ouvre le navigateur vers la page d'achat │
│ │
│ Découvrir Premium — 9$/mois │
│ → Ouvre le navigateur vers la page Premium │
└─────────────────────────────────────────────┘
```
#### Paramètres > Compte Maximus (nouveau card, sous Licence)
```
┌─────────────────────────────────────────────┐
│ 👤 Compte Maximus Optionnel │
│ │
│ Non connecté │
│ │
│ [Se connecter] │
│ │
│ Le compte est requis uniquement pour les │
│ fonctionnalités Premium (version web, sync). │
└─────────────────────────────────────────────┘
```
#### État connecté + Premium actif :
```
┌─────────────────────────────────────────────┐
│ 🔑 Licence │
│ │
│ Édition : Premium ✓ │
│ Abonnement actif jusqu'au 2026-05-08 │
│ │
│ [Gérer mon abonnement] │
│ → Ouvre Stripe Customer Portal │
├─────────────────────────────────────────────┤
│ 👤 Compte Maximus │
│ │
│ Connecté : max@example.com │
│ Dernière vérification : il y a 2 heures │
│ │
│ [Se déconnecter] │
└─────────────────────────────────────────────┘
```
### Données
#### Fichiers locaux (app data directory)
```
simpl-resultat/
├── profiles.json # Existant
├── license.key # NOUVEAU — clé de licence Base (JWT signé)
├── auth/ # NOUVEAU
│ ├── tokens.enc # Access + refresh tokens chiffrés (AES-256-GCM, clé dérivée du machine ID)
│ └── account.json # Métadonnées du compte (email, edition, subscription_status)
└── profile_*.db # Existant — bases SQLite par profil
```
> Aucune modification au schéma SQLite existant. La licence et l'auth sont stockées hors des bases de profils.
#### API Serveur de licences (propriétaire, sur VPS)
Nouveau microservice déployé sur le VPS (Coolify), base URL : `https://api.lacompagniemaximus.com/licenses`
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/licenses/generate` | POST | Webhook Stripe → génère une clé signée Ed25519 |
| `/licenses/activate` | POST | Activation (vérifie machine_limit, enregistre machine_id) |
| `/licenses/verify` | POST | Vérification en ligne (optionnelle, pour refresh status) |
| `/licenses/deactivate` | POST | Désactive une machine (libère un slot) |
| `/licenses/revoke` | POST | Révoque une licence (admin) |
| `/subscriptions/status` | GET | Statut d'abonnement (pour l'app desktop, auth JWT) |
| `/subscriptions/webhook` | POST | Webhook Stripe Billing (subscription events) |
> **🟡 SECURITE** — Aucune authentification ou rate limiting décrit sur les endpoints `/licenses/activate` et `/licenses/verify`. Un attaquant pourrait énumérer des clés valides ou brute-forcer les activations.
> **Résolution :** Requérir le JWT licence comme bearer token pour activation/verify. Ajouter rate limiting (ex: 5 req/min par IP). Utiliser des request bodies signés HMAC pour les webhooks.
> *Ref : OWASP API4:2023 (Unrestricted Resource Consumption)*
> **🟡 SECURITE** — Le spec mentionne les idempotency keys mais ne spécifie pas la vérification de signature des webhooks Stripe (`Stripe-Signature` header). Sans cela, n'importe qui peut forger des appels webhook pour générer des licences gratuites.
> **Résolution :** Exiger explicitement la vérification de signature Stripe webhook via le webhook signing secret dans les handlers `/licenses/generate` et `/subscriptions/webhook`.
> *Ref : CWE-345 (Insufficient Verification of Data Authenticity)*
### Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ DESKTOP (Tauri v2) │
│ │
│ React UI Rust Backend │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ LicenseCard │◄────────────────►│ license_commands │ │
│ │ AccountCard │ │ - validate_key() │ │
│ │ SettingsPage │ │ - read_license() │ │
│ └──────────────┘ │ - store_license() │ │
│ │ - get_edition() │ │
│ └───────────────────┘ │
│ ┌───────────────────┐ │
│ │ auth_commands │ │
│ │ - start_oauth() │ │
│ │ - handle_callback()│ │
│ │ - refresh_token() │ │
│ │ - get_account() │ │
│ │ - logout() │ │
│ └───────────────────┘ │
│ │ │
└─────────────────────────────────────────────┼────────────────────┘
│ HTTPS (optionnel)
┌──────────────────────────────────────────────────────────────────┐
│ SERVEUR (VPS, propriétaire) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Logto (IdP) │ │ License API │ │ Stripe Webhooks │ │
│ │ OAuth2/OIDC │ │ Ed25519 sign │ │ payment events │ │
│ └──────┬──────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └────────────────┼─────────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ PostgreSQL │ │
│ │ licenses │ │
│ │ activations │ │
│ │ subscriptions│ │
│ └──────────────┘ │
│ │
│ ┌───────────────────────────────────────┐ │
│ │ lacompagniemaximus.com (page d'achat) │ │
│ │ Stripe Checkout / Customer Portal │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
### Schéma PostgreSQL (serveur de licences)
```sql
-- Table des licences
CREATE TABLE licenses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_email TEXT NOT NULL,
edition TEXT NOT NULL DEFAULT 'base', -- 'base' | 'premium'
license_key TEXT NOT NULL UNIQUE, -- JWT signé complet
stripe_payment_id TEXT, -- Référence Stripe (payment_intent ou subscription)
machine_limit INTEGER NOT NULL DEFAULT 3,
is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- NULL = perpétuel (Base), date pour Premium
);
-- Activations (machines liées à une licence)
CREATE TABLE license_activations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
machine_id TEXT NOT NULL, -- Hash unique de la machine
machine_name TEXT, -- Nom lisible (ex: "Max-ThinkPad")
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE(license_id, machine_id)
);
-- Abonnements (lié au Compte Maximus)
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- FK vers Logto user
stripe_subscription_id TEXT NOT NULL UNIQUE,
stripe_customer_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- 'active', 'past_due', 'canceled', 'expired'
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_licenses_email ON licenses(user_email);
CREATE INDEX idx_licenses_stripe ON licenses(stripe_payment_id);
CREATE INDEX idx_activations_license ON license_activations(license_id);
CREATE INDEX idx_activations_machine ON license_activations(machine_id);
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
```
## Plan de travail
### Phase 1 — Infrastructure de licence (desktop)
#### Issue 1 — Commandes Tauri pour la gestion de licence [type:feature] ✅ COMPLÉTÉE
Forgejo: #46 (fermée), PR #56 (mergée 2026-04-09)
- [x] Ajouter les dépendances `jsonwebtoken`, `serde_json` au Cargo.toml
- [x] Créer `src-tauri/src/commands/license_commands.rs` (6 commandes)
- [x] Créer `src-tauri/src/commands/entitlements.rs` (système d'entitlements par édition)
- [x] Embarquer la clé publique Ed25519 dans le code Rust (constante)
- [x] Enregistrer les commandes dans `lib.rs`
- [x] Tests unitaires (licence valide, expirée, signature invalide, clé corrompue)
#### Issue 2 — UI Licence dans les paramètres [type:feature] ✅ COMPLÉTÉE
Forgejo: #47 (fermée), PR #57 (mergée 2026-04-10)
- [x] Créer `src/components/settings/LicenseCard.tsx` — affiche l'édition, permet d'entrer une clé
- [x] Créer `src/services/licenseService.ts` — wrapper des commandes Tauri license_*
- [x] Créer `src/hooks/useLicense.ts` — hook useReducer pour l'état de la licence
- [x] Ajouter les clés i18n dans `fr.json` et `en.json` (section `license.*`)
- [x] Intégrer le LicenseCard dans `SettingsPage.tsx`
- [x] Feedback visuel : édition courante, statut de la clé, erreurs de validation
#### Issue 3 — Conditionner les mises à jour auto à la licence [type:task] ✅ COMPLÉTÉE
Forgejo: #48 (fermée), PR #58 (mergée 2026-04-10)
- [x] Modifier `useUpdater.ts` : vérifier l'entitlement `auto-update` via `check_entitlement`
- [x] Si édition "free" → afficher un message "Mises à jour automatiques disponibles avec l'édition Base"
- [x] Si édition "base" ou "premium" → flux normal de mise à jour
- [x] Mettre à jour les traductions i18n
> **🟡 TECHNIQUE** — L'auto-update est le seul feature gaté par la licence Base. Les utilisateurs peuvent télécharger manuellement les nouvelles releases depuis le repo Forgejo public, rendant le paywall trivialement contournable sans modification du code.
> **Résolution :** Soit gater des features additionnelles (rapports avancés, multi-profil, export chiffré), soit assumer un soft paywall et reframer en "édition supporter". Considérer que la valeur réelle est dans le support + web/Premium.
> **🔴 TECHNIQUE** — Le projet est GPL-3.0-only (`Cargo.toml`, `CLAUDE.md`). Gater des features derrière une clé payante dans du code GPL signifie que quiconque peut forker et retirer le check. Le spec ne traite pas cette tension fondamentale.
> **Résolution :** Accepter explicitement que le gate est un honor system (cohérent avec Open Core), ou déplacer la validation de licence vers un composant serveur. Documenter cette décision dans la section "Décisions prises".
### Phase 2 — Serveur de licences + paiement
#### Issue 4 — API serveur de licences [type:feature] 🔜 PROCHAINE
Forgejo: #49 (ouverte, status:ready)
- [ ] Créer le projet Node.js/Express (ou Hono) pour l'API de licences
- [ ] Générer la paire de clés Ed25519 (clé privée sur le serveur uniquement)
- [ ] Implémenter les endpoints : generate, activate, verify, deactivate, revoke
- [ ] Schéma PostgreSQL (tables licenses, license_activations, subscriptions)
- [ ] Middleware auth (API key pour les webhooks Stripe, JWT Logto pour les endpoints utilisateur)
- [ ] Rate limiting
- [ ] Déployer sur Coolify (api.lacompagniemaximus.com)
- [ ] Tests d'intégration
#### Issue 5 — Intégration Stripe (paiement + webhooks) [type:feature]
Forgejo: #50 (ouverte, status:ready). Dépendances : Issue 4
- [ ] Créer le compte Stripe et configurer pour le Canada (TPS/TVQ via Stripe Tax)
- [ ] Configurer Stripe Checkout pour l'achat unique (édition Base)
- [ ] Configurer Stripe Billing pour l'abonnement mensuel (édition Premium)
- [ ] Configurer Stripe Customer Portal (gestion d'abonnement par l'utilisateur)
- [ ] Implémenter les webhooks Stripe :
- `checkout.session.completed` → générer licence Base + envoyer par email
- `invoice.payment_succeeded` → renouveler/activer abonnement Premium
- `customer.subscription.deleted` → marquer abonnement expiré
- `customer.subscription.updated` → mettre à jour statut
- [ ] Page d'achat sur lacompagniemaximus.com (lien vers Stripe Checkout)
- [ ] Email de confirmation avec clé de licence (via Stripe email ou service dédié)
#### Issue 6 — Intégration Logto dans l'app desktop (Compte Maximus) [type:feature]
Forgejo: #51 (ouverte, status:ready). Dépendances : Issue 4, Logto déployé
> **🟡 ARCHITECTURE** — `get_edition()` dans `license_commands` doit aussi vérifier le statut d'abonnement via `auth_commands`, créant un couplage entre les deux modules. Ce n'est pas documenté.
> **Résolution :** Soit ajouter un `get_edition()` top-level dans `mod.rs` qui combine les deux sources, soit documenter explicitement que `license_commands::get_edition()` délègue à `auth_commands` pour la détection Premium.
- [ ] Ajouter `tauri-plugin-deep-link` pour gérer le callback OAuth2 (`simpl-resultat://auth/callback`)
- [ ] Créer `src-tauri/src/commands/auth_commands.rs` :
- `start_oauth() -> Result<String, String>` — génère PKCE, ouvre le navigateur
- `handle_auth_callback(code: String) -> Result<AccountInfo, String>` — échange code → tokens
- `refresh_auth_token() -> Result<AccountInfo, String>` — refresh le token
- `get_account_info() -> Result<Option<AccountInfo>, String>` — lit les infos stockées
- `logout() -> Result<(), String>` — supprime les tokens
- [ ] Stocker les tokens chiffrés dans `auth/tokens.enc` (AES-256-GCM, clé = machine_id dérivé)
- [ ] Créer `src/components/settings/AccountCard.tsx` — connexion/déconnexion, infos compte
- [ ] Créer `src/services/authService.ts` — wrapper des commandes Tauri auth_*
- [ ] Ajouter les clés i18n
- [ ] Vérification périodique du statut d'abonnement (1x/jour au lancement)
### Phase 3 — Page d'achat et lancement
#### Issue 7 — Page d'achat sur le site web [type:feature]
Forgejo: #52 (ouverte, status:ready). Dépendances : Issue 5
- [ ] Créer la page `/simpl-resultat` ou `/acheter` sur lacompagniemaximus.com
- [ ] Présentation des tiers (Gratuit / Base / Premium) avec comparaison
- [ ] Boutons d'achat → Stripe Checkout (Base) ou inscription + Stripe Billing (Premium)
- [ ] FAQ (licence, remboursement, support, vie privée)
- [ ] Mentions légales (TPS/TVQ, politique de remboursement)
- [ ] i18n FR/EN
#### Issue 8 — Activation en ligne et machine limit [type:feature]
Forgejo: #53 (ouverte, status:ready). Dépendances : Issue 1 ✅, Issue 4
- [ ] Au premier lancement avec une clé, appeler `/licenses/activate` avec le machine_id
- [ ] Si machine_limit atteint → message d'erreur avec option de désactiver une autre machine
- [ ] Page de gestion des machines dans le Customer Portal ou sur le site
- [ ] Graceful degradation si le serveur est injoignable (accepter la clé offline, activer plus tard)
### Ordre d'exécution
```
Phase 1 (desktop, offline) Phase 2 (serveur, online)
======================== ========================
Issue 1 (Commandes licence) Issue 4 (API licences)
├── Issue 2 (UI Licence) ├── Issue 5 (Stripe)
├── Issue 3 (Updater conditionnel) ├── Issue 6 (Logto desktop)
└── Issue 8 (Activation) ───────────┘
└── Issue 7 (Page d'achat)
```
Les Phases 1 et 2 peuvent avancer en parallèle. La Phase 1 est autonome (tout offline).
## Fichiers concernés
### Nouveaux fichiers
| Fichier | Raison | Statut |
|---------|--------|--------|
| `src-tauri/src/commands/license_commands.rs` | Commandes Tauri : validation, stockage, lecture de licence | ✅ #46 |
| `src-tauri/src/commands/entitlements.rs` | Système d'entitlements par édition | ✅ #46 |
| `src-tauri/src/commands/auth_commands.rs` | Commandes Tauri : OAuth2 PKCE, tokens, compte | 🔜 #51 |
| `src/components/settings/LicenseCard.tsx` | UI licence dans les paramètres | ✅ #47 |
| `src/components/settings/AccountCard.tsx` | UI compte Maximus dans les paramètres | 🔜 #51 |
| `src/services/licenseService.ts` | Service TypeScript pour les opérations de licence | ✅ #47 |
| `src/services/authService.ts` | Service TypeScript pour l'auth Compte Maximus | 🔜 #51 |
| `src/hooks/useLicense.ts` | Hook useReducer pour l'état de la licence | ✅ #47 |
| `src/hooks/useAuth.ts` | Hook useReducer pour l'auth Compte Maximus | 🔜 #51 |
### Fichiers modifiés
| Fichier | Action | Raison |
|---------|--------|--------|
| `src-tauri/Cargo.toml` | Modifier | Ajouter `jsonwebtoken`, `serde_json` ✅, `tauri-plugin-deep-link` (🔜 #51) |
| `src-tauri/src/lib.rs` | Modifier | Enregistrer les nouvelles commandes et plugins ✅ |
| `src-tauri/tauri.conf.json` | Modifier | Configurer deep-link protocol `simpl-resultat://` |
| `src/pages/SettingsPage.tsx` | Modifier | Intégrer LicenseCard ✅ et AccountCard (🔜 #51) |
| `src/hooks/useUpdater.ts` | Modifier | Conditionner les mises à jour à la licence ✅ |
| `src/i18n/locales/fr.json` | Modifier | Clés `license.*` ✅ et `account.*` (🔜 #51) |
| `src/i18n/locales/en.json` | Modifier | Clés `license.*` ✅ et `account.*` (🔜 #51) |
### Nouveau projet (serveur, hors du repo simpl-resultat)
| Projet | Description |
|--------|-------------|
| `simpl-resultat-api` | API de licences + webhooks Stripe (Node.js, propriétaire) |
## Critères d'acceptation
- [ ] Un utilisateur peut entrer une clé de licence valide et voir son édition passer à "Base"
- [ ] Une clé invalide ou corrompue est rejetée avec un message d'erreur clair
- [ ] Les mises à jour automatiques ne sont proposées qu'aux éditions Base et Premium
- [ ] L'édition "Gratuite" fonctionne sans restriction fonctionnelle (sauf auto-update)
- [ ] La connexion au Compte Maximus est optionnelle et ne bloque aucune fonctionnalité Base
- [ ] Un abonnement Premium actif est détecté et affiche l'édition "Premium"
- [ ] L'expiration d'un abonnement repasse gracieusement en mode Base (7 jours de grâce)
- [ ] L'achat via Stripe Checkout génère automatiquement une clé de licence envoyée par email
- [ ] Le webhook Stripe met à jour le statut d'abonnement en temps réel
- [ ] La TPS/TVQ est correctement collectée pour les ventes au Canada
- [ ] L'app fonctionne 100% offline avec une clé Base validée (aucun appel réseau requis)
- [ ] L'activation machine fonctionne en mode dégradé si le serveur est injoignable
- [ ] Toutes les chaînes UI sont traduites FR/EN
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| L'utilisateur change de machine | Machine limit (3 par défaut) + endpoint de désactivation. UI de gestion des machines. |
| La clé publique Ed25519 est extraite du binaire | Risque accepté (GPL). La clé publique ne permet que la vérification, pas la génération. |
| Le serveur de licences est down | La validation offline fonctionne toujours. L'activation et le refresh Premium échouent gracieusement. |
| L'utilisateur compile le code sans licence | Fonctionnel mais sans auto-update ni features Premium. Acceptable sous GPL. |
| Stripe webhook rate | Implémenter idempotency keys et retry logic. Stripe a un mécanisme de retry intégré (jusqu'à 72h). |
| Remboursement Stripe | Webhook `charge.refunded` → révoquer la licence automatiquement. |
| Changement de prix | Stripe permet de modifier les prix sans affecter les abonnements existants (grandfather). |
| Double achat (même email) | Vérifier si une licence active existe déjà avant d'en générer une nouvelle. |
| Token OAuth expiré en mode offline | Grace period : si le refresh échoue, garder le statut Premium pendant 7 jours (stocké localement). |
| Migration de machine_id (réinstall OS) | Le machine_id change → l'utilisateur doit désactiver l'ancienne machine ou contacter le support. Prévoir un flow self-service. |
> **🔴 SECURITE** — Le PIN hashing existant dans `profile_commands.rs` utilise SHA-256 salé (hash rapide), pas Argon2. Un PIN de 4-6 chiffres avec SHA-256 est brute-forceable trivialement (~1M combinaisons). Avec l'ajout de tokens OAuth, la surface d'attaque locale augmente.
> **Résolution :** Migrer `hash_pin`/`verify_pin` vers Argon2id (déjà une dépendance via le crate `argon2` utilisé dans l'export). Vulnérabilité pré-existante à corriger en Phase 1.
> *Ref : CWE-916 (Use of Password Hash With Insufficient Computational Effort)*
> **🟢 SECURITE** — Le CSP est désactivé (`"csp": null` dans `tauri.conf.json`). Avec des tokens OAuth stockés dans l'app, un XSS dans le WebView pourrait exfiltrer les tokens.
> **Résolution :** Activer un CSP restrictif avant la Phase 2 : `default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com`.
> *Ref : OWASP A03:2021 (Injection)*
> **🟢 TECHNIQUE** — Le spec ne spécifie pas la stratégie `get_machine_id()` cross-plateforme (Windows vs Linux). Différentes approches (`/etc/machine-id`, WMI, SMBIOS UUID) ont des garanties de stabilité différentes.
> **Résolution :** Spécifier la stratégie par plateforme dans l'Issue 1 (ex: crate `machine-uid`). Documenter que la réinstallation de l'OS peut changer l'ID.
## Décisions prises
| Question | Décision | Raison |
|----------|----------|--------|
| Modèle de prix | Hybride : achat unique desktop + abonnement web | Maximise les revenus sur les deux segments, faible friction pour le desktop |
| Modèle de licence | Open Core (GPL desktop + serveur propriétaire) | Cohérent avec l'existant, la valeur est dans le service |
| Processeur de paiement | Stripe (recommandé), avec Stripe Tax pour TPS/TVQ | Meilleure API, frais les plus bas, Stripe Billing pour abonnements |
| Validation de licence | Ed25519 offline (JWT signé) | Privacy-first, fonctionne sans internet, crypto solide |
| Auth | Logto (OAuth2 PKCE, comme prévu dans le spec web) | Déjà planifié, self-hosted, standard OIDC |
| Stockage licence | Fichier `license.key` dans app data (hors SQLite) | Indépendant des profils, simple, pas de migration de schéma |
| Machine limit | 3 machines par licence Base | Standard industrie, équilibre entre flexibilité et protection |
| Grace period Premium | 7 jours après expiration | Évite de bloquer l'utilisateur pour un problème de carte |
| Square | Non retenu | Orienté commerce de détail, API d'abonnement immature, pas de MoR |
## Références
| Source | Pertinence |
|--------|------------|
| [Keyforge — License Tauri App](https://keyforge.dev/blog/how-to-license-tauri-app) | Pattern Ed25519 + JWT pour validation offline dans Tauri, avec stockage sécurisé |
| [Keygen.sh for Tauri](https://keygen.sh/for-tauri-apps/) | Plugin Tauri existant pour licence (alternative SaaS à notre API custom) |
| [tauri-plugin-better-auth-license](https://crates.io/crates/tauri-plugin-better-auth-license) | Device-bound licensing avec X25519 + JWE, offline-verifiable JWTs |
| [Stripe vs Paddle vs LemonSqueezy Comparison](https://appstackbuilder.com/blog/stripe-vs-lemon-squeezy-vs-paddle) | Comparaison détaillée des frais, features, et cas d'usage 2026 |
| [LemonSqueezy 2026 Update (Stripe acquisition)](https://www.lemonsqueezy.com/blog/2026-update) | LemonSqueezy intégré à l'écosystème Stripe depuis 2024 |
| [Stripe Tax — Canada](https://docs.stripe.com/tax/supported-countries/canada) | Documentation Stripe Tax pour la collecte TPS/TVQ automatique au Canada |
| [Monetizing Open Source: Open Core Strategies](https://www.getmonetizely.com/articles/monetizing-open-source-software-pricing-strategies-for-open-core-saas) | Pricing strategies pour Open Core SaaS, validation du modèle hybride |
| [Open Core Business Model Handbook](https://handbook.opencoreventures.com/open-core-business-model/) | Guide structuré du modèle Open Core, délimitation free vs premium |
| [FastSpring — Desktop Software Sales](https://fungies.io/best-subscription-billing-tools-saas-2026/) | FastSpring comme alternative spécialisée pour la vente de logiciels desktop |
## Revision — Synthese
> Date: 2026-04-08 | Experts: Securite, Architecture, Technique
### Verdict
🔴 **CRITIQUES A CORRIGER** — Le spec a une architecture solide mais présente des failles de sécurité crypto (JWT sans expiration, chiffrement tokens faible, PIN SHA-256) et des incohérences avec les patterns du codebase existant (hooks manquants).
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|-----|-----|-----|-------------|
| Securite | 4 | 2 | 1 | JWT sans expiry irrevocable, token encryption faible (machine ID), PIN SHA-256, license.key copiable, webhook signature manquante |
| Architecture | 1 | 1 | 0 | Hooks useLicense/useAuth manquants (pattern du projet), couplage get_edition() entre modules |
| Technique | 2 | 2 | 1 | Confusion ed25519-dalek vs jsonwebtoken, tension GPL vs feature gating, auto-update seul gate fragile |
### Actions requises
1. 🔴 Ajouter claim `exp` obligatoire au JWT licence (CWE-613)
2. 🔴 Remplacer le chiffrement machine-ID des tokens par OS keychain ou secret par installation (CWE-321)
3. ✅ ~~Migrer PIN hashing de SHA-256 vers Argon2id~~ — Corrigé dans #54 (PR #55, en attente de merge)
4. 🔴 Ajouter un token d'activation signé avec machine_id pour empêcher la copie de license.key — `store_activation_token()` implémenté dans #46, activation serveur dans #53
5. ✅ ~~Clarifier que `jsonwebtoken` (pas `ed25519-dalek`) est la dépendance primaire pour JWT~~ — Implémenté avec `jsonwebtoken` dans #46
6. 🔴 Documenter explicitement que le feature gating GPL est un honor system (décision Open Core)
7. ✅ ~~Ajouter `src/hooks/useLicense.ts` et `src/hooks/useAuth.ts`~~`useLicense.ts` ajouté dans #47. `useAuth.ts` à créer dans #51
8. 🟡 Ajouter vérification signature Stripe webhook (CWE-345)
9. 🟡 Ajouter rate limiting + auth sur les endpoints licence (OWASP API4)
10. 🟡 Documenter la config deep-link par plateforme pour OAuth2 callback
11. 🟡 Clarifier le couplage `get_edition()` entre license_commands et auth_commands
12. 🟡 Considérer des gates additionnels ou assumer le soft paywall pour l'édition Base

View file

@ -1,375 +0,0 @@
# Spec Plan — Refonte du seed de catégories vers IPC Statistique Canada
> Date: 2026-04-19
> Projet: simpl-resultat
> Statut: Draft
> Slug: refonte-seed-categories-ipc
> Decisions: [spec-decisions-refonte-seed-categories-ipc.md](./spec-decisions-refonte-seed-categories-ipc.md)
## Design
### UX / Interface
#### Livraison 1 — Découverte (Option E)
**Bannière dashboard** (première ouverture post-MAJ)
- Position : haut du dashboard, dismissable
- Texte : "Découvrez la nouvelle structure standard des catégories — inspirée de Statistique Canada"
- CTA : *Voir le guide* → navigue vers `/paramètres/categories/standard`
- Une fois dismiss, ne réapparaît plus (flag `user_preferences.categories_v1_banner_dismissed = true`)
**Page `/paramètres/categories/standard`** (lecture seule)
- Bloc pédagogique en haut : pourquoi cette structure, lien vers source Statistique Canada
- Arbre navigable avec expand/collapse des branches
- Hover sur catégorie : tooltip avec description + exemples de fournisseurs (ex: "Metro, IGA, Maxi, Loblaws...")
- Compteur global : "9 catégories racines, ~40 sous-catégories, ~90 feuilles"
- Bouton recherche full-text
- Bouton export PDF
- **Aucune action destructive** — lecture pure
**Entrée Paramètres**
- Dans `Paramètres > Gestion des catégories`, ajouter lien *Explorer la structure standard*
#### Livraison 2 — Migration (Option B)
Page 3 étapes séquentielles à `/paramètres/categories/migrer` :
**Étape 1 — Découvrir** (reprend la page de Livraison 1 en lecture)
- CTA : *Continuer vers l'aperçu de migration*
**Étape 2 — Simuler** (dry-run)
- Résumé impact : X catégories, Y transactions, Z règles, W budgets
- Table 3 colonnes : *Actuelle* | *Correspondance* | *v1 proposée*
- Badges confiance 🟢/🟡/🟠/🔴
- Panneau latéral cliquable par ligne : liste des transactions affectées + possibilité de changer la cible
- Compteur "N décisions à prendre" + bouton suivant désactivé tant que toutes les 🟠 ne sont pas résolues
- Les choix sont persistés en mémoire (pas encore BDD)
**Étape 3 — Consentir**
- Checklist explicite : "Je comprends que cette opération modifie mes catégories / Une sauvegarde sera créée avant tout changement / Je peux rétablir à tout moment"
- Bouton *Créer la sauvegarde et migrer* désactivé tant que checklist non cochée
- Pendant exécution : loader avec 4 étapes affichées (backup créé → vérifié → migration SQL → commit)
- Écran succès avec chemin du fichier SREF + CTA *Aller au tableau de bord*
- Écran échec backup : abort complet, aucune écriture, message clair + options (changer dossier / réessayer / annuler)
**Bannière post-migration** (Paramètres > Catégories, 90 jours)
- "Migration appliquée le <date>. Sauvegarde : <chemin>"
- Bouton *Rétablir la sauvegarde* → modale confirmation → `importFullProfile()` en mode replace
- Bouton *Ne plus afficher* → flag dans `user_preferences.categories_migration_banner_dismissed`
### Données
#### Nouvelle migration SQL v8 (additive)
Nom : `v8__category_schema_version.sql`
Ajoute :
- Colonne `categories.i18n_key TEXT NULL` — clé i18n technique pour les catégories seedées (ex: `seed.categories.alimentation.epicerie`). NULL pour les catégories custom → le renderer utilise `name` brut.
- Entrée dans `user_preferences` : `categories_schema_version` = `'v1'` ou `'v2'` (détermine quelle taxonomie le profil utilise).
**Important** : cette migration **n'écrase pas** le seed v2 des profils existants. Elle ajoute juste la colonne `i18n_key` (NULL par défaut) et pose `categories_schema_version='v2'` pour tous les profils existants.
#### `consolidated_schema.sql` mis à jour
- Contient le seed v1 complet (issu de `spike-archived/seed-standard/code/seed-proposal-v1.sql`)
- Pose `categories_schema_version='v1'` par défaut
- Les i18n_key sont peuplées au seed
#### Clés i18n ajoutées
Dans `src/i18n/locales/fr.json` et `en.json`, nouveau namespace `categoriesSeed` :
```json
{
"categoriesSeed": {
"revenus": { "root": "Revenus", "emploi": { "root": "Emploi", "paie": "Paie régulière", ... } },
"alimentation": { "root": "Alimentation", "epicerie": { "root": "Épicerie & marché", "reguliere": "Épicerie régulière", ... } },
...
}
}
```
Les `i18n_key` dans la BDD pointent vers ces clés : ex `categoriesSeed.alimentation.epicerie.reguliere`.
#### Rien de neuf en schéma v2 : pas de colonnes `frequency` ni `essentiality`
Ces attributs sont hors scope (cf. spec-decisions).
### Architecture
#### Composants React (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/pages/CategoriesStandardGuidePage.tsx` | Page read-only de Livraison 1 |
| `src/pages/CategoriesMigrationPage.tsx` | Page 3-étapes de Livraison 2 (routeur interne par étape) |
| `src/components/categories-migration/StepDiscover.tsx` | Étape 1 |
| `src/components/categories-migration/StepSimulate.tsx` | Étape 2 avec table 3 colonnes |
| `src/components/categories-migration/StepConsent.tsx` | Étape 3 avec checklist et loader |
| `src/components/categories-migration/MappingRow.tsx` | Ligne de table avec badge confiance + panneau latéral |
| `src/components/categories-migration/TransactionPreviewPanel.tsx` | Panneau latéral montrant transactions impactées |
| `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx` | Bannière dashboard one-shot |
| `src/components/settings/CategoriesMigrationBackupBanner.tsx` | Bannière post-migration (90j) |
#### Services (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/services/categoryTaxonomyService.ts` | Source de vérité de la taxonomie v1 (structure hardcodée en TS, utilisée par StepDiscover + StepSimulate pour afficher l'arbre cible). Import depuis JSON bundle. |
| `src/services/categoryMappingService.ts` | Calcule le mapping v2→v1 avec badge de confiance. Implémente l'algo 4-passes (keyword → supplier → défaut → revue). Retourne une structure `MigrationPlan` en mémoire, sans écriture BDD. |
| `src/services/categoryMigrationService.ts` | Orchestre la migration : prend un `MigrationPlan` + `BackupResult` validé, exécute la transaction SQL atomique (INSERT catégories v1 → UPDATE transactions/budgets/keywords → DELETE catégories v2 non mappées → création parent "Catégories personnalisées (migration)" si custom détectées). |
| `src/services/categoryBackupService.ts` | Wrapper autour de `dataExportService` pour le flow pre-migration : crée un fichier SREF nommé `<ProfileName>_avant-migration-<ISO8601>.sref` dans `~/Documents/Simpl-Resultat/backups/`, vérifie l'intégrité (read-back + checksum), retourne `BackupResult` ou lève une erreur claire. |
#### Hooks (nouveaux)
| Fichier | Rôle |
|---------|------|
| `src/hooks/useCategoryTaxonomy.ts` | Charge la taxonomie v1 depuis le service (useMemo). |
| `src/hooks/useCategoryMigration.ts` | useReducer pour l'état de la page de migration (étape courante, mapping plan, backup result, erreurs). |
#### Fichier JSON de taxonomie
`src/data/categoryTaxonomyV1.json` — structure hiérarchique de la taxonomie v1 utilisée par `categoryTaxonomyService.ts`. Régénéré depuis `spec-plan-*/code/seed-proposal-v1.sql` (source de vérité = le SQL, le JSON est dérivé pour l'UI).
#### Flow d'intégration
```
Utilisateur clique "Créer la sauvegarde et migrer"
useCategoryMigration dispatches START_MIGRATION
categoryBackupService.createAndVerify()
→ Tauri: pick_save_file + write_export_file + read_import_file
→ Si échec : dispatch BACKUP_FAILED, abort, aucune écriture BDD
→ Si succès : dispatch BACKUP_VERIFIED, retourne BackupResult
categoryMigrationService.applyMigration(plan, backup)
→ BEGIN TRANSACTION
→ INSERT catégories v1 (IDs 1000+, i18n_key peuplées, is_inputable calculé)
→ UPDATE transactions SET category_id = <mapping[old_id]>
→ UPDATE budgets SET category_id = <mapping[old_id]>
→ UPDATE keywords SET category_id = <mapping[old_id]>
→ Si custom détectées : INSERT parent "Catégories personnalisées (migration)" + re-parent
→ DELETE FROM categories WHERE id IN (<v2 non mappées, non custom>)
→ UPDATE user_preferences SET value='v1' WHERE key='categories_schema_version'
→ INSERT INTO user_preferences (key='last_categories_migration', value=<JSON métadonnées>)
→ COMMIT
→ Si erreur : ROLLBACK, backup reste disponible pour rétablissement
dispatch MIGRATION_SUCCESS, affiche écran succès
```
## Plan de travail
### Issue 1 — Seed v1 + i18n keys pour nouveaux profils [type:task]
Dépendances : aucune
- [ ] Ajouter migration SQL v8 : colonne `categories.i18n_key TEXT NULL` + `user_preferences('categories_schema_version', 'v2')` pour profils existants
- [ ] Mettre à jour `consolidated_schema.sql` avec le seed v1 complet (issu de `spike-archived/seed-standard/code/seed-proposal-v1.sql`) et poser `categories_schema_version='v1'` par défaut
- [ ] Créer `src/data/categoryTaxonomyV1.json` dérivé du SQL seed v1
- [ ] Ajouter les clés i18n FR et EN dans `src/i18n/locales/{fr,en}.json` sous `categoriesSeed.*`
- [ ] Adapter le renderer CategoryTree/CategoryCombobox pour utiliser `i18n_key` si présent, fallback sur `name`
- [ ] Tests : création d'un nouveau profil → vérifier que le seed v1 est appliqué, que `categories_schema_version='v1'`, et que les noms s'affichent traduits FR/EN
### Issue 2 — Service categoryTaxonomyService (source taxonomie v1 en TS) [type:task]
Dépendances : Issue 1
- [ ] Créer `src/services/categoryTaxonomyService.ts` avec `getTaxonomyV1()` retournant l'arbre typé depuis le JSON
- [ ] Créer `src/hooks/useCategoryTaxonomy.ts`
- [ ] Exposer des helpers : `findByPath(path)`, `getLeaves()`, `getParentById(id)`
### Issue 3 — Page "Guide des catégories standard" (Livraison 1) [type:feature]
Dépendances : Issue 2
- [ ] Créer route `/paramètres/categories/standard` dans `src/App.tsx`
- [ ] Créer `src/pages/CategoriesStandardGuidePage.tsx`
- [ ] Implémenter l'arbre navigable avec expand/collapse, tooltips, compteur global
- [ ] Bouton recherche full-text
- [ ] Bouton export PDF (via `window.print()` avec feuille style dédiée, ou lib PDF léger)
- [ ] Ajouter lien dans `src/components/settings/CategoriesCard.tsx` (ou équivalent)
### Issue 4 — Bannière dashboard one-shot + découverte [type:feature]
Dépendances : Issue 3
- [ ] Créer `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx`
- [ ] Ajouter flag `categories_v1_banner_dismissed` dans `user_preferences`
- [ ] Intégrer au `Dashboard.tsx` : affichée si `categories_schema_version='v2'` AND flag non-dismiss
- [ ] CTA dismissable vers la page Guide
### Issue 5 — Service categoryMappingService (algo ventillage 4 passes) [type:task]
Dépendances : Issue 2
- [ ] Créer `src/services/categoryMappingService.ts`
- [ ] Implémenter l'algo 4 passes (keyword → supplier → défaut → revue)
- [ ] Types : `MigrationPlan`, `MappingRow`, `ConfidenceBadge`
- [ ] Fonction `computeMigrationPlan(profileData): MigrationPlan` — pure, sans effet de bord BDD
- [ ] Mapping table encodée depuis `spike-archived/seed-standard/code/mapping-old-to-new.md`
- [ ] Détection des catégories custom (non présentes dans le seed v2)
### Issue 6 — Service categoryBackupService + wrapper SREF pre-migration [type:task]
Dépendances : aucune (peut aller en parallèle avec Issue 5)
- [ ] Créer `src/services/categoryBackupService.ts`
- [ ] Fonction `createPreMigrationBackup(profile): Promise<BackupResult>` :
- Génère nom fichier `<ProfileName>_avant-migration-<ISO8601>.sref`
- Emplacement par défaut `~/Documents/Simpl-Resultat/backups/`
- Appelle `dataExportService.performExport('transactions_with_categories', 'json', password)`
- Écrit via `write_export_file` (commande Tauri existante)
- Vérifie intégrité via `read_import_file` + checksum SHA-256
- Retourne `BackupResult { path, size, checksum, verifiedAt }` ou throw
- [ ] Gérer erreurs : espace disque, permissions, chiffrement si profil a un PIN
### Issue 7 — Page de migration 3-étapes (Livraison 2) [type:feature]
Dépendances : Issue 5, Issue 6
- [ ] Créer route `/paramètres/categories/migrer`
- [ ] Créer `src/pages/CategoriesMigrationPage.tsx` avec routeur interne (step 1/2/3)
- [ ] Créer `src/components/categories-migration/` avec StepDiscover, StepSimulate, StepConsent, MappingRow, TransactionPreviewPanel
- [ ] Créer `src/hooks/useCategoryMigration.ts` (useReducer)
- [ ] Créer `src/services/categoryMigrationService.ts` avec `applyMigration(plan, backup)` :
- Transaction SQL atomique (BEGIN/COMMIT/ROLLBACK)
- INSERT v1 + UPDATE transactions/budgets/keywords + création parent custom + DELETE v2 non mappées
- Journal dans `user_preferences.last_categories_migration`
- [ ] Écran succès/échec avec chemin backup et options de rollback
### Issue 8 — Bouton "Rétablir la sauvegarde" (90 jours) [type:feature]
Dépendances : Issue 6, Issue 7
- [ ] Créer `src/components/settings/CategoriesMigrationBackupBanner.tsx`
- [ ] Afficher dans `Paramètres > Catégories` si `last_categories_migration.timestamp` < 90 jours et flag `banner_dismissed=false`
- [ ] Modale de confirmation
- [ ] Appel à `dataImportService.importFullProfile(path, { mode: 'replace' })`
- [ ] Post-rollback : mettre à jour `categories_schema_version='v2'` et `last_categories_migration.reverted_at`
### Issue 9 — Tests complets [type:test]
Dépendances : Issues 1, 2, 5, 6, 7
- [ ] Tests unitaires `categoryMappingService` (algo 4 passes, badges confiance, détection custom)
- [ ] Tests unitaires `categoryBackupService` (création, vérification, erreurs)
- [ ] Tests intégration : flow complet `plan → backup → migrate → verify → rollback`
- [ ] Tests régression : transactions/budgets/keywords préservés post-migration avec IDs remappés
- [ ] Tests régression : fixtures paramétrées (ancien seed v2 ET nouveau seed v1) sur budget, transactions, splits, auto-catégorisation
- [ ] QA manuelle : checklist dans `docs/qa-refonte-seed-categories-ipc.md` couvrant les 3 étapes UI, les cas nominal/échec/rollback
### Ordre d'exécution
```
Livraison 1 (E read-only + seed nouveaux profils):
Issue 1 → Issue 2 → Issue 3 → Issue 4
Livraison 2 (B migration profils existants):
Issue 2 → Issue 5 ─┐
├→ Issue 7 → Issue 8
Issue 6 ───────────┘
Tests:
Issues 1,2,5,6,7 → Issue 9
```
Livraison 1 = Issues 1-4 (PR #1). Livraison 2 = Issues 5-9 (PR #2 ou series).
## Fichiers concernés
| Fichier | Action | Raison |
|---------|--------|--------|
| `src-tauri/src/lib.rs` | Modifier | Ajouter migration v8 (colonne `i18n_key` + pref `categories_schema_version`) |
| `src-tauri/src/database/migrations/v8__category_schema_version.sql` | Créer | Migration additive |
| `src-tauri/src/database/consolidated_schema.sql` | Modifier | Seed v1 complet pour nouveaux profils |
| `src/i18n/locales/fr.json` | Modifier | Nouveau namespace `categoriesSeed` + clés UI migration |
| `src/i18n/locales/en.json` | Modifier | Nouveau namespace `categoriesSeed` + clés UI migration |
| `src/data/categoryTaxonomyV1.json` | Créer | Dérivé du SQL seed v1 |
| `src/services/categoryTaxonomyService.ts` | Créer | Source taxonomie v1 côté TS |
| `src/services/categoryMappingService.ts` | Créer | Algo 4 passes |
| `src/services/categoryBackupService.ts` | Créer | Wrapper SREF pre-migration |
| `src/services/categoryMigrationService.ts` | Créer | Orchestration migration SQL atomique |
| `src/hooks/useCategoryTaxonomy.ts` | Créer | |
| `src/hooks/useCategoryMigration.ts` | Créer | useReducer état page migration |
| `src/pages/CategoriesStandardGuidePage.tsx` | Créer | Livraison 1 |
| `src/pages/CategoriesMigrationPage.tsx` | Créer | Livraison 2 |
| `src/components/categories-migration/*` | Créer | 5 composants (step 1/2/3 + mapping row + preview panel) |
| `src/components/dashboard/CategoriesV1DiscoveryBanner.tsx` | Créer | Bannière one-shot |
| `src/components/settings/CategoriesMigrationBackupBanner.tsx` | Créer | Bannière post-migration 90j |
| `src/components/settings/CategoriesCard.tsx` | Modifier | Ajouter lien vers page Guide + page Migrer |
| `src/pages/Dashboard.tsx` | Modifier | Intégrer la bannière découverte |
| `src/App.tsx` | Modifier | Nouvelles routes |
| `src/components/categories/CategoryTree.tsx` | Modifier | Support `i18n_key` fallback `name` |
| `src/components/categories/CategoryCombobox.tsx` | Modifier | Idem |
| `docs/architecture.md` | Modifier | Documenter nouveaux services, pages, migration v8 |
| `docs/adr/NNNN-refonte-seed-categories-ipc.md` | Créer | ADR pour le choix IPC + pattern prévisualisation-consentement |
| `docs/qa-refonte-seed-categories-ipc.md` | Créer | Checklist QA manuelle |
| `CHANGELOG.md` | Modifier | Entrée sous `[Unreleased]` — Added/Changed |
| `CHANGELOG.fr.md` | Modifier | Idem FR |
## Plan de tests
### Tests unitaires
- `categoryMappingService.computeMigrationPlan()` : chaque règle de mapping v2→v1 (18 haute / 12 moyenne / 3 basse / 1 aucune) retourne le bon badge et la bonne cible.
- Algo 4 passes :
- Pass 1 (keyword match) avec diverses combinaisons
- Pass 2 (supplier propagation)
- Pass 3 (fallback défaut)
- Pass 4 (flag "à réviser")
- Détection des catégories custom (absentes du seed v2) → bucket `preserved`.
- Détection des splits (ex: Transport en commun 28 v2 → Bus 1521 OR Train 1522 v1).
- `categoryBackupService.createPreMigrationBackup()` avec mocks Tauri :
- Succès normal : retourne BackupResult valide
- Échec write_export_file : throw erreur "Impossible de créer la sauvegarde"
- Échec integrity check : throw erreur "Sauvegarde corrompue"
- Profil avec PIN : chiffrement appliqué
### Tests d'intégration
- Flow complet `plan → backup → migrate → verify` sur profil fixture v2 réaliste :
- Catégories v2 mappées correctement
- Transactions re-liées aux nouvelles catégories v1
- Keywords re-liés
- Budgets re-liés
- Catégories custom regroupées sous "Catégories personnalisées (migration)"
- Flow `rollback` après migration : import SREF restaure l'état v2 exact (transactions, keywords, budgets, categories).
- Échec backup → abort → aucune écriture BDD (profil v2 intact).
- Échec migration SQL → ROLLBACK → profil v2 intact, backup reste disponible.
### Tests de régression
Fixtures paramétrées v2 ET v1 pour couvrir :
- Auto-catégorisation (`categorizationService.applyKeywordToTransaction`)
- Budgets mensuels et agrégation parent/enfant (`budgetService.getBudgetVsActual`)
- Splits de transactions sur catégories multiples (`transactionService.splitTransaction`)
- Import CSV avec matching supplier/keyword
- Export/Import SREF (pas de régression sur le format)
- UI : `CategoryTree` et `CategoryCombobox` rendent correctement v2 et v1
## Critères d'acceptation
### Livraison 1
- [ ] Tout nouveau profil créé après la MAJ a le seed v1 appliqué (vérifié par SELECT sur la BDD d'un fresh profile).
- [ ] La bannière dashboard s'affiche sur les profils v2 existants au premier lancement post-MAJ.
- [ ] La bannière disparaît après dismiss et ne réapparaît plus (persistant).
- [ ] La page `/paramètres/categories/standard` affiche correctement l'arbre complet v1 avec FR/EN.
- [ ] Recherche full-text trouve les catégories par nom ou par mot-clé associé.
- [ ] Export PDF produit un document lisible de la taxonomie.
- [ ] Aucun changement aux données des profils v2 existants (test : avant/après MAJ, `SELECT * FROM categories` identique).
### Livraison 2
- [ ] Page `/paramètres/categories/migrer` est accessible depuis la page Guide et depuis Paramètres.
- [ ] Étape 2 affiche le bon badge de confiance pour chaque catégorie (validation sur fixture).
- [ ] Toutes les catégories 🟠 "split requis" bloquent l'avancement tant que non résolues.
- [ ] Backup SREF est créé et vérifié AVANT toute écriture BDD.
- [ ] Échec backup → abort → aucune écriture BDD (profil v2 intact).
- [ ] Migration succès → transactions, budgets, keywords tous re-liés correctement.
- [ ] Catégories custom préservées sous "Catégories personnalisées (migration)".
- [ ] Bannière post-migration visible pendant 90 jours, dismissable.
- [ ] Bouton *Rétablir la sauvegarde* fonctionne : restaure exactement l'état v2.
### Global
- [ ] Tous les tests unitaires et intégration passent (`npm test`, `cargo test`).
- [ ] Type-check clean (`npm run build`).
- [ ] CHANGELOG mis à jour FR et EN.
- [ ] `docs/architecture.md` mis à jour.
- [ ] ADR rédigé.
- [ ] QA manuelle exécutée selon checklist `docs/qa-refonte-seed-categories-ipc.md`.
## Edge cases et risques
| Cas | Mitigation |
|-----|------------|
| Profil v2 avec 0 catégorie custom → mapping simple | Testé par fixture minimale |
| Profil v2 avec ≥50 catégories custom (utilisateur power) | UI pagine la liste dans l'étape 2 ; parent "Catégories personnalisées (migration)" absorbe tout |
| Utilisateur abandonne étape 2 en plein milieu | Aucune écriture BDD, le plan en mémoire est perdu — OK, aucun effet secondaire |
| Utilisateur abandonne étape 3 après checklist cochée mais avant backup | Bouton *Annuler* abort propre, aucune écriture |
| Espace disque insuffisant pour backup | `categoryBackupService` lève erreur claire → UI montre écran d'échec avec options "changer dossier / réessayer / annuler" |
| Migration SQL échoue au milieu | `ROLLBACK` automatique, backup reste disponible, UI affiche erreur + invite à rétablir |
| Utilisateur lance 2 instances de Simpl'Résultat en parallèle pendant la migration | SQLite lock naturel ; la 2e instance attend ; bas risque (app desktop mono-fenêtre en pratique) |
| Profil protégé par PIN : backup doit être chiffré | `categoryBackupService` récupère le PIN depuis le ProfileContext et passe en password à `write_export_file` |
| Utilisateur renomme/déplace le fichier SREF après migration | Le bouton *Rétablir* affiche un file picker si le chemin enregistré n'est plus valide |
| Déjà-migré : utilisateur re-lance la page de migration | Détection `categories_schema_version='v1'` → message "Votre profil utilise déjà la taxonomie v1" avec option "revoir la sauvegarde" uniquement |
| Utilisateur veut migrer APRÈS les 90 jours (bannière disparue) | Entrée permanente dans Paramètres > Catégories reste disponible → bouton *Explorer / Migrer* |
| Clé i18n manquante (typo dans le JSON) | Fallback sur le nom brut de la catégorie — pas de crash |
| Seed v1 manque une feuille qu'un utilisateur a en v2 (ex: "Projets") | Mapping badge 🔴 + prompt obligatoire étape 2, ou préservé en custom |
| Compatibilité forward : une future v2 du seed (refinement v1.1, v1.2) | `categories_schema_version` permet de détecter et ajouter des colonnes plus tard. Pattern réutilisable. |
| Performance : 139 catégories + 100+ keywords au seed pour un nouveau profil | < 200 ms sur SSD moderne, négligeable |
| Performance : migration de 5000 transactions | Transaction unique, < 2 s sur SSD moderne, loader visible pendant ce temps |

View file

@ -1,649 +0,0 @@
## Spec — Refonte complete des rapports
> Date : 2026-04-13
> Projet : simpl-resultat
> Statut : Revised post-review (2026-04-13) — prêt pour implémentation
## Contexte
La page `/reports` actuelle expose cinq onglets (`trends`, `byCategory`, `overTime`, `budgetVsActual`, `dynamic`) pensés comme autant de vues analytiques indépendantes. L'ensemble souffre de trois limites :
1. **Pas de récit** — aucune vue ne répond à la question « qu'est-ce qui est important à savoir sur mes finances ce mois-ci ? ». L'utilisateur doit naviguer entre les onglets et reconstituer le tableau d'ensemble lui-même.
2. **Pivot surdimensionné** — le tableau croisé dynamique (`DynamicReport`) est puissant mais complexe, peu utilisé dans la pratique, et ajoute une dette visuelle/cognitive. Son usage se résume en réalité à « zoomer sur une catégorie ».
3. **Classification déconnectée** — les mots-clés s'éditent uniquement depuis `/categories`. Quand l'utilisateur voit une transaction mal classée dans un rapport, il doit quitter son contexte pour aller modifier la règle puis revenir.
La refonte garde la signature visuelle (palette couleurs catégorie + patterns SVG grayscale-friendly) mais réorganise le contenu autour de **quatre axes d'analyse** correspondant à quatre questions utilisateur :
| Rapport | Question utilisateur |
|--|--|
| Faits saillants | « Qu'est-ce qui a bougé ce mois ? » |
| Tendances | « Où je vais sur les 12 derniers mois ? » |
| Comparables | « Comment je me situe vs période précédente ou vs budget ? » |
| Analyse ponctuelle | « Montre-moi tout sur cette catégorie. » |
## Objectif
Refondre `/reports` en un hub unifié qui présente un aperçu des faits saillants + quatre rapports dédiés (tendances, comparables, faits saillants, zoom catégorie), avec toggle graphique/tableau partout, signature visuelle conservée, et édition contextuelle des mots-clés (clic droit sur transaction → preview → appliquer) pour améliorer la classification sans quitter le rapport.
> **🟢 ARCHITECTURE** — Hub + sous-routes est le bon move.
> URLs bookmarkables, back button natif, chaque page charge ses propres données, meilleur code-splitting possible.
> **Resolution :** Procéder, mais résoudre d'abord les 3 critiques architecture (structure pages plate, split useReports, mécanisme de partage de période) pour ne pas bâtir sur des bases désalignées.
## Scope
### IN
- Nouvelle page hub `/reports` affichant en haut un panneau "Faits saillants" (top mouvements, top transactions récentes, solde net mois courant + YTD) suivi de quatre cartes menant aux quatre sous-rapports.
- Quatre sous-pages :
- `/reports/highlights` — version détaillée des faits saillants
- `/reports/trends` — revenus vs dépenses (flux global) + évolution par catégorie
- `/reports/compare` — mois vs mois-1, année vs année-1, réel vs budget (mois et année), navigation tabulaire
- `/reports/category` — zoom sur une catégorie avec rollup automatique des sous-catégories
- Toggle **graphique ↔ tableau** sur tous les sous-rapports, défaut graphique, préférence mémorisée par rapport dans `localStorage`.
- Conservation de la signature visuelle : palette couleurs existante + patterns SVG (`chartPatterns.tsx`) + Recharts. Ajout de deux nouveaux types de charts : sparklines (pour les faits saillants) et donut chart (pour la répartition dans le zoom catégorie).
- Rollup automatique des sous-catégories dans le zoom catégorie (sélectionner `Alimentation` inclut toutes ses enfants, avec sous-total par enfant). Toggle « direct seulement » disponible.
- Édition contextuelle des mots-clés : clic droit sur n'importe quelle transaction (dans n'importe quel rapport ou dans la page Transactions) → menu « Ajouter ce libellé comme mot-clé pour catégorie X » → dialog preview des matches → Appliquer / Annuler.
- Retrait du tableau croisé dynamique de l'UI : les composants `DynamicReport*` restent dans le code mais derrière un feature flag désactivé par défaut (ou route cachée `/reports/_pivot`), au cas où un usage avancé réémergerait.
- Période par défaut à l'ouverture du hub : année civile en cours (1er janvier → 31 décembre).
- Traductions FR/EN complètes pour toutes les nouvelles clés.
- Mise à jour de `CHANGELOG.md` et `CHANGELOG.fr.md` sous `## [Unreleased]`.
### OUT (explicitement exclu)
- Projection / prévision des prochains mois (pas de ML, pas d'extrapolation).
- Moyennes mobiles (mentionnées mais non retenues pour ce sprint).
- Anomalies / alertes automatiques (pas de détection "dépense inhabituelle").
- KPIs dérivés (taux d'épargne, ratio fixe/variable) — à reconsidérer plus tard.
- Édition des mots-clés directement inline dans le panneau du zoom catégorie (on se limite au clic droit contextuel).
- Migration de données : aucune nouvelle table SQL, aucun changement de schéma.
- Remplacement de Recharts par une autre librairie.
- Rapports par fournisseur / par tag / par compte bancaire (hors scope).
## Design
### UX / Interface
#### Hub `/reports`
```
┌─────────────────────────────────────────────────────────┐
│ Rapports [Période : 2026 ▾] │
├─────────────────────────────────────────────────────────┤
│ FAITS SAILLANTS │
│ ┌──────────┬──────────┬────────────┬──────────────┐ │
│ │ Solde │ Solde │ Top hausse │ Top baisse │ │
│ │ avril │ YTD │ Restos │ Épicerie │ │
│ │ +312 $ │ +1 845 $ │ +240 $ │ -85 $ │ │
│ │ [spark] │ [spark] │ vs mars │ vs mars │ │
│ └──────────┴──────────┴────────────┴──────────────┘ │
│ │
│ Top 5 transactions récentes │
│ • 2026-04-10 Loyer avril 1 450,00 $ │
│ • 2026-04-08 Remboursement prêt 680,00 $ │
│ • ... │
├─────────────────────────────────────────────────────────┤
│ EXPLORER │
│ ┌─────────────┬─────────────┬─────────────┬──────────┐ │
│ │ Tendances │ Comparables │ Faits saill.│ Analyse │ │
│ │ 📈 │ ⚖ │ ⭐ │ 🔍 │ │
│ └─────────────┴─────────────┴─────────────┴──────────┘ │
└─────────────────────────────────────────────────────────┘
```
- Le hub charge un `ReportsHighlights` résumé en haut, même données que `/reports/highlights` mais layout condensé.
- Quatre tuiles de navigation au bas mènent aux sous-pages.
- Sélecteur de période global en haut à droite, partagé avec les sous-rapports (contexte conservé à la navigation).
> **🔴 ARCHITECTURE** — Mécanisme de partage de période non spécifié.
> Avec 4 routes séparées, chaque page monte un `useReports` neuf → l'état local est perdu à chaque navigation. « Contexte conservé » n'est pas défini.
> **Resolution :** Utiliser une query string `?from=...&to=...&period=2026` (simple, bookmarkable, pas de contexte global — cohérent avec le reste du projet qui n'utilise pas de contexte React pour l'état UI).
#### `/reports/highlights`
Version détaillée des faits saillants :
- Bloc **Soldes** : grandes tuiles avec solde net mois courant + YTD, chacune avec un sparkline 12 mois.
- Bloc **Top mouvements** : tableau triable des catégories avec la plus forte variation absolue ($) ou relative (%) vs mois précédent. Toggle `$` / `%`.
- Bloc **Top transactions récentes** : liste des 10 plus grosses transactions des 30 derniers jours (configurable 30/60/90 jours).
- Toggle graphique/tableau s'applique aux tops (barres horizontales ou tableau).
#### `/reports/trends`
Deux sous-vues accessibles par un mini-toggle interne :
- **Flux global** — AreaChart revenus/dépenses/solde net sur la période (reprend `MonthlyTrendsChart`, maintenu tel quel visuellement). Version tableau : `MonthlyTrendsTable`.
- **Par catégorie** — sélection multi-catégories + courbes d'évolution (adapte `CategoryOverTimeChart`). Version tableau : `CategoryOverTimeTable`.
Un seul sélecteur graphique/tableau en haut qui s'applique à la sous-vue affichée.
#### `/reports/compare`
Trois modes accessibles par un tab bar secondaire :
- **Mois vs mois précédent** — tableau catégories × 2 colonnes + écart $ / % ; version graphique = diverging bar chart centré sur 0.
- **Année vs année précédente** — même principe sur 12 mois vs 12 mois.
- **Réel vs budget** — reprend la logique de `BudgetVsActualTable` existante ; toggle mensuel / annuel (YTD).
Navigation entre les trois modes conserve la période et les filtres.
#### `/reports/category`
Vue single-category :
- En haut : combobox de sélection de catégorie + toggle **« inclure sous-catégories »** (activé par défaut).
- Zone principale :
- **Donut chart** de la répartition par sous-catégorie (ou pie si pas de rollup), couleurs de catégorie.
- Chart d'évolution mensuelle de la catégorie sur la période (AreaChart).
- Tableau des transactions de la catégorie (sortable, filtrable par date/montant).
- Toggle graphique/tableau cache/montre les visualisations.
#### Édition contextuelle des mots-clés
Trigger : clic droit sur une ligne de transaction dans n'importe quel tableau (rapports, zoom catégorie, mais aussi éventuellement `/transactions`).
```
┌──────────────────────────────────────────┐
│ Ajouter le mot-clé « METRO » ? │
│ │
│ Catégorie cible : [Alimentation ▾] │
│ Priorité : [100 ] │
│ │
│ Ce mot-clé matchera aussi : │
│ ☑ 2026-03-15 METRO #123 45,00 $ │
│ ☑ 2026-03-02 METRO PLUS 67,20 $ │
│ ☐ 2026-02-18 METROPOLITAIN 12,00 $ │
│ │
│ ⚠ 3 transactions seront recatégorisées │
│ │
│ [Annuler] [Appliquer] │
└──────────────────────────────────────────┘
```
**Implémentation arrêtée** (post-review sécurité) :
- **Normalisation** : utiliser `normalizeDescription` et `buildKeywordRegex` depuis `categorizationService.ts` — ces helpers sont actuellement privés, à exporter dans Issue #6.
- **Validation longueur** : keyword obligatoire entre 2 et 64 caractères après `.trim()`, rejet whitespace-only. Prévient ReDoS (CWE-1333).
- **Preview via SQL paramétrée** : `SELECT ... FROM transactions WHERE description LIKE ?1` (jamais d'interpolation de chaîne), puis filtrage en mémoire avec le regex compilé par `buildKeywordRegex`. Prévient injection SQL (CWE-89).
- **Affichage limité à 50 matches** ; au-delà, une checkbox explicite « Appliquer aussi aux N-50 transactions non affichées » s'affiche (off par défaut).
- **Appliquer** = exécution dans une **transaction SQL englobante** (`BEGIN; INSERT keywords; UPDATE transactions; COMMIT;`) via `tauri-plugin-sql`, avec rollback + toast erreur en cas d'échec. Application uniquement aux lignes **cochées visibles** (sauf si l'utilisateur a explicitement coché l'option des N-50 non affichées).
- **Comportement « mot-clé déjà existant pour autre catégorie »** : `UPDATE keywords SET category_id=? WHERE keyword=?` + re-run de la catégorisation **uniquement** sur les matches visibles cochés (jamais rétroactif sur l'historique complet).
- **Rendu XSS-safe** : les descriptions de transaction sont rendues comme enfants React (`{tx.description}`) — jamais `dangerouslySetInnerHTML`. Troncature via CSS uniquement (CWE-79).
- **Annuler** = aucune modification, dialog fermé.
> **🔴 TECHNIQUE** — `normalizeString` n'existe pas dans `categorizationService.ts`.
> Le service n'expose que `buildKeywordRegex` ; il existe un `normalizeDescription` **privé** (non exporté). L'import référencé dans la spec est invalide.
> **Resolution :** Ajouter à Issue 5 une tâche « Exporter `normalizeDescription` et `buildKeywordRegex` depuis `categorizationService.ts` » et corriger le nom dans la spec.
> **🔴 SECURITE** — Preview SQL doit être paramétrée, jamais interpoler le mot-clé.
> SQLite n'a pas d'opérateur regex natif ; une implémentation naïve construirait un `LIKE '%' || keyword || '%'` interpolé à partir d'un texte de transaction potentiellement malveillant (CSV importé), ouvrant une injection SQL locale.
> **Resolution :** Charger les candidats via SQL paramétré (`LIKE ?1` ou scan complet via `tauri-plugin-sql` binding) puis filtrer en mémoire avec le regex compilé par `buildKeywordRegex`. Jamais de `string || keyword`.
> *Ref : OWASP A03:2021 / CWE-89*
> **🔴 SECURITE** — ReDoS / absence de cap sur la longueur du mot-clé.
> `buildKeywordRegex` échappe les metacharactères mais ne limite pas la longueur. Un mot-clé de 5000 caractères serait compilé puis rejoué à chaque import — freeze garanti de l'app.
> **Resolution :** Dans `AddKeywordDialog`, valider `keyword.trim().length` entre 2 et 64 avant INSERT ; rejeter whitespace-only. Ajouter cette règle à la table Edge cases.
> *Ref : CWE-1333*
> **🟡 SECURITE** — L'apply doit tourner en une seule transaction SQL (BEGIN/COMMIT).
> Le critère d'acceptation le mentionne mais la narration décrit INSERT + UPDATE séquentiels. Un crash entre les deux laisse un keyword orphelin ou des transactions non-recatégorisées — sur une app privacy-first sans backup par défaut, la confiance est ébranlée.
> **Resolution :** Envelopper explicitement INSERT keywords + UPDATE transactions dans `BEGIN / COMMIT / ROLLBACK` via `tauri-plugin-sql` ; surfacer les erreurs de rollback via un toast.
> *Ref : CWE-662*
> **🟡 SECURITE** — « Preview 50 + apply sur tous » viole le contrôle utilisateur.
> La Edge case dit « Dialog limite l'affichage à 50 matches avec + N autres ; l'UPDATE s'applique bien à tous ». L'utilisateur coche 50 lignes, l'app en modifie 300 sans undo.
> **Resolution :** Soit appliquer uniquement aux lignes cochées réellement affichées, soit exiger une confirmation explicite « Appliquer aussi aux N-50 transactions non affichées » (checkbox off par défaut).
> *Ref : OWASP ASVS V1.11.2*
> **🟡 SECURITE** — « Remplacer » un mot-clé existant n'est pas défini.
> La edge case dit « Ce mot-clé existe déjà pour catégorie X, remplacer ? » mais ne précise pas si ça UPDATE silencieusement l'ancien keyword (ce qui re-catégoriserait rétroactivement des années de transactions) ou crée un doublon.
> **Resolution :** Décider explicitement : `UPDATE keywords SET category_id=? WHERE keyword=?` + re-run de la catégorisation uniquement sur les matches visibles (pas sur l'historique). Écrire la décision dans la spec.
> **🟡 ARCHITECTURE** — `AddKeywordDialog` et `TransactionContextMenu` ne sont pas du domaine "reports".
> La spec dit explicitement qu'ils seront utilisés dans `/transactions` et les rapports. Les placer sous `components/reports/shared/` viole SRP.
> **Resolution :** Placer `AddKeywordDialog` dans `components/categories/` (domaine édition mot-clé) et `TransactionContextMenu` dans `components/shared/` (cross-page).
> **🟢 SECURITE** — Rendre les descriptions de transaction en enfants React uniquement.
> Les libellés affichés dans le menu et le dialog viennent de CSV imports (untrusted). Une utilisation naïve de `dangerouslySetInnerHTML` ou de `title=` avec HTML réintroduirait XSS dans le webview Tauri.
> **Resolution :** Spécifier dans la spec que les descriptions sont rendues comme enfants React (jamais `dangerouslySetInnerHTML`) et tronquées via CSS uniquement.
> *Ref : CWE-79*
### Données
**Aucune migration SQL.** Toutes les requêtes s'appuient sur les tables existantes :
- `transactions` — agrégats mensuels, tops, filtres date/catégorie.
- `categories` — hiérarchie pour rollup, couleurs, types.
- `keywords` — insertion nouvelle règle via dialog contextuel.
- `budget_entries` — réel vs budget.
- `import_sources` — filtres optionnels.
Nouveaux endpoints dans `reportService.ts` (SQL strictement paramétré, jamais d'interpolation) :
| Fonction | Rôle |
|--|--|
| `getHighlights(from, to)` | Retourne `{ netBalanceCurrent, netBalanceYtd, monthlyBalanceSeries, topMovers: {category, deltaAbs, deltaPct}[], topTransactions: Transaction[] }` |
| `getCompareMonthOverMonth(year, month)` | Retourne `CategoryDelta[]` pour mois cible vs mois précédent |
| `getCompareYearOverYear(year)` | Retourne `CategoryDelta[]` pour année cible vs année précédente |
| `getCategoryZoom(categoryId, from, to, includeSubcategories)` | Retourne `{ rollupTotal, byChild, monthlyEvolution, transactions }` |
**`getCategoryZoom` — cycle guard obligatoire** : le rollup des sous-catégories passe par une CTE SQLite récursive **bornée** pour se protéger contre d'éventuels cycles dans `parent_id` :
```sql
WITH RECURSIVE cat_tree(id, depth) AS (
SELECT id, 0 FROM categories WHERE id = ?1
UNION ALL
SELECT c.id, ct.depth + 1
FROM categories c JOIN cat_tree ct ON c.parent_id = ct.id
WHERE ct.depth < 5
)
SELECT ... WHERE category_id IN (SELECT id FROM cat_tree);
```
Un test unitaire avec fixture cyclique (A→B→A) doit valider la terminaison.
Le service `getBudgetVsActualData` de `budgetService.ts` est réutilisé tel quel pour le mode réel-vs-budget.
> **🔴 SECURITE** — `getCategoryZoom` rollup récursif sans garde-fou cyclique.
> La table `categories` autorise n'importe quel `parent_id` (pas de check FK contre cycles). Une donnée malformée A→B→A fait tourner un walk récursif à l'infini et fige l'UI. `getCategoryDepth` existant a déjà ce risque latent.
> **Resolution :** Implémenter le rollup via une CTE SQLite récursive bornée (`WITH RECURSIVE ... WHERE depth < 5`) ou tracer un `Set<visited>` en JS. Ajouter un test unitaire avec une fixture cyclique.
> *Ref : CWE-835*
### Architecture
#### Nouvelle structure des fichiers
Convention : **`src/pages/` et `src/components/reports/` restent plats** (aucun sous-dossier par domaine), cohérent avec le reste du projet. Distinction par préfixe de nom.
```
src/pages/ # plat, comme le reste du projet
├── ReportsPage.tsx # refonte : devient le hub
├── ReportsHighlightsPage.tsx # NOUVEAU
├── ReportsTrendsPage.tsx # NOUVEAU
├── ReportsComparePage.tsx # NOUVEAU
└── ReportsCategoryPage.tsx # NOUVEAU
src/components/reports/ # plat, préfixes par domaine
# Hub
├── HubHighlightsPanel.tsx # NOUVEAU — panneau condensé pour le hub
├── HubReportNavCard.tsx # NOUVEAU — les 4 tuiles de navigation
├── HubNetBalanceTile.tsx # NOUVEAU — tuile solde + sparkline
├── HubTopMoversTile.tsx # NOUVEAU
├── HubTopTransactionsTile.tsx # NOUVEAU
# Highlights
├── HighlightsTopMoversTable.tsx # NOUVEAU
├── HighlightsTopMoversChart.tsx # NOUVEAU — diverging bar chart
├── HighlightsTopTransactionsList.tsx # NOUVEAU
# Compare
├── CompareModeTabs.tsx # NOUVEAU
├── ComparePeriodTable.tsx # NOUVEAU
├── ComparePeriodChart.tsx # NOUVEAU — diverging bar chart
├── CompareBudgetView.tsx # NOUVEAU — wrap BudgetVsActualTable
# Category zoom
├── CategoryZoomHeader.tsx # NOUVEAU — combobox + toggle rollup
├── CategoryDonutChart.tsx # NOUVEAU — template : dashboard/CategoryPieChart.tsx
├── CategoryEvolutionChart.tsx # NOUVEAU
├── CategoryTransactionsTable.tsx # NOUVEAU
# Shared (intra-reports)
├── ViewModeToggle.tsx # NOUVEAU — toggle graphique/tableau
├── Sparkline.tsx # NOUVEAU — mini chart Recharts
# EXISTANTS réutilisés tels quels
├── MonthlyTrendsChart.tsx # par TrendsPage (flux global)
├── MonthlyTrendsTable.tsx
├── CategoryOverTimeChart.tsx # par TrendsPage (par catégorie)
├── CategoryOverTimeTable.tsx
├── BudgetVsActualTable.tsx # wrapé par CompareBudgetView
├── CategoryBarChart.tsx
├── CategoryTable.tsx
└── ReportFilterPanel.tsx
# Autres emplacements hors src/components/reports/
src/components/shared/ContextMenu.tsx # NOUVEAU — shell générique (click-outside + Escape)
src/components/shared/ChartContextMenu.tsx # REFACTORÉ — compose ContextMenu
src/components/categories/AddKeywordDialog.tsx # NOUVEAU — domaine édition mot-clé, pas reports
# SUPPRIMÉS (pivot retiré franchement, git conserve l'historique)
src/components/reports/DynamicReport.tsx
src/components/reports/DynamicReportPanel.tsx
src/components/reports/DynamicReportTable.tsx
src/components/reports/DynamicReportChart.tsx
```
> **🔴 ARCHITECTURE** — `src/pages/reports/` casse la convention flat du projet.
> Aucune page du projet n'utilise de sous-dossier aujourd'hui (`src/pages/` est strictement plat : `DashboardPage`, `ImportPage`, `BudgetPage`, etc.). Introduire un sous-dossier pour un seul domaine crée une règle ad-hoc.
> **Resolution :** Garder `src/pages/` plat : nommer `ReportsHighlightsPage.tsx`, `ReportsTrendsPage.tsx`, `ReportsComparePage.tsx`, `ReportsCategoryPage.tsx` à côté de `ReportsPage.tsx`. Cohérent avec le reste du projet.
> **🟡 ARCHITECTURE** — Split `components/reports/` en sous-dossiers incohérent.
> La spec crée `hub/`, `highlights/`, `compare/`, `category/`, `shared/` mais laisse `MonthlyTrendsChart`, `BudgetVsActualTable`, `DynamicReport*` à la racine. Mix flat+nested ; les autres dossiers composants (`import/`, `dashboard/`, `profile/`) sont strictement plats.
> **Resolution :** Tout garder plat avec préfixes de nom (`HubNetBalanceTile`, `HighlightsTopMovers`, `CompareModeTabs`, `CategoryDonutChart`, etc.). Moins de churn git, cohérent avec le reste.
> **🟡 TECHNIQUE** — `TransactionContextMenu` duplique `ChartContextMenu` existant.
> `src/components/shared/ChartContextMenu.tsx` implémente déjà click-outside + Escape handling avec un shell menu réutilisable. La spec crée un nouveau composant from scratch sans le référencer.
> **Resolution :** Généraliser `ChartContextMenu` en `ContextMenu` réutilisable (items passés en children/props) et le réutiliser pour les transactions. Ajouter la refactorisation comme tâche explicite d'Issue 5.
> **🟢 TECHNIQUE** — Recharts 3.7 supporte déjà `<Pie innerRadius>` (donut).
> Vérifié : `recharts@^3.7.0` est installé et `src/components/dashboard/CategoryPieChart.tsx` rend déjà un `<Pie>` avec patterns. Le donut est juste `innerRadius`, pas de nouvelle dépendance.
> **Resolution :** Référencer `CategoryPieChart.tsx` comme template de démarrage pour `CategoryDonutChart.tsx` dans Issue 5.
#### Routing (`src/App.tsx`)
Nouvelles routes imbriquées :
```tsx
<Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<HighlightsPage />} />
<Route path="/reports/trends" element={<TrendsPage />} />
<Route path="/reports/compare" element={<ComparePage />} />
<Route path="/reports/category" element={<CategoryZoomPage />} />
```
#### Hooks par domaine (refonte de `useReports`)
Le hook monolithique `useReports` est splitté en **hooks par domaine**, conformément au pattern « useReducer par domaine » documenté dans CLAUDE.md. Chaque page monte uniquement son propre hook — pas de god-object ni de refetch de champs hors-section.
| Hook | Rôle |
|---|---|
| `useReportsPeriod` | Lit/écrit la période via **query string** (`?from=YYYY-MM-DD&to=YYYY-MM-DD`) avec `useSearchParams` de react-router. Bookmarkable. Par défaut : année civile en cours. |
| `useHighlights` | Fetch + state du rapport faits saillants, `useReducer` dédié |
| `useTrends` | idem pour tendances (sous-vue flux global / par catégorie) |
| `useCompare` | idem pour comparables (mode MoM / YoY / budget) |
| `useCategoryZoom` | idem pour le zoom catégorie (`zoomedCategoryId`, `rollupChildren`) |
Les préférences `viewMode` (chart / table) sont persistées par section dans `localStorage` via une `storageKey` passée en prop à `ViewModeToggle` (`reports-viewmode-highlights`, `-trends`, `-compare`, `-category`).
Pendant la transition (Issue #2), `useReports` conserve temporairement ses champs legacy (`monthlyTrends`, `categorySpending`, etc.) recâblés sur `useReportsPeriod`, pour que les 4 rapports existants continuent de fonctionner jusqu'à ce qu'Issues #36 les migrent. Une fois tous les rapports migrés (Issue #8), `useReports` est supprimé.
> **🔴 ARCHITECTURE** — Un seul hook partagé entre 4 routes = god-object.
> Avec 4 routes react-router, chaque page monte/démonte son propre hook ; un seul `useReports` portant `section + compareMode + trendsSubView + zoomedCategoryId + rollupChildren + period` perd le bénéfice du routing : chaque page refetche tout et les champs hors-section polluent l'état.
> **Resolution :** Splitter en hooks par domaine : `useReportsPeriod` (partagé via query string), `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`. Cohérent avec le pattern « useReducer par domaine » documenté dans CLAUDE.md.
#### Suppression du pivot (tableau croisé dynamique)
Le pivot est **supprimé franchement** — pas de feature flag, pas de route cachée. Git conserve l'historique si on veut le ressusciter un jour. Concrètement :
- Delete `src/components/reports/DynamicReport.tsx`, `DynamicReportPanel.tsx`, `DynamicReportTable.tsx`, `DynamicReportChart.tsx`
- Retirer `pivotConfig`, `pivotResult`, les actions `setPivotConfig` et la logique `tab === 'dynamic'` de `src/hooks/useReports.ts`
- Retirer toutes les clés `reports.pivot.*` dans `src/i18n/locales/{fr,en}.json`
- Nettoyer le type `ReportTab` (plus de `'dynamic'`)
**Sidebar (`NAV_ITEMS` dans `src/shared/constants/index.ts`)** : l'entrée `/reports` reste seule — les 4 sous-rapports ne sont accessibles que via les cartes du hub.
> **🔴 TECHNIQUE + ARCHITECTURE** — `src/shared/constants.ts` n'existe pas + feature flag probablement YAGNI.
> Le projet utilise `src/shared/constants/index.ts` (dossier avec barrel). En plus, un flag pour du code déjà retiré de l'UI = route jamais atteinte + dette i18n (`reports.pivot.*` conservées) sans bénéfice clair — git garde l'historique.
> **Resolution :** Soit supprimer franchement les `DynamicReport*` et leurs clés i18n (git = historique), soit ajouter le flag dans `src/shared/constants/index.ts` avec un TODO de suppression à 2 versions. Trancher maintenant et écrire le choix dans la spec.
> **🟢 SECURITE** — Pivot flag runtime laisse le code dans le bundle.
> Un constant JS ne tree-shake pas : `getDynamicReportData` et son `FIELD_SQL` dynamique (dont les filtres viennent de l'utilisateur) restent dans le JS shippé = surface d'attaque morte mais live.
> **Resolution :** Si on garde l'option, utiliser un flag build-time via `import.meta.env.VITE_ENABLE_LEGACY_PIVOT` ou un `define` Vite pour permettre au bundler d'éliminer l'import conditionnel.
> *Ref : OWASP A05:2021*
### i18n
Nouveaux espaces dans `src/i18n/locales/{fr,en}.json` :
```json
"reports": {
"hub": {
"title": "Rapports",
"explore": "Explorer",
"highlights": "Faits saillants",
"trends": "Tendances",
"compare": "Comparables",
"categoryZoom": "Analyse par catégorie"
},
"highlights": {
"netBalanceCurrent": "Solde du mois",
"netBalanceYtd": "Solde cumulatif (YTD)",
"topMovers": "Top mouvements",
"topTransactions": "Plus grosses transactions récentes",
"variationAbs": "Écart $",
"variationPct": "Écart %",
"vsLastMonth": "vs mois précédent"
},
"trends": {
"subviewGlobal": "Flux global",
"subviewByCategory": "Par catégorie"
},
"compare": {
"modeMoM": "Mois vs mois précédent",
"modeYoY": "Année vs année précédente",
"modeBudget": "Réel vs budget",
"delta": "Écart",
"current": "Courant",
"previous": "Précédent"
},
"category": {
"selectCategory": "Choisir une catégorie",
"includeSubcategories": "Inclure sous-catégories",
"directOnly": "Directe seulement",
"breakdown": "Répartition",
"evolution": "Évolution",
"transactions": "Transactions"
},
"viewMode": {
"chart": "Graphique",
"table": "Tableau"
},
"keyword": {
"addFromTransaction": "Ajouter comme mot-clé",
"dialogTitle": "Nouveau mot-clé",
"willMatch": "Matchera aussi",
"nMatches_one": "{{count}} transaction matchée",
"nMatches_other": "{{count}} transactions matchées",
"applyAndRecategorize": "Appliquer et recatégoriser",
"tooShort": "Minimum 2 caractères",
"tooLong": "Maximum 64 caractères",
"alreadyExists": "Ce mot-clé existe déjà pour la catégorie « {{category}} ». Remplacer ?"
},
"empty": {
"noData": "Aucune donnée pour cette période",
"importCta": "Importer un relevé"
}
}
```
**Suffixes de pluriel** : i18next v25 + react-i18next v16 exige le format v4 JSON (`_one` / `_other`), pas `_plural`. Les clés `reports.pivot.*` existantes sont **supprimées** avec le code du pivot (pas conservées).
> **🔴 TECHNIQUE** — Suffixe `_plural` incorrect pour i18next v25.
> Le projet tourne avec `i18next` v25 + `react-i18next` v16 qui exige le format v4 JSON (`_one` / `_other`). Les clés existantes utilisent déjà `fileCount_one` / `fileCount_other`. `nMatches_plural` ne résoudra jamais, silencieusement.
> **Resolution :** Remplacer `nMatches` / `nMatches_plural` par `nMatches_one` / `nMatches_other` dans le snippet i18n et dans la task d'Issue 5.
## Plan de travail
Découpage en **8 issues Forgejo** (milestone `spec-refonte-rapports`). Les tâches détaillées vivent dans chaque issue Forgejo — cette section en donne l'index.
### Issue #1 (1a) — Fondation non-breaking [type:task]
**Dépendances :** aucune
Supprime le pivot franchement, ajoute les 4 squelettes de pages, crée les shared components (`ViewModeToggle`, `Sparkline`, `ContextMenu` générique), ajoute les sous-routes, met à jour les clés i18n. N'introduit pas encore le hub ni la refonte `useReports` — rien n'est cassé côté rapports existants.
### Issue #2 (1b) — Refonte `useReports` en hooks par domaine + query string période [type:task]
**Dépendances :** #1
Crée `useReportsPeriod` (query string), `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`. Garde `useReports` en mode legacy temporaire pour que les 4 rapports existants continuent de tourner pendant la transition.
### Issue #3 — Rapport Faits saillants + Hub [type:feature]
**Dépendances :** #2
Implémente `getHighlights` et les tuiles (`HubNetBalanceTile`, `HubTopMoversTile`, `HubTopTransactionsTile`), compose `HubHighlightsPanel`, transforme `/reports` en hub (grille de 4 `HubReportNavCard`), implémente la version détaillée `ReportsHighlightsPage`.
### Issue #4 — Rapport Tendances [type:feature]
**Dépendances :** #2
`ReportsTrendsPage` avec sous-toggle `Flux global` / `Par catégorie`. Réutilise `MonthlyTrendsChart/Table` et `CategoryOverTimeChart/Table` existants, les recâble sur `useTrends` + `useReportsPeriod`.
### Issue #5 — Rapport Comparables [type:feature]
**Dépendances :** #2
`getCompareMonthOverMonth`, `getCompareYearOverYear` (SQL paramétré). `ReportsComparePage` avec `CompareModeTabs` (MoM / YoY / Budget), `ComparePeriodTable`, `ComparePeriodChart` (diverging bar), `CompareBudgetView` (wrap de `BudgetVsActualTable`).
### Issue #6 — Zoom catégorie + édition contextuelle mot-clé (scope limité) [type:feature]
**Dépendances :** #2
`getCategoryZoom` avec **CTE récursive bornée** (cycle guard). `ReportsCategoryPage` avec combobox + rollup, `CategoryDonutChart` (template `dashboard/CategoryPieChart.tsx`), `CategoryEvolutionChart`, `CategoryTransactionsTable`.
Édition contextuelle des mots-clés : exporte `normalizeDescription`, `buildKeywordRegex`, `compileKeywords` depuis `categorizationService.ts` ; crée `src/components/categories/AddKeywordDialog.tsx` avec toutes les contraintes de sécurité (SQL paramétré, validation longueur 264, transaction SQL englobante, apply uniquement aux cochées visibles, rendu XSS-safe). Branche `ContextMenu` **uniquement sur `CategoryTransactionsTable`** dans cette issue. Test unitaire cycle guard avec fixture cyclique.
### Issue #7 — Propagation du clic droit (follow-up) [type:feature]
**Dépendances :** #3, #4, #5, #6
Étend `ContextMenu` + `AddKeywordDialog` aux autres tables : `HighlightsTopMoversTable`, `HighlightsTopTransactionsList`, `ComparePeriodTable`, `MonthlyTrendsTable`, `CategoryOverTimeTable`, et la table principale de `TransactionsPage`. Pas de nouveau code métier — réutilisation pure.
### Issue #8 — Polish, tests, documentation, changelog [type:task]
**Dépendances :** #3, #4, #5, #6, #7
Smoke tests vitest (`getHighlights`, `getCompareMonthOverMonth`, `getCategoryZoom` avec fixture cyclique, validation longueur `AddKeywordDialog`). Validation manuelle des flows. Mise à jour `docs/architecture.md` + `docs/guide-utilisateur.md` + ADR. Entrées `CHANGELOG.md` / `CHANGELOG.fr.md` sous `## [Unreleased]`. Suppression définitive des champs legacy de `useReports`. Build + tests verts.
### Ordre d'exécution
```
#1#2#3 ─┐
#4 ─┤
#5 ─┼→ #7#8
#6 ─┘
```
Issues #3, #4, #5, #6 parallélisables après #2.
## Fichiers concernés
| Fichier | Action | Raison |
|---|---|---|
| `src/pages/ReportsPage.tsx` | Refondre | Devient le hub (#3) |
| `src/pages/ReportsHighlightsPage.tsx` | Créer | Sous-page plat (#1 skeleton, #3 contenu) |
| `src/pages/ReportsTrendsPage.tsx` | Créer | Sous-page (#1 skeleton, #4 contenu) |
| `src/pages/ReportsComparePage.tsx` | Créer | Sous-page (#1 skeleton, #5 contenu) |
| `src/pages/ReportsCategoryPage.tsx` | Créer | Sous-page (#1 skeleton, #6 contenu) |
| `src/App.tsx` | Modifier | Ajout des 4 sous-routes (#1) |
| `src/hooks/useReports.ts` | Refondre → supprimer | Nettoyage pivot (#1), déprécié (#2), supprimé (#8) |
| `src/hooks/useReportsPeriod.ts` | Créer | Période via query string (#2) |
| `src/hooks/useHighlights.ts` | Créer | Hook domaine (#2) |
| `src/hooks/useTrends.ts` | Créer | Hook domaine (#2) |
| `src/hooks/useCompare.ts` | Créer | Hook domaine (#2) |
| `src/hooks/useCategoryZoom.ts` | Créer | Hook domaine (#2) |
| `src/services/reportService.ts` | Étendre | `getHighlights` (#3), `getCompareMoM`/`YoY` (#5), `getCategoryZoom` CTE bornée (#6) |
| `src/services/categorizationService.ts` | Étendre | Exporter `normalizeDescription`, `buildKeywordRegex`, `compileKeywords` (#6) |
| `src/components/reports/*` (plat, préfixes) | Créer | `Hub*`, `Highlights*`, `Compare*`, `Category*`, `ViewModeToggle`, `Sparkline` |
| `src/components/reports/DynamicReport*.tsx` | Supprimer | Pivot supprimé franchement (#1) |
| `src/components/shared/ContextMenu.tsx` | Créer | Shell générique clic droit (#1) |
| `src/components/shared/ChartContextMenu.tsx` | Refactorer | Compose `ContextMenu` (#1) |
| `src/components/categories/AddKeywordDialog.tsx` | Créer | Dialog édition mot-clé avec garanties sécurité (#6) |
| `src/shared/constants/index.ts` | Aucun changement NAV_ITEMS | `/reports` reste seul point d'entrée |
| `src/i18n/locales/fr.json` + `en.json` | Étendre + nettoyer | Ajouter clés hub/highlights/trends/compare/category/keyword/empty/viewMode ; **supprimer** `reports.pivot.*` (#1) |
| `CHANGELOG.md` + `CHANGELOG.fr.md` | Modifier | Entrée `## [Unreleased]` (#8) |
| `docs/architecture.md` | Modifier | Section Rapports mise à jour (#8) |
| `docs/guide-utilisateur.md` | Modifier | Nouveau flow utilisateur (#8) |
| `docs/adr/NNNN-refonte-rapports.md` | Créer | Décision architecturale (#8) |
## Critères d'acceptation
- [ ] La page `/reports` affiche un hub avec un panneau Faits saillants en haut et 4 cartes de navigation.
- [ ] Chacune des 4 sous-pages (`/reports/highlights`, `/trends`, `/compare`, `/category`) est accessible et fonctionnelle.
- [ ] Le toggle graphique/tableau fonctionne sur toutes les sous-pages et la préférence est mémorisée par rapport.
- [ ] Le rapport faits saillants affiche solde mois courant, solde YTD (avec sparklines), top movers par catégorie et top transactions récentes.
- [ ] Le rapport tendances expose flux global et par catégorie via un toggle interne.
- [ ] Le rapport comparables permet de basculer entre MoM, YoY et Réel vs budget en conservant la période.
- [ ] Le rapport zoom catégorie inclut automatiquement les sous-catégories et offre un toggle pour se limiter aux transactions directes.
- [ ] Le donut chart de répartition s'affiche avec les couleurs des catégories et les patterns SVG.
- [ ] Clic droit sur une transaction ouvre un menu permettant d'ajouter un mot-clé.
- [ ] Le dialog d'ajout de mot-clé montre la preview des transactions qui seront recatégorisées, avec cases à cocher individuelles.
- [ ] Appliquer le mot-clé met à jour `keywords` + les transactions cochées dans une **transaction SQL englobante** (BEGIN/COMMIT/ROLLBACK).
- [ ] La validation de longueur du mot-clé (264 caractères, rejet whitespace-only) est active côté dialog.
- [ ] `getCategoryZoom` termine correctement avec une fixture de catégories cyclique (test unitaire).
- [ ] Le tableau croisé dynamique est complètement supprimé du code et des traductions.
- [ ] Les 4 rapports respectent la signature visuelle : palette couleurs catégorie + patterns SVG + Recharts.
- [ ] Toutes les chaînes sont traduites en FR et EN.
- [ ] `CHANGELOG.md` et `CHANGELOG.fr.md` sont mis à jour sous `## [Unreleased]`.
- [ ] `npm run build` et `cargo check` passent verts.
- [ ] Un smoke test vitest couvre `getHighlights` et un rapport comparables.
## Edge cases et risques
| Cas | Mitigation |
|---|---|
| Profil vide (aucune transaction) | Chaque rapport affiche un empty-state i18n avec CTA vers /import |
| Catégorie sans sous-catégories mais rollup activé | Le donut affiche uniquement la catégorie elle-même, pas de plantage |
| Transaction sans catégorie dans top transactions | Afficher "Non catégorisé" avec couleur `#9ca3af` existante |
| Mot-clé trop court / trop long / whitespace-only | Validation dialog : longueur 264 après `.trim()`, rejet avec i18n `reports.keyword.tooShort` / `tooLong` avant INSERT (prévient ReDoS) |
| Mot-clé qui matcherait des centaines de transactions | Dialog limite l'affichage à 50 matches. Si plus, checkbox explicite « Appliquer aussi aux N-50 non affichées » (off par défaut). Apply ne touche QUE les lignes cochées réellement visibles, sauf confirmation explicite |
| Mot-clé avec regex spéciale (ex : `*`, `?`) | `buildKeywordRegex` échappe déjà les metacharactères — couvert par test |
| Clic droit sur transaction déjà dans la catégorie cible | Le dialog propose juste d'ajouter le mot-clé, sans UPDATE inutile ; afficher "déjà classée" |
| Conflit de mot-clé (déjà existant pour autre catégorie) | Dialog affiche `reports.keyword.alreadyExists`. « Remplacer » = `UPDATE keywords SET category_id=? WHERE keyword=?` + re-run catégorisation **uniquement** sur matches visibles cochés (pas rétroactif sur l'historique) |
| Crash au milieu de l'apply (INSERT ok, UPDATE partiel) | Tout l'apply tourne dans une transaction SQL (BEGIN/COMMIT/ROLLBACK). En cas d'échec, rollback complet + toast erreur |
| Catégorie avec cycle dans `parent_id` (A→B→A) | `getCategoryZoom` utilise une CTE récursive bornée `WHERE depth < 5`. Test unitaire avec fixture cyclique |
| Year-over-year sans données année précédente | Afficher empty-state i18n `reports.empty.noData` |
| Budget absent dans Réel vs budget | Réutiliser le comportement existant de `BudgetVsActualTable` |
| Profil existant avec `tab: 'dynamic'` en localStorage | Fallback sur hub à l'ouverture — le pivot n'existe plus |
| Période personnalisée très longue (5+ ans) | Laisser passer, Recharts gère bien jusqu'à ~60 points |
| XSS via description de transaction importée | Descriptions rendues comme enfants React uniquement (jamais `dangerouslySetInnerHTML`), troncature CSS |
## Décisions prises
| Question | Décision | Raison |
|---|---|---|
| Organisation UI | Hub + 4 sous-pages | Offre un récit (faits saillants d'abord) + place pour chaque rapport |
| Contenu faits saillants | Top mouvements + top transactions + solde mois/YTD | Répond à la question "qu'est-ce qui a bougé ?" sans complexifier |
| Contenu comparables | MoM, YoY, Réel vs budget avec navigation facile | Couvre les 3 comparaisons naturelles (temps court, temps long, vs plan) |
| Analyse ponctuelle | Zoom sur catégorie (pas pivot) | 90% des usages du pivot revenaient à zoomer une catégorie |
| Sort du pivot | **Supprimé franchement** (code + i18n + types) | Git conserve l'historique. Feature flag runtime laisserait le code dans le bundle (surface d'attaque morte) + dette i18n inutile. YAGNI |
| Contenu tendances | Flux global + par catégorie uniquement | Retire projection et moyennes mobiles (hors scope) |
| Toggle chart/table | Partout, défaut graphique, mémorisé localStorage | Cohérence + flexibilité + mémoire utilisateur |
| Librairie de charts | Conserver Recharts + patterns SVG | Déjà en place, adapté aux petits datasets, signature visuelle intacte |
| Nouveaux types | Sparklines + donut chart | Utiles pour faits saillants et répartition, faisables en Recharts natif |
| Rollup sous-catégories | Auto activé, toggle pour désactiver | Intuition conforme à l'arbre, reste contrôlable |
| Édition mots-clés | Clic droit contextuel sur transaction | Contexte naturel, pas de duplication d'UI dans categories |
| Reclassification | Preview + apply | Sécurité + feedback + contrôle utilisateur |
| Période par défaut | Année civile en cours | Naturel fiscalement, cohérent avec budget |
| Découpage issues | **8 issues** : #1 fondation non-breaking, #2 refonte hooks, #36 rapports (parallélisables), #7 propagation clic droit, #8 polish | Split fondation en 1a+1b évite de casser les rapports existants pendant la refonte du hook. Issue #7 de follow-up évite que Issue #6 dépende de #3/#4/#5 |
| Partage de période entre routes | Query string `?from=YYYY-MM-DD&to=YYYY-MM-DD` via `useSearchParams` | Bookmarkable, pas de contexte React global, cohérent avec le reste du projet |
| Hook `useReports` | Split en hooks par domaine (`useReportsPeriod`, `useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) | Chaque route monte uniquement son hook, pas de god-object, pas de refetch de champs hors-section |
| Structure `src/pages/` | Plate (pas de sous-dossier) | Cohérent avec le reste du projet (`DashboardPage`, `ImportPage`, etc.) |
| Structure `src/components/reports/` | Plate avec préfixes de nom (`HubNetBalanceTile`, etc.) | Cohérent avec les autres dossiers composants (`import/`, `dashboard/`, `profile/`) qui sont plats |
| `AddKeywordDialog` emplacement | `src/components/categories/` | Domaine édition mot-clé, utilisé hors reports (page transactions aussi) |
| `ContextMenu` générique | Créer `src/components/shared/ContextMenu.tsx` + refactorer `ChartContextMenu` pour le composer | Évite de dupliquer la logique click-outside + Escape déjà dans `ChartContextMenu` |
| Sécurité du dialog mot-clé | SQL paramétré + longueur 264 + transaction SQL + apply sur cochées visibles + rendu React children | 3 critiques sécurité (SQL injection, ReDoS, transaction atomique) + 1 XSS latent dans le webview Tauri |
| Cycle guard rollup catégorie | CTE SQLite récursive bornée `WHERE depth < 5` + test fixture cyclique | `categories.parent_id` ne protège pas contre les cycles, risque de boucle infinie |
## Références
| Source | Pertinence |
|---|---|
| [My Take on React Chart Libraries — Kyle Gill](https://www.kylegill.com/essays/react-chart-libraries) | Confirme que Recharts reste un choix solide pour React + petits datasets ; pas de raison de migrer |
| [Top React Chart Libraries 2026 — Querio](https://querio.ai/articles/top-react-chart-libraries-data-visualization) | Compare Recharts / Visx / ECharts ; ECharts pertinent uniquement >100k points, pas notre cas |
| [Recharts — Create a Donut Chart (GeeksforGeeks)](https://www.geeksforgeeks.org/reactjs/create-a-donut-chart-using-recharts-in-reactjs/) | Donut chart = `Pie` avec `innerRadius`, pas de dépendance supplémentaire |
| [MUI X — Sparkline Chart](https://mui.com/x/react-charts/sparkline/) | Pattern sparkline : LineChart compact sans axes, intégrable dans une tuile |
## Revision — Synthese
> Date : 2026-04-13 | Experts : Securite, Architecture, Technique
### Verdict
🔴 **CRITIQUES A CORRIGER** — L'orientation produit (hub + 4 rapports) est validée, mais 9 findings critiques doivent être résolus avant d'ouvrir les issues : incohérences avec la structure du projet, références à du code inexistant, et risques de sécurité autour de l'édition contextuelle des mots-clés.
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|----|----|----|-------------|
| Securite | 3 | 3 | 2 | ReDoS + SQL injection sur dialog mot-clé, absence de cycle guard rollup catégorie, transaction SQL manquante |
| Architecture | 3 | 3 | 1 | `pages/reports/` casse la convention flat, `useReports` god-object, partage de période non spécifié |
| Technique | 3 | 3 | 2 | `normalizeString` inexistant, `constants.ts` mauvais path, i18next v25 exige `_one`/`_other` |
### Actions requises
**🔴 Critiques à corriger avant d'ouvrir les issues**
1. 🔴 **Preview SQL doit être paramétrée** — charger les candidats via `LIKE ?` puis filtrer en mémoire avec `buildKeywordRegex` ; jamais de string concat.
2. 🔴 **ReDoS : cap la longueur du mot-clé** — validation 264 caractères dans `AddKeywordDialog`, rejet whitespace-only.
3. 🔴 **Cycle guard sur le rollup catégorie** — CTE récursive bornée (`WHERE depth < 5`) ou `Set<visited>` en JS + test unitaire.
4. 🔴 **Structure `src/pages/` plate** — renommer `ReportsHighlightsPage.tsx`, `ReportsTrendsPage.tsx`, etc. à côté de `ReportsPage.tsx`, pas de sous-dossier.
5. 🔴 **Splitter `useReports`** — un hook par domaine (`useHighlights`, `useTrends`, `useCompare`, `useCategoryZoom`) + `useReportsPeriod` partagé via query string.
6. 🔴 **Mécanisme de partage de période** — query string `?from=...&to=...`, pas de contexte React global.
7. 🔴 **`nMatches_one` / `nMatches_other`** — pas `_plural` (i18next v25).
8. 🔴 **Corriger `src/shared/constants/index.ts`** — ou mieux : supprimer franchement le pivot, git garde l'historique (trancher YAGNI).
9. 🔴 **Exporter `normalizeDescription` et `buildKeywordRegex`** — et corriger le nom dans la spec (pas `normalizeString`).
**🟡 Améliorations recommandées**
10. 🟡 Wrapper INSERT + UPDATE du dialog mot-clé dans une transaction SQL explicite.
11. 🟡 Decider et écrire le comportement de « remplacer un mot-clé existant ».
12. 🟡 Apply ne modifie que les lignes cochées affichées (ou confirmation explicite pour les N-50 non affichées).
13. 🟡 Garder `components/reports/` plat avec préfixes de nom (`HubNetBalanceTile`, etc.), comme les autres dossiers composants.
14. 🟡 Déplacer `AddKeywordDialog``components/categories/`, `TransactionContextMenu``components/shared/`.
15. 🟡 Splitter Issue 1 en 1a (routing + skeletons) et 1b (refactor `useReports`) pour éviter de casser les 4 rapports existants.
16. 🟡 Trancher l'exposition Sidebar des sous-routes (probablement : seule `/reports` reste dans le menu).
17. 🟡 Généraliser `ChartContextMenu` existant plutôt que dupliquer en `TransactionContextMenu`.
18. 🟡 Scope du clic droit dans Issue 5 : limiter à `CategoryTransactionsTable` + issue de follow-up pour propagation.

View file

@ -1,559 +0,0 @@
# Spec — Simpl-Resultat Web
> Date: 2026-03-30
> Projet: simpl-resultat
> Statut: Draft
> Dependance: Logto IdP (spec-compte-maximus dans la-compagnie-maximus)
## Contexte
Simpl-Resultat est actuellement une app desktop Tauri v2 (Windows/Linux) avec stockage SQLite local et protection par PIN Argon2. L'objectif est de rendre l'application disponible via le web, permettant aux utilisateurs connectes avec un Compte Maximus d'acceder a leurs donnees financieres depuis un navigateur.
L'app desktop continue de fonctionner en offline sans compte. Le compte et le web sont optionnels.
**Decision securite** : Les donnees financieres sont stockees en clair cote serveur (Option D — chiffrement au repos + isolation forte). L'E2EE zero-knowledge a ete evalue et rejete pour la v1 car incompatible avec les fonctionnalites web (recherche serveur, rapports, import CSV). L'hebergement local au Quebec (VPS OVH Beauharnois, Loi 25) est le differenciateur securite, pas le zero-knowledge. L'E2EE reste une option premium future ("mode Coffre-fort").
## Objectif
Permettre aux utilisateurs de gerer leur budget et transactions depuis un navigateur web a `resultat.lacompagniemaximus.com`, avec possibilite de synchronisation avec l'app desktop.
## Scope
### IN
- API REST backend pour toutes les operations CRUD (transactions, categories, budgets, ajustements, imports, fournisseurs, keywords)
- Schema PostgreSQL (migration des 13 tables SQLite, schema `sr_`)
- Frontend web porte depuis le code React/Vite/Tailwind existant (bonne reutilisabilite)
- Import CSV cote serveur (upload → parsing → auto-categorisation → preview → confirmation)
- Auth via le service auth dedie (Compte Maximus)
- Multi-profil par utilisateur (comme l'app desktop)
- Hebergement sur Coolify (resultat.lacompagniemaximus.com)
- i18n FR/EN
- Dark mode
- Securite Option D (chiffrement au repos + isolation)
### OUT
- E2EE zero-knowledge (decision documentee, option premium future)
- Connexion bancaire directe (Plaid/Flinks — future phase)
- App mobile simpl-resultat
- Partage de budget entre utilisateurs (ex: conjoint — future phase)
- Notifications/alertes serveur sur depassements budgetaires (future phase)
- Export PDF de rapports (future phase)
## Design
### Schema PostgreSQL (schema `sr_`)
Migration des 13 tables SQLite existantes vers PostgreSQL. Changements principaux :
- Ajout de `user_id` et `profile_id` pour isoler les donnees par utilisateur et par profil
- Ajout d'une table `sr_profiles` pour gerer les profils multiples par utilisateur (remplace le systeme de fichiers de profils dans Tauri)
- UUID comme cles primaires au lieu de INTEGER AUTOINCREMENT
- TIMESTAMPTZ au lieu de TEXT/DATETIME pour les dates
- NUMERIC(12,2) au lieu de REAL pour les montants (precision financiere)
- Conservation de la structure hierarchique des categories (parent_id)
- Conservation du systeme d'auto-categorisation par mots-cles
- Seed des 54 categories par defaut et 60+ keywords par profil
```sql
CREATE SCHEMA IF NOT EXISTS sr_;
-- Profils utilisateur (remplace le systeme fichiers Tauri)
CREATE TABLE sr_.profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- FK vers le service auth (Compte Maximus)
name TEXT NOT NULL,
pin_hash TEXT, -- Argon2 hash, optionnel sur le web
currency TEXT NOT NULL DEFAULT 'CAD',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, name)
);
-- Sources d'import (configurations de parsing)
CREATE TABLE sr_.import_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
column_mapping JSONB NOT NULL,
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, name)
);
-- Fichiers importes (deduplication)
CREATE TABLE sr_.imported_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_id UUID NOT NULL REFERENCES sr_.import_sources(id) ON DELETE CASCADE,
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
file_hash TEXT NOT NULL,
import_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
row_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed',
notes TEXT,
UNIQUE(source_id, filename)
);
-- Categories hierarchiques (parent_id, pre-seeded)
CREATE TABLE sr_.categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
parent_id UUID REFERENCES sr_.categories(id) ON DELETE SET NULL,
color TEXT,
icon TEXT,
type TEXT NOT NULL DEFAULT 'expense', -- 'expense' | 'income'
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_inputable BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Fournisseurs (normalises pour matching)
CREATE TABLE sr_.suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
category_id UUID REFERENCES sr_.categories(id) ON DELETE SET NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, normalized_name)
);
-- Mots-cles pour auto-categorisation (pre-seeded)
CREATE TABLE sr_.keywords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
keyword TEXT NOT NULL,
category_id UUID NOT NULL REFERENCES sr_.categories(id) ON DELETE CASCADE,
supplier_id UUID REFERENCES sr_.suppliers(id) ON DELETE SET NULL,
priority INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE(profile_id, keyword, category_id)
);
-- Transactions (avec support split via parent_transaction_id)
CREATE TABLE sr_.transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
date DATE NOT NULL,
description TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
category_id UUID REFERENCES sr_.categories(id) ON DELETE SET NULL,
supplier_id UUID REFERENCES sr_.suppliers(id) ON DELETE SET NULL,
source_id UUID REFERENCES sr_.import_sources(id) ON DELETE SET NULL,
file_id UUID REFERENCES sr_.imported_files(id) ON DELETE SET NULL,
original_description TEXT,
notes TEXT,
is_manually_categorized BOOLEAN NOT NULL DEFAULT FALSE,
is_split BOOLEAN NOT NULL DEFAULT FALSE,
parent_transaction_id UUID REFERENCES sr_.transactions(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Ajustements (ponctuels ou recurrents)
CREATE TABLE sr_.adjustments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
date DATE NOT NULL,
is_recurring BOOLEAN NOT NULL DEFAULT FALSE,
recurrence_rule TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Entrees d'ajustement (montant par categorie)
CREATE TABLE sr_.adjustment_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
adjustment_id UUID NOT NULL REFERENCES sr_.adjustments(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES sr_.categories(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
description TEXT
);
-- Entrees budgetaires (grille 12 mois par categorie)
CREATE TABLE sr_.budget_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES sr_.categories(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
month INTEGER NOT NULL, -- 1-12
amount NUMERIC(12,2) NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, category_id, year, month)
);
-- Templates de budget (reutilisables)
CREATE TABLE sr_.budget_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, name)
);
-- Entrees de template de budget
CREATE TABLE sr_.budget_template_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES sr_.budget_templates(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES sr_.categories(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
UNIQUE(template_id, category_id)
);
-- Templates de configuration d'import
CREATE TABLE sr_.import_config_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
delimiter TEXT NOT NULL DEFAULT ';',
encoding TEXT NOT NULL DEFAULT 'utf-8',
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
skip_lines INTEGER NOT NULL DEFAULT 0,
has_header BOOLEAN NOT NULL DEFAULT TRUE,
column_mapping JSONB NOT NULL,
amount_mode TEXT NOT NULL DEFAULT 'single', -- 'single' | 'dual'
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(profile_id, name)
);
-- Preferences utilisateur (cle-valeur par profil)
CREATE TABLE sr_.user_preferences (
profile_id UUID NOT NULL REFERENCES sr_.profiles(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (profile_id, key)
);
-- Journal d'audit (securite)
CREATE TABLE sr_.audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
profile_id UUID,
action TEXT NOT NULL, -- 'login', 'export', 'delete', 'import', 'bulk_delete'
entity_type TEXT, -- 'transaction', 'profile', 'category', etc.
entity_id UUID,
metadata JSONB, -- details supplementaires (ex: nombre de lignes importees)
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index
CREATE INDEX idx_sr_profiles_user ON sr_.profiles(user_id);
CREATE INDEX idx_sr_transactions_profile_date ON sr_.transactions(profile_id, date);
CREATE INDEX idx_sr_transactions_category ON sr_.transactions(category_id);
CREATE INDEX idx_sr_transactions_supplier ON sr_.transactions(supplier_id);
CREATE INDEX idx_sr_transactions_source ON sr_.transactions(source_id);
CREATE INDEX idx_sr_transactions_file ON sr_.transactions(file_id);
CREATE INDEX idx_sr_transactions_parent ON sr_.transactions(parent_transaction_id);
CREATE INDEX idx_sr_categories_profile_parent ON sr_.categories(profile_id, parent_id);
CREATE INDEX idx_sr_categories_type ON sr_.categories(profile_id, type);
CREATE INDEX idx_sr_suppliers_profile_category ON sr_.suppliers(profile_id, category_id);
CREATE INDEX idx_sr_suppliers_normalized ON sr_.suppliers(profile_id, normalized_name);
CREATE INDEX idx_sr_keywords_profile_category ON sr_.keywords(profile_id, category_id);
CREATE INDEX idx_sr_keywords_keyword ON sr_.keywords(profile_id, keyword);
CREATE INDEX idx_sr_budget_entries_period ON sr_.budget_entries(profile_id, year, month);
CREATE INDEX idx_sr_adjustment_entries_adjustment ON sr_.adjustment_entries(adjustment_id);
CREATE INDEX idx_sr_imported_files_source ON sr_.imported_files(source_id);
CREATE INDEX idx_sr_audit_log_user ON sr_.audit_log(user_id, created_at);
CREATE INDEX idx_sr_audit_log_profile ON sr_.audit_log(profile_id, created_at);
-- Preferences par defaut (inserees a la creation d'un profil)
-- INSERT INTO sr_.user_preferences (profile_id, key, value) VALUES
-- ($profile_id, 'language', 'fr'),
-- ($profile_id, 'theme', 'light'),
-- ($profile_id, 'currency', 'CAD'),
-- ($profile_id, 'date_format', 'DD/MM/YYYY');
```
### API REST
Base URL: `https://resultat.lacompagniemaximus.com/api`
Auth: Bearer token (JWT depuis Logto)
Tous les endpoints scopes par `user_id` (extrait du JWT) + `profile_id` (header ou parametre)
#### Profils
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/profiles` | Lister les profils de l'utilisateur |
| POST | `/profiles` | Creer un profil (seed categories + keywords) |
| GET | `/profiles/:id` | Details d'un profil |
| PUT | `/profiles/:id` | Modifier un profil (nom, devise, format date) |
| DELETE | `/profiles/:id` | Supprimer un profil et toutes ses donnees |
| POST | `/profiles/:id/verify-pin` | Verifier le PIN (couche securite optionnelle) |
#### Transactions
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/transactions` | Lister avec filtres (date range, category_id, supplier_id, search, min/max amount, page, limit) |
| POST | `/transactions` | Creer une transaction manuelle |
| GET | `/transactions/:id` | Detail d'une transaction |
| PUT | `/transactions/:id` | Modifier (description, categorie, notes, montant) |
| DELETE | `/transactions/:id` | Supprimer |
| POST | `/transactions/:id/split` | Splitter en plusieurs entrees par categorie |
| DELETE | `/transactions/bulk` | Suppression en lot (body: { ids: [] }) |
#### Categories
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/categories` | Arbre hierarchique complet |
| POST | `/categories` | Creer une categorie custom |
| PUT | `/categories/:id` | Modifier (nom, couleur, icone, parent, sort_order) |
| DELETE | `/categories/:id` | Supprimer (SET NULL sur les transactions liees) |
| PUT | `/categories/reorder` | Reorganiser l'ordre des categories |
#### Fournisseurs & Mots-cles
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/suppliers` | Lister les fournisseurs |
| POST | `/suppliers` | Creer un fournisseur |
| PUT | `/suppliers/:id` | Modifier |
| DELETE | `/suppliers/:id` | Supprimer |
| GET | `/keywords` | Lister les mots-cles |
| POST | `/keywords` | Creer un mot-cle |
| PUT | `/keywords/:id` | Modifier |
| DELETE | `/keywords/:id` | Supprimer |
#### Budgets
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/budgets/:year` | Grille budgetaire 12 mois pour une annee |
| PUT | `/budgets` | Mettre a jour des entrees budgetaires (batch) |
| GET | `/budgets/templates` | Lister les templates de budget |
| POST | `/budgets/templates` | Creer un template |
| PUT | `/budgets/templates/:id` | Modifier un template |
| DELETE | `/budgets/templates/:id` | Supprimer un template |
| POST | `/budgets/apply-template` | Appliquer un template a une annee |
#### Ajustements
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/adjustments` | Lister les ajustements |
| POST | `/adjustments` | Creer un ajustement (avec entrees) |
| GET | `/adjustments/:id` | Detail d'un ajustement |
| PUT | `/adjustments/:id` | Modifier |
| DELETE | `/adjustments/:id` | Supprimer |
#### Import CSV
| Methode | Endpoint | Description |
|---------|----------|-------------|
| POST | `/import/upload` | Upload un fichier CSV (stockage temporaire) |
| POST | `/import/parse` | Parser le CSV avec config (delimiter, encoding, date_format, column mapping) |
| GET | `/import/preview/:upload_id` | Preview des transactions parsees avec auto-categorisation |
| POST | `/import/confirm` | Confirmer l'import (inserer les transactions) |
| GET | `/import/sources` | Configurations de sources d'import |
| POST | `/import/sources` | Sauvegarder une config de source |
| PUT | `/import/sources/:id` | Modifier une config |
| DELETE | `/import/sources/:id` | Supprimer une config |
| GET | `/import/history` | Historique des imports |
| GET | `/import/config-templates` | Templates de configuration d'import |
| POST | `/import/config-templates` | Creer un template de config |
#### Rapports (rendus possibles par Option D)
| Methode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/reports/monthly-summary/:year/:month` | Totaux par categorie, budget vs reel |
| GET | `/reports/trends` | Tendances mensuelles sur une periode (query: start, end, category_ids) |
| GET | `/reports/category-breakdown` | Repartition des depenses par categorie (query: start, end) |
### Frontend Web
C'est le gros avantage : l'app Tauri existante est deja **React 19 + Vite + Tailwind CSS v4 + TypeScript**. Les composants UI, hooks et logique metier sont hautement reutilisables. Changements principaux :
- Remplacer les commandes Tauri (Rust → SQLite) par des appels API (fetch → REST)
- Remplacer `@tauri-apps/plugin-sql` par un client API
- Remplacer les operations systeme de fichiers (import CSV depuis fichier local) par un upload fichier
- Remplacer la selection de profil Tauri → auth web + selecteur de profil
- Conserver tous les composants React : table de transactions, arbre de categories, grille budget, graphiques (Recharts), wizard import
- Conserver `react-router-dom`, `i18next`, `lucide-react`, `recharts`, `papaparse` (pour preview client), `@dnd-kit` (drag-and-drop)
- Retirer les dependances Tauri : `@tauri-apps/api`, `@tauri-apps/plugin-*`
Heberge sur Coolify a `resultat.lacompagniemaximus.com`.
### Securite (Option D — detaillee)
Puisque l'app traite des donnees financieres sensibles, voici le modele de securite complet :
1. **Chiffrement au repos** : Repertoire de donnees PostgreSQL sur volume chiffre (LUKS/dm-crypt sur le VPS)
2. **TLS en transit** : HTTPS obligatoire, headers HSTS
3. **Isolation du datastore** :
- Schema `sr_` separe des autres apps dans PostgreSQL
- Credentials PostgreSQL dedies au service simpl-resultat (pas de superuser partage)
- Le service n'a pas acces a Forgejo, MinIO (sauf pour import CSV temporaire), ni aux autres schemas
4. **Isolation par utilisateur** : Toutes les requetes filtrees par `user_id` + `profile_id`. Pas de requetes cross-user possibles.
5. **Backups chiffres** : `pg_dump` du schema `sr_` chiffre avec une cle stockee hors du VPS (ex: sur la machine locale ou dans un secret manager)
6. **Politique d'acces** : Jamais de consultation des donnees utilisateurs sans consentement explicite. Pas de dashboard admin montrant les donnees financieres des utilisateurs.
7. **Audit log** : Table `sr_.audit_log` (user_id, action, entity, timestamp) pour tracer les acces sensibles (exports, suppressions, imports)
8. **Rate limiting** : Sur tous les endpoints, particulierement import CSV (upload) et auth
9. **Input validation** : Sanitization de toutes les entrees, particulierement les descriptions de transactions et les notes
10. **Session** : JWT configure dans Logto (access token ~1h, refresh token ~14j), SDK gere le refresh automatiquement
**E2EE future ("mode Coffre-fort")** : Option premium ou le profil entier est chiffre cote client (AES-256-GCM, cle derivee du mot de passe utilisateur). Le serveur stocke un blob opaque. Incompatible avec les fonctionnalites serveur (rapports, search, import CSV serveur). L'utilisateur choisit : fonctionnalites completes OU privacy maximale.
### Gestion d'acces
- Acces a l'app web reserve aux utilisateurs avec un Compte Maximus
- Plan gratuit : acces basique (illimite pour la v1, pas de paywall)
- Plan premium (futur) : import CSV illimite, rapports avances, multi-profil, backup automatique
- Verification du JWT claims `apps.simpl-resultat` pour confirmer l'acces
- Profile-level access : un utilisateur peut avoir plusieurs profils (personnel, couple, etc.)
- PIN optionnel par profil sur le web (couche de securite supplementaire, comme sur desktop)
### Sync Desktop <-> Web
Deux approches, la plus simple pour la v1 :
**Option A — Export/Import (v1, simple)** [retenue]
- L'app desktop a deja un export/import SREF (AES-256-GCM chiffre)
- Ajouter un bouton "Synchroniser avec le cloud" dans l'app desktop
- Upload le fichier SREF vers le serveur, le serveur dechiffre et importe
- Pas de sync temps reel, mais suffisant pour un backup/migration
**Option B — Sync continue (v2, complexe)** [future]
- Change tracking sur chaque table (updated_at + sync tokens)
- Sync bidirectionnelle comme simpl-liste
- Conflict resolution par entite
- Plus complexe a cause du nombre de tables (13) et des relations
Recommandation : Option A pour la v1 (reutilise l'infrastructure export/import existante), Option B comme amelioration future.
## Plan de travail
### Issue 1 — Schema PostgreSQL et migrations [type:task]
Dependances: Logto deploye et operationnel
- [ ] Creer le schema `sr_` dans PostgreSQL (15 tables, index, contraintes)
- [ ] Table sr_profiles (multi-profil par utilisateur)
- [ ] Table sr_audit_log
- [ ] Script de seed : 54 categories + 60+ keywords par profil
- [ ] Script de migration
- [ ] Tests du schema (contraintes, cascades, unicite)
### Issue 2 — API REST backend [type:feature]
Dependances: Issue 1
- [ ] Setup projet (Next.js ou Express sur Coolify)
- [ ] Middleware auth (verification JWT, extraction user_id, injection profile_id)
- [ ] Endpoints profils (CRUD + PIN)
- [ ] Endpoints transactions (CRUD + filtres + splits + bulk delete)
- [ ] Endpoints categories (CRUD + arbre hierarchique + reorder)
- [ ] Endpoints fournisseurs + keywords
- [ ] Endpoints budgets (grille mensuelle + templates + apply)
- [ ] Endpoints ajustements (CRUD avec entrees)
- [ ] Endpoints rapports (monthly summary, trends, category breakdown)
- [ ] Rate limiting + input validation
- [ ] Audit logging middleware
### Issue 3 — Import CSV serveur [type:feature]
Dependances: Issue 2
- [ ] Upload CSV vers stockage temporaire (MinIO ou /tmp)
- [ ] Parsing multi-encoding (UTF-8, Windows-1252, ISO-8859-15) comme l'app desktop
- [ ] Auto-detection delimiteur
- [ ] Column mapping configurable
- [ ] Auto-categorisation par keywords (reproduire la logique Rust existante)
- [ ] Preview avant confirmation
- [ ] Deduplication par filename + file_hash (comme l'app desktop)
- [ ] Gestion des import sources et config templates
### Issue 4 — Frontend web [type:feature]
Dependances: Issue 2, Issue 3
- [ ] Setup projet (React + Vite + Tailwind CSS v4, port de la config existante)
- [ ] Client API (wrapper fetch avec auth JWT, gestion erreurs, profile_id)
- [ ] Auth flow (login via Logto, SDK @logto/react ou OIDC standard)
- [ ] Selecteur de profil
- [ ] Page transactions (table, filtres, tri, recherche, pagination)
- [ ] Page detail transaction (edition, re-categorisation, split)
- [ ] Saisie manuelle de transactions
- [ ] Page categories (arbre hierarchique, CRUD, drag-and-drop)
- [ ] Page budget (grille 12 mois, budget vs reel, templates)
- [ ] Page ajustements (CRUD, recurrence)
- [ ] Wizard import CSV (port des 13 etapes existantes vers upload serveur)
- [ ] Page rapports (graphiques Recharts : tendances, repartition par categorie)
- [ ] Page fournisseurs et keywords
- [ ] Dark mode
- [ ] i18n FR/EN (reutiliser les fichiers de traduction existants)
- [ ] Responsive
- [ ] Deployer sur Coolify (resultat.lacompagniemaximus.com)
### Issue 5 — Sync desktop <-> web (v1: export/import) [type:feature]
Dependances: Issue 2
- [ ] Ajouter bouton "Synchroniser avec le cloud" dans l'app desktop Tauri
- [ ] Auth flow desktop : OAuth2 via Logto (OIDC standard, tauri-plugin-oauth ou WebView)
- [ ] Export SREF → upload vers le serveur
- [ ] Le serveur dechiffre le SREF et importe dans le schema sr_
- [ ] Download depuis le serveur → import SREF dans l'app desktop
- [ ] Indicateur de derniere synchronisation
### Issue 6 — Securite et audit [type:task]
Dependances: Issue 2
- [ ] Configurer le chiffrement au repos du volume PostgreSQL (LUKS)
- [ ] Credentials PostgreSQL dedies (pas de superuser partage)
- [ ] Configurer les backups chiffres du schema sr_
- [ ] Implementer sr_audit_log (middleware automatique)
- [ ] Headers de securite (HSTS, CSP, X-Frame-Options, X-Content-Type-Options)
- [ ] Tests de securite (injection SQL, XSS, IDOR cross-user)
### Ordre d'execution
```
Logto (prerequis externe)
└── Issue 1 (Schema PostgreSQL)
├── Issue 2 (API REST)
│ ├── Issue 3 (Import CSV)
│ ├── Issue 5 (Sync desktop)
│ └── Issue 6 (Securite)
└── Issue 4 (Frontend web) — depends on Issue 2 + Issue 3
```
## Criteres d'acceptation
- [ ] Un utilisateur connecte peut acceder a ses finances depuis resultat.lacompagniemaximus.com
- [ ] Les operations CRUD fonctionnent pour les transactions, categories, budgets, ajustements
- [ ] L'import CSV fonctionne cote serveur (upload, parsing, auto-categorisation, preview, confirmation)
- [ ] Les rapports (tendances, repartition) s'affichent correctement avec des graphiques
- [ ] Le multi-profil fonctionne (creer, switcher, supprimer des profils)
- [ ] L'app desktop continue de fonctionner sans compte (offline-first preserve)
- [ ] La sync export/import entre desktop et web fonctionne
- [ ] Le site fonctionne en FR et EN, en light et dark mode
- [ ] Le site est responsive
- [ ] Les donnees financieres sont isolees des autres services (schema separe, credentials dedies)
- [ ] Un audit log trace les acces sensibles
## Estimation
8-12 sessions de travail (hors service auth). C'est le plus gros chantier des apps web en raison de la complexite du modele de donnees (13 tables desktop → 15 tables web) et de la logique metier (import CSV, auto-categorisation, budgets, rapports).
## Reutilisabilite du code existant
| Composant | Reutilisable ? | Notes |
|-----------|---------------|-------|
| Composants React (53 fichiers) | **Oui** | Deja React 19 + Tailwind CSS v4, memes primitives que le web |
| Recharts (graphiques) | **Oui** | Librairie web native, memes graphiques |
| Types TypeScript | **Oui** | Interfaces, enums, types de donnees |
| Hooks custom (12 hooks useReducer) | **Partiellement** | Adapter les appels services → fetch API |
| Services metier (14 services) | **Partiellement** | Remplacer tauri-plugin-sql par client API REST |
| i18n (traductions FR/EN) | **Oui** | Fichiers JSON reutilisables directement |
| @dnd-kit (drag-and-drop) | **Oui** | Librairie web native |
| papaparse (parsing CSV) | **Oui** | Utile pour preview cote client |
| lucide-react (icones) | **Oui** | Librairie web native |
| react-router-dom (routing) | **Oui** | Meme routing |
| Commandes Rust (17 commandes Tauri) | **Non** | Remplacees par l'API REST |
| Crypto Rust (Argon2, AES-256-GCM) | **Non** | PIN optionnel reimplemente en JS si necessaire |
| Import CSV Rust (parsing) | **Non** | Reimplemente cote serveur (Node.js) |
| tauri-plugin-sql | **Non** | Remplace par client API |
| tauri-plugin-updater | **Non** | Non applicable au web |

930
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "simpl-result" name = "simpl-result"
version = "0.9.1" version = "0.6.3"
description = "Personal finance management app" description = "Personal finance management app"
license = "GPL-3.0-only" license = "GPL-3.0-only"
authors = ["you"] authors = ["you"]
@ -25,8 +25,6 @@ tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-process = "2" tauri-plugin-process = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
libsqlite3-sys = { version = "0.30", features = ["bundled"] } libsqlite3-sys = { version = "0.30", features = ["bundled"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@ -36,33 +34,5 @@ encoding_rs = "0.8"
walkdir = "2" walkdir = "2"
aes-gcm = "0.10" aes-gcm = "0.10"
argon2 = "0.5" argon2 = "0.5"
subtle = "2"
rand = "0.8" rand = "0.8"
jsonwebtoken = "9"
machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] }
# Date arithmetic for the Modified Dietz return calculator (Issue #142):
# we need day-precision diffs to weight cash flows W_i = (T - t_i) / T.
# `serde` feature lets `NaiveDate` cross the Tauri command boundary in JSON.
chrono = { version = "0.4", default-features = false, features = ["serde", "std"] }
tokio = { version = "1", features = ["macros"] }
hostname = "0.4"
urlencoding = "2"
base64 = "0.22"
# OAuth token storage in OS keychain (Credential Manager on Windows,
# Secret Service on Linux). We use sync-secret-service to get sync
# methods that are safe to call from async Tauri commands without
# tokio runtime entanglement. Requires libdbus-1-dev at build time
# on Linux (libdbus-1-3 is present on every desktop Linux at runtime).
keyring = { version = "3.6", default-features = false, features = ["sync-secret-service", "crypto-rust", "windows-native"] }
zeroize = "1"
hmac = "0.12"
[dev-dependencies]
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`
# feature because the `LineEnding` re-export path varies between versions
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
# for Ed25519.
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
mockito = "1.6"

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

@ -1,292 +0,0 @@
// Integrity-protected cache for cached account info.
//
// The user's subscription tier is used by `license_commands::current_edition`
// to unlock Premium features. Until this module, the subscription_status
// claim was read directly from a plaintext `account.json` on disk, which
// meant any local process (malware, nosy user, curl) could write
// `{"subscription_status": "active"}` and bypass the paywall without
// ever touching the Logto session. This module closes that trap.
//
// Approach: an HMAC-SHA256 signature is computed over the serialized
// AccountInfo bytes using a per-install key stored in the OS keychain
// (via the `keyring` crate, same backend as token_store). The signed
// payload is wrapped as `{"data": {...}, "sig": "<base64>"}`. On read,
// verification requires the same key; tampering with either `data` or
// `sig` invalidates the cache.
//
// Trust chain:
// - Key lives in the keychain, scoped to service "com.simpl.resultat",
// user "account-hmac-key". Compromising it requires compromising the
// keychain, which is the existing trust boundary for OAuth tokens.
// - A legacy unsigned `account.json` (from v0.7.x) is still readable
// for display purposes (email, name, picture), but the gating path
// uses `load_verified` which returns None for legacy payloads —
// Premium features stay locked until the next token refresh rewrites
// the file with a signature.
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::fs;
use super::auth_commands::AccountInfo;
use super::token_store::{auth_dir, write_restricted};
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
const KEYCHAIN_USER_HMAC: &str = "account-hmac-key";
const ACCOUNT_FILE: &str = "account.json";
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Serialize, Deserialize)]
struct SignedAccount {
data: AccountInfo,
sig: String,
}
/// Read the HMAC key from the keychain, creating a fresh random key on
/// first use. The key is never persisted to disk — if the keychain is
/// unreachable, the whole cache signing/verification path falls back
/// to "not signed" which means gating stays locked until the keychain
/// is available again. This is intentional (fail-closed).
fn get_or_create_key() -> Result<[u8; 32], String> {
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC)
.map_err(|e| format!("Keychain entry init failed: {}", e))?;
match entry.get_password() {
Ok(b64) => decode_key(&b64),
Err(keyring::Error::NoEntry) => {
use rand::RngCore;
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
let encoded = encode_key(&key);
entry
.set_password(&encoded)
.map_err(|e| format!("Keychain HMAC key write failed: {}", e))?;
Ok(key)
}
Err(e) => Err(format!("Keychain HMAC key read failed: {}", e)),
}
}
fn encode_key(key: &[u8]) -> String {
use base64::{engine::general_purpose::STANDARD, Engine};
STANDARD.encode(key)
}
fn decode_key(raw: &str) -> Result<[u8; 32], String> {
use base64::{engine::general_purpose::STANDARD, Engine};
let bytes = STANDARD
.decode(raw.trim())
.map_err(|e| format!("Invalid HMAC key encoding: {}", e))?;
if bytes.len() != 32 {
return Err(format!(
"HMAC key must be 32 bytes, got {}",
bytes.len()
));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(arr)
}
fn sign(key: &[u8; 32], payload: &[u8]) -> String {
use base64::{engine::general_purpose::STANDARD, Engine};
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take any key length");
mac.update(payload);
STANDARD.encode(mac.finalize().into_bytes())
}
fn verify(key: &[u8; 32], payload: &[u8], sig_b64: &str) -> bool {
use base64::{engine::general_purpose::STANDARD, Engine};
let Ok(sig_bytes) = STANDARD.decode(sig_b64) else {
return false;
};
let Ok(mut mac) = HmacSha256::new_from_slice(key) else {
return false;
};
mac.update(payload);
mac.verify_slice(&sig_bytes).is_ok()
}
/// Write the account cache as `{"data": {...}, "sig": "..."}`. The key
/// is fetched from (or created in) the keychain. Writes fall back to an
/// unsigned legacy-shape payload only when the keychain is unreachable
/// — this keeps the UI functional on keychain-less systems but means
/// the gating path will refuse to grant Premium until the keychain
/// comes back.
pub fn save(app: &tauri::AppHandle, account: &AccountInfo) -> Result<(), String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
match get_or_create_key() {
Ok(key) => {
// Serialise the AccountInfo alone (compact, deterministic
// for a given struct layout), sign the bytes, then re-wrap
// into the signed envelope. We write the signed envelope
// as pretty-printed JSON for readability in the audit log.
let data_bytes =
serde_json::to_vec(account).map_err(|e| format!("Serialize error: {}", e))?;
let sig = sign(&key, &data_bytes);
let envelope = SignedAccount {
data: account.clone(),
sig,
};
let json = serde_json::to_string_pretty(&envelope)
.map_err(|e| format!("Serialize envelope error: {}", e))?;
write_restricted(&path, &json)
}
Err(err) => {
eprintln!(
"account_cache: keychain HMAC key unavailable, writing unsigned legacy payload ({})",
err
);
// Fallback: unsigned payload. UI still works, but
// `load_verified` will reject this file for gating.
let json = serde_json::to_string_pretty(account)
.map_err(|e| format!("Serialize error: {}", e))?;
write_restricted(&path, &json)
}
}
}
/// Load the cached account for **display purposes**. Accepts both the
/// new signed envelope and legacy plaintext AccountInfo files. Does
/// NOT verify the signature — suitable for showing the user's name /
/// email / picture in the UI, but never for gating decisions.
pub fn load_unverified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
// Prefer the signed envelope shape; fall back to legacy flat
// AccountInfo so upgraded users see their account info immediately
// rather than a blank card until the next token refresh.
if let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) {
return Ok(Some(envelope.data));
}
if let Ok(flat) = serde_json::from_str::<AccountInfo>(&raw) {
return Ok(Some(flat));
}
Err("Invalid account cache payload".to_string())
}
/// Load the cached account and verify the HMAC signature. Used by the
/// license gating path (`current_edition`). Returns Ok(None) when:
/// - no cache exists,
/// - the cache is in legacy unsigned shape (pre-v0.8 or post-fallback),
/// - the keychain HMAC key is unreachable,
/// - the signature does not verify.
///
/// Any of these states must cause Premium features to stay locked —
/// never accept an unverifiable payload for a gating decision.
pub fn load_verified(app: &tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path).map_err(|e| format!("Cannot read account: {}", e))?;
// Only signed envelopes are acceptable here. Legacy flat payloads
// are treated as "no verified account".
let Ok(envelope) = serde_json::from_str::<SignedAccount>(&raw) else {
return Ok(None);
};
let Ok(key) = get_or_create_key() else {
return Ok(None);
};
let data_bytes = match serde_json::to_vec(&envelope.data) {
Ok(v) => v,
Err(_) => return Ok(None),
};
if !verify(&key, &data_bytes, &envelope.sig) {
return Ok(None);
}
Ok(Some(envelope.data))
}
/// Delete the cached account file AND the keychain HMAC key. Called on
/// logout so the next login generates a fresh key bound to the new
/// session.
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
let dir = auth_dir(app)?;
let path = dir.join(ACCOUNT_FILE);
if path.exists() {
let _ = fs::remove_file(&path);
}
if let Ok(entry) = keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_HMAC) {
let _ = entry.delete_credential();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_account() -> AccountInfo {
AccountInfo {
email: "user@example.com".into(),
name: Some("Test User".into()),
picture: None,
subscription_status: Some("active".into()),
}
}
#[test]
fn sign_then_verify_same_key() {
let key = [7u8; 32];
let payload = b"hello world";
let sig = sign(&key, payload);
assert!(verify(&key, payload, &sig));
}
#[test]
fn verify_rejects_tampered_payload() {
let key = [7u8; 32];
let sig = sign(&key, b"original");
assert!(!verify(&key, b"tampered", &sig));
}
#[test]
fn verify_rejects_wrong_key() {
let key_a = [7u8; 32];
let key_b = [8u8; 32];
let sig = sign(&key_a, b"payload");
assert!(!verify(&key_b, b"payload", &sig));
}
#[test]
fn envelope_roundtrip_serde() {
let account = sample_account();
let key = [3u8; 32];
let data = serde_json::to_vec(&account).unwrap();
let sig = sign(&key, &data);
let env = SignedAccount {
data: account.clone(),
sig,
};
let json = serde_json::to_string(&env).unwrap();
let decoded: SignedAccount = serde_json::from_str(&json).unwrap();
let decoded_data = serde_json::to_vec(&decoded.data).unwrap();
assert!(verify(&key, &decoded_data, &decoded.sig));
}
#[test]
fn encode_decode_key_roundtrip() {
let key = [42u8; 32];
let encoded = encode_key(&key);
let decoded = decode_key(&encoded).unwrap();
assert_eq!(decoded, key);
}
#[test]
fn decode_key_rejects_wrong_length() {
use base64::{engine::general_purpose::STANDARD, Engine};
let short = STANDARD.encode([1u8; 16]);
assert!(decode_key(&short).is_err());
}
}

View file

@ -1,323 +0,0 @@
// OAuth2 PKCE flow for Compte Maximus (Logto) integration.
//
// Architecture:
// - The desktop app is registered as a "Native App" in Logto (public
// client, no secret).
// - OAuth2 Authorization Code + PKCE flow via the system browser.
// - Deep-link callback: simpl-resultat://auth/callback?code=...
// - Tokens are persisted through `token_store` which prefers the OS
// keychain (Credential Manager / Secret Service) and falls back to a
// restricted file only when no prior keychain success has been
// recorded. See `token_store.rs` for details.
//
// The PKCE verifier is held in memory via Tauri managed state, so it
// cannot be intercepted by another process. It is cleared after the
// callback exchange.
use serde::{Deserialize, Serialize};
use std::fs;
use std::sync::Mutex;
use tauri::Manager;
use super::account_cache;
use super::token_store::{
self, auth_dir, chrono_now, write_restricted, StoredTokens,
};
// Logto endpoint — overridable via env var for development.
fn logto_endpoint() -> String {
std::env::var("LOGTO_ENDPOINT")
.unwrap_or_else(|_| "https://auth.lacompagniemaximus.com".to_string())
}
// Logto app ID for the desktop native app.
fn logto_app_id() -> String {
std::env::var("LOGTO_APP_ID").unwrap_or_else(|_| "sr-desktop-native".to_string())
}
const REDIRECT_URI: &str = "simpl-resultat://auth/callback";
const LAST_CHECK_FILE: &str = "last_check";
const CHECK_INTERVAL_SECS: i64 = 86400; // 24 hours
/// PKCE state held in memory during the OAuth2 flow.
pub struct OAuthState {
pub code_verifier: Mutex<Option<String>>,
}
/// Account info exposed to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountInfo {
pub email: String,
pub name: Option<String>,
pub picture: Option<String>,
pub subscription_status: Option<String>,
}
fn generate_pkce() -> (String, String) {
use rand::Rng;
let mut rng = rand::thread_rng();
let verifier: String = (0..64)
.map(|_| {
let idx = rng.gen_range(0..62);
let c = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"[idx];
c as char
})
.collect();
use sha2::{Digest, Sha256};
let hash = Sha256::digest(verifier.as_bytes());
let challenge = base64_url_encode(&hash);
(verifier, challenge)
}
fn base64_url_encode(data: &[u8]) -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
URL_SAFE_NO_PAD.encode(data)
}
/// Start the OAuth2 PKCE flow. Generates a code verifier/challenge, stores the verifier
/// in memory, and returns the authorization URL to open in the system browser.
#[tauri::command]
pub fn start_oauth(app: tauri::AppHandle) -> Result<String, String> {
let (verifier, challenge) = generate_pkce();
// Store verifier in managed state
let state = app.state::<OAuthState>();
*state.code_verifier.lock().map_err(|e| format!("Mutex poisoned: {}", e))? = Some(verifier);
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let url = format!(
"{}/oidc/auth?client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=openid%20profile%20email%20offline_access",
endpoint,
urlencoding::encode(&client_id),
urlencoding::encode(REDIRECT_URI),
urlencoding::encode(&challenge),
);
Ok(url)
}
/// Exchange the authorization code for tokens. Called from the deep-link callback handler.
#[tauri::command]
pub async fn handle_auth_callback(app: tauri::AppHandle, code: String) -> Result<AccountInfo, String> {
let verifier = {
let state = app.state::<OAuthState>();
let verifier = state.code_verifier.lock().map_err(|e| format!("Mutex poisoned: {}", e))?.take();
verifier.ok_or("No pending OAuth flow (verifier missing)")?
};
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/oidc/token", endpoint))
.form(&[
("grant_type", "authorization_code"),
("client_id", &client_id),
("redirect_uri", REDIRECT_URI),
("code", &code),
("code_verifier", &verifier),
])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Token exchange failed: {}", e))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Token exchange error: {}", body));
}
let token_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid token response: {}", e))?;
let access_token = token_resp["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();
let refresh_token = token_resp["refresh_token"].as_str().map(|s| s.to_string());
let id_token = token_resp["id_token"].as_str().map(|s| s.to_string());
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let expires_at = chrono_now() + expires_in;
// Persist tokens through token_store (prefers keychain over file).
let tokens = StoredTokens {
access_token: access_token.clone(),
refresh_token,
id_token,
expires_at,
};
token_store::save(&app, &tokens)?;
// Fetch user info
let account = fetch_userinfo(&endpoint, &access_token).await?;
// Store account info with an HMAC signature so the license gating
// path can trust the cached subscription_status without re-calling
// Logto on every entitlement check.
account_cache::save(&app, &account)?;
Ok(account)
}
/// Refresh the access token using the stored refresh token.
#[tauri::command]
pub async fn refresh_auth_token(app: tauri::AppHandle) -> Result<AccountInfo, String> {
let tokens = token_store::load(&app)?.ok_or_else(|| "Not authenticated".to_string())?;
let refresh_token = tokens
.refresh_token
.ok_or("No refresh token available")?;
let endpoint = logto_endpoint();
let client_id = logto_app_id();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/oidc/token", endpoint))
.form(&[
("grant_type", "refresh_token"),
("client_id", &client_id),
("refresh_token", &refresh_token),
])
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Token refresh failed: {}", e))?;
if !resp.status().is_success() {
// Clear stored tokens on refresh failure.
let _ = token_store::delete(&app);
let _ = account_cache::delete(&app);
return Err("Session expired, please sign in again".to_string());
}
let token_resp: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid token response: {}", e))?;
let new_access = token_resp["access_token"]
.as_str()
.ok_or("Missing access_token")?
.to_string();
let new_refresh = token_resp["refresh_token"].as_str().map(|s| s.to_string());
let expires_in = token_resp["expires_in"].as_i64().unwrap_or(3600);
let new_tokens = StoredTokens {
access_token: new_access.clone(),
refresh_token: new_refresh.or(Some(refresh_token)),
id_token: token_resp["id_token"].as_str().map(|s| s.to_string()),
expires_at: chrono_now() + expires_in,
};
token_store::save(&app, &new_tokens)?;
let account = fetch_userinfo(&endpoint, &new_access).await?;
account_cache::save(&app, &account)?;
Ok(account)
}
/// Read cached account info without network call. Used for UI display
/// only — accepts both signed (v0.8+) and legacy (v0.7.x) payloads so
/// upgraded users still see their name/email immediately. The license
/// gating path uses `account_cache::load_verified` instead.
#[tauri::command]
pub fn get_account_info(app: tauri::AppHandle) -> Result<Option<AccountInfo>, String> {
account_cache::load_unverified(&app)
}
/// Log out: clear all stored tokens and account info, including the
/// HMAC key so the next session starts with a fresh cryptographic
/// anchor.
#[tauri::command]
pub fn logout(app: tauri::AppHandle) -> Result<(), String> {
token_store::delete(&app)?;
account_cache::delete(&app)?;
Ok(())
}
/// Check subscription status if the last check was more than 24h ago.
/// Returns the refreshed account info, or the cached info if no check was needed.
/// Graceful: returns Ok(None) if not authenticated, silently skips on network errors.
#[tauri::command]
pub async fn check_subscription_status(
app: tauri::AppHandle,
) -> Result<Option<AccountInfo>, String> {
// Not authenticated — nothing to check. This also triggers migration
// from a legacy tokens.json file into the keychain when present,
// because token_store::load() performs the migration eagerly.
if token_store::load(&app)?.is_none() {
return Ok(None);
}
let dir = auth_dir(&app)?;
let last_check_path = dir.join(LAST_CHECK_FILE);
let now = chrono_now();
// Check if we need to verify (more than 24h since last check)
if last_check_path.exists() {
if let Ok(raw) = fs::read_to_string(&last_check_path) {
if let Ok(ts) = raw.trim().parse::<i64>() {
if now - ts < CHECK_INTERVAL_SECS {
// Recent check — return cached account info
return get_account_info(app);
}
}
}
}
// Try to refresh the token to get fresh subscription status
match refresh_auth_token(app.clone()).await {
Ok(account) => {
// Update last check timestamp
let _ = write_restricted(&last_check_path, &now.to_string());
Ok(Some(account))
}
Err(_) => {
// Network error or expired session — graceful degradation.
// Still update the timestamp to avoid hammering on every launch.
let _ = write_restricted(&last_check_path, &now.to_string());
get_account_info(app)
}
}
}
async fn fetch_userinfo(endpoint: &str, access_token: &str) -> Result<AccountInfo, String> {
let client = reqwest::Client::new();
let resp = client
.get(format!("{}/oidc/me", endpoint))
.bearer_auth(access_token)
.timeout(std::time::Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Userinfo fetch failed: {}", e))?;
if !resp.status().is_success() {
return Err("Cannot fetch user info".to_string());
}
let info: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid userinfo response: {}", e))?;
Ok(AccountInfo {
email: info["email"]
.as_str()
.unwrap_or_default()
.to_string(),
name: info["name"].as_str().map(|s| s.to_string()),
picture: info["picture"].as_str().map(|s| s.to_string()),
subscription_status: info["custom_data"]["subscription_status"]
.as_str()
.map(|s| s.to_string()),
})
}

View file

@ -1,63 +0,0 @@
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
/// Subdirectory under the user's Documents folder where pre-migration backups
/// are written by default. Keeping the location predictable makes it easy for
/// users to find their backup files even if the app is uninstalled.
const BACKUP_SUBDIR: &str = "Simpl-Resultat/backups";
fn resolve_backup_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let documents = app
.path()
.document_dir()
.map_err(|e| format!("Cannot resolve Documents directory: {}", e))?;
Ok(documents.join(BACKUP_SUBDIR))
}
/// Resolve `~/Documents/Simpl-Resultat/backups/` and create it if missing.
/// Returns the absolute path as a string. Used by the pre-migration backup
/// flow to place SREF files in a predictable, user-visible location.
#[tauri::command]
pub fn ensure_backup_dir(app: tauri::AppHandle) -> Result<String, String> {
let dir = resolve_backup_dir(&app)?;
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| {
// Surface permission issues explicitly — the TS layer maps this to
// a user-facing i18n key.
if e.kind() == std::io::ErrorKind::PermissionDenied {
format!("permission_denied: {}", dir.to_string_lossy())
} else {
format!("create_dir_failed: {}: {}", dir.to_string_lossy(), e)
}
})?;
}
Ok(dir.to_string_lossy().to_string())
}
/// Return the size of a file on disk in bytes. Used to report the size of a
/// freshly-written backup to the UI. Returns a clear error if the file does
/// not exist or cannot be read.
#[tauri::command]
pub fn get_file_size(file_path: String) -> Result<u64, String> {
let metadata =
fs::metadata(&file_path).map_err(|e| format!("Cannot stat file {}: {}", file_path, e))?;
Ok(metadata.len())
}
/// Return true when the given path points to an existing regular file. Used
/// by the post-migration restore flow to detect that the recorded backup
/// path is still reachable before opening the confirmation modal — when the
/// file was moved or deleted, the UI falls back to a manual file picker.
/// Never throws on a missing file (just returns `false`); only returns an
/// error for unexpected I/O conditions other than "not found".
#[tauri::command]
pub fn file_exists(file_path: String) -> Result<bool, String> {
match fs::metadata(&file_path) {
Ok(meta) => Ok(meta.is_file()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(format!("Cannot stat file {}: {}", file_path, e)),
}
}

View file

@ -1,532 +0,0 @@
//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155.
//!
//! Commands:
//! - `compute_account_return` (Issue #142): Modified Dietz return for one
//! account over a period. Reads snapshot endpoints + linked transfer amounts
//! in a single Rust pass.
//! - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for
//! a given `(symbol, date)` pair. Privacy-strict: sends only
//! `Authorization`, `Accept`, and `User-Agent` headers.
//!
//! Database access pattern:
//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
//! matching the existing `repair_migrations` helper in `profile_commands.rs`.
//! - The frontend passes `db_filename` (the active profile DB), exactly
//! like it does for `repair_migrations` and `delete_profile_db`. Keeps
//! the active-profile resolution where it already lives (in TS) and
//! avoids re-reading `profiles.json` on every call.
//! - Reads are short-lived: connection opens, runs ≤ 3 SQL statements,
//! drops at end of function. No connection pooling needed (commands run
//! on the Tauri async runtime, one at a time per invocation).
use chrono::NaiveDate;
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use tauri::Manager;
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
// ---------------------------------------------------------------------------
// fetch_price types (Issue #155)
// ---------------------------------------------------------------------------
/// Successful price response from `GET /v1/prices`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PriceResponse {
pub symbol: String,
pub date: String,
pub actual_date: Option<String>,
pub price: f64,
pub currency: String,
pub source: String,
pub fetched_at: String,
pub cached: bool,
}
/// Typed error returned by `fetch_price`. Serialized as JSON to cross the
/// Tauri command boundary (the JS layer `JSON.parse`s the error string).
///
/// The `tag = "code"` + `rename_all = "snake_case"` combination produces
/// `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}`, etc. —
/// matching the `error.code` shape defined in `docs/api-contract-prices.md §5`.
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum FetchPriceError {
Auth,
PremiumRequired,
SymbolNotFound,
RateLimit { retry_after_s: u64 },
ProviderUnavailable,
Network,
Internal,
}
impl std::fmt::Display for FetchPriceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FetchPriceError::Auth => write!(f, "auth"),
FetchPriceError::PremiumRequired => write!(f, "premium_required"),
FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"),
FetchPriceError::RateLimit { retry_after_s } => {
write!(f, "rate_limit (retry after {}s)", retry_after_s)
}
FetchPriceError::ProviderUnavailable => write!(f, "provider_unavailable"),
FetchPriceError::Network => write!(f, "network"),
FetchPriceError::Internal => write!(f, "internal"),
}
}
}
/// Serialize a `FetchPriceError` to the stable JSON string returned across
/// the Tauri boundary. Falls back to `{"code":"internal"}` on serialization
/// failure (which should never happen in practice).
fn price_error_to_string(err: &FetchPriceError) -> String {
serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string())
}
/// API base URL for maximus-api. Overridable via `MAXIMUS_API_URL` for tests
/// and development environments.
fn base_url() -> String {
std::env::var("MAXIMUS_API_URL")
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}
/// Read the stored activation token from disk (raw JWT string).
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable.
fn read_stored_activation_token(app: &tauri::AppHandle) -> Result<String, FetchPriceError> {
let app_dir = app
.path()
.app_data_dir()
.map_err(|_| FetchPriceError::Auth)?;
let token_path = app_dir.join("activation.token");
std::fs::read_to_string(&token_path)
.map(|s| s.trim().to_string())
.map_err(|_| FetchPriceError::Auth)
}
/// Core implementation — separated from the Tauri command so tests can inject
/// an arbitrary token string and base URL without touching the file system.
///
/// Design note (MEDIUM decision in decisions-log.md): the public `fetch_price`
/// command is a thin wrapper that loads the token then delegates here. Tests
/// call this inner function directly with an explicit `api_base` to avoid
/// env-var races between concurrent test threads.
async fn fetch_price_with_token(
token: &str,
symbol: &str,
date: &str,
) -> Result<PriceResponse, FetchPriceError> {
fetch_price_inner(token, symbol, date, &base_url()).await
}
async fn fetch_price_inner(
token: &str,
symbol: &str,
date: &str,
api_base: &str,
) -> Result<PriceResponse, FetchPriceError> {
let url = format!(
"{}/v1/prices?symbol={}&date={}",
api_base,
urlencoding::encode(symbol),
urlencoding::encode(date),
);
// Build client with User-Agent set on the builder — NOT as a manual header.
// This satisfies the privacy contract (§3.1): UA is set at the transport
// level, not injected as an explicit per-request header alongside
// Accept-Language, cookies, or other identifying headers.
let client = reqwest::Client::builder()
.user_agent("simpl-resultat")
.build()
.map_err(|_| FetchPriceError::Internal)?;
let resp = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/json")
// DO NOT add User-Agent here — it is already set on the client builder.
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|_| FetchPriceError::Network)?;
let status = resp.status();
match status.as_u16() {
200 => {
let price_resp: PriceResponse = resp
.json()
.await
.map_err(|_| FetchPriceError::Internal)?;
Ok(price_resp)
}
401 => Err(FetchPriceError::Auth),
403 => Err(FetchPriceError::PremiumRequired),
404 => Err(FetchPriceError::SymbolNotFound),
429 => {
// Parse `error.retry_after` from the JSON body.
let body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::Value::Null);
let retry_after_s = body
.pointer("/error/retry_after")
.and_then(|v| v.as_u64())
.unwrap_or(60);
Err(FetchPriceError::RateLimit { retry_after_s })
}
s if s >= 500 => Err(FetchPriceError::ProviderUnavailable),
_ => Err(FetchPriceError::Internal),
}
}
/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api.
///
/// Reads the stored activation token, then calls `GET /v1/prices`. Returns a
/// serialized `FetchPriceError` JSON string on error so the JS layer can
/// `JSON.parse` and branch on `code`.
///
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
/// as a manual header — so no fingerprinting headers leak.
#[tauri::command]
pub async fn fetch_price(
app: tauri::AppHandle,
symbol: String,
date: String,
) -> Result<PriceResponse, String> {
let token = read_stored_activation_token(&app).map_err(|e| price_error_to_string(&e))?;
fetch_price_with_token(&token, &symbol, &date)
.await
.map_err(|e| price_error_to_string(&e))
}
/// Compute the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. Reads:
/// - `value_start`: latest snapshot line for the account whose
/// `snapshot_date <= period_start` (None if no prior snapshot).
/// - `value_end`: latest snapshot line for the account whose
/// `snapshot_date <= period_end` (None if no snapshot in range).
/// - cash flows: every linked transfer in `[period_start, period_end]`,
/// sign applied per direction (`in` → `+`, `out` → ``).
///
/// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn`
/// (Serialize) ready to ship across the Tauri boundary.
#[tauri::command]
pub fn compute_account_return(
app: tauri::AppHandle,
db_filename: String,
account_id: i64,
period_start: String,
period_end: String,
) -> Result<AccountReturn, String> {
let start_date = parse_iso_date(&period_start, "period_start")?;
let end_date = parse_iso_date(&period_end, "period_end")?;
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
let db_path = app_dir.join(&db_filename);
if !db_path.exists() {
return Err(format!(
"Profile database not found: {}",
db_path.display()
));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Cannot open database: {}", e))?;
let value_start = read_value_at_or_before(&conn, account_id, &period_start)?;
let value_end = read_value_at_or_before(&conn, account_id, &period_end)?;
let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?;
Ok(modified_dietz(
value_start,
value_end,
&cash_flows,
start_date,
end_date,
))
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------
fn parse_iso_date(input: &str, field: &str) -> Result<NaiveDate, String> {
NaiveDate::parse_from_str(input, "%Y-%m-%d")
.map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e))
}
/// Reads the value of the snapshot line for `account_id` at the most recent
/// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no
/// such snapshot exists for this account.
fn read_value_at_or_before(
conn: &Connection,
account_id: i64,
as_of_date: &str,
) -> Result<Option<f64>, String> {
// Single-row query: pick the latest snapshot date for this account that
// is on or before `as_of_date`, then return that line's value. Indexed
// on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`.
let mut stmt = conn
.prepare(
"SELECT l.value
FROM balance_snapshot_lines l
JOIN balance_snapshots s ON s.id = l.snapshot_id
WHERE l.account_id = ?1
AND s.snapshot_date <= ?2
ORDER BY s.snapshot_date DESC
LIMIT 1",
)
.map_err(|e| format!("prepare value query: {}", e))?;
let mut rows = stmt
.query(rusqlite::params![account_id, as_of_date])
.map_err(|e| format!("execute value query: {}", e))?;
match rows.next().map_err(|e| format!("read value row: {}", e))? {
Some(row) => Ok(Some(
row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?,
)),
None => Ok(None),
}
}
/// Reads every linked transfer for `account_id` whose underlying
/// transaction's `transaction_date` falls inside `[period_start, period_end]`.
/// Returns `(NaiveDate, signed_amount)` — sign applied per `direction`
/// (`in` → `+`, `out` → ``). Amounts come from the linked transaction.
fn read_cash_flows(
conn: &Connection,
account_id: i64,
period_start: &str,
period_end: &str,
) -> Result<Vec<(NaiveDate, f64)>, String> {
// NOTE: the transactions table column is `date` (not `transaction_date`).
// See `src-tauri/src/database/schema.sql:67`.
let mut stmt = conn
.prepare(
"SELECT t.date,
ABS(t.amount) AS abs_amount,
bat.direction
FROM balance_account_transfers bat
JOIN transactions t ON t.id = bat.transaction_id
WHERE bat.account_id = ?1
AND t.date BETWEEN ?2 AND ?3
ORDER BY t.date",
)
.map_err(|e| format!("prepare flows query: {}", e))?;
let rows = stmt
.query_map(
rusqlite::params![account_id, period_start, period_end],
|row| {
// `transactions.date` may come back as String (TEXT) — keep
// the decoder generic enough.
let date_str: String = row.get(0)?;
let amount: f64 = row.get(1)?;
let direction: String = row.get(2)?;
Ok((date_str, amount, direction))
},
)
.map_err(|e| format!("execute flows query: {}", e))?;
let mut flows: Vec<(NaiveDate, f64)> = Vec::new();
for row_result in rows {
let (date_str, amount, direction) =
row_result.map_err(|e| format!("decode flow row: {}", e))?;
// `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column —
// see consolidated_schema.sql). Defensive trim of any trailing
// time component just in case.
let iso = date_str.split('T').next().unwrap_or(&date_str).to_string();
let date = parse_iso_date(&iso, "transaction_date")?;
let signed = match direction.as_str() {
"in" => amount,
"out" => -amount,
other => {
return Err(format!(
"Invalid transfer direction stored in DB: {}",
other
));
}
};
flows.push((date, signed));
}
Ok(flows)
}
// =============================================================================
// Tests for fetch_price (Issue #155)
// =============================================================================
//
// Strategy: use `mockito::Server::new_async()` as an in-process HTTP server.
// Each test calls `fetch_price_inner` directly, passing the mock server URL
// as `api_base`. This avoids env-var races between concurrent test threads
// (all tokio tests share the same process) and bypasses the file-system
// activation token loading.
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn it_returns_price_on_200() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#,
)
.create_async()
.await;
let result = fetch_price_inner("test-token", "AAPL", "2026-04-25", &server.url()).await;
assert!(result.is_ok(), "expected Ok, got {:?}", result);
let resp = result.unwrap();
assert_eq!(resp.symbol, "AAPL");
assert!((resp.price - 173.45).abs() < f64::EPSILON);
assert_eq!(resp.currency, "USD");
assert!(!resp.cached);
}
#[tokio::test]
async fn it_returns_auth_error_on_401() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"invalid_token","message":"Invalid token"}}"#)
.create_async()
.await;
let result = fetch_price_inner("bad-token", "AAPL", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "auth");
}
#[tokio::test]
async fn it_returns_premium_required_on_403() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(403)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"premium_required","message":"Premium required"}}"#)
.create_async()
.await;
let result = fetch_price_inner("base-token", "AAPL", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "premium_required");
}
#[tokio::test]
async fn it_returns_symbol_not_found_on_404() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(404)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"symbol_not_found","message":"Unknown symbol"}}"#)
.create_async()
.await;
let result = fetch_price_inner("tok", "BOGUS", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "symbol_not_found");
}
#[tokio::test]
async fn it_parses_retry_after_on_429() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(429)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded","retry_after":42}}"#)
.create_async()
.await;
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "rate_limit");
assert_eq!(parsed["retry_after_s"], 42);
}
#[tokio::test]
async fn it_returns_provider_unavailable_on_502() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(502)
.with_header("content-type", "application/json")
.with_body(r#"{"error":{"code":"provider_unavailable","message":"Yahoo unavailable"}}"#)
.create_async()
.await;
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
let err_str = price_error_to_string(&result.unwrap_err());
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
assert_eq!(parsed["code"], "provider_unavailable");
}
/// Privacy assertion: the request must only carry `Authorization`, `Accept`,
/// `User-Agent`, and `Host`. No `Accept-Language`, no cookies, no `X-*`
/// tracking headers.
///
/// mockito's `match_header` with `Matcher::Missing` asserts that a header
/// is absent from the request. We assert absence for each forbidden header.
#[tokio::test]
async fn it_sends_only_authorization_accept_user_agent() {
let mut server = mockito::Server::new_async().await;
// Forbidden headers — must be absent from every request.
let forbidden = [
"cookie",
"accept-language",
"x-forwarded-for",
"x-real-ip",
"x-custom-tracking",
];
let mut mock_builder = server
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"symbol":"BTC","date":"2026-04-25","actual_date":null,"price":60000.0,"currency":"USD","source":"kraken","fetched_at":"2026-04-25T10:00:00Z","cached":true}"#,
);
for header in &forbidden {
mock_builder = mock_builder.match_header(*header, mockito::Matcher::Missing);
}
// Also assert the required headers ARE present.
mock_builder = mock_builder
.match_header("authorization", mockito::Matcher::Regex("^Bearer ".to_string()))
.match_header("accept", "application/json")
.match_header("user-agent", "simpl-resultat");
let _m = mock_builder.create_async().await;
let result =
fetch_price_inner("test-privacy-token", "BTC", "2026-04-25", &server.url()).await;
assert!(
result.is_ok(),
"expected Ok for privacy test, got {:?}",
result
);
// If any forbidden header was present, mockito would return 501 and the
// JSON parse would fail. A successful 200 parse confirms the privacy contract.
assert_eq!(result.unwrap().symbol, "BTC");
}
}

View file

@ -1,70 +0,0 @@
// Centralized feature → tier mapping for license entitlements.
//
// This module is the single source of truth for which features are gated by which tier.
// To change what is gated where, modify FEATURE_TIERS only — never sprinkle edition checks
// throughout the codebase.
/// Editions, ordered from least to most privileged.
pub const EDITION_FREE: &str = "free";
pub const EDITION_BASE: &str = "base";
pub const EDITION_PREMIUM: &str = "premium";
/// Maps feature name → list of editions allowed to use it.
/// A feature absent from this list is denied for all editions.
const FEATURE_TIERS: &[(&str, &[&str])] = &[
// auto-update is temporarily open to FREE until the license server (issue #49)
// is live. Re-gate to [BASE, PREMIUM] once paid activation works end-to-end.
("auto-update", &[EDITION_FREE, EDITION_BASE, EDITION_PREMIUM]),
("web-sync", &[EDITION_PREMIUM]),
("cloud-backup", &[EDITION_PREMIUM]),
("advanced-reports", &[EDITION_PREMIUM]),
];
/// Pure check: does `edition` grant access to `feature`?
pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
FEATURE_TIERS
.iter()
.find(|(name, _)| *name == feature)
.map(|(_, tiers)| tiers.contains(&edition))
.unwrap_or(false)
}
#[tauri::command]
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
let edition = crate::commands::license_commands::current_edition(&app);
Ok(is_feature_allowed(&feature, &edition))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_allows_auto_update_temporarily() {
// Temporary: auto-update is open to FREE until the license server is live.
assert!(is_feature_allowed("auto-update", EDITION_FREE));
}
#[test]
fn base_unlocks_auto_update() {
assert!(is_feature_allowed("auto-update", EDITION_BASE));
}
#[test]
fn premium_unlocks_everything() {
assert!(is_feature_allowed("auto-update", EDITION_PREMIUM));
assert!(is_feature_allowed("web-sync", EDITION_PREMIUM));
assert!(is_feature_allowed("cloud-backup", EDITION_PREMIUM));
}
#[test]
fn base_does_not_unlock_premium_features() {
assert!(!is_feature_allowed("web-sync", EDITION_BASE));
assert!(!is_feature_allowed("cloud-backup", EDITION_BASE));
}
#[test]
fn unknown_feature_denied() {
assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM));
}
}

View file

@ -1,159 +0,0 @@
// Feedback Hub client — forwards user-submitted feedback to the central
// feedback-api service. Routed through Rust (not direct fetch) so that:
// - CORS is bypassed (Tauri origin is not whitelisted server-side by design)
// - The exact payload leaving the machine is auditable in a single place
// - The pattern matches the other outbound calls (OAuth, license, updater)
//
// The feedback-api contract is documented in
// `la-compagnie-maximus/docs/feedback-hub-ops.md`. The server silently drops
// any context key outside its whitelist, so this module only sends the
// fields declared in `Context` below.
use serde::{Deserialize, Serialize};
use std::time::Duration;
fn feedback_endpoint() -> String {
std::env::var("FEEDBACK_HUB_URL")
.unwrap_or_else(|_| "https://feedback.lacompagniemaximus.com".to_string())
}
/// Context payload sent with a feedback submission. Keys MUST match the
/// server whitelist in `feedback-api/index.js` — unknown keys are dropped
/// silently. Each field is capped at 500 chars server-side.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FeedbackContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport: Option<String>,
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Serialize)]
struct FeedbackPayload<'a> {
app_id: &'a str,
content: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<FeedbackContext>,
}
#[derive(Debug, Serialize)]
pub struct FeedbackSuccess {
pub id: String,
pub created_at: String,
}
#[derive(Debug, Deserialize)]
struct FeedbackResponse {
id: String,
created_at: String,
}
/// Return a composed User-Agent string for the context payload, e.g.
/// `"Simpl'Résultat/0.8.1 (linux)"`. Uses std::env::consts::OS so we don't
/// pull in an extra Tauri plugin just for this.
#[tauri::command]
pub fn get_feedback_user_agent(app: tauri::AppHandle) -> String {
let version = app.package_info().version.to_string();
let os = std::env::consts::OS;
format!("Simpl'Résultat/{} ({})", version, os)
}
/// Submit a feedback to the Feedback Hub. Error strings are stable codes
/// ("invalid", "rate_limit", "server_error", "network_error") that the
/// frontend maps to i18n messages.
#[tauri::command]
pub async fn send_feedback(
content: String,
user_id: Option<String>,
context: Option<FeedbackContext>,
) -> Result<FeedbackSuccess, String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Err("invalid".to_string());
}
let payload = FeedbackPayload {
app_id: "simpl-resultat",
content: trimmed,
user_id,
context,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|_| "network_error".to_string())?;
let url = format!("{}/api/feedback", feedback_endpoint());
let res = client
.post(&url)
.json(&payload)
.send()
.await
.map_err(|_| "network_error".to_string())?;
match res.status().as_u16() {
201 => {
let body: FeedbackResponse = res
.json()
.await
.map_err(|_| "server_error".to_string())?;
Ok(FeedbackSuccess {
id: body.id,
created_at: body.created_at,
})
}
400 => Err("invalid".to_string()),
429 => Err("rate_limit".to_string()),
_ => Err("server_error".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_skips_none_fields() {
let ctx = FeedbackContext {
page: Some("/settings".to_string()),
locale: Some("fr".to_string()),
theme: None,
viewport: None,
user_agent: None,
timestamp: None,
};
let json = serde_json::to_value(&ctx).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 2);
assert!(obj.contains_key("page"));
assert!(obj.contains_key("locale"));
}
#[test]
fn context_serializes_user_agent_camelcase() {
let ctx = FeedbackContext {
user_agent: Some("Simpl'Résultat/0.8.1 (linux)".to_string()),
..Default::default()
};
let json = serde_json::to_string(&ctx).unwrap();
assert!(json.contains("\"userAgent\""));
assert!(!json.contains("\"user_agent\""));
}
#[tokio::test]
async fn empty_content_is_rejected_locally() {
let res = send_feedback(" \n\t".to_string(), None, None).await;
assert_eq!(res.unwrap_err(), "invalid");
}
}

View file

@ -1,680 +0,0 @@
// License validation, storage and reading for the Base/Premium editions.
//
// Architecture:
// - License key = "SR-BASE-<JWT>" or "SR-PREMIUM-<JWT>", JWT signed Ed25519 by the server
// - Activation token = separate JWT, also signed by the server, binds the license to a machine
// (machine_id claim must match the local machine_id). Without it, a copied license.key would
// work on any machine. Activation tokens are issued by the server in a separate flow (Issue #49).
// - Both files live in app_data_dir/ — license.key and activation.token
// - get_edition() returns "free" unless BOTH license JWT is valid (signature + exp) AND
// either there is no activation token (graceful pre-activation state) OR the activation token
// matches the local machine_id.
//
// CWE-613: every license JWT MUST carry an `exp` claim. We reject licenses without it.
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
// Ed25519 public key for license verification.
//
// Production key generated 2026-04-25 alongside the maximus-api scaffold.
// The matching private key lives ONLY on the license server as env var
// ED25519_PRIVATE_KEY_PEM (see maximus-api/.env on Coolify).
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAmUTcl7xjt01uc2FhPgvP0at0I/Pie0JLh73AApNy+o8=\n\
-----END PUBLIC KEY-----\n";
const LICENSE_FILE: &str = "license.key";
const ACTIVATION_FILE: &str = "activation.token";
const KEY_PREFIX_BASE: &str = "SR-BASE-";
const KEY_PREFIX_PREMIUM: &str = "SR-PREMIUM-";
/// Decoded license metadata exposed to the frontend.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseInfo {
pub edition: String,
pub email: String,
pub features: Vec<String>,
pub machine_limit: u32,
pub issued_at: i64,
pub expires_at: i64,
}
/// Claims embedded in the license JWT (signed by the license server).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LicenseClaims {
sub: String, // email
iss: String,
iat: i64,
exp: i64, // mandatory — see CWE-613
edition: String,
#[serde(default)]
features: Vec<String>,
machine_limit: u32,
}
/// Claims embedded in the activation token JWT (server-signed, machine-bound).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ActivationClaims {
sub: String, // license id or hash
iat: i64,
exp: i64,
machine_id: String,
}
fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))
}
fn license_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app_data_dir(app)?.join(LICENSE_FILE))
}
fn activation_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app_data_dir(app)?.join(ACTIVATION_FILE))
}
/// Strip the human-readable prefix and return the bare JWT.
fn strip_prefix(key: &str) -> Result<&str, String> {
let trimmed = key.trim();
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_BASE) {
return Ok(jwt);
}
if let Some(jwt) = trimmed.strip_prefix(KEY_PREFIX_PREMIUM) {
return Ok(jwt);
}
Err("License key must start with SR-BASE- or SR-PREMIUM-".to_string())
}
/// Build a `Validation` with `exp` and `iat` mandatory. Assertions are explicit so a future
/// config change cannot silently disable expiry checking (CWE-613).
fn strict_validation() -> Validation {
let mut validation = Validation::new(Algorithm::EdDSA);
validation.validate_exp = true;
validation.leeway = 0;
validation.set_required_spec_claims(&["exp", "iat"]);
validation
}
/// Build the production `DecodingKey` from the embedded PEM constant.
fn embedded_decoding_key() -> Result<DecodingKey, String> {
DecodingKey::from_ed_pem(PUBLIC_KEY_PEM.as_bytes())
.map_err(|e| format!("Invalid public key: {}", e))
}
/// Pure validation: decode the JWT, verify signature with the provided key, ensure the
/// edition claim is one we recognize. Returns `LicenseInfo` on success.
///
/// Separated from the Tauri command so tests can pass their own key.
fn validate_with_key(key: &str, decoding_key: &DecodingKey) -> Result<LicenseInfo, String> {
let jwt = strip_prefix(key)?;
let validation = strict_validation();
let data = decode::<LicenseClaims>(jwt, decoding_key, &validation)
.map_err(|e| format!("Invalid license: {}", e))?;
let claims = data.claims;
if claims.edition != EDITION_BASE && claims.edition != EDITION_PREMIUM {
return Err(format!("Unknown edition '{}'", claims.edition));
}
Ok(LicenseInfo {
edition: claims.edition,
email: claims.sub,
features: claims.features,
machine_limit: claims.machine_limit,
issued_at: claims.iat,
expires_at: claims.exp,
})
}
/// Validate an activation token against the local machine. The token must be signed by the
/// license server and its `machine_id` claim must match the local machine identifier.
fn validate_activation_with_key(
token: &str,
local_machine_id: &str,
decoding_key: &DecodingKey,
) -> Result<(), String> {
let validation = strict_validation();
let data = decode::<ActivationClaims>(token.trim(), decoding_key, &validation)
.map_err(|e| format!("Invalid activation token: {}", e))?;
if data.claims.machine_id != local_machine_id {
return Err("Activation token belongs to a different machine".to_string());
}
Ok(())
}
// === Tauri commands ===========================================================================
/// Validate a license key without persisting it. Used by the UI to give immediate feedback
/// before the user confirms storage.
#[tauri::command]
pub fn validate_license_key(key: String) -> Result<LicenseInfo, String> {
let decoding_key = embedded_decoding_key()?;
validate_with_key(&key, &decoding_key)
}
/// Persist a previously-validated license key to disk. The activation token (machine binding)
/// is stored separately by [`store_activation_token`] once the server has issued one.
#[tauri::command]
pub fn store_license(app: tauri::AppHandle, key: String) -> Result<LicenseInfo, String> {
let decoding_key = embedded_decoding_key()?;
let info = validate_with_key(&key, &decoding_key)?;
let path = license_path(&app)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
}
fs::write(&path, key.trim()).map_err(|e| format!("Cannot write license file: {}", e))?;
Ok(info)
}
/// Persist a server-issued activation token (machine binding). The token is opaque to the
/// caller — it must validate against the local machine_id to be considered active.
#[tauri::command]
pub fn store_activation_token(app: tauri::AppHandle, token: String) -> Result<(), String> {
let local_id = machine_id_internal()?;
let decoding_key = embedded_decoding_key()?;
validate_activation_with_key(&token, &local_id, &decoding_key)?;
let path = activation_path(&app)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("Cannot create app data dir: {}", e))?;
}
fs::write(&path, token.trim()).map_err(|e| format!("Cannot write activation file: {}", e))
}
/// Read the stored license without revalidating. Returns `None` when no license is present.
/// The returned info is only structurally decoded — call [`get_edition`] for the gating value.
#[tauri::command]
pub fn read_license(app: tauri::AppHandle) -> Result<Option<LicenseInfo>, String> {
let path = license_path(&app)?;
if !path.exists() {
return Ok(None);
}
let key = fs::read_to_string(&path).map_err(|e| format!("Cannot read license file: {}", e))?;
let Ok(decoding_key) = embedded_decoding_key() else {
return Ok(None);
};
Ok(validate_with_key(&key, &decoding_key).ok())
}
/// Returns the active edition (`"free"`, `"base"`, or `"premium"`) for use by feature gates.
///
/// Returns "free" when:
/// - no license is stored,
/// - the license JWT is invalid or expired,
/// - an activation token exists but does not match this machine.
///
/// Note: a missing activation token is treated as a graceful pre-activation state and does
/// NOT downgrade the edition. Server-side activation happens later (Issue #53).
#[tauri::command]
pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
Ok(current_edition(&app))
}
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
/// failure resolves to "free" so feature gates fail closed.
///
/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
// Check Compte Maximus subscription first — Premium overrides Base
if let Some(edition) = check_account_edition(app) {
if edition == EDITION_PREMIUM {
return edition;
}
}
let Ok(path) = license_path(app) else {
return EDITION_FREE.to_string();
};
if !path.exists() {
return EDITION_FREE.to_string();
}
let Ok(key) = fs::read_to_string(&path) else {
return EDITION_FREE.to_string();
};
let Ok(decoding_key) = embedded_decoding_key() else {
return EDITION_FREE.to_string();
};
let Ok(info) = validate_with_key(&key, &decoding_key) else {
return EDITION_FREE.to_string();
};
// If an activation token exists, it must match the local machine. A missing token is
// accepted (graceful pre-activation).
if let Ok(activation_path) = activation_path(app) {
if activation_path.exists() {
let Ok(token) = fs::read_to_string(&activation_path) else {
return EDITION_FREE.to_string();
};
let Ok(local_id) = machine_id_internal() else {
return EDITION_FREE.to_string();
};
if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() {
return EDITION_FREE.to_string();
}
}
}
info.edition
}
/// Read the HMAC-verified account cache to check for an active Premium
/// subscription. Legacy unsigned caches (from v0.7.x) and tampered
/// payloads return None — Premium features stay locked until the user
/// re-authenticates or the next token refresh re-signs the cache.
///
/// This is intentional: before HMAC signing, any local process could
/// write `{"subscription_status": "active"}` to account.json and
/// bypass the paywall. Fail-closed is the correct posture here.
fn check_account_edition(app: &tauri::AppHandle) -> Option<String> {
let account = super::account_cache::load_verified(app).ok().flatten()?;
match account.subscription_status.as_deref() {
Some("active") => Some(EDITION_PREMIUM.to_string()),
_ => None,
}
}
/// Cross-platform machine identifier. Stable across reboots; will change after an OS reinstall
/// or hardware migration, in which case the user must re-activate (handled in Issue #53).
#[tauri::command]
pub fn get_machine_id() -> Result<String, String> {
machine_id_internal()
}
fn machine_id_internal() -> Result<String, String> {
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
}
// License server API base URL. Overridable via MAXIMUS_API_URL env var for development.
fn api_base_url() -> String {
std::env::var("MAXIMUS_API_URL")
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
}
/// Machine info returned by the license server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MachineInfo {
pub machine_id: String,
pub machine_name: Option<String>,
pub activated_at: String,
pub last_seen_at: String,
}
/// Activation status for display in the UI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivationStatus {
pub is_activated: bool,
pub machine_id: String,
}
/// Activate this machine with the license server. Reads the stored license key, sends
/// the machine_id to the API, and stores the returned activation token.
#[tauri::command]
pub async fn activate_machine(app: tauri::AppHandle) -> Result<(), String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Err("No license key stored".to_string());
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let machine_id = machine_id_internal()?;
let machine_name = hostname::get()
.ok()
.and_then(|h| h.into_string().ok());
let url = format!("{}/licenses/activate", api_base_url());
let client = reqwest::Client::new();
let mut body = serde_json::json!({
"license_key": license_key.trim(),
"machine_id": machine_id,
});
if let Some(name) = machine_name {
body["machine_name"] = serde_json::Value::String(name);
}
let resp = client
.post(&url)
.json(&body)
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
let status = resp.status();
let resp_body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid server response: {}", e))?;
if !status.is_success() {
let error = resp_body["error"]
.as_str()
.unwrap_or("Activation failed");
return Err(error.to_string());
}
let token = resp_body["activation_token"]
.as_str()
.ok_or("Server did not return an activation token")?;
// store_activation_token validates the token against local machine_id before writing
store_activation_token(app, token.to_string())?;
Ok(())
}
/// Deactivate a machine on the license server, freeing a slot.
#[tauri::command]
pub async fn deactivate_machine(
app: tauri::AppHandle,
machine_id: String,
) -> Result<(), String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Err("No license key stored".to_string());
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let url = format!("{}/licenses/deactivate", api_base_url());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"license_key": license_key.trim(),
"machine_id": machine_id,
}))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
let status = resp.status();
if !status.is_success() {
let resp_body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::json!({"error": "Deactivation failed"}));
let error = resp_body["error"].as_str().unwrap_or("Deactivation failed");
return Err(error.to_string());
}
// If deactivating this machine, remove the local activation token
let local_id = machine_id_internal()?;
if machine_id == local_id {
let act_path = activation_path(&app)?;
if act_path.exists() {
let _ = fs::remove_file(&act_path);
}
}
Ok(())
}
/// List all machines currently activated for the stored license.
#[tauri::command]
pub async fn list_activated_machines(app: tauri::AppHandle) -> Result<Vec<MachineInfo>, String> {
let key_path = license_path(&app)?;
if !key_path.exists() {
return Ok(vec![]);
}
let license_key =
fs::read_to_string(&key_path).map_err(|e| format!("Cannot read license: {}", e))?;
let url = format!("{}/licenses/verify", api_base_url());
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&serde_json::json!({
"license_key": license_key.trim(),
}))
.timeout(std::time::Duration::from_secs(15))
.send()
.await
.map_err(|e| format!("Cannot reach license server: {}", e))?;
if !resp.status().is_success() {
return Err("Cannot verify license".to_string());
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid server response: {}", e))?;
// The verify endpoint returns machines in the response when valid
let machines = body["machines"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| serde_json::from_value::<MachineInfo>(m.clone()).ok())
.collect()
})
.unwrap_or_default();
Ok(machines)
}
/// Check the local activation status without contacting the server.
#[tauri::command]
pub fn get_activation_status(app: tauri::AppHandle) -> Result<ActivationStatus, String> {
let machine_id = machine_id_internal()?;
let act_path = activation_path(&app)?;
let is_activated = if act_path.exists() {
if let Ok(token) = fs::read_to_string(&act_path) {
if let Ok(decoding_key) = embedded_decoding_key() {
validate_activation_with_key(&token, &machine_id, &decoding_key).is_ok()
} else {
false
}
} else {
false
}
} else {
false
};
Ok(ActivationStatus {
is_activated,
machine_id,
})
}
// === Tests ====================================================================================
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
use jsonwebtoken::{encode, EncodingKey, Header};
// === Manual DER encoder for the Ed25519 private key =======================================
// We avoid the `pem` feature on `ed25519-dalek` because the `LineEnding` re-export path
// varies across `pkcs8`/`spki`/`der` versions. The Ed25519 PKCS#8 v1 byte layout is fixed
// and trivial: 16-byte prefix + 32-byte raw seed.
//
// Note the asymmetry in jsonwebtoken's API:
// - `EncodingKey::from_ed_der` expects a PKCS#8-wrapped private key (passed to ring's
// `Ed25519KeyPair::from_pkcs8`).
// - `DecodingKey::from_ed_der` expects the *raw* 32-byte public key (passed to ring's
// `UnparsedPublicKey::new` which takes raw bytes, not a SubjectPublicKeyInfo).
/// Wrap a 32-byte Ed25519 seed in a PKCS#8 v1 PrivateKeyInfo DER blob.
fn ed25519_pkcs8_private_der(seed: &[u8; 32]) -> Vec<u8> {
// SEQUENCE(46) {
// INTEGER(1) 0 // version v1
// SEQUENCE(5) {
// OID(3) 1.3.101.112 // Ed25519
// }
// OCTET STRING(34) {
// OCTET STRING(32) <32 bytes>
// }
// }
let mut der = vec![
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22,
0x04, 0x20,
];
der.extend_from_slice(seed);
der
}
/// Build a deterministic test keypair so signed tokens are reproducible across runs.
fn test_keys(seed: [u8; 32]) -> (EncodingKey, DecodingKey) {
let signing_key = SigningKey::from_bytes(&seed);
let pubkey_bytes = signing_key.verifying_key().to_bytes();
let priv_der = ed25519_pkcs8_private_der(&seed);
let encoding_key = EncodingKey::from_ed_der(&priv_der);
// Raw 32-byte public key (NOT SubjectPublicKeyInfo) — see note above.
let decoding_key = DecodingKey::from_ed_der(&pubkey_bytes);
(encoding_key, decoding_key)
}
fn default_keys() -> (EncodingKey, DecodingKey) {
test_keys([42u8; 32])
}
fn make_token<T: serde::Serialize>(encoding_key: &EncodingKey, claims: &T) -> String {
encode(&Header::new(Algorithm::EdDSA), claims, encoding_key).unwrap()
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[test]
fn rejects_key_without_prefix() {
let (_enc, dec) = default_keys();
let result = validate_with_key("nonsense", &dec);
assert!(result.is_err());
}
#[test]
fn accepts_well_formed_base_license() {
let (enc, dec) = default_keys();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features: vec!["auto-update".to_string()],
machine_limit: 3,
};
let jwt = make_token(&enc, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let info = validate_with_key(&key, &dec).unwrap();
assert_eq!(info.edition, EDITION_BASE);
assert_eq!(info.email, "user@example.com");
assert_eq!(info.machine_limit, 3);
}
#[test]
fn rejects_expired_license() {
let (enc, dec) = default_keys();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now() - 1000,
exp: now() - 100,
edition: EDITION_BASE.to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&enc, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, &dec);
assert!(result.is_err(), "expired license must be rejected");
}
#[test]
fn rejects_license_signed_with_wrong_key() {
let (enc_signer, _dec_signer) = default_keys();
let (_enc_other, dec_other) = test_keys([7u8; 32]);
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&enc_signer, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, &dec_other);
assert!(result.is_err(), "wrong-key signature must be rejected");
}
#[test]
fn rejects_corrupted_jwt() {
let (_enc, dec) = default_keys();
let key = format!("{}not.a.real.jwt", KEY_PREFIX_BASE);
let result = validate_with_key(&key, &dec);
assert!(result.is_err());
}
#[test]
fn rejects_unknown_edition() {
let (enc, dec) = default_keys();
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: "enterprise".to_string(),
features: vec![],
machine_limit: 3,
};
let jwt = make_token(&enc, &claims);
let key = format!("{}{}", KEY_PREFIX_BASE, jwt);
let result = validate_with_key(&key, &dec);
assert!(result.is_err());
}
#[test]
fn activation_token_matches_machine() {
let (enc, dec) = default_keys();
let claims = ActivationClaims {
sub: "license-id".to_string(),
iat: now(),
exp: now() + 86400,
machine_id: "this-machine".to_string(),
};
let token = make_token(&enc, &claims);
assert!(validate_activation_with_key(&token, "this-machine", &dec).is_ok());
}
#[test]
fn activation_token_rejects_other_machine() {
let (enc, dec) = default_keys();
let claims = ActivationClaims {
sub: "license-id".to_string(),
iat: now(),
exp: now() + 86400,
machine_id: "machine-A".to_string(),
};
let token = make_token(&enc, &claims);
let result = validate_activation_with_key(&token, "machine-B", &dec);
assert!(result.is_err(), "copied activation token must be rejected");
}
#[test]
fn embedded_public_key_pem_parses() {
// Sanity check that the production PEM constant is well-formed.
assert!(embedded_decoding_key().is_ok());
}
}

View file

@ -1,26 +1,7 @@
pub mod account_cache;
pub mod auth_commands;
pub mod backup_commands;
pub mod balance_commands;
pub mod entitlements;
pub mod export_import_commands;
pub mod feedback_commands;
pub mod fs_commands; pub mod fs_commands;
pub mod license_commands; pub mod export_import_commands;
pub mod profile_commands; pub mod profile_commands;
// Modified Dietz return calculator — private helper module used by
// `balance_commands.rs`. Kept out of the wildcard re-export below because
// nothing outside `commands/` should depend on it.
pub(crate) mod return_calculator;
pub mod token_store;
pub use auth_commands::*;
pub use backup_commands::*;
pub use balance_commands::*;
pub use entitlements::*;
pub use export_import_commands::*;
pub use feedback_commands::*;
pub use fs_commands::*; pub use fs_commands::*;
pub use license_commands::*; pub use export_import_commands::*;
pub use profile_commands::*; pub use profile_commands::*;
pub use token_store::*;

View file

@ -1,9 +1,7 @@
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha384}; use sha2::{Digest, Sha256, Sha384};
use std::fs; use std::fs;
use subtle::ConstantTimeEq;
use tauri::Manager; use tauri::Manager;
use crate::database; use crate::database;
@ -114,112 +112,50 @@ pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(
#[tauri::command] #[tauri::command]
pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> { pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
// Brand-new profiles ship with the v1 IPC-aligned category taxonomy: the Ok(vec![
// consolidated schema bakes the v1 seed (categories + keywords + the database::CONSOLIDATED_SCHEMA.to_string(),
// categories_schema_version='v1' preference) directly. The legacy v2 seed database::SEED_CATEGORIES.to_string(),
// migration still runs first because tauri-plugin-sql applies every ])
// declared migration on `Database.load`; the consolidated script then
// deletes the v2 rows and re-inserts the v1 rows in the 1000+ id range.
Ok(vec![database::CONSOLIDATED_SCHEMA.to_string()])
}
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
const ARGON2_M_COST: u32 = 65536; // 64 MiB
const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 1;
const ARGON2_OUTPUT_LEN: usize = 32;
const ARGON2_SALT_LEN: usize = 16;
fn argon2_hash(pin: &str, salt: &[u8]) -> Result<Vec<u8>, String> {
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN))
.map_err(|e| format!("Argon2 params error: {}", e))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut hash = vec![0u8; ARGON2_OUTPUT_LEN];
argon2
.hash_password_into(pin.as_bytes(), salt, &mut hash)
.map_err(|e| format!("Argon2 hash error: {}", e))?;
Ok(hash)
} }
#[tauri::command] #[tauri::command]
pub fn hash_pin(pin: String) -> Result<String, String> { pub fn hash_pin(pin: String) -> Result<String, String> {
let mut salt = [0u8; ARGON2_SALT_LEN]; let mut salt = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut salt); rand::rngs::OsRng.fill_bytes(&mut salt);
let salt_hex = hex_encode(&salt); let salt_hex = hex_encode(&salt);
let hash = argon2_hash(&pin, &salt)?;
let hash_hex = hex_encode(&hash);
// Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash"
Ok(format!("argon2id:{}:{}", salt_hex, hash_hex))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyPinResult {
pub valid: bool,
/// New Argon2id hash when a legacy SHA-256 PIN was successfully verified and re-hashed
pub rehashed: Option<String>,
}
#[tauri::command]
pub fn verify_pin(pin: String, stored_hash: String) -> Result<VerifyPinResult, String> {
// Argon2id format: "argon2id:salt_hex:hash_hex"
if let Some(rest) = stored_hash.strip_prefix("argon2id:") {
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() != 2 {
return Err("Invalid Argon2id hash format".to_string());
}
let salt = hex_decode(parts[0])?;
let expected_hash = hex_decode(parts[1])?;
let computed = argon2_hash(&pin, &salt)?;
let valid = computed.ct_eq(&expected_hash).into();
return Ok(VerifyPinResult { valid, rehashed: None });
}
// Legacy SHA-256 format: "salt_hex:hash_hex"
let parts: Vec<&str> = stored_hash.split(':').collect();
if parts.len() != 2 {
return Err("Invalid stored hash format".to_string());
}
let salt_hex = parts[0];
let expected_hash = hex_decode(parts[1])?;
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes()); hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes()); hasher.update(pin.as_bytes());
let result = hasher.finalize(); let result = hasher.finalize();
let hash_hex = hex_encode(&result);
let valid: bool = result.as_slice().ct_eq(&expected_hash).into(); // Store as "salt:hash"
Ok(format!("{}:{}", salt_hex, hash_hex))
}
if valid { #[tauri::command]
// Re-hash with Argon2id so this legacy PIN is upgraded. pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
// If rehash fails, still allow login — don't block the user. let parts: Vec<&str> = stored_hash.split(':').collect();
let rehashed = hash_pin(pin).ok(); if parts.len() != 2 {
Ok(VerifyPinResult { valid: true, rehashed }) return Err("Invalid stored hash format".to_string());
} else {
Ok(VerifyPinResult { valid: false, rehashed: None })
} }
let salt_hex = parts[0];
let expected_hash = parts[1];
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes());
let result = hasher.finalize();
let computed_hash = hex_encode(&result);
Ok(computed_hash == expected_hash)
} }
fn hex_encode(bytes: &[u8]) -> String { fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect() bytes.iter().map(|b| format!("{:02x}", b)).collect()
} }
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if !hex.len().is_multiple_of(2) {
return Err("Invalid hex string length".to_string());
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(|e| format!("Invalid hex character: {}", e))
})
.collect()
}
/// Repair migration checksums for a profile database. /// Repair migration checksums for a profile database.
/// Updates stored checksums to match current migration SQL, avoiding re-application /// Updates stored checksums to match current migration SQL, avoiding re-application
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords). /// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
@ -281,98 +217,3 @@ pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<b
Ok(repaired) Ok(repaired)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_pin_produces_argon2id_format() {
let hash = hash_pin("1234".to_string()).unwrap();
assert!(hash.starts_with("argon2id:"), "Hash should start with 'argon2id:' prefix");
let parts: Vec<&str> = hash.split(':').collect();
assert_eq!(parts.len(), 3, "Hash should have 3 parts: prefix:salt:hash");
assert_eq!(parts[1].len(), ARGON2_SALT_LEN * 2, "Salt should be {} hex chars", ARGON2_SALT_LEN * 2);
assert_eq!(parts[2].len(), ARGON2_OUTPUT_LEN * 2, "Hash should be {} hex chars", ARGON2_OUTPUT_LEN * 2);
}
#[test]
fn test_hash_pin_different_salts() {
let h1 = hash_pin("1234".to_string()).unwrap();
let h2 = hash_pin("1234".to_string()).unwrap();
assert_ne!(h1, h2, "Two hashes of the same PIN should use different salts");
}
#[test]
fn test_verify_argon2id_pin_correct() {
let hash = hash_pin("5678".to_string()).unwrap();
let result = verify_pin("5678".to_string(), hash).unwrap();
assert!(result.valid, "Correct PIN should verify");
assert!(result.rehashed.is_none(), "Argon2id PIN should not be rehashed");
}
#[test]
fn test_verify_argon2id_pin_wrong() {
let hash = hash_pin("5678".to_string()).unwrap();
let result = verify_pin("0000".to_string(), hash).unwrap();
assert!(!result.valid, "Wrong PIN should not verify");
assert!(result.rehashed.is_none());
}
#[test]
fn test_verify_legacy_sha256_correct_and_rehash() {
// Create a legacy SHA-256 hash: "salt_hex:sha256(salt_hex + pin)"
let salt_hex = "abcdef0123456789";
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(b"4321");
let hash_bytes = hasher.finalize();
let hash_hex = hex_encode(&hash_bytes);
let stored = format!("{}:{}", salt_hex, hash_hex);
let result = verify_pin("4321".to_string(), stored).unwrap();
assert!(result.valid, "Correct legacy PIN should verify");
assert!(result.rehashed.is_some(), "Legacy PIN should be rehashed to Argon2id");
// Verify the rehashed value is a valid Argon2id hash
let new_hash = result.rehashed.unwrap();
assert!(new_hash.starts_with("argon2id:"));
// Verify the rehashed value works for future verification
let result2 = verify_pin("4321".to_string(), new_hash).unwrap();
assert!(result2.valid, "Rehashed PIN should verify");
assert!(result2.rehashed.is_none(), "Already Argon2id, no rehash needed");
}
#[test]
fn test_verify_legacy_sha256_wrong() {
let salt_hex = "abcdef0123456789";
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(b"4321");
let hash_bytes = hasher.finalize();
let hash_hex = hex_encode(&hash_bytes);
let stored = format!("{}:{}", salt_hex, hash_hex);
let result = verify_pin("9999".to_string(), stored).unwrap();
assert!(!result.valid, "Wrong legacy PIN should not verify");
assert!(result.rehashed.is_none(), "Failed verification should not rehash");
}
#[test]
fn test_verify_invalid_format() {
let result = verify_pin("1234".to_string(), "invalid".to_string());
assert!(result.is_err(), "Single-part hash should fail");
let result = verify_pin("1234".to_string(), "argon2id:bad".to_string());
assert!(result.is_err(), "Argon2id with wrong part count should fail");
}
#[test]
fn test_hex_roundtrip() {
let original = vec![0u8, 127, 255, 1, 16];
let encoded = hex_encode(&original);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(original, decoded);
}
}

View file

@ -1,378 +0,0 @@
//! Modified Dietz return calculator (Issue #142 / Bilan #4).
//!
//! Computes the time- and contribution-weighted return of a single account
//! over a period, given:
//! - the account value at `period_start` (snapshot lookup, may be missing),
//! - the account value at `period_end` (snapshot lookup, may be missing),
//! - the cash flows during the period (linked transfers — `+` for IN,
//! `-` for OUT; the caller already applies the direction sign).
//!
//! Modified Dietz formula:
//!
//! ```text
//! 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
//! to flow date`. A flow on day 0 is fully invested for the whole period
//! (W_i = 1) and a flow on the last day contributes nothing (W_i = 0).
//!
//! Annualization: `(1 + R)^(365 / T) - 1` for periods of strictly positive
//! length. A zero-length period (`period_start == period_end`) skips the
//! annualization step (would divide by zero).
//!
//! Edge cases (each surface as a typed flag on `AccountReturn` so the UI can
//! render an explicit warning instead of an opaque empty value):
//! - `value_start == None` → `is_partial = true`, `return_pct = None`
//! - `value_end == None` → `is_partial = true`, `return_pct = None`
//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`,
//! return collapses to the simple `(V_end - V_start) / V_start`
//! - `period_start == period_end` → no annualization (stays = return_pct)
//! - V_start = 0 and first flow > 0 → account created mid-period; the
//! denominator is `0 + W_first * CF_first`, which is positive as long as
//! the flow lands strictly before period_end
//! - account depleted then refilled → mathematically defined; the function
//! does not panic but the magnitude can look extreme — that is the
//! inherent Modified Dietz behaviour on accounts with near-zero invested
//! capital.
//!
//! Module is **private to the crate** (`pub(crate)`) and lives under
//! `commands/` per the spec — reused only by `balance_commands.rs`.
use chrono::NaiveDate;
use serde::Serialize;
/// Result of a Modified Dietz computation, ready to ship across the Tauri
/// boundary. Optional fields are `None` whenever the calculation cannot be
/// completed (missing snapshot endpoints) — the UI renders a dash + a tooltip
/// pointing at `is_partial` / `has_no_transfers_warning`.
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct AccountReturn {
/// Account value at `period_start` (latest snapshot ≤ period_start).
pub value_start: Option<f64>,
/// Account value at `period_end` (latest snapshot ≤ period_end).
pub value_end: Option<f64>,
/// Sum of signed cash flows during the period (`+` IN, `-` OUT).
pub net_contributions: f64,
/// Modified Dietz return as a fraction (0.05 = +5%). `None` if either
/// endpoint snapshot is missing or the denominator is non-positive.
pub return_pct: Option<f64>,
/// Annualized return `(1 + R)^(365 / T) - 1`. `None` for zero-length
/// periods or whenever `return_pct` is `None`.
pub annualized_pct: Option<f64>,
/// `true` when at least one snapshot endpoint is missing — the UI labels
/// the result as "partial / non-significatif".
pub is_partial: bool,
/// `true` when the account had zero linked transfers during the period —
/// Modified Dietz collapses to the simple `(V_end - V_start) / V_start`,
/// but the UI surfaces a warning so the user can verify whether real
/// transfers were forgotten (untagged contributions skew the return).
pub has_no_transfers_warning: bool,
}
impl AccountReturn {
/// Default partial return when an endpoint is missing — keeps the
/// constructor calls in the algorithm body terse.
fn partial(
value_start: Option<f64>,
value_end: Option<f64>,
net_contributions: f64,
has_no_transfers_warning: bool,
) -> Self {
Self {
value_start,
value_end,
net_contributions,
return_pct: None,
annualized_pct: None,
is_partial: true,
has_no_transfers_warning,
}
}
}
/// Computes the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. See module docs for the full formula and
/// edge-case handling.
///
/// `cash_flows` is `(date, signed_amount)`. The caller is responsible for
/// applying the direction sign (`in` → `+`, `out` → ``) and for filtering
/// flows to the period; flows outside `[period_start, period_end]` are
/// skipped here too as a safety net.
pub(crate) fn modified_dietz(
value_start: Option<f64>,
value_end: Option<f64>,
cash_flows: &[(NaiveDate, f64)],
period_start: NaiveDate,
period_end: NaiveDate,
) -> AccountReturn {
// Filter flows to the period (defensive — caller already does this via
// SQL, but keep the guarantee here so the math never sees out-of-range
// weights).
let in_period: Vec<(NaiveDate, f64)> = cash_flows
.iter()
.copied()
.filter(|(d, _)| *d >= period_start && *d <= period_end)
.collect();
let net_contributions: f64 = in_period.iter().map(|(_, cf)| *cf).sum();
let has_no_transfers_warning = in_period.is_empty();
// Endpoint guards — without both V_start and V_end we cannot return a
// numeric result.
let v_start = match value_start {
Some(v) => v,
None => {
return AccountReturn::partial(
value_start,
value_end,
net_contributions,
has_no_transfers_warning,
);
}
};
let v_end = match value_end {
Some(v) => v,
None => {
return AccountReturn::partial(
value_start,
value_end,
net_contributions,
has_no_transfers_warning,
);
}
};
// Period length in days. `(period_end - period_start)` returns
// `chrono::Duration`; `.num_days()` is `i64`. A zero-length period
// (same-day) skips weighting and annualization.
let total_days = (period_end - period_start).num_days();
let denominator: f64 = if total_days <= 0 {
// Same-day period: weights collapse to either 0 or undefined; treat
// every flow as fully invested (W = 1) so the denominator is
// V_start + sum(CF). This keeps the function defined when callers
// pass `period_start == period_end`.
v_start + net_contributions
} else {
let total = total_days as f64;
let weighted_sum: f64 = in_period
.iter()
.map(|(date, cf)| {
let t_i = (*date - period_start).num_days() as f64;
let w_i = (total - t_i) / total;
w_i * cf
})
.sum();
v_start + weighted_sum
};
// A non-positive denominator means we have no invested base to annualize
// against (e.g. depleted then refilled with a single late flow). Return
// the raw V_end - V_start - CF as the numerator and flag is_partial so
// the UI can show "Performance non significative" — but only when V_start
// is also 0 / negative; if V_start > 0 we keep the standard math.
if denominator <= 0.0 {
return AccountReturn {
value_start: Some(v_start),
value_end: Some(v_end),
net_contributions,
return_pct: None,
annualized_pct: None,
is_partial: true,
has_no_transfers_warning,
};
}
let numerator = v_end - v_start - net_contributions;
let return_pct = numerator / denominator;
// Annualization only makes sense for strictly positive periods.
let annualized_pct = if total_days > 0 {
let exponent = 365.0 / total_days as f64;
Some((1.0 + return_pct).powf(exponent) - 1.0)
} else {
None
};
AccountReturn {
value_start: Some(v_start),
value_end: Some(v_end),
net_contributions,
return_pct: Some(return_pct),
annualized_pct,
is_partial: false,
has_no_transfers_warning,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Small helper that turns a `YYYY-MM-DD` string literal into a
/// `NaiveDate` — keeps the test bodies readable.
fn d(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("test date parses")
}
fn approx(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
#[test]
fn nominal_two_flows_at_one_quarter_and_three_quarters() {
// 100-day period (2026-01-01 → 2026-04-11). V_start = 1000, V_end =
// 1100. CF1 = +50 at day 25, CF2 = +30 at day 75.
let start = d("2026-01-01");
let end = d("2026-04-11"); // 100 days later
let flows = vec![(d("2026-01-26"), 50.0), (d("2026-03-17"), 30.0)];
let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end);
// Sanity / shape
assert_eq!(r.value_start, Some(1000.0));
assert_eq!(r.value_end, Some(1100.0));
assert_eq!(r.net_contributions, 80.0);
assert!(!r.is_partial);
assert!(!r.has_no_transfers_warning);
// Hand calc:
// T = 100, t1 = 25, t2 = 75
// W1 = 75/100 = 0.75, W2 = 25/100 = 0.25
// numerator = 1100 - 1000 - 80 = 20
// denominator = 1000 + 0.75*50 + 0.25*30 = 1045
// R = 20 / 1045 ≈ 0.01913876
let r_pct = r.return_pct.expect("nominal case has a return");
assert!(
approx(r_pct, 20.0 / 1045.0, 1e-9),
"return_pct = {} (expected ≈ {})",
r_pct,
20.0 / 1045.0
);
// Annualization: (1 + R)^(365/100) - 1
let expected_ann = (1.0_f64 + 20.0 / 1045.0).powf(365.0 / 100.0) - 1.0;
let ann = r.annualized_pct.expect("nominal case is annualized");
assert!(approx(ann, expected_ann, 1e-9), "annualized = {}", ann);
}
#[test]
fn no_prior_snapshot_marks_partial() {
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-02-01"), 200.0)];
let r = modified_dietz(None, Some(1500.0), &flows, start, end);
assert_eq!(r.value_start, None);
assert_eq!(r.value_end, Some(1500.0));
assert!(r.is_partial, "missing V_start must flag is_partial");
assert_eq!(r.return_pct, None);
assert_eq!(r.annualized_pct, None);
assert!(!r.has_no_transfers_warning);
// Still surface the contributions sum for the UI breakdown card.
assert_eq!(r.net_contributions, 200.0);
}
#[test]
fn no_end_snapshot_marks_partial() {
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-02-15"), -100.0)];
let r = modified_dietz(Some(2000.0), None, &flows, start, end);
assert_eq!(r.value_start, Some(2000.0));
assert_eq!(r.value_end, None);
assert!(r.is_partial);
assert_eq!(r.return_pct, None);
assert_eq!(r.annualized_pct, None);
assert_eq!(r.net_contributions, -100.0);
}
#[test]
fn account_created_mid_period_with_first_flow() {
// V_start = 0, single +500 flow at day 30 of a 90-day period, V_end
// = 510. The flow's weight is W = (90-30)/90 = 2/3.
let start = d("2026-01-01");
let end = d("2026-04-01"); // 90 days
let flows = vec![(d("2026-01-31"), 500.0)];
let r = modified_dietz(Some(0.0), Some(510.0), &flows, start, end);
// numerator = 510 - 0 - 500 = 10
// W = (90-30)/90 ≈ 0.6666667
// denominator = 0 + 0.6666667 * 500 ≈ 333.3333
// R ≈ 10 / 333.3333 = 0.03
let expected = 10.0 / ((90.0 - 30.0) / 90.0 * 500.0);
let r_pct = r.return_pct.expect("account-created case computes");
assert!(
approx(r_pct, expected, 1e-9),
"return_pct = {} (expected ≈ {})",
r_pct,
expected
);
assert!(!r.is_partial);
assert!(!r.has_no_transfers_warning);
}
#[test]
fn depleted_then_refilled_does_not_panic() {
// Pathological: V_start = 100, then -100 flow on day 1 (account
// emptied), then +200 flow on day 60 of a 90-day period, V_end =
// 210. Modified Dietz handles this without panicking; the value
// may look extreme but the function must stay defined.
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows = vec![(d("2026-01-02"), -100.0), (d("2026-03-02"), 200.0)];
let r = modified_dietz(Some(100.0), Some(210.0), &flows, start, end);
// Whatever the math says, the call must complete cleanly. We don't
// assert a precise return — the goal is "no panic, finite output if
// the denominator is positive, else partial flag".
if let Some(rp) = r.return_pct {
assert!(rp.is_finite(), "return must be a finite f64");
}
// Net flows = -100 + 200 = 100
assert_eq!(r.net_contributions, 100.0);
// Not flagged "no transfers" since we have two flows.
assert!(!r.has_no_transfers_warning);
}
#[test]
fn no_transfers_collapses_to_simple_return() {
// No cash flows → R should equal (V_end - V_start) / V_start exactly.
let start = d("2026-01-01");
let end = d("2026-04-01");
let flows: Vec<(NaiveDate, f64)> = vec![];
let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end);
assert!(r.has_no_transfers_warning);
assert_eq!(r.net_contributions, 0.0);
let r_pct = r.return_pct.expect("simple-return case has a value");
let simple = (1100.0 - 1000.0) / 1000.0; // = 0.1
assert!(approx(r_pct, simple, 1e-12), "simple return mismatch: {}", r_pct);
}
#[test]
fn annualization_on_90_day_period_matches_compound_formula() {
// Direct check of the annualization branch with a clean R.
let start = d("2026-01-01");
let end = d("2026-04-01"); // 90 days
let flows: Vec<(NaiveDate, f64)> = vec![];
// V_start = 1000, V_end = 1050 → R = 0.05
let r = modified_dietz(Some(1000.0), Some(1050.0), &flows, start, end);
let expected_ann = (1.0_f64 + 0.05).powf(365.0 / 90.0) - 1.0;
let ann = r.annualized_pct.expect("90-day period annualizes");
assert!(
approx(ann, expected_ann, 1e-12),
"annualized = {} (expected {})",
ann,
expected_ann
);
}
}

View file

@ -1,393 +0,0 @@
// OAuth token storage abstraction.
//
// This module centralises how OAuth2 tokens are persisted. It tries the OS
// keychain first (Credential Manager on Windows, Secret Service on Linux
// via libdbus) and falls back to a restricted file on disk if the keychain
// is unavailable.
//
// Security properties:
// - The keychain service name matches the Tauri bundle identifier
// (com.simpl.resultat) so OS tools and future macOS builds can scope
// credentials correctly.
// - A `store_mode` flag is persisted next to the fallback file. Once the
// keychain has been used successfully, the store refuses to silently
// downgrade to the file fallback: a subsequent keychain failure is
// surfaced as an error so the caller can force re-authentication
// instead of leaving the user with undetected plaintext tokens.
// - Migration from an existing `tokens.json` file zeros the file contents
// and fsyncs before unlinking, reducing the window where the refresh
// token is recoverable from unallocated disk blocks.
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tauri::Manager;
use zeroize::Zeroize;
// Keychain identifiers. The service name matches tauri.conf.json's
// `identifier` so credentials are scoped to the real app identity.
const KEYCHAIN_SERVICE: &str = "com.simpl.resultat";
const KEYCHAIN_USER_TOKENS: &str = "oauth-tokens";
pub(crate) const AUTH_DIR: &str = "auth";
const TOKENS_FILE: &str = "tokens.json";
const STORE_MODE_FILE: &str = "store_mode";
/// Where token material currently lives. Exposed via a Tauri command so
/// the frontend can display a security banner when the app has fallen
/// back to the file store.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StoreMode {
Keychain,
File,
}
impl StoreMode {
fn as_str(&self) -> &'static str {
match self {
StoreMode::Keychain => "keychain",
StoreMode::File => "file",
}
}
fn parse(raw: &str) -> Option<Self> {
match raw.trim() {
"keychain" => Some(StoreMode::Keychain),
"file" => Some(StoreMode::File),
_ => None,
}
}
}
/// Serialised OAuth token bundle. Owned by `token_store` because this is
/// the only module that should reach for the persisted bytes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub expires_at: i64,
}
/// Resolve `<app_data_dir>/auth/`, creating it if needed. Shared with
/// auth_commands.rs which still writes non-secret files (account info,
/// last-check timestamp) in the same directory.
pub(crate) fn auth_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?
.join(AUTH_DIR);
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create auth dir: {}", e))?;
}
Ok(dir)
}
/// Write a file with 0600 permissions on Unix. Windows has no cheap
/// equivalent here; callers that rely on this function for secrets should
/// treat the Windows path as a last-resort fallback and surface the
/// degraded state to the user (see StoreMode).
pub(crate) fn write_restricted(path: &Path, contents: &str) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.write_all(contents.as_bytes())
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
file.sync_all()
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
}
#[cfg(not(unix))]
{
fs::write(path, contents)
.map_err(|e| format!("Cannot write {}: {}", path.display(), e))?;
}
Ok(())
}
pub(crate) fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn read_store_mode(dir: &Path) -> Option<StoreMode> {
let path = dir.join(STORE_MODE_FILE);
let raw = fs::read_to_string(&path).ok()?;
StoreMode::parse(&raw)
}
fn write_store_mode(dir: &Path, mode: StoreMode) -> Result<(), String> {
write_restricted(&dir.join(STORE_MODE_FILE), mode.as_str())
}
/// Overwrite the file contents with zeros, fsync, then unlink. Best-effort
/// mitigation against recovery of the refresh token from unallocated
/// blocks on copy-on-write filesystems where a plain unlink leaves the
/// ciphertext recoverable. Not a substitute for proper disk encryption.
fn zero_and_delete(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let len = fs::metadata(path)
.map(|m| m.len() as usize)
.unwrap_or(0)
.max(512);
let mut zeros = vec![0u8; len];
{
let mut f = fs::OpenOptions::new()
.write(true)
.truncate(false)
.open(path)
.map_err(|e| format!("Cannot open {} for wipe: {}", path.display(), e))?;
f.write_all(&zeros)
.map_err(|e| format!("Cannot zero {}: {}", path.display(), e))?;
f.sync_all()
.map_err(|e| format!("Cannot fsync {}: {}", path.display(), e))?;
}
zeros.zeroize();
fs::remove_file(path).map_err(|e| format!("Cannot remove {}: {}", path.display(), e))
}
fn tokens_to_json(tokens: &StoredTokens) -> Result<String, String> {
serde_json::to_string(tokens).map_err(|e| format!("Serialize error: {}", e))
}
fn tokens_from_json(raw: &str) -> Result<StoredTokens, String> {
serde_json::from_str(raw).map_err(|e| format!("Invalid tokens payload: {}", e))
}
fn keychain_entry() -> Result<keyring::Entry, keyring::Error> {
keyring::Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER_TOKENS)
}
fn keychain_save(json: &str) -> Result<(), keyring::Error> {
keychain_entry()?.set_password(json)
}
fn keychain_load() -> Result<Option<String>, keyring::Error> {
match keychain_entry()?.get_password() {
Ok(v) => Ok(Some(v)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e),
}
}
fn keychain_delete() -> Result<(), keyring::Error> {
match keychain_entry()?.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e),
}
}
/// Persist the current OAuth token bundle.
///
/// Tries the OS keychain first. If the keychain write fails AND the
/// persisted `store_mode` flag shows the keychain has worked before, the
/// caller receives an error instead of a silent downgrade — this
/// prevents a hostile local process from forcing the app into the
/// weaker file-fallback path. On a fresh install (no prior flag), the
/// fallback is allowed but recorded so subsequent calls know the app is
/// running in degraded mode.
pub fn save(app: &tauri::AppHandle, tokens: &StoredTokens) -> Result<(), String> {
let json = tokens_to_json(tokens)?;
let dir = auth_dir(app)?;
let previous_mode = read_store_mode(&dir);
match keychain_save(&json) {
Ok(()) => {
// Keychain succeeded. Clean up any residual file from a prior
// fallback or migration source so nothing stays readable.
let residual = dir.join(TOKENS_FILE);
if residual.exists() {
let _ = zero_and_delete(&residual);
}
write_store_mode(&dir, StoreMode::Keychain)?;
Ok(())
}
Err(err) => {
if previous_mode == Some(StoreMode::Keychain) {
// Refuse to downgrade after a prior success — surface the
// failure so the caller can force re-auth instead of
// silently leaking tokens to disk.
return Err(format!(
"Keychain unavailable after prior success — refusing to downgrade. \
Original error: {}",
err
));
}
eprintln!(
"token_store: keychain unavailable, falling back to file store ({})",
err
);
write_restricted(&dir.join(TOKENS_FILE), &json)?;
write_store_mode(&dir, StoreMode::File)?;
Ok(())
}
}
}
/// Load the current OAuth token bundle.
///
/// Tries the keychain first. If the keychain is empty but a legacy
/// `tokens.json` file exists, this is a first-run migration: the tokens
/// are copied into the keychain, the file is zeroed and unlinked, and
/// `store_mode` is updated. If the keychain itself is unreachable, the
/// function falls back to reading the file — unless the `store_mode`
/// flag indicates the keychain has worked before, in which case it
/// returns an error to force re-auth.
pub fn load(app: &tauri::AppHandle) -> Result<Option<StoredTokens>, String> {
let dir = auth_dir(app)?;
let previous_mode = read_store_mode(&dir);
let residual = dir.join(TOKENS_FILE);
match keychain_load() {
Ok(Some(raw)) => {
let tokens = tokens_from_json(&raw)?;
// Defensive: if a leftover file is still around (e.g. a prior
// crash between keychain write and file delete), clean it up.
if residual.exists() {
let _ = zero_and_delete(&residual);
}
if previous_mode != Some(StoreMode::Keychain) {
write_store_mode(&dir, StoreMode::Keychain)?;
}
Ok(Some(tokens))
}
Ok(None) => {
// Keychain reachable but empty. Migrate from a legacy file if
// one exists, otherwise report no stored session.
if residual.exists() {
let raw = fs::read_to_string(&residual)
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
let tokens = tokens_from_json(&raw)?;
// Push into keychain and wipe the file. If the keychain
// push fails here, we keep the file rather than losing
// the user's session.
match keychain_save(&raw) {
Ok(()) => {
let _ = zero_and_delete(&residual);
write_store_mode(&dir, StoreMode::Keychain)?;
}
Err(e) => {
eprintln!("token_store: migration to keychain failed ({})", e);
write_store_mode(&dir, StoreMode::File)?;
}
}
Ok(Some(tokens))
} else {
Ok(None)
}
}
Err(err) => {
if previous_mode == Some(StoreMode::Keychain) {
return Err(format!(
"Keychain unavailable after prior success: {}",
err
));
}
// No prior keychain success: honour the file fallback if any.
eprintln!(
"token_store: keychain unreachable, using file fallback ({})",
err
);
if residual.exists() {
let raw = fs::read_to_string(&residual)
.map_err(|e| format!("Cannot read {}: {}", residual.display(), e))?;
write_store_mode(&dir, StoreMode::File)?;
Ok(Some(tokens_from_json(&raw)?))
} else {
Ok(None)
}
}
}
}
/// Delete the stored tokens from both the keychain and the file
/// fallback. Both deletions are best-effort and ignore "no entry"
/// errors to stay idempotent.
pub fn delete(app: &tauri::AppHandle) -> Result<(), String> {
let dir = auth_dir(app)?;
if let Err(err) = keychain_delete() {
eprintln!("token_store: keychain delete failed ({})", err);
}
let residual = dir.join(TOKENS_FILE);
if residual.exists() {
let _ = zero_and_delete(&residual);
}
// Leave the store_mode flag alone: it still describes what the app
// should trust the next time `save` is called.
Ok(())
}
/// Current store mode, derived from the persisted flag. Returns `None`
/// if no tokens have ever been written (no flag file).
pub fn store_mode(app: &tauri::AppHandle) -> Result<Option<StoreMode>, String> {
let dir = auth_dir(app)?;
Ok(read_store_mode(&dir))
}
/// Tauri command: expose the current store mode to the frontend.
/// Returns `"keychain"`, `"file"`, or `null` if the app has no stored
/// session yet. Used by the settings UI to show a security banner when
/// the fallback is active.
#[tauri::command]
pub fn get_token_store_mode(app: tauri::AppHandle) -> Result<Option<String>, String> {
Ok(store_mode(&app)?.map(|m| m.as_str().to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_mode_roundtrip() {
assert_eq!(StoreMode::parse("keychain"), Some(StoreMode::Keychain));
assert_eq!(StoreMode::parse("file"), Some(StoreMode::File));
assert_eq!(StoreMode::parse("other"), None);
assert_eq!(StoreMode::Keychain.as_str(), "keychain");
assert_eq!(StoreMode::File.as_str(), "file");
}
#[test]
fn stored_tokens_serde_roundtrip() {
let tokens = StoredTokens {
access_token: "at".into(),
refresh_token: Some("rt".into()),
id_token: Some("it".into()),
expires_at: 42,
};
let json = tokens_to_json(&tokens).unwrap();
let decoded = tokens_from_json(&json).unwrap();
assert_eq!(decoded.access_token, "at");
assert_eq!(decoded.refresh_token.as_deref(), Some("rt"));
assert_eq!(decoded.id_token.as_deref(), Some("it"));
assert_eq!(decoded.expires_at, 42);
}
#[test]
fn zero_and_delete_removes_file() {
use std::io::Write as _;
let tmp = std::env::temp_dir().join(format!(
"simpl-resultat-token-store-test-{}",
std::process::id()
));
let mut f = fs::File::create(&tmp).unwrap();
f.write_all(b"sensitive").unwrap();
drop(f);
assert!(tmp.exists());
zero_and_delete(&tmp).unwrap();
assert!(!tmp.exists());
}
}

View file

@ -1,153 +0,0 @@
-- Balance sheet schema (Bilan) — Migration v9
-- Created: 2026-04-25
-- Issue: #138 (Bilan #1a — Schema migration + balance.service skeleton + AccountsPage)
--
-- Adds 5 tables, 7 indexes, and seeds 7 standard categories (5 simple + 2 priced).
-- Conventions aligned with consolidated_schema.sql:
-- - INTEGER PRIMARY KEY AUTOINCREMENT
-- - REAL for monetary amounts (matches transactions.amount)
-- - snake_case
-- - FK with explicit ON DELETE policies
-- - is_* INTEGER NOT NULL DEFAULT for booleans
-- - DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP for timestamps
--
-- MVP constraints (decisions-log + spec-decisions-bilan.md):
-- - balance_accounts.currency hardcoded to 'CAD' via CHECK — v2 will lift this
-- - balance_account_transfers.transaction_id ON DELETE RESTRICT (preserves
-- reproducibility of Modified Dietz returns calculated on past periods)
-- - balance_snapshot_lines kind invariants: (quantity, unit_price) both NULL
-- (simple kind) OR both NOT NULL (priced kind)
-- =========================================================================
-- balance_categories — taxonomy of asset types
-- =========================================================================
-- Seeded with 7 standard categories (is_seed = 1). Users can add custom
-- categories with their own kind ('simple' or 'priced'). Seeded categories
-- can be renamed but never deleted.
CREATE TABLE IF NOT EXISTS balance_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE, -- 'cash', 'tfsa', 'rrsp', 'fund', 'stock', 'crypto', 'other'
i18n_key TEXT NOT NULL, -- 'balance.category.cash', etc.
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0
);
-- =========================================================================
-- balance_accounts — user's specific holdings
-- =========================================================================
-- A "TFSA at Wealthsimple", a "BTC in Ledger", etc.
-- For priced categories, `symbol` identifies the security/coin.
-- For simple categories, `symbol` is NULL.
-- MVP: currency hardcoded to 'CAD' — v2 lifts the CHECK and adds a rate table.
CREATE TABLE IF NOT EXISTS balance_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
balance_category_id INTEGER NOT NULL,
name TEXT NOT NULL,
symbol TEXT,
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
);
-- =========================================================================
-- balance_snapshots — point-in-time captures
-- =========================================================================
-- One snapshot per `snapshot_date` (UNIQUE). Editing a snapshot = updating
-- its lines, not creating a duplicate.
CREATE TABLE IF NOT EXISTS balance_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date DATE NOT NULL UNIQUE,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- =========================================================================
-- balance_snapshot_lines — one row per (snapshot, account)
-- =========================================================================
-- Storage shape:
-- - simple kind: value is set, quantity/unit_price are NULL
-- - priced kind: quantity + unit_price are set, value = quantity * unit_price
-- Stored denormalized (value always set, even for priced rows) so reports
-- are reproducible without re-fetching prices and the user can override a
-- fetched price.
-- The CHECK enforces kind invariants at SQL level for direct-write safety.
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
quantity REAL,
unit_price REAL,
value REAL NOT NULL,
price_source TEXT, -- 'manual' | 'maximus-api' | NULL for simple
price_fetched_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
UNIQUE(snapshot_id, account_id),
CHECK (
(quantity IS NULL AND unit_price IS NULL)
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
)
);
-- =========================================================================
-- balance_account_transfers — links transactions to accounts (capital flows)
-- =========================================================================
-- Used by the Modified Dietz return calculator (Issue #142 / Bilan #4) to
-- separate contributions from gains. Direction follows the account's
-- perspective: 'in' = capital added (deposit/buy), 'out' = capital removed
-- (withdrawal/sell). The amount is taken from the linked transaction (no
-- duplication).
--
-- transaction_id ON DELETE RESTRICT: preserves reproducibility of past
-- Modified Dietz returns. The UI must force unlink before allowing the
-- transaction to be deleted.
CREATE TABLE IF NOT EXISTS balance_account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
UNIQUE(transaction_id, account_id)
);
-- =========================================================================
-- Indexes (7 total)
-- =========================================================================
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
-- =========================================================================
-- Seed (7 standard categories — idempotent via INSERT OR IGNORE on `key`)
-- =========================================================================
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
('fund', 'balance.category.fund', 'simple', 40, 1),
('other', 'balance.category.other', 'simple', 50, 1),
('stock', 'balance.category.stock', 'priced', 60, 1),
('crypto', 'balance.category.crypto', 'priced', 70, 1);

View file

@ -1,9 +1,6 @@
-- Consolidated schema for new profile databases -- Consolidated schema for new profile databases
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds -- This file bakes in the base schema + all migrations (v3-v6)
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately -- Used ONLY for initializing new profile databases (not for the default profile)
-- use the new standard. Existing profiles keep their v2 seed and are only
-- tagged via the migration (categories_schema_version = 'v2').
-- Used ONLY for initializing new profile databases (not for the default profile).
CREATE TABLE IF NOT EXISTS import_sources ( CREATE TABLE IF NOT EXISTS import_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -42,7 +39,6 @@ CREATE TABLE IF NOT EXISTS categories (
is_active INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1,
is_inputable INTEGER NOT NULL DEFAULT 1, is_inputable INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,
i18n_key TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
); );
@ -181,584 +177,8 @@ CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, mon
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id); CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id); CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
-- ============================================================================ -- Default preferences
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
-- ============================================================================
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
-- New profiles created from this consolidated schema get the balance feature
-- preinstalled without needing to replay v9 separately.
CREATE TABLE IF NOT EXISTS balance_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
i18n_key TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0,
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto'))
);
CREATE TABLE IF NOT EXISTS balance_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
balance_category_id INTEGER NOT NULL,
name TEXT NOT NULL,
symbol TEXT,
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
notes TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
archived_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS balance_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date DATE NOT NULL UNIQUE,
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
quantity REAL,
unit_price REAL,
value REAL NOT NULL,
price_source TEXT,
price_fetched_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
UNIQUE(snapshot_id, account_id),
CHECK (
(quantity IS NULL AND unit_price IS NULL)
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
)
);
CREATE TABLE IF NOT EXISTS balance_account_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
UNIQUE(transaction_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL),
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
('other', 'balance.category.other', 'simple', 50, 1, NULL),
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so
-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on
-- balance_accounts) — once created they are indistinguishable from
-- user-created accounts and can be renamed/archived freely. Existing profiles
-- get the same 4 proposed via StarterAccountsModal on first /balance visit.
INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) VALUES
((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1),
((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD', 1),
((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER', 'CAD', 1),
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1);
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr'); INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('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');
-- 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
-- ----------------------------------------------------------------------------
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
-- the historical v2 seed migration on this fresh DB before this script
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
-- ============================================================================
DELETE FROM keywords;
UPDATE transactions SET category_id = NULL;
DELETE FROM categories;
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
-- 1000 — Revenus
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
-- 1100 — Alimentation
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
-- 1200 — Logement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
-- 1300 — Ménage & ameublement
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
-- 1400 — Vêtements & chaussures
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
-- 1500 — Transport
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
-- 1600 — Santé & soins personnels
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
-- 1700 — Loisirs, formation & lecture
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
-- 1800 — Boissons, tabac & cannabis
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
-- 1900 — Finances & obligations
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
-- 1950 — Transferts & placements
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
-- ============================================================================
-- Keywords — Canadian suppliers (150+ entries)
-- ============================================================================
-- Alimentation > Épicerie régulière (1111)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
-- Épicerie > Boucherie (1112)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
-- Épicerie > Boulangerie (1113)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
-- Épicerie > Dépanneur (1114)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
-- Restauration > Restaurant (1121)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
-- Restauration > Café (1122)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
-- Restauration > Fast food (1123)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
-- Restauration > Livraison (1124)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
-- Logement > Hypothèque (1212)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
-- Logement > Taxes municipales (1213)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
-- Électricité (1221)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
-- Gaz (1222)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
-- Internet (1231)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
-- Mobile (1232)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
-- Entretien maison (1241)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
-- Matériaux & outils (1243)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
-- Assurance habitation (1250)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
-- Meubles (1311)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
-- Électroménagers (1312)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
-- Décoration (1313)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
-- Vêtements (1410)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
-- Transport — Essence (1512)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
-- Permis / SAAQ (1514)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
-- Transport public — autobus/métro (1521)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
-- Train de banlieue (1522)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
-- Taxi / Uber (1523)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
-- Avion (1531)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
-- Hébergement (1533)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
-- Pharmacie (1611)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
-- Thérapies (1615)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
-- Cinéma & spectacles (1711)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
-- Jeux vidéo (1712)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
-- Streaming vidéo (1713)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
-- Streaming musique (1714)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
-- Jouets & hobbies (1715)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
-- Équipement sportif (1722)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
-- Parcs & activités (1723)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
-- Lecture (1741)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
-- Animaux — nourriture (1751)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
-- Alcool (1810)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
-- Cannabis (1820)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
-- Frais bancaires (1911)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
-- Impôts (1921, 1922)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
-- Dons de charité (1931)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
-- Cotisations professionnelles (1932)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
-- Cadeaux (1940)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
-- Divers (1946) — catch-all marketplace
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
-- Placements (1964)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
-- Paie (1011)
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);

View file

@ -1,4 +1,3 @@
pub const SCHEMA: &str = include_str!("schema.sql"); pub const SCHEMA: &str = include_str!("schema.sql");
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql"); pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql"); pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");

File diff suppressed because it is too large Load diff

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.6.3",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -18,15 +18,14 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com https://auth.lacompagniemaximus.com https://feedback.lacompagniemaximus.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:" "csp": null
} }
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis", "deb", "rpm"], "targets": ["nsis", "deb", "rpm", "appimage"],
"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",
@ -35,11 +34,6 @@
"createUpdaterArtifacts": true "createUpdaterArtifacts": true
}, },
"plugins": { "plugins": {
"deep-link": {
"desktop": {
"schemes": ["simpl-resultat"]
}
},
"updater": { "updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK", "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgyRDc4MDEyQjQ0MzAxRTMKUldUakFVTzBFb0RYZ3NRNmFxMHdnTzBMZzFacTlCbTdtMEU3Ym5pZWNSN3FRZk43R3lZSUM2OHQK",
"endpoints": [ "endpoints": [

View file

@ -10,21 +10,7 @@ import CategoriesPage from "./pages/CategoriesPage";
import AdjustmentsPage from "./pages/AdjustmentsPage"; import AdjustmentsPage from "./pages/AdjustmentsPage";
import BudgetPage from "./pages/BudgetPage"; import BudgetPage from "./pages/BudgetPage";
import ReportsPage from "./pages/ReportsPage"; import ReportsPage from "./pages/ReportsPage";
import ReportsHighlightsPage from "./pages/ReportsHighlightsPage"; import SettingsPage from "./pages/SettingsPage";
import ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsLayout from "./pages/settings/SettingsLayout";
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 BalancePage from "./pages/BalancePage";
import SnapshotEditPage from "./pages/SnapshotEditPage";
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
import DocsPage from "./pages/DocsPage"; import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage"; import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage";
@ -115,28 +101,7 @@ export default function App() {
<Route path="/adjustments" element={<AdjustmentsPage />} /> <Route path="/adjustments" element={<AdjustmentsPage />} />
<Route path="/budget" element={<BudgetPage />} /> <Route path="/budget" element={<BudgetPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/reports/trends" element={<ReportsTrendsPage />} />
<Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsLayout />}>
<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/accounts" element={<AccountsPage />} />
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
<Route
path="/settings/categories/standard"
element={<CategoriesStandardGuidePage />}
/>
<Route
path="/settings/categories/migrate"
element={<CategoriesMigrationPage />}
/>
<Route path="/docs" element={<DocsPage />} /> <Route path="/docs" element={<DocsPage />} />
<Route path="/changelog" element={<ChangelogPage />} /> <Route path="/changelog" element={<ChangelogPage />} />
</Route> </Route>

View file

@ -1,180 +0,0 @@
/**
* Profile fixtures for the v2 v1 categories migration tests.
*
* These fixtures are intentionally loose they match the minimal shapes
* consumed by categoryMappingService.ProfileData and the broader migration
* test helpers (budget/categorization regression). They are *not* a mock of
* the full Tauri DB layer; tests that need DB access still mock `getDb`.
*
* Three flavours are provided:
* - `makeV2Profile()` realistic v2-seeded profile (~30 cats)
* - `makeV1Profile()` same user data but already on v1 taxonomy
* - `makeV2ProfileWithCustom()` v2 profile with 3 user-created categories
*/
import type {
ProfileData,
V2CategoryInput,
V2KeywordInput,
V2TransactionInput,
V2SupplierInput,
} from "../services/categoryMappingService";
// ---------------------------------------------------------------------------
// v2 seed (excerpt matching DEFAULT_MAPPINGS keys)
// ---------------------------------------------------------------------------
const V2_STRUCTURAL_PARENTS: V2CategoryInput[] = [
{ id: 1, name: "Revenus", parent_id: null },
{ id: 2, name: "Dépenses récurrentes", parent_id: null },
{ id: 3, name: "Dépenses ponctuelles", parent_id: null },
{ id: 4, name: "Maison", parent_id: null },
{ id: 5, name: "Placements", parent_id: null },
{ id: 6, name: "Autres", parent_id: null },
];
const V2_SEEDED_CATS: V2CategoryInput[] = [
// Revenus
{ id: 10, name: "Paie", parent_id: 1 },
{ id: 11, name: "Autres revenus", parent_id: 1 },
// Dépenses récurrentes
{ id: 20, name: "Loyer", parent_id: 2 },
{ id: 21, name: "Électricité", parent_id: 2 },
{ id: 22, name: "Épicerie", parent_id: 2 },
{ id: 23, name: "Dons", parent_id: 2 },
{ id: 24, name: "Restaurant", parent_id: 2 },
{ id: 25, name: "Frais bancaires", parent_id: 2 },
{ id: 26, name: "Jeux, Films & Livres", parent_id: 2 },
{ id: 27, name: "Abonnements Musique", parent_id: 2 },
{ id: 28, name: "Transport en commun", parent_id: 2 },
{ id: 29, name: "Internet & Télécom", parent_id: 2 },
{ id: 30, name: "Animaux", parent_id: 2 },
{ id: 31, name: "Assurances", parent_id: 2 },
{ id: 32, name: "Pharmacie", parent_id: 2 },
// Dépenses ponctuelles
{ id: 40, name: "Voiture", parent_id: 3 },
{ id: 41, name: "Amazon", parent_id: 3 },
{ id: 42, name: "Électroniques", parent_id: 3 },
{ id: 43, name: "Alcool", parent_id: 3 },
{ id: 44, name: "Cadeaux", parent_id: 3 },
{ id: 45, name: "Vêtements", parent_id: 3 },
{ id: 47, name: "Voyage", parent_id: 3 },
{ id: 48, name: "Sports & Plein air", parent_id: 3 },
{ id: 49, name: "Spectacles & sorties", parent_id: 3 },
// Maison
{ id: 50, name: "Hypothèque", parent_id: 4 },
{ id: 51, name: "Achats maison", parent_id: 4 },
{ id: 52, name: "Entretien maison", parent_id: 4 },
{ id: 53, name: "Électroménagers & Meubles", parent_id: 4 },
// Autres
{ id: 70, name: "Impôts", parent_id: 6 },
{ id: 71, name: "Paiement CC", parent_id: 6 },
{ id: 72, name: "Retrait cash", parent_id: 6 },
];
const V2_KEYWORDS: V2KeywordInput[] = [
{ id: 101, keyword: "PAIE DEPOT", category_id: 10 },
{ id: 102, keyword: "IGA", category_id: 22 },
{ id: 103, keyword: "METRO PLUS", category_id: 22 },
{ id: 104, keyword: "STM", category_id: 28 },
{ id: 105, keyword: "SHELL", category_id: 40 },
{ id: 106, keyword: "NETFLIX", category_id: 26 },
{ id: 107, keyword: "PRIMEVIDEO", category_id: 26 },
{ id: 108, keyword: "AMAZON", category_id: 41 },
];
const V2_SUPPLIERS: V2SupplierInput[] = [
{ id: 501, name: "STM" },
{ id: 502, name: "Shell Canada" },
{ id: 503, name: "Hilton Montreal" },
{ id: 504, name: "IGA" },
{ id: 505, name: "Hydro-Québec" },
];
const V2_TRANSACTIONS: V2TransactionInput[] = [
{ id: 1, description: "DEPOT PAIE MAX", category_id: 10 },
{ id: 2, description: "IGA #5555", category_id: 22, supplier_id: 504 },
{ id: 3, description: "SHELL #231 LAVAL", category_id: 40, supplier_id: 502 },
{ id: 4, description: "STM CARTE OPUS", category_id: 28, supplier_id: 501 },
{ id: 5, description: "HYDRO-QUEBEC FACTURE", category_id: 21, supplier_id: 505 },
{ id: 6, description: "NETFLIX.COM", category_id: 26 },
{ id: 7, description: "HILTON SEATTLE", category_id: 47, supplier_id: 503 },
{ id: 8, description: "AMAZON.CA *XYZ", category_id: 41 },
];
// ---------------------------------------------------------------------------
// Public builders
// ---------------------------------------------------------------------------
export function makeV2Profile(): ProfileData {
return {
v2Categories: [...V2_STRUCTURAL_PARENTS, ...V2_SEEDED_CATS],
keywords: [...V2_KEYWORDS],
transactions: [...V2_TRANSACTIONS],
suppliers: [...V2_SUPPLIERS],
};
}
export function makeV2ProfileWithCustom(): ProfileData {
const base = makeV2Profile();
const custom: V2CategoryInput[] = [
{ id: 9001, name: "Projet maison", parent_id: 3 },
{ id: 9002, name: "Activités enfants", parent_id: 3 },
{ id: 9003, name: "Hobby moto", parent_id: 3 },
];
return {
...base,
v2Categories: [...base.v2Categories, ...custom],
};
}
/**
* v1 "profile" same user data after the migration has been applied.
* Category ids follow the v1 taxonomy (`categoryTaxonomyV1.json`) and the
* DEFAULT_MAPPINGS table from categoryMappingService. Useful for parameterised
* regression tests where the behaviour must be identical on v1 and v2.
*/
export function makeV1Profile(): ProfileData {
// v1 categories in this shape are just for iteration — the SQL write-over
// path runs the real v1 taxonomy. We only need a few leaves for keyword /
// budget tests that don't actually inspect the full tree.
const v1Cats: V2CategoryInput[] = [
{ id: 1011, name: "Paie régulière", parent_id: 1010 },
{ id: 1090, name: "Autres revenus", parent_id: 1000 },
{ id: 1111, name: "Épicerie régulière", parent_id: 1110 },
{ id: 1121, name: "Restaurants & sorties", parent_id: 1120 },
{ id: 1211, name: "Loyer", parent_id: 1210 },
{ id: 1221, name: "Électricité", parent_id: 1220 },
{ id: 1521, name: "Autobus & métro", parent_id: 1520 },
{ id: 1512, name: "Essence", parent_id: 1510 },
{ id: 1713, name: "Abonnements streaming", parent_id: 1710 },
{ id: 1533, name: "Hébergement en voyage", parent_id: 1530 },
{ id: 1946, name: "Achats divers", parent_id: 1940 },
];
// Keywords/suppliers/transactions carry v1 category_id values now.
const v1Keywords: V2KeywordInput[] = [
{ id: 101, keyword: "PAIE DEPOT", category_id: 1011 },
{ id: 102, keyword: "IGA", category_id: 1111 },
{ id: 103, keyword: "METRO PLUS", category_id: 1111 },
{ id: 104, keyword: "STM", category_id: 1521 },
{ id: 105, keyword: "SHELL", category_id: 1512 },
{ id: 106, keyword: "NETFLIX", category_id: 1713 },
{ id: 107, keyword: "PRIMEVIDEO", category_id: 1713 },
{ id: 108, keyword: "AMAZON", category_id: 1946 },
];
const v1Tx: V2TransactionInput[] = [
{ id: 1, description: "DEPOT PAIE MAX", category_id: 1011 },
{ id: 2, description: "IGA #5555", category_id: 1111, supplier_id: 504 },
{ id: 3, description: "SHELL #231 LAVAL", category_id: 1512, supplier_id: 502 },
{ id: 4, description: "STM CARTE OPUS", category_id: 1521, supplier_id: 501 },
{ id: 5, description: "HYDRO-QUEBEC FACTURE", category_id: 1221, supplier_id: 505 },
{ id: 6, description: "NETFLIX.COM", category_id: 1713 },
{ id: 7, description: "HILTON SEATTLE", category_id: 1533, supplier_id: 503 },
{ id: 8, description: "AMAZON.CA *XYZ", category_id: 1946 },
];
return {
v2Categories: v1Cats,
keywords: v1Keywords,
transactions: v1Tx,
suppliers: [...V2_SUPPLIERS],
};
}

View file

@ -1,575 +0,0 @@
/**
* Integration tests for the Bilan (balance sheet) feature Issue #144.
*
* Cross-cutting tests that exercise the *whole* TS surface in one go:
*
* account priced category priced snapshot linked transfer return
*
* Like `category-migration.test.ts` we cannot spin up real `tauri-plugin-sql`
* (the bridge only lives inside the Tauri WebView). Instead we drive every
* service against an in-memory FakeDb that:
* - records every executed SQL,
* - returns hand-tuned `select` results to mimic the real schema,
* - simulates `lastInsertId` / `rowsAffected` for INSERT/DELETE.
*
* The Tauri `invoke` is mocked `computeAccountReturn` lives on the Rust
* side (`compute_account_return`), so we assert the request payload and
* have the mock return a stable `AccountReturn` shape. The Rust math itself
* is covered by `return_calculator.rs`'s `#[cfg(test)] mod tests`.
*
* Scope (from spec-plan-bilan.md, Issue #144):
* 1. End-to-end happy path
* 2. Currency-lock (CHECK `currency = 'CAD'`) at the service level
* 3. Migration v9 on a seeded DB covered in Rust (lib.rs `mod tests`)
* 4. TransactionsPage non-regression for the inlined transfer icon
* 5. Coverage best-effort (deferred see decisions-log.md)
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("../services/profileService", () => ({
loadProfiles: vi.fn(),
}));
import { getDb } from "../services/db";
import { invoke } from "@tauri-apps/api/core";
import { loadProfiles } from "../services/profileService";
import {
createBalanceCategory,
createBalanceAccount,
listBalanceAccounts,
createSnapshot,
upsertSnapshotLines,
listLinesBySnapshot,
linkTransfer,
unlinkTransfer,
listAccountTransfers,
computeAccountReturn,
BalanceServiceError,
PRICED_VALUE_TOLERANCE,
} from "../services/balance.service";
// ---------------------------------------------------------------------------
// FakeDb harness: scripted select results, recorded execute calls.
// ---------------------------------------------------------------------------
interface FakeDb {
calls: Array<{ sql: string; params?: unknown[] }>;
selectQueue: Array<unknown[]>;
executeQueue: Array<{ lastInsertId?: number; rowsAffected?: number }>;
select: ReturnType<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
}
function makeFakeDb(): FakeDb {
const db: FakeDb = {
calls: [],
selectQueue: [],
executeQueue: [],
select: vi.fn(),
execute: vi.fn(),
};
db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
db.calls.push({ sql, params });
if (db.selectQueue.length === 0) {
throw new Error(`Unscripted SELECT (no queued result): ${sql}`);
}
return db.selectQueue.shift();
});
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
db.calls.push({ sql, params });
if (db.executeQueue.length === 0) {
// Default: 1 affected row, monotonically increasing lastInsertId
return { rowsAffected: 1, lastInsertId: db.calls.length };
}
return db.executeQueue.shift();
});
return db;
}
let fake: FakeDb;
beforeEach(() => {
fake = makeFakeDb();
vi.mocked(getDb).mockResolvedValue(
{ select: fake.select, execute: fake.execute } as never
);
vi.mocked(invoke).mockReset();
vi.mocked(loadProfiles).mockReset();
});
// Helper: queue a sequence of SELECT results in FIFO order.
function queueSelects(...rows: unknown[][]) {
for (const r of rows) fake.selectQueue.push(r);
}
// Helper: queue a sequence of EXECUTE results in FIFO order.
function queueExecutes(
...results: Array<{ lastInsertId?: number; rowsAffected?: number }>
) {
for (const r of results) fake.executeQueue.push(r);
}
// ---------------------------------------------------------------------------
// 1. End-to-end happy path
// ---------------------------------------------------------------------------
//
// Walks the full Bilan flow as if the user just installed the app:
// 1. Create a custom priced category ("etf-prov")
// 2. Create an account on that category with a stock symbol
// 3. Reload the joined accounts list and confirm the account is there
// 4. Create a snapshot dated today
// 5. Save a priced line for the new account (qty * price = value)
// 6. Read the lines back and confirm what was persisted
// 7. Link a transaction to the account as a +CAD deposit
// 8. Compute the account's return → mock returns a stable shape, we
// assert the wiring uses the active profile's db_filename and forwards
// every parameter as ISO YYYY-MM-DD.
//
// Each step is asserted at the service-call level (params + queued SQL),
// then we run cross-step sanity checks.
describe("integration — Bilan end-to-end happy path", () => {
it("walks account → priced category → snapshot → transfer → return cleanly", async () => {
// ---- 1. Create a custom priced category ----
queueExecutes({ lastInsertId: 100 });
const categoryId = await createBalanceCategory({
key: "etf-prov",
i18n_key: "balance.category.etf_prov",
kind: "priced",
sort_order: 80,
asset_type: "stock",
});
expect(categoryId).toBe(100);
// ---- 2. Create the account on that category ----
// Service first SELECTs the category to validate it exists, then
// INSERTs the account.
queueSelects([
{
id: 100,
key: "etf-prov",
i18n_key: "balance.category.etf_prov",
kind: "priced",
sort_order: 80,
is_active: 1,
is_seed: 0,
},
]);
queueExecutes({ lastInsertId: 7 });
const accountId = await createBalanceAccount({
balance_category_id: categoryId,
name: "VFV (Wealthsimple)",
symbol: "VFV.TO",
});
expect(accountId).toBe(7);
// ---- 3. listBalanceAccounts: account joined with category ----
queueSelects([
{
id: 7,
balance_category_id: 100,
name: "VFV (Wealthsimple)",
symbol: "VFV.TO",
currency: "CAD",
notes: null,
is_active: 1,
archived_at: null,
created_at: "",
updated_at: "",
category_key: "etf-prov",
category_i18n_key: "balance.category.etf_prov",
category_kind: "priced",
},
]);
const accounts = await listBalanceAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0].category_kind).toBe("priced");
expect(accounts[0].symbol).toBe("VFV.TO");
// ---- 4. Create a snapshot dated 2026-04-25 ----
// createSnapshot first SELECTs by date (must be empty) then INSERTs.
queueSelects([]); // no existing snapshot
queueExecutes({ lastInsertId: 50 });
const snapshotId = await createSnapshot({ snapshot_date: "2026-04-25" });
expect(snapshotId).toBe(50);
// ---- 5. Save a priced line: 10 shares × $200 = $2000 ----
// upsertSnapshotLines: SELECT snapshot, then DELETE existing lines, then
// one INSERT per line, then UPDATE updated_at.
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
queueExecutes(
{ rowsAffected: 0 }, // delete (no prior lines)
{ lastInsertId: 200 }, // insert priced line
{ rowsAffected: 1 } // bump updated_at
);
await upsertSnapshotLines(50, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 200,
value: 2000,
},
]);
// The 2nd execute call should be the INSERT with the priced placeholders.
const insertCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_snapshot_lines")
);
expect(insertCall).toBeDefined();
expect(insertCall!.params).toEqual([50, 7, 10, 200, 2000]);
// ---- 6. Read the lines back ----
queueSelects([
{
id: 200,
snapshot_id: 50,
account_id: 7,
quantity: 10,
unit_price: 200,
value: 2000,
price_source: "manual",
price_fetched_at: null,
created_at: "",
updated_at: "",
},
]);
const lines = await listLinesBySnapshot(50);
expect(lines).toHaveLength(1);
expect(lines[0].value).toBe(2000);
expect(lines[0].quantity).toBe(10);
expect(lines[0].unit_price).toBe(200);
// ---- 7. Link a transaction (id=42) as a +CAD deposit (in) ----
// linkTransfer: SELECT existing duplicate (none), then INSERT.
queueSelects([]); // no existing duplicate
queueExecutes({ lastInsertId: 9 });
const transferId = await linkTransfer(7, 42, "in", "monthly contribution");
expect(transferId).toBe(9);
const linkCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_account_transfers")
);
expect(linkCall).toBeDefined();
expect(linkCall!.params).toEqual([7, 42, "in", "monthly contribution"]);
// ---- 8. Compute the account return ----
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#3b82f6",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
const fakeReturn = {
value_start: 1500,
value_end: 2000,
net_contributions: 400,
return_pct: 0.0667, // (2000 - 1500 - 400) / (1500 + W*400) ≈ 6.67%
annualized_pct: 0.28,
is_partial: false,
has_no_transfers_warning: false,
};
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
const ret = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
expect(ret).toEqual(fakeReturn);
// Wiring check: profile resolution + ISO date forwarding.
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
dbFilename: "max.db",
accountId: 7,
periodStart: "2026-01-01",
periodEnd: "2026-04-25",
});
// ---- Cross-step sanity: every coherent value matches expectations.
// The end snapshot value (2000) matches what we saved.
expect(ret.value_end).toBe(2000);
// The reported return is a finite, non-zero number on a non-trivial period.
expect(ret.return_pct).not.toBeNull();
expect(Number.isFinite(ret.return_pct!)).toBe(true);
// Net contributions match the 1 linked transfer (+400 in).
expect(ret.net_contributions).toBeGreaterThan(0);
});
it("supports unlink as the inverse of link", async () => {
queueExecutes({ rowsAffected: 1 });
await expect(unlinkTransfer(7, 42)).resolves.toBeUndefined();
const unlinkCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("DELETE FROM balance_account_transfers")
);
expect(unlinkCall!.params).toEqual([7, 42]);
});
it("listAccountTransfers reads back what link wrote (joined view)", async () => {
queueSelects([
{
id: 9,
account_id: 7,
transaction_id: 42,
direction: "in",
notes: "monthly contribution",
created_at: "2026-04-25 10:00:00",
transaction_date: "2026-04-15",
transaction_description: "Wealthsimple contrib",
transaction_amount: -400,
account_name: "VFV (Wealthsimple)",
},
]);
const links = await listAccountTransfers(7);
expect(links).toHaveLength(1);
expect(links[0].direction).toBe("in");
expect(links[0].account_name).toBe("VFV (Wealthsimple)");
});
});
// ---------------------------------------------------------------------------
// 2. Currency lock — CAD only at the MVP
// ---------------------------------------------------------------------------
//
// The MVP locks accounts to CAD: the SQL CHECK is `currency = 'CAD'` and the
// service rejects any other value with a typed `currency_unsupported` before
// the SQL even fires. Asserts:
// - USD is rejected with the typed code,
// - the rejection happens BEFORE any SELECT/EXECUTE on the DB,
// - the default (no `currency` field) flows through and lands as 'CAD',
// - the SQL CHECK side is covered in Rust (lib.rs `migration_v9_*` tests).
describe("integration — currency lock (CAD only)", () => {
it("rejects USD at the service level with a typed error", async () => {
await expect(
createBalanceAccount({
balance_category_id: 1,
name: "USD account",
currency: "USD",
})
).rejects.toBeInstanceOf(BalanceServiceError);
try {
await createBalanceAccount({
balance_category_id: 1,
name: "USD account",
currency: "USD",
});
} catch (e) {
expect((e as BalanceServiceError).code).toBe("currency_unsupported");
}
// CRITICAL: the rejection must happen up-front — no DB hit.
expect(fake.calls.length).toBe(0);
});
it("accepts the default and persists 'CAD' explicitly", async () => {
queueSelects([
{
id: 1,
key: "cash",
i18n_key: "balance.category.cash",
kind: "simple",
sort_order: 10,
is_active: 1,
is_seed: 1,
},
]);
queueExecutes({ lastInsertId: 5 });
await createBalanceAccount({
balance_category_id: 1,
name: "Encaisse",
});
const insertCall = fake.calls.find(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("INSERT INTO balance_accounts")
);
expect(insertCall).toBeDefined();
// [category_id, name, symbol, currency, notes]
expect(insertCall!.params).toEqual([1, "Encaisse", null, "CAD", null]);
});
it("rejects EUR / GBP / JPY too — not a CAD-only typo allowlist", async () => {
for (const ccy of ["EUR", "GBP", "JPY", "AUD"]) {
await expect(
createBalanceAccount({
balance_category_id: 1,
name: `Mystery ${ccy}`,
currency: ccy,
})
).rejects.toMatchObject({ code: "currency_unsupported" });
}
expect(fake.calls.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// 3. Priced-kind invariant — coherence of the qty × price = value chain
// ---------------------------------------------------------------------------
//
// Tied to the priced-kind path, but at the integration layer: a snapshot
// saved with a drifting (qty * price ≠ value) line must be rejected before
// any DB mutation, so the SQL CHECK never has the chance to fire and we
// don't accidentally clear pre-existing lines.
describe("integration — priced invariant rejects out-of-tolerance saves", () => {
it("does not run DELETE when one line is bad", async () => {
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
await expect(
upsertSnapshotLines(50, [
{ account_id: 1, value: 1000 },
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 25,
// expected ≈ 250, way beyond ε
value: 999,
},
])
).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
// Critical safety: the DELETE must not have fired — otherwise the user
// would lose all existing lines on a partial save.
const deletes = fake.calls.filter(
(c) =>
typeof c.sql === "string" &&
c.sql.includes("DELETE FROM balance_snapshot_lines")
);
expect(deletes).toHaveLength(0);
});
it("accepts a drift just within the tolerance", async () => {
queueSelects([
{
id: 50,
snapshot_date: "2026-04-25",
notes: null,
created_at: "",
updated_at: "",
},
]);
queueExecutes(
{ rowsAffected: 0 },
{ lastInsertId: 1 },
{ rowsAffected: 1 }
);
// 12.34 * 1.07 = 13.2038... — drift well within ε = 0.01
const drift = PRICED_VALUE_TOLERANCE * 0.5;
await expect(
upsertSnapshotLines(50, [
{
account_id: 7,
account_kind: "priced",
quantity: 10,
unit_price: 10,
value: 100 + drift,
},
])
).resolves.toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// 4. Returns: malformed period dates rejected before the Tauri invoke
// ---------------------------------------------------------------------------
describe("integration — computeAccountReturn validates dates client-side", () => {
it("rejects non-ISO dates without invoking the Rust command", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#000",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
await expect(
computeAccountReturn(7, "01/01/2026", "2026-04-25")
).rejects.toBeInstanceOf(BalanceServiceError);
// The Tauri side must NOT have been hit — fail-fast on bad dates.
expect(invoke).not.toHaveBeenCalled();
});
it("rejects when the active profile cannot be resolved", async () => {
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "ghost",
profiles: [],
});
await expect(
computeAccountReturn(7, "2026-01-01", "2026-04-25")
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
expect(invoke).not.toHaveBeenCalled();
});
it("forwards a partial-period AccountReturn shape unchanged", async () => {
// When `is_partial = true` (no V_start), the Rust side returns a payload
// with explicit nulls. The TS shim must not coerce them away.
vi.mocked(loadProfiles).mockResolvedValueOnce({
active_profile_id: "max",
profiles: [
{
id: "max",
name: "Max",
color: "#000",
pin_hash: null,
db_filename: "max.db",
created_at: "0",
},
],
});
const partial = {
value_start: null,
value_end: 1500,
net_contributions: 200,
return_pct: null,
annualized_pct: null,
is_partial: true,
has_no_transfers_warning: false,
};
vi.mocked(invoke).mockResolvedValueOnce(partial);
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-25");
expect(out).toEqual(partial);
expect(out.is_partial).toBe(true);
expect(out.value_start).toBeNull();
});
});

View file

@ -1,332 +0,0 @@
/**
* Integration-flavoured tests for the v2 v1 categories migration.
*
* We intentionally do NOT spin up a real sqlite: the `tauri-plugin-sql` bridge
* is only available inside the Tauri WebView. Instead we use a carefully
* crafted in-memory fake DB that:
* - records every SQL statement (so we can assert ordering),
* - simulates `rowsAffected` for UPDATEs,
* - optionally fails on a matched SQL to simulate a mid-run error.
*
* The goal is to catch cross-service ordering bugs and the planbackupmigrate
* sequencing required by the spec (ADR: pre-migration backup is a hard gate).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/app", () => ({
getVersion: vi.fn(async () => "0.8.3-test"),
}));
// dataExportService helpers are used by both the backup service and the
// restore service. We mock the "DB-reading" ones and keep the parsers real.
vi.mock("../services/dataExportService", async () => {
const actual = await vi.importActual<
typeof import("../services/dataExportService")
>("../services/dataExportService");
return {
...actual,
getExportCategories: vi.fn(async () => []),
getExportSuppliers: vi.fn(async () => []),
getExportKeywords: vi.fn(async () => []),
getExportTransactions: vi.fn(async () => []),
importTransactionsWithCategories: vi.fn(async () => undefined),
};
});
import { getDb } from "../services/db";
import { invoke } from "@tauri-apps/api/core";
import { computeMigrationPlan } from "../services/categoryMappingService";
import { createPreMigrationBackup } from "../services/categoryBackupService";
import { applyMigration } from "../services/categoryMigrationService";
import { restoreFromBackup } from "../services/categoryRestoreService";
import { importTransactionsWithCategories } from "../services/dataExportService";
import { makeV2Profile, makeV2ProfileWithCustom } from "../__fixtures__/profiles";
import type { Profile } from "../services/profileService";
const mockInvoke = vi.mocked(invoke);
interface FakeDb {
calls: Array<{ sql: string; params?: unknown[] }>;
failAt: { sql: RegExp; error: string } | null;
preferences: Map<string, string>;
select: ReturnType<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
}
function makeFakeDb(): FakeDb {
const db: FakeDb = {
calls: [],
failAt: null,
preferences: new Map(),
select: vi.fn(),
execute: vi.fn(),
};
db.execute.mockImplementation(async (sql: string, params?: unknown[]) => {
db.calls.push({ sql, params });
if (db.failAt && db.failAt.sql.test(sql)) {
throw new Error(db.failAt.error);
}
// Capture preference upserts so readLastMigrationJournal / getPreference
// can observe the written values.
if (/INSERT INTO user_preferences/i.test(sql) && params) {
const [key, value] = params as [string, string];
db.preferences.set(key, value);
}
const upper = sql.trim().toUpperCase();
if (/^(BEGIN|COMMIT|ROLLBACK)/.test(upper)) return { rowsAffected: 0 };
return { rowsAffected: 1 };
});
db.select.mockImplementation(async (sql: string, params?: unknown[]) => {
if (/FROM user_preferences WHERE key/i.test(sql)) {
const [key] = (params as [string]) ?? [""];
const value = db.preferences.get(key);
return value ? [{ key, value, updated_at: "2026-04-20T00:00:00Z" }] : [];
}
return [];
});
return db;
}
let fake: FakeDb;
beforeEach(() => {
fake = makeFakeDb();
vi.mocked(getDb).mockResolvedValue(fake as never);
mockInvoke.mockReset();
vi.mocked(importTransactionsWithCategories).mockReset();
vi.mocked(importTransactionsWithCategories).mockResolvedValue(undefined);
});
const PROFILE: Profile = {
id: "p1",
name: "Max",
color: "#f59e0b",
pin_hash: null,
db_filename: "max.db",
created_at: "2026-01-01T00:00:00Z",
};
// ---------------------------------------------------------------------------
// Flow 1 — plan → backup → migrate on a realistic v2 profile
// ---------------------------------------------------------------------------
describe("integration: plan → backup → migrate (happy path)", () => {
it("produces a plan, verifies backup round-trip, then applies migration atomically", async () => {
// 1. Compute the plan from a realistic v2 profile fixture.
const plan = computeMigrationPlan(makeV2Profile());
expect(plan.rows.length).toBeGreaterThan(20);
// 2. Backup invoke stubs — round-trip the content so the checksum matches.
let captured: string | null = null;
mockInvoke.mockImplementation(async (cmd, args) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") {
captured = (args as { content: string }).content;
return undefined;
}
if (cmd === "read_import_file") return captured ?? "";
if (cmd === "get_file_size") return 2048;
throw new Error(`unexpected invoke: ${cmd}`);
});
const backup = await createPreMigrationBackup({ profile: PROFILE });
expect(backup.encrypted).toBe(false);
expect(backup.path).toMatch(/\.sref$/);
// 3. Apply the migration.
const outcome = await applyMigration(plan, backup);
expect(outcome.succeeded).toBe(true);
expect(outcome.backupPath).toBe(backup.path);
// Plan has no unresolved after default fallback; `customPreservedCount`
// is 0 because the fixture has no custom cats.
expect(outcome.customPreservedCount).toBe(0);
// 4. Verify ordering: BEGIN comes before any UPDATE and COMMIT is last.
const upperCalls = fake.calls.map((c) => c.sql.trim().toUpperCase());
const beginIdx = upperCalls.indexOf("BEGIN");
const commitIdx = upperCalls.indexOf("COMMIT");
expect(beginIdx).toBeGreaterThanOrEqual(0);
expect(commitIdx).toBeGreaterThan(beginIdx);
expect(commitIdx).toBe(upperCalls.length - 1);
});
});
// ---------------------------------------------------------------------------
// Flow 2 — custom-preserving migration on a profile with custom cats
// ---------------------------------------------------------------------------
describe("integration: migration preserves custom categories", () => {
it("creates the custom parent and re-parents the 3 custom cats", async () => {
const plan = computeMigrationPlan(makeV2ProfileWithCustom());
expect(plan.preserved).toHaveLength(3);
// Backup stub (plaintext)
let captured: string | null = null;
mockInvoke.mockImplementation(async (cmd, args) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") {
captured = (args as { content: string }).content;
return undefined;
}
if (cmd === "read_import_file") return captured ?? "";
if (cmd === "get_file_size") return 2048;
throw new Error(`unexpected invoke: ${cmd}`);
});
const backup = await createPreMigrationBackup({ profile: PROFILE });
const outcome = await applyMigration(plan, backup);
expect(outcome.succeeded).toBe(true);
expect(outcome.customPreservedCount).toBe(3);
// Custom parent created (id 2000)
const parentInsert = fake.calls.find(
(c) =>
/INSERT OR IGNORE INTO categories/i.test(c.sql) &&
(c.params?.[0] as number) === 2000,
);
expect(parentInsert).toBeDefined();
// All 3 customs re-parented
const reparent = fake.calls.filter((c) =>
/UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
);
expect(reparent.length).toBe(3);
});
});
// ---------------------------------------------------------------------------
// Flow 3 — backup failure aborts (no DB writes)
// ---------------------------------------------------------------------------
describe("integration: backup failure aborts before any DB write", () => {
it("migration never runs when createPreMigrationBackup throws", async () => {
mockInvoke.mockImplementation(async (cmd) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") throw new Error("disk full");
throw new Error(`unexpected invoke: ${cmd}`);
});
const plan = computeMigrationPlan(makeV2Profile());
// The caller (UI) is responsible for calling backup FIRST; this test
// simulates that policy.
let backupErr: Error | null = null;
try {
await createPreMigrationBackup({ profile: PROFILE });
} catch (e) {
backupErr = e as Error;
}
expect(backupErr).not.toBeNull();
// No migration call yet → DB calls must be empty.
expect(fake.calls).toHaveLength(0);
// Guard: applyMigration with a fake empty/invalid backup still refuses.
const outcome = await applyMigration(plan, {
path: "",
size: 0,
checksum: "",
verifiedAt: "",
encrypted: false,
});
expect(outcome.succeeded).toBe(false);
expect(fake.calls).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Flow 4 — SQL failure rolls back and backup remains usable
// ---------------------------------------------------------------------------
describe("integration: SQL failure rolls back, backup remains", () => {
it("ROLLBACK is emitted and the backup is unaffected", async () => {
let writtenBackup: string | null = null;
mockInvoke.mockImplementation(async (cmd, args) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") {
writtenBackup = (args as { content: string }).content;
return undefined;
}
if (cmd === "read_import_file") return writtenBackup ?? "";
if (cmd === "get_file_size") return 2048;
// The migration never touches Tauri fs commands other than via
// dataExportService helpers (already mocked). An unexpected cmd here
// would signal a leak.
throw new Error(`unexpected invoke: ${cmd}`);
});
const plan = computeMigrationPlan(makeV2Profile());
const backup = await createPreMigrationBackup({ profile: PROFILE });
expect(writtenBackup).not.toBeNull();
// Force a mid-run SQL failure.
fake.failAt = {
sql: /UPDATE budget_entries SET category_id/i,
error: "fk_violation",
};
const outcome = await applyMigration(plan, backup);
expect(outcome.succeeded).toBe(false);
expect(outcome.error).toMatch(/fk_violation/);
const upper = fake.calls.map((c) => c.sql.trim().toUpperCase());
expect(upper).toContain("ROLLBACK");
expect(upper).not.toContain("COMMIT");
// The backup is still a valid SREF string from the caller's perspective:
// nothing in the migration writes to disk. We verify the captured content
// still parses.
expect(() => JSON.parse(writtenBackup!)).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// Flow 5 — restore flow (rollback by SREF import)
// ---------------------------------------------------------------------------
describe("integration: restore from SREF after a migration", () => {
it("imports the backup, flips schema back to v2, stamps reverted_at", async () => {
// 1. Run a successful migration so the journal is in place.
let writtenBackup: string | null = null;
mockInvoke.mockImplementation(async (cmd, args) => {
if (cmd === "ensure_backup_dir") return "/tmp/sr-backups";
if (cmd === "write_export_file") {
writtenBackup = (args as { content: string }).content;
return undefined;
}
if (cmd === "read_import_file") return writtenBackup ?? "";
if (cmd === "get_file_size") return 2048;
if (cmd === "file_exists") return true;
if (cmd === "is_file_encrypted") return false;
throw new Error(`unexpected invoke: ${cmd}`);
});
const plan = computeMigrationPlan(makeV2Profile());
const backup = await createPreMigrationBackup({ profile: PROFILE });
const outcome = await applyMigration(plan, backup);
expect(outcome.succeeded).toBe(true);
// 2. Now call restoreFromBackup using the same path.
const restoreResult = await restoreFromBackup(backup.path, null);
expect(restoreResult.filePath).toBe(backup.path);
expect(restoreResult.revertedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// 3. importTransactionsWithCategories must have been called exactly once.
expect(vi.mocked(importTransactionsWithCategories)).toHaveBeenCalledTimes(1);
// 4. schema version reset + journal updated with reverted_at.
expect(fake.preferences.get("categories_schema_version")).toBe("v2");
const journal = JSON.parse(
fake.preferences.get("last_categories_migration") as string,
);
expect(journal.reverted_at).toBe(restoreResult.revertedAt);
});
});

View file

@ -1,382 +0,0 @@
/**
* Regression-style tests parameterised on both v2 (current seed) and v1
* (new IPC taxonomy) category ids. These exercise the same app services on
* both shapes and assert identical observable behaviour the spec's
* guarantee that the migration does not silently break:
*
* - categorizationService (keyword regex category_id matching)
* - budgetService.getBudgetVsActualData (parent/child aggregation)
* - dataExportService envelope round-trip (SREF format parity)
*
* We mock the DB per test and drive each service with a small, deterministic
* dataset scoped to either v2 or v1 category ids.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../services/db", () => ({
getDb: vi.fn(),
}));
import { getDb } from "../services/db";
import {
normalizeDescription,
buildKeywordRegex,
compileKeywords,
categorizeBatch,
} from "../services/categorizationService";
import { getBudgetVsActualData } from "../services/budgetService";
import {
parseImportedJson,
serializeToJson,
type ExportEnvelope,
} from "../services/dataExportService";
import type { Keyword, Category, BudgetEntry } from "../shared/types";
// ---------------------------------------------------------------------------
// Shared mock DB harness — each test resets and stubs specific SELECTs.
// ---------------------------------------------------------------------------
const mockSelect = vi.fn();
const mockExecute = vi.fn();
beforeEach(() => {
vi.mocked(getDb).mockResolvedValue({ select: mockSelect, execute: mockExecute } as never);
mockSelect.mockReset();
mockExecute.mockReset();
});
// ---------------------------------------------------------------------------
// normalizeDescription — identical on v2/v1 inputs (no schema dependency)
// ---------------------------------------------------------------------------
describe("regression: normalizeDescription is schema-agnostic", () => {
it.each([
["IGA #5555", "iga #5555"],
["SHELL\t#231", "shell #231"],
[" Hydro-Québec FACTURE ", "hydro-quebec facture"],
])("%s -> %s", (input, expected) => {
expect(normalizeDescription(input)).toBe(expected);
});
});
describe("regression: buildKeywordRegex boundaries", () => {
it("matches whole-word keywords on both v2- and v1-style descriptions", () => {
const re = buildKeywordRegex(normalizeDescription("STM"));
expect(re.test(normalizeDescription("STM CARTE OPUS"))).toBe(true);
expect(re.test(normalizeDescription("METROSTMONTREAL"))).toBe(false);
});
it("handles a keyword with a non-word leading char (common in bank exports)", () => {
const re = buildKeywordRegex(normalizeDescription("[INTERAC]"));
expect(re.test(normalizeDescription("PAIEMENT [INTERAC] XXX"))).toBe(true);
});
});
// ---------------------------------------------------------------------------
// categorizeBatch parameterised per schema — same description must map to the
// same (schema-specific) category id.
// ---------------------------------------------------------------------------
type Schema = "v2" | "v1";
function kwFixture(schema: Schema): Keyword[] {
const shellCat = schema === "v2" ? 40 : 1512;
const igaCat = schema === "v2" ? 22 : 1111;
const stmCat = schema === "v2" ? 28 : 1521;
return [
{
id: 1,
keyword: "SHELL",
category_id: shellCat,
priority: 100,
is_active: true,
} as Keyword,
{
id: 2,
keyword: "IGA",
category_id: igaCat,
priority: 100,
is_active: true,
} as Keyword,
{
id: 3,
keyword: "STM",
category_id: stmCat,
priority: 100,
is_active: true,
} as Keyword,
];
}
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: categorizeBatch [%s]",
(schema) => {
it("matches the expected category ids for each description", async () => {
mockSelect.mockResolvedValueOnce(kwFixture(schema));
const results = await categorizeBatch([
"SHELL #231 LAVAL",
"IGA EXTRA #5555",
"STM CARTE OPUS",
"UNRELATED",
]);
const [shell, iga, stm, none] = results;
expect(shell.category_id).toBe(schema === "v2" ? 40 : 1512);
expect(iga.category_id).toBe(schema === "v2" ? 22 : 1111);
expect(stm.category_id).toBe(schema === "v2" ? 28 : 1521);
expect(none.category_id).toBeNull();
});
},
);
describe("regression: compileKeywords parity across schemas", () => {
it("produces identical regex patterns regardless of the category_id", () => {
const v2Kw = kwFixture("v2");
const v1Kw = kwFixture("v1");
const v2Compiled = compileKeywords(v2Kw);
const v1Compiled = compileKeywords(v1Kw);
expect(v2Compiled.map((c) => c.regex.source)).toEqual(
v1Compiled.map((c) => c.regex.source),
);
});
});
// ---------------------------------------------------------------------------
// budgetService.getBudgetVsActualData parameterised per schema
// ---------------------------------------------------------------------------
function budgetFixture(schema: Schema): {
categories: Category[];
entries: BudgetEntry[];
} {
if (schema === "v2") {
const categories: Category[] = [
// Parent + 2 children to exercise aggregation
mkCat(22, "Épicerie", null, "expense"),
mkCat(220, "Épicerie courante", 22, "expense"),
mkCat(221, "Gros achats", 22, "expense"),
];
const entries: BudgetEntry[] = [
mkEntry(220, 2026, 3, 400),
mkEntry(221, 2026, 3, 200),
];
return { categories, entries };
}
// v1: Épicerie → 1110 (subcategory Alimentation), 1111 + 1112 leaves
const categories: Category[] = [
mkCat(1100, "Alimentation", null, "expense"),
mkCat(1110, "Épicerie", 1100, "expense"),
mkCat(1111, "Régulière", 1110, "expense"),
mkCat(1112, "Bio / spécialisée", 1110, "expense"),
];
const entries: BudgetEntry[] = [
mkEntry(1111, 2026, 3, 400),
mkEntry(1112, 2026, 3, 200),
];
return { categories, entries };
}
function mkCat(
id: number,
name: string,
parent_id: number | null,
type: "expense" | "income" | "transfer",
): Category {
return {
id,
name,
parent_id,
color: "#000",
type,
is_active: 1,
is_inputable: 1,
sort_order: id,
i18n_key: null,
} as unknown as Category;
}
function mkEntry(
category_id: number,
year: number,
month: number,
amount: number,
): BudgetEntry {
return {
id: category_id * 100 + month,
category_id,
year,
month,
amount,
notes: null,
} as unknown as BudgetEntry;
}
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: getBudgetVsActualData [%s]",
(schema) => {
it("aggregates leaf budgets under their parent and multiplies by -1 for expenses", async () => {
const { categories, entries } = budgetFixture(schema);
// Stub the 4 parallel selects in order:
// 1) getAllActiveCategories
// 2) getBudgetEntriesForYear
// 3) getActualsByCategoryRange (month)
// 4) getActualsByCategoryRange (ytd)
mockSelect.mockImplementation((sql: string) => {
if (/FROM categories/i.test(sql)) return Promise.resolve(categories);
if (/FROM budget_entries WHERE year/i.test(sql))
return Promise.resolve(entries);
if (/GROUP BY category_id/i.test(sql)) return Promise.resolve([]);
return Promise.resolve([]);
});
const rows = await getBudgetVsActualData(2026, 3);
// We expect at least the two leaves to appear. In both schemas the
// budget sum on the parent = -600 (expenses → sign -1).
const leafIds = schema === "v2" ? [220, 221] : [1111, 1112];
const parentId = schema === "v2" ? 22 : 1110;
// Collect budgets per row keyed by category_id.
const budgetById = new Map<number, number>();
for (const r of rows) {
budgetById.set(r.category_id, r.monthBudget);
}
// Both leaves get their stored budget × -1.
expect(budgetById.get(leafIds[0])).toBe(-400);
expect(budgetById.get(leafIds[1])).toBe(-200);
// The parent aggregates to -600.
expect(budgetById.get(parentId)).toBe(-600);
});
},
);
// ---------------------------------------------------------------------------
// dataExportService envelope round-trip parity — the SREF JSON format must
// remain identical before and after the migration.
// ---------------------------------------------------------------------------
function envelopeFixture(schema: Schema): ExportEnvelope {
const catId = schema === "v2" ? 22 : 1111;
return {
export_type: "transactions_with_categories",
app_version: "0.8.3-test",
exported_at: "2026-04-20T00:00:00Z",
data: {
categories: [
{
id: catId,
name: "Épicerie",
parent_id: null,
color: "#000",
type: "expense",
is_active: 1,
is_inputable: 1,
sort_order: 1,
i18n_key: null,
} as unknown as Category,
],
suppliers: [],
keywords: [],
transactions: [],
},
};
}
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: SREF envelope round-trip [%s]",
(schema) => {
it("serialize → parse returns an equivalent envelope", () => {
const original = envelopeFixture(schema);
const serialized = serializeToJson(
original.export_type,
original.data,
original.app_version,
);
const { envelope } = parseImportedJson(serialized);
expect(envelope.export_type).toBe(original.export_type);
expect(envelope.data.categories).toHaveLength(1);
expect(envelope.data.categories![0].id).toBe(
schema === "v2" ? 22 : 1111,
);
});
},
);
// ---------------------------------------------------------------------------
// Split transactions — the parent_transaction_id / is_split columns must be
// honoured identically after a migration. We exercise them through the
// export envelope, which is the canonical observable surface.
// ---------------------------------------------------------------------------
describe.each<[Schema]>([["v2"], ["v1"]])(
"regression: split transactions survive export [%s]",
(schema) => {
it("preserves is_split and parent_transaction_id in the envelope", () => {
const parentCat = schema === "v2" ? 28 : 1521;
const leg1Cat = schema === "v2" ? 28 : 1521;
const leg2Cat = schema === "v2" ? 22 : 1111;
const envelope: ExportEnvelope = {
export_type: "transactions_with_categories",
app_version: "0.8.3-test",
exported_at: "2026-04-20T00:00:00Z",
data: {
categories: [],
suppliers: [],
keywords: [],
transactions: [
{
id: 100,
date: "2026-03-10",
description: "STM Opus + snack",
amount: -50,
category_id: parentCat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 0,
is_split: 1,
parent_transaction_id: null,
},
{
id: 101,
date: "2026-03-10",
description: "STM leg",
amount: -30,
category_id: leg1Cat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 1,
is_split: 0,
parent_transaction_id: 100,
},
{
id: 102,
date: "2026-03-10",
description: "Snack leg",
amount: -20,
category_id: leg2Cat,
category_name: null,
original_description: null,
notes: null,
is_manually_categorized: 1,
is_split: 0,
parent_transaction_id: 100,
},
],
},
};
const json = serializeToJson(envelope.export_type, envelope.data, envelope.app_version);
const parsed = parseImportedJson(json).envelope;
const txs = parsed.data.transactions!;
expect(txs).toHaveLength(3);
expect(txs[0].is_split).toBe(1);
expect(txs[1].parent_transaction_id).toBe(100);
expect(txs[2].parent_transaction_id).toBe(100);
// Category ids are schema-specific but never null.
expect(typeof txs[0].category_id).toBe("number");
expect(typeof txs[1].category_id).toBe("number");
expect(typeof txs[2].category_id).toBe("number");
});
},
);

View file

@ -1,96 +0,0 @@
/**
* Non-regression check for the inlined transfer icon in TransactionTable
* (Issue #142 #144 follow-up).
*
* The spec promises that without any linked transfers the transactions
* table renders exactly as it did before #142 inlined the `<Link2>` icon.
* The icon is gated by a single conditional in the JSX:
*
* {linkedTransfersByTxId?.has(row.id) && (...)}
*
* If `linkedTransfersByTxId` is undefined OR the map has no entry for `row.id`,
* the icon block is short-circuited and the row layout is unchanged.
*
* Why this approach: this project does not bundle `@testing-library/react`
* (see `package.json`), and adding it just for one non-regression check is
* out of scope here. Existing component tests (`CategoryCombobox.test.ts`,
* `ViewModeToggle.test.ts`, `TrendsChartTypeToggle.test.ts`) likewise extract
* pure helpers and assert on them rather than mounting JSX. So we go one
* level lower: assert the source-level shape of `TransactionTable.tsx`.
*
* The assertions are structural on the source file:
* 1. The conditional block exists and is gated by `linkedTransfersByTxId?.has`.
* 2. The block consumes `Link2` from `lucide-react`.
* 3. The prop is OPTIONAL on the component's interface passing nothing
* must remain a valid call (zero-impact path).
* 4. The tooltip text comes from the i18n key family `transactions.transferIcon.*`
* (so a future rename catches our attention here).
* 5. The icon uses `aria-label` for accessibility (Issue #142 acceptance criterion).
* 6. The condition uses optional-chaining (so passing `undefined` short-circuits
* cleanly without throwing).
*
* If the icon is ever pulled out into its own component, the tests should be
* rewritten to import and exercise that component directly instead. Until
* then, this is a tight static contract that catches accidental regressions.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
const TABLE_SRC = readFileSync(
resolve(
import.meta.dirname,
"..",
"components",
"transactions",
"TransactionTable.tsx"
),
"utf-8"
);
describe("non-regression: TransactionTable transfer icon (#142)", () => {
it("guards the icon block behind `linkedTransfersByTxId?.has(row.id)`", () => {
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?\.has\(row\.id\)/);
});
it("uses optional chaining so the icon is opt-in (undefined short-circuits)", () => {
// Optional chaining is the safe-render guarantee: if the parent never
// passes the prop, `?.has` returns undefined → the && short-circuits to
// false, the JSX block is skipped, and the row layout is unchanged.
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?\./);
});
it("imports `Link2` from lucide-react for the icon glyph", () => {
expect(TABLE_SRC).toMatch(/from\s+["']lucide-react["']/);
expect(TABLE_SRC).toMatch(/\bLink2\b/);
});
it("declares `linkedTransfersByTxId` as an OPTIONAL prop", () => {
// The "?" after the name on the interface is the contract that omitting
// the prop is allowed. Without it the entire transactions page would
// need to thread the lookup through, breaking pre-#142 callers.
expect(TABLE_SRC).toMatch(/linkedTransfersByTxId\?:/);
});
it("uses `transactions.transferIcon.*` i18n keys for the tooltip and aria-label", () => {
// Both the tooltip body and the aria label go through i18n — neither
// is a hardcoded English/French string.
expect(TABLE_SRC).toMatch(/transactions\.transferIcon\.tooltip/);
expect(TABLE_SRC).toMatch(/transactions\.transferIcon\.ariaLabel/);
});
it("attaches an `aria-label` for screen readers (a11y)", () => {
expect(TABLE_SRC).toMatch(/aria-label=/);
});
it("keeps the description column structure shared with non-linked rows", () => {
// The icon lives inside the description cell, in a flex container
// alongside the original `<span class="truncate" title=...>` that
// existed pre-#142. If someone moved the description span into a
// wrapper that the icon required, this assertion would fail.
expect(TABLE_SRC).toMatch(
/<span\s+className="truncate"\s+title=\{row\.description\}/
);
});
});

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,490 +0,0 @@
// AccountForm — account or category variant.
//
// Mode = 'account' (Issue #138 / Bilan #1a): create / edit a balance_account
// row bound to an existing category.
// Mode = 'category' (Issue #140 / Bilan #2): create a balance_category row
// with a kind selector (`simple | priced`).
//
// Both variants live in the same component because they share the surrounding
// wiring (form layout, save / cancel buttons, validation feedback) and only
// the input fields differ.
import { FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
BalanceAccount,
BalanceAssetType,
BalanceCategory,
BalanceCategoryKind,
} from "../../shared/types";
import type {
CreateBalanceAccountInput,
CreateBalanceCategoryInput,
UpdateBalanceAccountInput,
} from "../../services/balance.service";
// -----------------------------------------------------------------------------
// Account variant types
// -----------------------------------------------------------------------------
export interface AccountFormValues {
balance_category_id: number;
name: string;
symbol: string;
notes: string;
}
interface AccountVariantProps {
mode: "account";
/** When provided, the form is in edit mode; otherwise creation. */
initialAccount?: BalanceAccount | null;
categories: BalanceCategory[];
isSaving: boolean;
onSubmit: (
values: CreateBalanceAccountInput | UpdateBalanceAccountInput
) => Promise<void> | void;
onCancel: () => void;
}
// -----------------------------------------------------------------------------
// Category variant types (Issue #140)
// -----------------------------------------------------------------------------
export interface CategoryFormValues {
key: string;
i18n_key: string;
kind: BalanceCategoryKind;
/** Required when kind === 'priced' (Issue #169). NULL otherwise. */
asset_type: BalanceAssetType | null;
}
interface CategoryVariantProps {
mode: "category";
isSaving: boolean;
onSubmit: (values: CreateBalanceCategoryInput) => Promise<void> | void;
onCancel: () => void;
}
type Props = AccountVariantProps | CategoryVariantProps;
function defaultAccountValues(
initial: BalanceAccount | null | undefined,
categories: BalanceCategory[]
): AccountFormValues {
if (initial) {
return {
balance_category_id: initial.balance_category_id,
name: initial.name,
symbol: initial.symbol ?? "",
notes: initial.notes ?? "",
};
}
// First active category as a sane default
const first = categories.find((c) => c.is_active) ?? categories[0];
return {
balance_category_id: first?.id ?? 0,
name: "",
symbol: "",
notes: "",
};
}
export default function AccountForm(props: Props) {
if (props.mode === "category") {
return <CategoryVariant {...props} />;
}
return <AccountVariant {...props} />;
}
// -----------------------------------------------------------------------------
// Account variant
// -----------------------------------------------------------------------------
function AccountVariant({
initialAccount,
categories,
isSaving,
onSubmit,
onCancel,
}: AccountVariantProps) {
const { t } = useTranslation();
const [values, setValues] = useState<AccountFormValues>(() =>
defaultAccountValues(initialAccount, categories)
);
const [touched, setTouched] = useState(false);
// Reset form when target account changes (edit different row).
useEffect(() => {
setValues(defaultAccountValues(initialAccount, categories));
setTouched(false);
}, [initialAccount, categories]);
const isEditing = !!initialAccount;
const selectedCategory = categories.find(
(c) => c.id === values.balance_category_id
);
const isPriced = selectedCategory?.kind === "priced";
const trimmedName = values.name.trim();
const trimmedSymbol = values.symbol.trim();
const nameInvalid = touched && trimmedName.length === 0;
// Priced categories require a symbol — surfaced as a validation error.
const symbolMissingForPriced = touched && isPriced && trimmedSymbol.length === 0;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setTouched(true);
if (!trimmedName) return;
if (isPriced && !trimmedSymbol) return;
const payload: CreateBalanceAccountInput = {
balance_category_id: values.balance_category_id,
name: trimmedName,
symbol: trimmedSymbol || null,
notes: values.notes.trim() || null,
};
if (isEditing) {
const updatePayload: UpdateBalanceAccountInput = {
balance_category_id: payload.balance_category_id,
name: payload.name,
symbol: payload.symbol,
notes: payload.notes,
};
await onSubmit(updatePayload);
} else {
await onSubmit(payload);
}
};
const renderCategoryLabel = (cat: BalanceCategory) =>
t(cat.i18n_key, { defaultValue: cat.key });
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-category">
{t("balance.account.form.category")}
</label>
<select
id="account-category"
value={values.balance_category_id}
onChange={(e) =>
setValues({
...values,
balance_category_id: Number(e.target.value),
})
}
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)]"
>
{categories.length === 0 ? (
<option value={0}>{t("balance.account.form.noCategory")}</option>
) : (
categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{renderCategoryLabel(cat)}
</option>
))
)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-name">
{t("balance.account.form.name")}
</label>
<input
id="account-name"
type="text"
value={values.name}
onChange={(e) => setValues({ ...values, name: e.target.value })}
onBlur={() => setTouched(true)}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
nameInvalid
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
autoFocus
autoComplete="off"
/>
{nameInvalid && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.nameRequired")}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-symbol">
{t("balance.account.form.symbol")}
{isPriced && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
({t("balance.account.form.symbolPricedHint")})
</span>
)}
</label>
<input
id="account-symbol"
type="text"
value={values.symbol}
onChange={(e) => setValues({ ...values, symbol: e.target.value })}
onBlur={() => setTouched(true)}
placeholder={
isPriced
? t("balance.account.form.symbolPlaceholderPriced")
: t("balance.account.form.symbolPlaceholderSimple")
}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
symbolMissingForPriced
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
autoComplete="off"
/>
{symbolMissingForPriced && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.symbolRequiredForPriced")}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-notes">
{t("balance.account.form.notes")}
</label>
<textarea
id="account-notes"
value={values.notes}
onChange={(e) => setValues({ ...values, notes: e.target.value })}
rows={2}
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)] resize-none"
/>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{t("balance.account.form.currencyMvpNotice")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={
isSaving ||
!trimmedName ||
categories.length === 0 ||
(isPriced && !trimmedSymbol)
}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{isEditing
? t("balance.account.form.save")
: t("balance.account.form.create")}
</button>
</div>
</form>
);
}
// -----------------------------------------------------------------------------
// Category variant (Issue #140)
// -----------------------------------------------------------------------------
function CategoryVariant({
isSaving,
onSubmit,
onCancel,
}: CategoryVariantProps) {
const { t } = useTranslation();
const [values, setValues] = useState<CategoryFormValues>({
key: "",
i18n_key: "",
kind: "simple",
asset_type: null,
});
const [touched, setTouched] = useState(false);
const trimmedKey = values.key.trim();
const trimmedLabel = values.i18n_key.trim();
const keyInvalid = touched && trimmedKey.length === 0;
const assetTypeMissing =
touched && values.kind === "priced" && !values.asset_type;
const submitDisabled =
isSaving ||
!trimmedKey ||
(values.kind === "priced" && !values.asset_type);
const handleKindChange = (next: BalanceCategoryKind) => {
// Switching priced → simple resets asset_type so the NULL invariant for
// simple kind is preserved (the service would coerce it anyway).
setValues((prev) => ({
...prev,
kind: next,
asset_type: next === "priced" ? prev.asset_type : null,
}));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setTouched(true);
if (!trimmedKey) return;
if (values.kind === "priced" && !values.asset_type) return;
// Fall back to the key if no human label was supplied.
const i18nKey = trimmedLabel || trimmedKey;
await onSubmit({
key: trimmedKey,
i18n_key: i18nKey,
kind: values.kind,
sort_order: 100, // user-created categories sort after seeded ones
asset_type: values.kind === "priced" ? values.asset_type : null,
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-key"
>
{t("balance.category.form.key")}
</label>
<input
id="category-key"
type="text"
value={values.key}
onChange={(e) =>
setValues({ ...values, key: e.target.value })
}
onBlur={() => setTouched(true)}
placeholder={t("balance.category.form.keyPlaceholder")}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
keyInvalid
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
autoComplete="off"
autoFocus
/>
{keyInvalid && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.nameRequired")}
</p>
)}
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-label"
>
{t("balance.category.form.label")}
</label>
<input
id="category-label"
type="text"
value={values.i18n_key}
onChange={(e) =>
setValues({ ...values, i18n_key: e.target.value })
}
placeholder={t("balance.category.form.labelPlaceholder")}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
autoComplete="off"
/>
</div>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-kind"
>
{t("balance.category.form.kindLabel")}
</label>
<select
id="category-kind"
value={values.kind}
onChange={(e) =>
handleKindChange(e.target.value as BalanceCategoryKind)
}
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)]"
>
<option value="simple">{t("balance.category.kind.simple")}</option>
<option value="priced">{t("balance.category.kind.priced")}</option>
</select>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{values.kind === "priced"
? t("balance.category.form.kindHintPriced")
: t("balance.category.form.kindHintSimple")}
</p>
</div>
{values.kind === "priced" && (
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-asset-type"
>
{t("balance.category.assetType.label")}
</label>
<select
id="category-asset-type"
value={values.asset_type ?? ""}
onChange={(e) =>
setValues({
...values,
asset_type:
e.target.value === ""
? null
: (e.target.value as BalanceAssetType),
})
}
onBlur={() => setTouched(true)}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
assetTypeMissing
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
>
<option value="">{t("balance.category.assetType.required")}</option>
<option value="stock">
{t("balance.category.assetType.stock")}
</option>
<option value="crypto">
{t("balance.category.assetType.crypto")}
</option>
</select>
{assetTypeMissing && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.category.assetType.required")}
</p>
)}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={submitDisabled}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("balance.category.form.create")}
</button>
</div>
</form>
);
}

View file

@ -1,388 +0,0 @@
// BalanceAccountsTable — one-row-per-active-account table on /balance.
//
// Issue #141 (Bilan #3) introduced the table with name/category/latest-value/Δ%
// + actions menu. Issue #142 (Bilan #4) adds 4 return columns, computed via
// the Modified Dietz `compute_account_return` Tauri command:
//
// - 3M (last 90 days)
// - 1A (last 365 days)
// - Depuis création (from earliest snapshot date to today)
// - Non-ajusté (simple `(V_end - V_start) / V_start`, no contribution
// weighting — shown side-by-side as a sanity check / explanation)
//
// Returns load lazily on mount via `Promise.all` over (account × horizon),
// keyed by `account_id`. Each cell renders "—" while loading and shows the
// `is_partial` / `has_no_transfers_warning` badges via tooltip when set.
//
// Issue #142 also adds a "Lier transferts" item in the per-row actions menu
// that opens `LinkTransfersModal` (the modal handles its own state; this
// component just bubbles up the request via `onLinkTransfers`).
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Archive, MoreVertical, Link as LinkIcon, AlertTriangle } from "lucide-react";
import type {
AccountLatestSnapshot,
AccountPeriodAnchor,
} from "../../services/balance.service";
import { computeAccountReturn } from "../../services/balance.service";
import type { AccountReturn } from "../../shared/types";
const cadFormatter = (locale: string) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
});
/** Horizon definition: how many days back from today to start the period. */
type HorizonKey = "3M" | "1A" | "since";
interface HorizonRange {
key: HorizonKey;
/** ISO date for `period_start`. */
from: string;
/** ISO date for `period_end` (always today, computed in the local civil day). */
to: string;
}
function localISO(d: Date): string {
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
function isoDaysAgo(days: number, today: Date): string {
const d = new Date(today);
d.setDate(d.getDate() - days);
return localISO(d);
}
interface BalanceAccountsTableProps {
accounts: AccountLatestSnapshot[];
periodAnchor: AccountPeriodAnchor[];
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
/**
* Earliest snapshot date across the whole profile, used to anchor the
* "depuis création" horizon. Falls back to "1A" range if not provided
* (avoids triggering computation against the unix epoch).
*/
sinceCreationDate?: string | null;
}
/**
* Per-account, per-horizon return shape used by the local cache state.
* Indexed `[accountId][horizonKey]`.
*/
type ReturnsByAccount = Record<number, Partial<Record<HorizonKey, AccountReturn>>>;
export default function BalanceAccountsTable({
accounts,
periodAnchor,
onArchiveAccount,
onLinkTransfers,
sinceCreationDate,
}: BalanceAccountsTableProps) {
const { t, i18n } = useTranslation();
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
/** account_id → period anchor (start-of-period value). */
const anchorMap = useMemo(() => {
const m = new Map<number, AccountPeriodAnchor>();
for (const a of periodAnchor) m.set(a.account_id, a);
return m;
}, [periodAnchor]);
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
// Returns cache. Cleared whenever the account list changes (new accounts,
// archive, etc.). Loaded lazily after mount.
const [returns, setReturns] = useState<ReturnsByAccount>({});
const [returnsLoading, setReturnsLoading] = useState(false);
// Horizon definitions — recomputed once per mount via today's local civil
// day. We don't memoize against `accounts` because the dates don't depend
// on the row list.
const horizons = useMemo<HorizonRange[]>(() => {
const today = new Date();
const todayISO = localISO(today);
const sinceFrom = sinceCreationDate ?? isoDaysAgo(365, today);
return [
{ key: "3M", from: isoDaysAgo(90, today), to: todayISO },
{ key: "1A", from: isoDaysAgo(365, today), to: todayISO },
{ key: "since", from: sinceFrom, to: todayISO },
];
}, [sinceCreationDate]);
useEffect(() => {
let cancelled = false;
async function loadReturns() {
if (accounts.length === 0) {
setReturns({});
return;
}
setReturnsLoading(true);
const next: ReturnsByAccount = {};
// Run sequentially per account to avoid SQLite contention; per-horizon
// we can parallelize because they hit the same table set.
await Promise.all(
accounts.map(async (acc) => {
next[acc.account_id] = {};
const tasks = horizons.map(async (h) => {
try {
const r = await computeAccountReturn(
acc.account_id,
h.from,
h.to
);
next[acc.account_id]![h.key] = r;
} catch {
// Per-cell failure: leave the slot undefined → renders "—".
}
});
await Promise.all(tasks);
})
);
if (!cancelled) {
setReturns(next);
setReturnsLoading(false);
}
}
void loadReturns();
return () => {
cancelled = true;
};
}, [accounts, horizons]);
if (accounts.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
{t("balance.overview.noAccounts")}
</div>
);
}
/** Format a return percentage with sign + colour-aware classname. */
function renderReturnCell(r: AccountReturn | undefined) {
if (!r) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.return_pct === null) {
return (
<span
className="text-[var(--muted-foreground)] inline-flex items-center gap-1"
title={t("balance.returns.partialTooltip")}
>
<AlertTriangle size={12} />
</span>
);
}
const pct = r.return_pct * 100;
return (
<span className="inline-flex items-center gap-1">
<span
className={
pct >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{pct >= 0 ? "+" : ""}
{pct.toFixed(2)}%
</span>
{r.has_no_transfers_warning && (
<AlertTriangle
size={12}
className="text-amber-500"
aria-label={t("balance.returns.noTransfersWarning")}
/>
)}
</span>
);
}
/**
* Unadjusted (simple) return = `(value_end - value_start) / value_start`
* same numbers Modified Dietz already returns when no flows exist, but
* this column shows the simple version for ALL accounts as a side-by-side
* sanity check. Computed from the same `AccountReturn` payload (uses the
* `value_start` / `value_end` fields filled by the Rust side).
*/
function renderUnadjustedCell(r: AccountReturn | undefined) {
if (!r || r.value_start === null || r.value_end === null) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
if (r.value_start === 0) {
return <span className="text-[var(--muted-foreground)]"></span>;
}
const simple = ((r.value_end - r.value_start) / r.value_start) * 100;
return (
<span
className={
simple >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{simple >= 0 ? "+" : ""}
{simple.toFixed(2)}%
</span>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30">
<tr>
<th className="text-left px-4 py-3 font-medium">
{t("balance.account.fields.name")}
</th>
<th className="text-left px-4 py-3 font-medium">
{t("balance.account.fields.category")}
</th>
<th className="text-right px-4 py-3 font-medium">
{t("balance.overview.latestValue")}
</th>
<th className="text-right px-4 py-3 font-medium">
{t("balance.overview.periodDelta")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return3mTooltip")}>
{t("balance.accountsTable.return3m")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.return1yTooltip")}>
{t("balance.accountsTable.return1y")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.sinceCreationTooltip")}>
{t("balance.accountsTable.sinceCreation")}
</th>
<th className="text-right px-4 py-3 font-medium" title={t("balance.accountsTable.unadjustedTooltip")}>
{t("balance.accountsTable.unadjusted")}
</th>
<th className="text-right px-4 py-3 font-medium w-12">
{t("balance.account.fields.actions")}
</th>
</tr>
</thead>
<tbody>
{accounts.map((acc) => {
const anchor = anchorMap.get(acc.account_id);
const deltaPct =
acc.latest_value !== null && anchor && anchor.anchor_value !== 0
? ((acc.latest_value - anchor.anchor_value) /
Math.abs(anchor.anchor_value)) *
100
: null;
const accReturns = returns[acc.account_id] ?? {};
return (
<tr
key={acc.account_id}
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
>
<td className="px-4 py-3 font-medium">
{acc.account_name}
{acc.symbol ? (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
({acc.symbol})
</span>
) : null}
</td>
<td className="px-4 py-3 text-[var(--muted-foreground)]">
{t(acc.category_i18n_key, { defaultValue: acc.category_key })}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{deltaPct !== null ? (
<span
className={
deltaPct >= 0
? "text-[var(--positive)]"
: "text-[var(--negative)]"
}
>
{deltaPct >= 0 ? "+" : ""}
{deltaPct.toFixed(2)}%
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["3M"]
? "…"
: renderReturnCell(accReturns["3M"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderReturnCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["since"]
? "…"
: renderReturnCell(accReturns["since"])}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{returnsLoading && !accReturns["1A"]
? "…"
: renderUnadjustedCell(accReturns["1A"])}
</td>
<td className="px-4 py-3 text-right relative">
<button
type="button"
onClick={() =>
setOpenMenuFor(
openMenuFor === acc.account_id ? null : acc.account_id
)
}
className="p-1 rounded hover:bg-[var(--muted)]/40"
aria-label={t("balance.account.fields.actions")}
>
<MoreVertical size={16} />
</button>
{openMenuFor === acc.account_id && (
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
<button
type="button"
disabled
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
title={t("balance.overview.detailComingSoon")}
>
{t("balance.overview.detailAction")}
</button>
{onLinkTransfers && (
<button
type="button"
onClick={() => {
setOpenMenuFor(null);
onLinkTransfers(acc);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
>
<LinkIcon size={14} />
{t("balance.transfers.linkAction")}
</button>
)}
<button
type="button"
onClick={() => {
setOpenMenuFor(null);
onArchiveAccount?.(acc);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
>
<Archive size={14} />
{t("balance.account.actions.archive")}
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -1,287 +0,0 @@
// BalanceEvolutionChart — line / stacked-area chart of net worth over time.
//
// Issue #141 (Bilan #3). Reuses the established Recharts patterns from the
// reports/* charts (see decisions-log #141 — native SVG was reconsidered;
// Recharts is the single chart pattern in this codebase). Two modes:
// - 'line' : a single LineChart of `SUM(value)` per snapshot date.
// - 'stacked' : an AreaChart with one Area per category (stackId='all').
//
// Tooltip shows per-category breakdown in stacked mode and just the total in
// line mode.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
ReferenceLine,
} from "recharts";
import type {
SnapshotTotalPoint,
SnapshotCategoryBreakdownPoint,
} from "../../services/balance.service";
import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
import type { BalanceAccountTransferWithTransaction } from "../../shared/types";
// Stable palette for the stacked-by-category areas. Indexed deterministically
// by category sort order so the colour assignment stays consistent across
// renders and period changes. Reused from the reports CategoryBarChart palette.
const CATEGORY_PALETTE = [
"#3b82f6", // blue
"#10b981", // emerald
"#f59e0b", // amber
"#8b5cf6", // violet
"#ef4444", // red
"#06b6d4", // cyan
"#ec4899", // pink
"#84cc16", // lime
"#f97316", // orange
"#6366f1", // indigo
];
export interface BalanceEvolutionChartProps {
mode: BalanceChartMode;
totals: SnapshotTotalPoint[];
byCategory: SnapshotCategoryBreakdownPoint[];
/** Map category_key → translated label so the legend reads naturally. */
categoryLabels?: Record<string, string>;
/**
* Issue #142 every linked transfer in the visible range. Rendered as
* vertical `<ReferenceLine>` markers on the X axis: green for `in`
* (capital added), red for `out` (capital removed). The label tooltip
* shows the underlying transaction date + description.
*/
transferMarkers?: BalanceAccountTransferWithTransaction[];
}
export default function BalanceEvolutionChart({
mode,
totals,
byCategory,
categoryLabels = {},
transferMarkers = [],
}: BalanceEvolutionChartProps) {
const { t, i18n } = useTranslation();
const cadFormatter = useMemo(
() =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}),
[i18n.language]
);
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(dateLocale, {
year: "numeric",
month: "short",
day: "numeric",
});
// --- Line-mode dataset ---
const lineData = useMemo(
() =>
totals.map((p) => ({
snapshot_date: p.snapshot_date,
total: p.total,
})),
[totals]
);
// --- Stacked-area dataset ---
// We transpose the per-snapshot bucket into one row per snapshot_date with
// one column per category_key. Categories absent at a snapshot date are
// emitted as 0 so Recharts renders a continuous stack.
const { stackedData, categoryKeys } = useMemo(() => {
const keys = new Set<string>();
for (const point of byCategory) {
for (const k of Object.keys(point.byCategory)) keys.add(k);
}
const orderedKeys = Array.from(keys).sort();
const data = byCategory.map((point) => {
const row: Record<string, string | number> = {
snapshot_date: point.snapshot_date,
};
for (const k of orderedKeys) {
row[k] = point.byCategory[k] ?? 0;
}
return row;
});
return { stackedData: data, categoryKeys: orderedKeys };
}, [byCategory]);
const isEmpty =
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
// Filter transfer markers to dates that are actually rendered on the X
// axis (categorical scale ignores unknown ticks). We don't aggregate or
// dedupe — the user can have several transfers on the same day across
// accounts; ReferenceLine tolerates duplicates fine.
const xAxisDates = useMemo(() => {
const dates = new Set<string>();
if (mode === "line") {
for (const p of lineData) dates.add(p.snapshot_date);
} else {
for (const p of stackedData) dates.add(p.snapshot_date as string);
}
return dates;
}, [mode, lineData, stackedData]);
const renderableMarkers = useMemo(
() =>
transferMarkers
.filter((m) => xAxisDates.has(m.transaction_date))
// Sort so 'in' (green) draws before 'out' (red) for stable z-order.
.sort((a, b) =>
a.direction === b.direction ? 0 : a.direction === "in" ? -1 : 1
),
[transferMarkers, xAxisDates]
);
if (isEmpty) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
<p className="text-center text-[var(--muted-foreground)] italic py-12">
{t("balance.chart.empty")}
</p>
</div>
);
}
const tooltipContentStyle = {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
color: "var(--foreground)",
};
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={360}>
{mode === "line" ? (
<LineChart
data={lineData}
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="snapshot_date"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(s: string) => formatDate(s)}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => cadFormatter.format(v)}
width={88}
/>
<Tooltip
formatter={(value: number | undefined) =>
cadFormatter.format(value ?? 0)
}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={tooltipContentStyle}
/>
<Line
type="monotone"
dataKey="total"
name={t("balance.chart.totalSeriesLabel")}
stroke="var(--primary)"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
label={{
value: t(
m.direction === "in"
? "balance.evolution.transferIn"
: "balance.evolution.transferOut"
),
position: "insideTopRight",
fontSize: 9,
fill: m.direction === "in" ? "var(--positive)" : "var(--negative)",
}}
/>
))}
</LineChart>
) : (
<AreaChart
data={stackedData}
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="snapshot_date"
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(s: string) => formatDate(s)}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) => cadFormatter.format(v)}
width={88}
/>
<Tooltip
formatter={(value: number | undefined, name) => [
cadFormatter.format(value ?? 0),
categoryLabels[String(name)] ?? String(name),
]}
labelFormatter={(label) => formatDate(String(label))}
contentStyle={tooltipContentStyle}
/>
<Legend
formatter={(value) => categoryLabels[String(value)] ?? String(value)}
/>
{categoryKeys.map((key, idx) => (
<Area
key={key}
type="monotone"
dataKey={key}
stackId="all"
stroke={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
fill={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
fillOpacity={0.5}
name={key}
/>
))}
{renderableMarkers.map((m) => (
<ReferenceLine
key={`tm-${m.id}`}
x={m.transaction_date}
stroke={
m.direction === "in" ? "var(--positive)" : "var(--negative)"
}
strokeDasharray="3 3"
strokeWidth={1}
ifOverflow="extendDomain"
/>
))}
</AreaChart>
)}
</ResponsiveContainer>
</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

@ -1,128 +0,0 @@
// BalanceOverviewCard — top summary tile of /balance.
//
// Issue #141 (Bilan #3). Displays:
// - The latest aggregate snapshot total (sum across all accounts on the
// most recent snapshot date).
// - Δ% versus the previous chronological snapshot (null when only one
// snapshot exists; rendered as "—").
// - A staleness warning when the latest snapshot is older than 60 days.
// - "+ Nouveau snapshot" CTA → `/balance/snapshot`.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
import { Link } from "react-router-dom";
import type { SnapshotTotalPoint } from "../../services/balance.service";
const STALENESS_DAYS = 60;
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
}).format(value);
interface BalanceOverviewCardProps {
/** The full evolution series for the active period (latest at the end). */
totals: SnapshotTotalPoint[];
}
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
const { t, i18n } = useTranslation();
const summary = useMemo(() => {
if (totals.length === 0) {
return null;
}
const last = totals[totals.length - 1];
const prev = totals.length >= 2 ? totals[totals.length - 2] : null;
const deltaPct =
prev && prev.total !== 0
? ((last.total - prev.total) / Math.abs(prev.total)) * 100
: null;
const ageMs = Date.now() - new Date(last.snapshot_date).getTime();
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
return {
latest: last,
deltaPct,
isStale: ageDays > STALENESS_DAYS,
ageDays,
};
}, [totals]);
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(dateLocale, {
year: "numeric",
month: "long",
day: "numeric",
});
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.overview.latestTotal")}
</p>
{summary ? (
<>
<p className="text-3xl font-bold mt-1">
{cadFormatter(summary.latest.total)}
</p>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
{t("balance.overview.asOf", {
date: formatDate(summary.latest.snapshot_date),
})}
</p>
</>
) : (
<p className="text-sm text-[var(--muted-foreground)] mt-2">
{t("balance.overview.noSnapshots")}
</p>
)}
</div>
<div className="flex flex-col items-stretch sm:items-end gap-2">
{summary && summary.deltaPct !== null && (
<div
className={`inline-flex items-center gap-1 text-sm font-medium ${
summary.deltaPct >= 0
? "text-[var(--positive)]"
: "text-[var(--negative)]"
}`}
>
{summary.deltaPct >= 0 ? (
<TrendingUp size={16} />
) : (
<TrendingDown size={16} />
)}
{summary.deltaPct >= 0 ? "+" : ""}
{summary.deltaPct.toFixed(2)}%
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
{t("balance.overview.vsPrevious")}
</span>
</div>
)}
<Link
to="/balance/snapshot"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
<Plus size={16} />
{t("balance.overview.newSnapshot")}
</Link>
</div>
</div>
{summary?.isStale && (
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/30 text-sm">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>
{t("balance.overview.staleWarning", { days: summary.ageDays })}
</span>
</div>
)}
</div>
);
}

View file

@ -1,418 +0,0 @@
// LinkTransfersModal — multi-select transactions and link them to a balance
// account in one shot. Issue #142 / Bilan #4.
//
// Filters available:
// - Period (from / to ISO dates) — default: last 90 days.
// - Category dropdown.
// - Free-text search on description.
//
// Each row shows: date, description, amount, suggested direction
// (auto-proposed via `suggestTransferDirection` from the signed amount,
// can be flipped per row), and a checkbox.
//
// On submit, calls `linkTransfer` for every selected row in sequence and
// reports any failures (most likely `transfer_already_linked` if the user
// double-clicked or another tab linked them already).
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Loader2, AlertCircle } from "lucide-react";
import { getTransactionPage } from "../../services/transactionService";
import {
linkTransfer,
suggestTransferDirection,
BalanceServiceError,
} from "../../services/balance.service";
import type {
Category,
TransactionRow,
BalanceTransferDirection,
} from "../../shared/types";
const DEFAULT_PAGE_SIZE = 100;
function isoDaysAgo(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return localISO(d);
}
function localISO(d: Date): string {
const yy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}-${mm}-${dd}`;
}
export interface LinkTransfersModalProps {
/** Account that the selected transfers will be attached to. */
accountId: number;
accountName: string;
/** Full category list for the filter dropdown. */
categories: Category[];
/** Optional pre-fill date bounds (defaults to last 90 days). */
initialFrom?: string;
initialTo?: string;
onClose: () => void;
/** Fired after at least one transfer was linked (parent typically reloads). */
onLinked?: (linkedCount: number) => void;
}
export default function LinkTransfersModal({
accountId,
accountName,
categories,
initialFrom,
initialTo,
onClose,
onLinked,
}: LinkTransfersModalProps) {
const { t, i18n } = useTranslation();
const [from, setFrom] = useState(initialFrom ?? isoDaysAgo(90));
const [to, setTo] = useState(initialTo ?? localISO(new Date()));
const [categoryId, setCategoryId] = useState<number | null>(null);
const [search, setSearch] = useState("");
const [rows, setRows] = useState<TransactionRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Selection state: id → direction. Presence in the map = selected.
const [selection, setSelection] = useState<
Map<number, BalanceTransferDirection>
>(new Map());
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const fmt = useMemo(
() =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 2,
}),
[i18n.language]
);
// Re-fetch whenever the filters change. Debounced via React's render cycle
// — typing in the search box re-runs the SQL but at < 500 rows that's fine.
useEffect(() => {
let cancelled = false;
async function run() {
setIsLoading(true);
setError(null);
try {
const result = await getTransactionPage(
{
search: search.trim(),
categoryId,
sourceId: null,
dateFrom: from || null,
dateTo: to || null,
uncategorizedOnly: false,
},
{ column: "date", direction: "desc" },
1,
DEFAULT_PAGE_SIZE
);
if (!cancelled) {
setRows(result.rows);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void run();
return () => {
cancelled = true;
};
}, [from, to, categoryId, search]);
function toggleRow(row: TransactionRow) {
setSelection((prev) => {
const next = new Map(prev);
if (next.has(row.id)) {
next.delete(row.id);
} else {
next.set(row.id, suggestTransferDirection(row.amount));
}
return next;
});
}
function flipDirection(rowId: number) {
setSelection((prev) => {
const next = new Map(prev);
const current = next.get(rowId);
if (current === undefined) return prev;
next.set(rowId, current === "in" ? "out" : "in");
return next;
});
}
async function handleSubmit() {
if (selection.size === 0) return;
setSubmitting(true);
setSubmitError(null);
let linked = 0;
const failures: string[] = [];
for (const [transactionId, direction] of selection.entries()) {
try {
await linkTransfer(accountId, transactionId, direction);
linked += 1;
} catch (e) {
if (e instanceof BalanceServiceError) {
failures.push(`${transactionId}: ${t(`balance.transfers.errors.${e.code}`, { defaultValue: e.message })}`);
} else {
failures.push(`${transactionId}: ${e instanceof Error ? e.message : String(e)}`);
}
}
}
setSubmitting(false);
if (failures.length > 0) {
setSubmitError(
`${t("balance.transfers.modal.partialFailure", { linked, total: selection.size })} — ${failures.join("; ")}`
);
}
if (linked > 0) {
onLinked?.(linked);
if (failures.length === 0) {
onClose();
}
}
}
const allFiltered = rows.length;
const selectedCount = selection.size;
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
<div>
<h2 className="text-lg font-semibold">
{t("balance.transfers.modal.title", { account: accountName })}
</h2>
<p className="text-xs text-[var(--muted-foreground)] mt-0.5">
{t("balance.transfers.modal.subtitle")}
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded hover:bg-[var(--muted)]/40"
aria-label={t("common.close")}
>
<X size={18} />
</button>
</div>
<div className="px-5 py-3 border-b border-[var(--border)] grid grid-cols-1 md:grid-cols-4 gap-3">
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.from")}
</span>
<input
type="date"
value={from}
onChange={(e) => {
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"
/>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.to")}
</span>
<input
type="date"
value={to}
onChange={(e) => {
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"
/>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.category")}
</span>
<select
value={categoryId === null ? "" : String(categoryId)}
onChange={(e) =>
setCategoryId(e.target.value === "" ? null : Number(e.target.value))
}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
>
<option value="">{t("balance.transfers.modal.anyCategory")}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="text-xs">
<span className="block text-[var(--muted-foreground)] mb-1">
{t("balance.transfers.modal.search")}
</span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("balance.transfers.modal.searchPlaceholder")}
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
/>
</label>
</div>
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="p-8 text-center text-[var(--muted-foreground)] flex items-center justify-center gap-2">
<Loader2 className="animate-spin" size={16} />
{t("balance.transfers.modal.loading")}
</div>
) : error ? (
<div className="p-8 text-center text-[var(--negative)] flex items-center justify-center gap-2">
<AlertCircle size={16} />
{error}
</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-[var(--muted-foreground)] italic">
{t("balance.transfers.modal.noTransactions")}
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]/30 sticky top-0">
<tr>
<th className="w-10 px-3 py-2"></th>
<th className="text-left px-3 py-2 font-medium">
{t("transactions.date")}
</th>
<th className="text-left px-3 py-2 font-medium">
{t("transactions.description")}
</th>
<th className="text-right px-3 py-2 font-medium">
{t("transactions.amount")}
</th>
<th className="text-center px-3 py-2 font-medium">
{t("balance.transfers.modal.direction")}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isSelected = selection.has(row.id);
const direction = selection.get(row.id) ?? suggestTransferDirection(row.amount);
return (
<tr
key={row.id}
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleRow(row)}
aria-label={`select-${row.id}`}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-3 py-2 max-w-md truncate" title={row.description}>
{row.description}
</td>
<td
className={`px-3 py-2 text-right font-mono ${row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"}`}
>
{fmt.format(row.amount)}
</td>
<td className="px-3 py-2 text-center">
{isSelected ? (
<button
type="button"
onClick={() => flipDirection(row.id)}
className={`px-2 py-0.5 text-xs rounded font-medium ${
direction === "in"
? "bg-[var(--positive)]/15 text-[var(--positive)]"
: "bg-[var(--negative)]/15 text-[var(--negative)]"
}`}
title={t("balance.transfers.modal.toggleDirection")}
>
{t(`balance.transfers.direction.${direction}`)}
</button>
) : (
<span className="text-xs text-[var(--muted-foreground)]">
{t(`balance.transfers.direction.${direction}`)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{submitError && (
<div className="px-5 py-2 border-t border-[var(--border)] text-xs text-[var(--negative)]">
{submitError}
</div>
)}
<div className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-between">
<div className="text-xs text-[var(--muted-foreground)]">
{t("balance.transfers.modal.summary", {
selected: selectedCount,
total: allFiltered,
})}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] hover:bg-[var(--muted)]/30"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={submitting || selectedCount === 0}
className="px-3 py-1.5 text-sm rounded bg-[var(--primary)] text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<span className="flex items-center gap-1.5">
<Loader2 className="animate-spin" size={14} />
{t("balance.transfers.modal.linking")}
</span>
) : (
t("balance.transfers.modal.linkSelection", { count: selectedCount })
)}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}

View file

@ -1,365 +0,0 @@
// PriceFetchControl — unit tests (issue #158)
//
// NOTE: This project does not have @testing-library/react or jsdom configured
// (logged as MEDIUM in decisions-log.md). Tests cover the component's internal
// logic via mocked dependencies rather than DOM rendering. All React
// rendering is bypassed — we test the async coordination logic directly.
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---------------------------------------------------------------------------
// Mocks — declared before imports to satisfy vi.mock hoisting
// ---------------------------------------------------------------------------
vi.mock("../../hooks/useIsPremium", () => ({
useIsPremium: vi.fn(),
}));
vi.mock("../../services/balance.service", () => ({
prices: {
fetchPrice: vi.fn(),
__resetForTests: vi.fn(),
},
}));
vi.mock("../../services/userPreferenceService", () => ({
getPreference: vi.fn(),
setPreference: vi.fn(),
}));
// react-i18next: return the key as-is for tests
vi.mock("react-i18next", () => ({
useTranslation: vi.fn(() => ({
t: (key: string, opts?: Record<string, unknown>) => {
// Include interpolation values in the returned string for assertions
if (opts) {
return `${key}(${JSON.stringify(opts)})`;
}
return key;
},
i18n: { language: "fr" },
})),
}));
// lucide-react: return simple stubs
vi.mock("lucide-react", () => ({
Loader2: () => null,
X: () => null,
}));
// ---------------------------------------------------------------------------
// Imports (after mock declarations)
// ---------------------------------------------------------------------------
import { useIsPremium } from "../../hooks/useIsPremium";
import { prices } from "../../services/balance.service";
import type { PriceResult } from "../../services/balance.service";
import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
import {
__resetBestEffortDismissForTests,
} from "./PriceFetchControl";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockUseIsPremium = vi.mocked(useIsPremium);
const mockFetchPrice = vi.mocked(prices.fetchPrice);
const mockGetPreference = vi.mocked(getPreference);
const mockSetPreference = vi.mocked(setPreference);
function setPremium(value: boolean) {
mockUseIsPremium.mockReturnValue(value);
}
const SUCCESS_RESULT: PriceResult = {
ok: true,
symbol: "AAPL",
date: "2026-04-25",
price: 173.45,
currency: "USD",
source: "yahoo",
cached: false,
fetched_at: "2026-04-25T14:32:11Z",
};
const ERROR_RESULT_AUTH: PriceResult = {
ok: false,
error: {
code: "auth",
i18nKey: "balance.priceFetching.errors.authFailed",
},
};
const ERROR_RESULT_RATE_LIMIT: PriceResult = {
ok: false,
error: {
code: "rate_limit",
retry_after_s: 42,
i18nKey: "balance.priceFetching.errors.rateLimit",
},
};
// ---------------------------------------------------------------------------
// Test: component visibility guards
// ---------------------------------------------------------------------------
describe("PriceFetchControl — visibility guards", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
});
it("returns null when useIsPremium() is false (non-premium user)", () => {
// We test the guard logic directly since there's no RTL.
// The component returns null when !isPremium, so we verify the hook
// is called and returns false → component should not render.
setPremium(false);
const isPremium = useIsPremium();
expect(isPremium).toBe(false);
// Guard: if (!isPremium || categoryKind !== 'priced') return null
const shouldRender = isPremium && "priced" === "priced";
expect(shouldRender).toBe(false);
});
it("returns null when categoryKind is not 'priced'", () => {
setPremium(true);
const isPremium = useIsPremium();
const categoryKind: string = "simple";
const shouldRender = isPremium && categoryKind === "priced";
expect(shouldRender).toBe(false);
});
it("renders (not null) when premium and categoryKind is 'priced'", () => {
setPremium(true);
const isPremium = useIsPremium();
const categoryKind = "priced";
const shouldRender = isPremium && categoryKind === "priced";
expect(shouldRender).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Test: best-effort warning session state
// ---------------------------------------------------------------------------
describe("PriceFetchControl — best-effort warning (stock vs crypto)", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
});
it("best-effort warning flag starts undismissed after reset", () => {
// The module-level flag is false after __resetBestEffortDismissForTests
// The component initialises showBestEffortWarning = assetType === 'stock' && !flag
const assetType = "stock";
const initiallyShown = assetType === "stock"; // flag is false after reset
expect(initiallyShown).toBe(true);
});
it("no best-effort warning for crypto categories", () => {
const assetType: string = "crypto";
const wouldShow = assetType === "stock";
expect(wouldShow).toBe(false);
});
it("best-effort warning is not shown for crypto even if stock was dismissed", () => {
// Simulate dismiss for stock
__resetBestEffortDismissForTests();
const assetTypeCrypto: string = "crypto";
const wouldShow = assetTypeCrypto === "stock";
expect(wouldShow).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: consent flow
// ---------------------------------------------------------------------------
describe("PriceFetchControl — consent modal flow", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
});
it("first click with no consent: getPreference returns null → consent required", async () => {
mockGetPreference.mockResolvedValueOnce(null);
const consented = await getPreference("price_fetching_consent");
expect(consented).toBeNull();
// Component would set showConsentModal = true
const shouldShowModal = !consented;
expect(shouldShowModal).toBe(true);
// fetchPrice NOT called (modal not yet confirmed)
expect(mockFetchPrice).not.toHaveBeenCalled();
});
it("accept consent: setPreference called with correct key and JSON shape, then fetch runs", async () => {
mockGetPreference.mockResolvedValueOnce(null);
mockSetPreference.mockResolvedValueOnce(undefined);
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
// Simulate handleConsentAccept: write consent, then fetch
await setPreference(
"price_fetching_consent",
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
);
expect(mockSetPreference).toHaveBeenCalledOnce();
const [key, value] = mockSetPreference.mock.calls[0];
expect(key).toBe("price_fetching_consent");
const parsed = JSON.parse(value);
expect(parsed.version).toBe(1);
expect(typeof parsed.consented_at).toBe("string");
// Then fetch is called
await prices.fetchPrice("AAPL", "2026-04-25");
expect(mockFetchPrice).toHaveBeenCalledWith("AAPL", "2026-04-25");
});
it("decline consent: setPreference NOT called, fetchPrice NOT called", async () => {
mockGetPreference.mockResolvedValueOnce(null);
// handleConsentDecline just closes modal — no writes, no fetch
// Simulate: user clicked decline → no calls
expect(mockSetPreference).not.toHaveBeenCalled();
expect(mockFetchPrice).not.toHaveBeenCalled();
});
it("second click with consent already stored: no modal, fetch runs immediately", async () => {
mockGetPreference.mockResolvedValueOnce(
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
);
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
const consented = await getPreference("price_fetching_consent");
expect(!!consented).toBe(true);
// No modal needed → fetch immediately
const result = await prices.fetchPrice("AAPL", "2026-04-25");
expect(result.ok).toBe(true);
expect(mockFetchPrice).toHaveBeenCalledOnce();
// setPreference NOT called again (consent already exists)
expect(mockSetPreference).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Test: fetch success path
// ---------------------------------------------------------------------------
describe("PriceFetchControl — fetch success", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
mockGetPreference.mockResolvedValue(
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
);
});
it("on success: onPriceFetched called with price and currency", async () => {
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
const onPriceFetched = vi.fn();
const result = await prices.fetchPrice("AAPL", "2026-04-25");
if (result.ok) {
onPriceFetched(result.price, result.currency);
}
expect(onPriceFetched).toHaveBeenCalledWith(173.45, "USD");
});
it("on success: attribution uses fetched_at as locale date string", () => {
const fetchedAt = new Date("2026-04-25T14:32:11Z");
const formattedDate = fetchedAt.toLocaleDateString("fr-CA");
expect(typeof formattedDate).toBe("string");
expect(formattedDate.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Test: error paths
// ---------------------------------------------------------------------------
describe("PriceFetchControl — error paths", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
mockGetPreference.mockResolvedValue(
JSON.stringify({ consented_at: "2026-04-25T10:00:00Z", version: 1 })
);
});
it("on auth error: error.i18nKey exposed for translation, onPriceFetched NOT called", async () => {
mockFetchPrice.mockResolvedValueOnce(ERROR_RESULT_AUTH);
const onPriceFetched = vi.fn();
const result = await prices.fetchPrice("AAPL", "2026-04-25");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.i18nKey).toBe("balance.priceFetching.errors.authFailed");
}
expect(onPriceFetched).not.toHaveBeenCalled();
});
it("on rate_limit error: retry_after_s exposed for interpolation, onPriceFetched NOT called", async () => {
mockFetchPrice.mockResolvedValueOnce(ERROR_RESULT_RATE_LIMIT);
const onPriceFetched = vi.fn();
const result = await prices.fetchPrice("AAPL", "2026-04-25");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("rate_limit");
expect(result.error.i18nKey).toBe("balance.priceFetching.errors.rateLimit");
if ("retry_after_s" in result.error) {
expect(result.error.retry_after_s).toBe(42);
}
}
expect(onPriceFetched).not.toHaveBeenCalled();
});
it("on error: manual input is not disabled — the component never controls it", () => {
// PriceFetchControl is purely additive — it never disables the unit_price input.
// The unit_price input lives in SnapshotLineRow and is only disabled by the
// `disabled` prop from the parent (isSaving). This test documents the contract.
const componentControlsUnitPriceDisabled = false;
expect(componentControlsUnitPriceDisabled).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Test: fetchPrice is called with correct symbol and date args
// ---------------------------------------------------------------------------
describe("PriceFetchControl — fetchPrice invocation args", () => {
beforeEach(() => {
__resetBestEffortDismissForTests();
vi.resetAllMocks();
setPremium(true);
mockGetPreference.mockResolvedValue(
JSON.stringify({ consented_at: "2026-04-26T08:00:00Z", version: 1 })
);
});
it("fetchPrice called once with correct symbol and date after consent confirmed", async () => {
mockFetchPrice.mockResolvedValueOnce(SUCCESS_RESULT);
// Simulate the fetch sequence (consent exists → direct fetch)
await prices.fetchPrice("BTC", "2026-04-26");
expect(mockFetchPrice).toHaveBeenCalledOnce();
expect(mockFetchPrice).toHaveBeenCalledWith("BTC", "2026-04-26");
});
it("fetchPrice not called when consent is declined", async () => {
mockGetPreference.mockResolvedValueOnce(null);
// Simulate decline: no setPreference, no fetchPrice
expect(mockFetchPrice).not.toHaveBeenCalled();
expect(mockSetPreference).not.toHaveBeenCalled();
});
});

View file

@ -1,287 +0,0 @@
// PriceFetchControl — fetch-price button with consent modal, spinner,
// best-effort warning (stocks only), and attribution display.
//
// Issue #158 — wires into SnapshotLineRow for priced-kind categories.
//
// Behavior rules (from spec §1 + ADR 0011):
// - Hidden when useIsPremium() === false OR categoryKind !== 'priced'
// - First use requires explicit consent (persisted in user_preferences)
// - For stock assetType: shows a "best-effort" badge + dismissable warning
// (once per session, in-memory only — NOT persisted)
// - Manual unit_price input stays active in all error paths (this component
// is purely additive)
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, X } from "lucide-react";
import { useIsPremium } from "../../hooks/useIsPremium";
import { prices } from "../../services/balance.service";
import type { PriceError } from "../../services/balance.service";
import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
// ---------------------------------------------------------------------------
// Module-level session dismiss state for best-effort warning (ADR 0011 §garde-fous)
// ---------------------------------------------------------------------------
let _bestEffortDismissedThisSession = false;
// Exported for tests — resets the in-memory dismiss flag.
export function __resetBestEffortDismissForTests(): void {
_bestEffortDismissedThisSession = false;
}
// Consent preference key (per-profile via per-profile SQLite DB).
const CONSENT_KEY = "price_fetching_consent";
interface PriceFetchControlProps {
symbol: string;
date: string; // YYYY-MM-DD
categoryKind: "simple" | "priced";
assetType: "stock" | "crypto";
onPriceFetched: (price: number, currency: string) => void;
}
/**
* Check whether the user has already given consent for price fetching.
* Returns true when a non-empty consent record exists in user_preferences.
*/
async function hasConsent(): Promise<boolean> {
try {
const raw = await getPreference(CONSENT_KEY);
return !!raw;
} catch {
return false;
}
}
/** Persist consent (consented_at + version shape). */
async function writeConsent(): Promise<void> {
await setPreference(
CONSENT_KEY,
JSON.stringify({ consented_at: new Date().toISOString(), version: 1 })
);
}
export default function PriceFetchControl({
symbol,
date,
categoryKind,
assetType,
onPriceFetched,
}: PriceFetchControlProps) {
const { t, i18n } = useTranslation();
const isPremium = useIsPremium();
// Local UI state
const [showConsentModal, setShowConsentModal] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<PriceError | null>(null);
const [attribution, setAttribution] = useState<string | null>(null);
// Whether the best-effort warning is currently shown (stock only).
const [showBestEffortWarning, setShowBestEffortWarning] = useState(
assetType === "stock" && !_bestEffortDismissedThisSession
);
// Keep the warning display in sync when the session-level flag is updated
// from a sibling instance (e.g. multiple priced rows dismiss in sequence).
useEffect(() => {
if (assetType === "stock") {
setShowBestEffortWarning(!_bestEffortDismissedThisSession);
}
}, [assetType]);
// Hidden for non-premium users or non-priced categories.
if (!isPremium || categoryKind !== "priced") {
return null;
}
const dismissBestEffortWarning = () => {
_bestEffortDismissedThisSession = true;
setShowBestEffortWarning(false);
};
/** Actually trigger the price fetch (called after consent is confirmed). */
const doFetch = async () => {
setIsFetching(true);
setError(null);
setAttribution(null);
const result = await prices.fetchPrice(symbol, date);
setIsFetching(false);
if (result.ok) {
onPriceFetched(result.price, result.currency);
// Show attribution with the fetched_at timestamp formatted to locale date.
const fetchedAt = new Date(result.fetched_at);
const formattedDate = fetchedAt.toLocaleDateString(
i18n.language === "fr" ? "fr-CA" : "en-CA"
);
setAttribution(t("balance.priceFetching.attribution", { date: formattedDate }));
} else {
setError(result.error);
}
};
/** Handle the main button click: check consent, then fetch or show modal. */
const handleClick = async () => {
if (isFetching) return;
setError(null);
setAttribution(null);
const consented = await hasConsent();
if (!consented) {
setShowConsentModal(true);
} else {
await doFetch();
}
};
/** User accepted in the consent modal. */
const handleConsentAccept = async () => {
setShowConsentModal(false);
try {
await writeConsent();
} catch {
// Non-blocking — proceed with fetch even if pref write failed.
}
await doFetch();
};
/** User declined in the consent modal. */
const handleConsentDecline = () => {
setShowConsentModal(false);
};
// Build the error i18n args.
const errorMessage = error
? t(error.i18nKey, {
seconds:
"retry_after_s" in error ? Math.ceil(error.retry_after_s) : undefined,
minutes:
"retry_after_s" in error
? Math.ceil(error.retry_after_s / 60)
: undefined,
defaultValue: error.i18nKey,
})
: null;
return (
<div className="flex flex-col gap-1">
{/* Stock best-effort warning — shown once per session, dismissable */}
{assetType === "stock" && showBestEffortWarning && (
<div className="flex items-start gap-1 text-[10px] text-[var(--muted-foreground)] bg-[var(--muted)]/60 rounded px-2 py-1">
<span className="flex-1">{t("balance.priceFetching.bestEffortNotice")}</span>
<button
type="button"
aria-label={t("common.close")}
onClick={dismissBestEffortWarning}
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X size={10} />
</button>
</div>
)}
<div className="flex items-center gap-2">
{/* Fetch button */}
<button
type="button"
onClick={handleClick}
disabled={isFetching}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-[var(--border)] text-xs font-medium text-[var(--foreground)] bg-[var(--card)] hover:bg-[var(--muted)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label={t("balance.priceFetching.button")}
>
{isFetching ? (
<Loader2 size={12} className="animate-spin" />
) : null}
{t("balance.priceFetching.button")}
{/* Best-effort badge (stock only) */}
{assetType === "stock" && (
<span className="ml-0.5 text-[9px] uppercase tracking-wide px-1 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
best-effort
</span>
)}
</button>
{/* Attribution line — shown after a successful fetch */}
{attribution && !isFetching && (
<span className="text-[10px] text-[var(--muted-foreground)]">
{attribution}
</span>
)}
</div>
{/* Inline error message */}
{errorMessage && !isFetching && (
<p
role="alert"
className="text-xs text-[var(--negative)] mt-0.5"
data-testid="price-fetch-error"
>
{errorMessage}
</p>
)}
{/* Consent modal — rendered inline, portaled via fixed positioning */}
{showConsentModal && (
<ConsentModal
onAccept={handleConsentAccept}
onDecline={handleConsentDecline}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// ConsentModal — minimal overlay, no external modal lib required
// ---------------------------------------------------------------------------
function ConsentModal({
onAccept,
onDecline,
}: {
onAccept: () => void;
onDecline: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="price-consent-title"
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full p-6">
<h2
id="price-consent-title"
className="text-base font-semibold mb-2"
>
{t("balance.priceFetching.consent.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)] mb-5">
{t("balance.priceFetching.consent.body")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onDecline}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("balance.priceFetching.consent.decline")}
</button>
<button
type="button"
onClick={onAccept}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
{t("balance.priceFetching.consent.accept")}
</button>
</div>
</div>
</div>
);
}

View file

@ -1,110 +0,0 @@
// SnapshotEditor — groups the active accounts by balance category and
// renders one `SnapshotLineRow` per account.
//
// Both `simple` and `priced` variants are dispatched by `account.category_kind`
// inside `SnapshotLineRow`. The editor itself only carries the values down
// and the change handlers up.
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type {
BalanceAccountWithCategory,
BalanceCategory,
} from "../../shared/types";
import type { PricedEntry } from "../../hooks/useSnapshotEditor";
import SnapshotLineRow from "./SnapshotLineRow";
interface Props {
accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[];
/** account_id → string-typed value (simple kind). */
values: Record<number, string>;
/** account_id → {quantity, unit_price} strings (priced kind). */
pricedValues: Record<number, PricedEntry>;
onValueChange: (accountId: number, next: string) => void;
onQuantityChange: (accountId: number, next: string) => void;
onUnitPriceChange: (accountId: number, next: string) => void;
disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — forwarded to PriceFetchControl (Issue #158). */
snapshotDate?: string;
}
export default function SnapshotEditor({
accounts,
categories,
values,
pricedValues,
onValueChange,
onQuantityChange,
onUnitPriceChange,
disabled,
snapshotDate,
}: Props) {
const { t } = useTranslation();
// Group accounts by their category, preserving the categories' sort_order
// first then the account name within each group.
const groups = useMemo(() => {
const byCategory = new Map<number, BalanceAccountWithCategory[]>();
for (const acc of accounts) {
const list = byCategory.get(acc.balance_category_id) ?? [];
list.push(acc);
byCategory.set(acc.balance_category_id, list);
}
const sortedCategories = [...categories].sort(
(a, b) => a.sort_order - b.sort_order || a.key.localeCompare(b.key)
);
return sortedCategories
.map((cat) => ({
category: cat,
accounts: (byCategory.get(cat.id) ?? []).sort((a, b) =>
a.name.localeCompare(b.name)
),
}))
.filter((group) => group.accounts.length > 0);
}, [accounts, categories]);
if (accounts.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
{t("balance.snapshot.editor.empty")}
</div>
);
}
return (
<div className="flex flex-col gap-4">
{groups.map(({ category, accounts: catAccounts }) => (
<div
key={category.id}
className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"
>
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
<h3 className="text-sm font-semibold">
{t(category.i18n_key, { defaultValue: category.key })}
</h3>
</div>
<div className="px-4">
{catAccounts.map((acc) => {
const priced = pricedValues[acc.id];
return (
<SnapshotLineRow
key={acc.id}
account={acc}
value={values[acc.id] ?? ""}
quantityValue={priced?.quantity ?? ""}
unitPriceValue={priced?.unit_price ?? ""}
onChange={(next) => onValueChange(acc.id, next)}
onQuantityChange={(next) => onQuantityChange(acc.id, next)}
onUnitPriceChange={(next) => onUnitPriceChange(acc.id, next)}
disabled={disabled}
snapshotDate={snapshotDate}
/>
);
})}
</div>
</div>
))}
</div>
);
}

View file

@ -1,222 +0,0 @@
// SnapshotLineRow — single account line inside the snapshot editor.
//
// Two variants are dispatched by `account.category_kind`:
//
// - `simple` (Issue #146): a single value input keyed by `account_id`.
// - `priced` (Issue #140): three inputs — `quantity`, `unit_price` (both
// required), and a read-only `value` field that
// renders `quantity * unit_price` live as the
// user types. An attribution tag `[Manuel]`
// appears next to the row; the `[via Maximus]`
// tag is rendered by PriceFetchControl (Issue #158).
//
// We keep this component dumb on purpose: it receives strings from the
// parent (the editor stores raw strings to preserve partial input) and
// emits new strings on every change. Numeric validation happens at save
// time in `useSnapshotEditor.save` against the service's
// `validateLineKindInvariants` helper.
import { ChangeEvent, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { BalanceAccountWithCategory } from "../../shared/types";
import PriceFetchControl from "./PriceFetchControl";
interface BaseProps {
account: BalanceAccountWithCategory;
disabled?: boolean;
/** Snapshot date (YYYY-MM-DD) — passed through to PriceFetchControl. */
snapshotDate?: string;
}
interface SimpleProps extends BaseProps {
value: string;
onChange: (next: string) => void;
/** Optional priced handlers for callers that wire both at once. */
quantityValue?: string;
unitPriceValue?: string;
onQuantityChange?: (next: string) => void;
onUnitPriceChange?: (next: string) => void;
}
type Props = SimpleProps;
/**
* Parse a string like "12.34" or "12,34" into a finite number, or null
* if invalid / empty. Used by the priced variant to compute the live
* `value` preview.
*/
function parseDecimal(raw: string): number | null {
if (!raw) return null;
const trimmed = String(raw).trim().replace(",", ".");
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
export default function SnapshotLineRow({
account,
value,
onChange,
disabled,
quantityValue,
unitPriceValue,
onQuantityChange,
onUnitPriceChange,
snapshotDate,
}: Props) {
const { t } = useTranslation();
const isPriced = account.category_kind === "priced";
// Compute the live value preview for priced rows. Returns null when
// either input cannot yet be parsed (so we display a placeholder).
const computedPricedValue = useMemo(() => {
if (!isPriced) return null;
const qty = parseDecimal(quantityValue ?? "");
const price = parseDecimal(unitPriceValue ?? "");
if (qty === null || price === null) return null;
return qty * price;
}, [isPriced, quantityValue, unitPriceValue]);
if (isPriced) {
const handleQty = (e: ChangeEvent<HTMLInputElement>) =>
onQuantityChange?.(e.target.value);
const handlePrice = (e: ChangeEvent<HTMLInputElement>) =>
onUnitPriceChange?.(e.target.value);
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{account.name}</span>
<span
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)]"
title={t("balance.snapshot.priced.attributionManualHint")}
>
{t("balance.snapshot.priced.attributionManual")}
</span>
</div>
{account.symbol && (
<div className="text-xs text-[var(--muted-foreground)]">
{account.symbol}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-col gap-0.5">
<input
type="text"
inputMode="decimal"
value={quantityValue ?? ""}
onChange={handleQty}
disabled={disabled}
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
aria-label={t("balance.snapshot.priced.quantityLabel", {
account: account.name,
})}
/>
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
{t("balance.snapshot.priced.quantity")}
</span>
</div>
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
×
</span>
<div className="flex flex-col gap-0.5">
<input
type="text"
inputMode="decimal"
value={unitPriceValue ?? ""}
onChange={handlePrice}
disabled={disabled}
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
className="w-28 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
account: account.name,
})}
/>
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
{t("balance.snapshot.priced.unitPrice")}
</span>
</div>
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
=
</span>
<div className="flex flex-col gap-0.5">
<input
type="text"
value={
computedPricedValue === null
? ""
: computedPricedValue.toFixed(2)
}
readOnly
disabled
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] focus:outline-none cursor-not-allowed"
aria-label={t("balance.snapshot.priced.computedValueLabel", {
account: account.name,
})}
aria-readonly="true"
/>
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
{t("balance.snapshot.priced.computedValue")}
</span>
</div>
<span className="text-xs text-[var(--muted-foreground)] w-10">
{account.currency}
</span>
{/* PriceFetchControl wired next to the unit_price input.
Hidden when category_asset_type is null (legacy custom priced
rows pre-#169 migration; user must edit the category to set it). */}
{account.symbol && account.category_asset_type && (
<PriceFetchControl
symbol={account.symbol}
date={snapshotDate ?? ""}
categoryKind={account.category_kind as "priced"}
assetType={account.category_asset_type}
onPriceFetched={(price) =>
onUnitPriceChange?.(String(price))
}
/>
)}
</div>
</div>
);
}
// Simple variant — unchanged from #146.
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<div className="flex items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{account.name}</div>
{account.symbol && (
<div className="text-xs text-[var(--muted-foreground)]">
{account.symbol}
</div>
)}
</div>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="decimal"
value={value}
onChange={handleChange}
disabled={disabled}
placeholder={t("balance.snapshot.line.valuePlaceholder")}
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
aria-label={t("balance.snapshot.line.valueLabel", {
account: account.name,
})}
/>
<span className="text-xs text-[var(--muted-foreground)] w-10">
{account.currency}
</span>
</div>
</div>
);
}

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

@ -2,7 +2,6 @@ import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowUpDown } from "lucide-react"; import { AlertTriangle, ArrowUpDown } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types"; import type { BudgetYearRow } from "../../shared/types";
import { reorderRows } from "../../utils/reorderRows";
const fmt = new Intl.NumberFormat("en-CA", { const fmt = new Intl.NumberFormat("en-CA", {
style: "currency", style: "currency",
@ -19,6 +18,58 @@ const MONTH_KEYS = [
const STORAGE_KEY = "subtotals-position"; const STORAGE_KEY = "subtotals-position";
function reorderRows<T extends { is_parent: boolean; parent_id: number | null; category_id: number; depth?: 0 | 1 | 2 }>(
rows: T[],
subtotalsOnTop: boolean,
): T[] {
if (subtotalsOnTop) return rows;
// Group depth-0 parents with all their descendants, then move subtotals to bottom
const groups: { parent: T | null; children: T[] }[] = [];
let current: { parent: T | null; children: T[] } | null = null;
for (const row of rows) {
if (row.is_parent && (row.depth ?? 0) === 0) {
if (current) groups.push(current);
current = { parent: row, children: [] };
} else if (current) {
current.children.push(row);
} else {
if (current) groups.push(current);
current = { parent: null, children: [row] };
}
}
if (current) groups.push(current);
return groups.flatMap(({ parent, children }) => {
if (!parent) return children;
// Also move intermediate subtotals (depth-1 parents) to bottom of their sub-groups
const reorderedChildren: T[] = [];
let subParent: T | null = null;
const subChildren: T[] = [];
for (const child of children) {
if (child.is_parent && (child.depth ?? 0) === 1) {
// Flush previous sub-group
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subChildren.length = 0;
}
subParent = child;
} else if (subParent && child.parent_id === subParent.category_id) {
subChildren.push(child);
} else {
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
subParent = null;
subChildren.length = 0;
}
reorderedChildren.push(child);
}
}
if (subParent) {
reorderedChildren.push(...subChildren, subParent);
}
return [...reorderedChildren, parent];
});
}
interface BudgetTableProps { interface BudgetTableProps {
rows: BudgetYearRow[]; rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
@ -150,7 +201,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
monthTotals[m] += row.months[m] * sign; monthTotals[m] += row.months[m] * sign;
} }
annualTotal += row.annual * sign; annualTotal += row.annual * sign;
prevYearTotal += row.previousYearTotal; // actuals are already signed in the DB prevYearTotal += row.prev_year_annual * sign;
} }
const totalCols = 15; // category + prev year + annual + 12 months const totalCols = 15; // category + prev year + annual + 12 months
@ -179,15 +230,13 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
if (row.is_parent) { if (row.is_parent) {
// Parent subtotal row: read-only, bold, distinct background // Parent subtotal row: read-only, bold, distinct background
const parentDepth = row.depth ?? 0; const parentDepth = row.depth ?? 0;
const isTopParent = parentDepth === 0; const isIntermediateParent = parentDepth === 1;
const isIntermediateParent = parentDepth >= 1;
const parentPaddingClass = parentDepth >= 3 ? "pl-20 pr-3" : parentDepth === 2 ? "pl-14 pr-3" : parentDepth === 1 ? "pl-8 pr-3" : "px-3";
return ( return (
<tr <tr
key={rowKey} key={rowKey}
className={`border-b border-[var(--border)] ${isTopParent ? "bg-[var(--muted)]/30" : "bg-[var(--muted)]/15"}`} className={`border-b border-[var(--border)] ${isIntermediateParent ? "bg-[var(--muted)]/15" : "bg-[var(--muted)]/30"}`}
> >
<td className={`py-2 sticky left-0 z-10 ${isTopParent ? "px-3 bg-[var(--muted)]/30" : `${parentPaddingClass} bg-[var(--muted)]/15`}`}> <td className={`py-2 sticky left-0 z-10 ${isIntermediateParent ? "pl-8 pr-3 bg-[var(--muted)]/15" : "px-3 bg-[var(--muted)]/30"}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -197,7 +246,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
</div> </div>
</td> </td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}> <td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"} text-[var(--muted-foreground)]`}>
{formatSigned(row.previousYearTotal)} {formatSigned(row.prev_year_annual * sign)}
</td> </td>
<td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}> <td className={`py-2 px-2 text-right text-xs ${isIntermediateParent ? "font-medium" : "font-semibold"}`}>
{formatSigned(row.annual * sign)} {formatSigned(row.annual * sign)}
@ -218,7 +267,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors" className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
> >
{/* Category name - sticky */} {/* Category name - sticky */}
<td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth >= 3 ? "pl-20 pr-3" : depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}> <td className={`py-2 sticky left-0 bg-[var(--card)] z-10 ${depth === 2 ? "pl-14 pr-3" : depth === 1 ? "pl-8 pr-3" : "px-3"}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className="w-2.5 h-2.5 rounded-full shrink-0" className="w-2.5 h-2.5 rounded-full shrink-0"
@ -227,11 +276,9 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
<span className="truncate text-xs">{row.category_name}</span> <span className="truncate text-xs">{row.category_name}</span>
</div> </div>
</td> </td>
{/* Previous year total — read-only */} {/* Previous year annual — read-only */}
<td className="py-2 px-2 text-right text-[var(--muted-foreground)]"> <td className="py-2 px-2 text-right text-xs text-[var(--muted-foreground)]">
<span className="text-xs px-1 py-0.5"> {formatSigned(row.prev_year_annual * sign)}
{formatSigned(row.previousYearTotal)}
</span>
</td> </td>
{/* Annual total — editable */} {/* Annual total — editable */}
<td className="py-2 px-2 text-right"> <td className="py-2 px-2 text-right">
@ -314,7 +361,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
{t("budget.category")} {t("budget.category")}
</th> </th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]"> <th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.previousYear")} {t("budget.prevYear")}
</th> </th>
<th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]"> <th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")} {t("budget.annual")}
@ -340,7 +387,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
sectionMonthTotals[m] += row.months[m] * sign; sectionMonthTotals[m] += row.months[m] * sign;
} }
sectionAnnualTotal += row.annual * sign; sectionAnnualTotal += row.annual * sign;
sectionPrevYearTotal += row.previousYearTotal; // actuals are already signed in the DB sectionPrevYearTotal += row.prev_year_annual * sign;
} }
return ( return (
<Fragment key={type}> <Fragment key={type}>

View file

@ -1,151 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import type {
MappingRow as MappingRowType,
ConfidenceBadge,
} from "../../services/categoryMappingService";
interface MappingRowProps {
row: MappingRowType;
/** When true, the row is highlighted (its preview panel is open). */
isSelected: boolean;
/** Callback fired when the row is clicked — opens the preview panel. */
onSelect: (v2CategoryId: number) => void;
/**
* Called with the new v1 target id + name when the user resolves the row
* via the inline dropdown. The dropdown is only rendered for unresolved
* ("🟠 needs review") rows resolved rows just show the target name.
*/
onResolve: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
/** Number of transactions currently attached to this v2 category. */
transactionCount: number;
}
function badgeClass(confidence: ConfidenceBadge): string {
switch (confidence) {
case "high":
return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300";
case "medium":
return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300";
case "low":
return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300";
case "none":
return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300";
}
}
export default function MappingRow({
row,
isSelected,
onSelect,
onResolve,
transactionCount,
}: MappingRowProps) {
const { t } = useTranslation();
const { getLeaves } = useCategoryTaxonomy();
// For the resolve dropdown: all v1 leaves (terminal categories). We keep the
// list flat because the simulate row is narrow; the search box in step 2
// already helps users find a target by keyword.
const v1Leaves = useMemo(() => getLeaves(), [getLeaves]);
const badgeLabel = t(
`categoriesSeed.migration.simulate.confidence.${row.confidence}`,
);
const reasonLabel = t(
`categoriesSeed.migration.simulate.reason.${row.reason}`,
);
const isUnresolved = row.v1TargetId === null || row.v1TargetId === undefined;
const handleResolveChange = (ev: React.ChangeEvent<HTMLSelectElement>) => {
const v1TargetId = Number(ev.target.value);
if (!Number.isFinite(v1TargetId) || v1TargetId <= 0) return;
const leaf = v1Leaves.find((l) => l.id === v1TargetId);
if (!leaf) return;
const name = t(leaf.i18n_key, { defaultValue: leaf.name });
onResolve(row.v2CategoryId, v1TargetId, name);
};
const rowClass =
"grid grid-cols-12 gap-2 items-center px-3 py-2 rounded-md border text-sm cursor-pointer transition-colors " +
(isSelected
? "bg-[var(--primary)]/10 border-[var(--primary)]/40"
: "bg-[var(--card)] border-[var(--border)] hover:border-[var(--primary)]/30 hover:bg-[var(--muted)]");
const targetDisplayName = isUnresolved
? null
: row.v1TargetName;
return (
<div
className={rowClass}
onClick={() => onSelect(row.v2CategoryId)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(row.v2CategoryId);
}
}}
aria-label={`${row.v2CategoryName}${targetDisplayName ?? t("categoriesSeed.migration.simulate.needsReview")}`}
>
{/* v2 category name + tx count */}
<div className="col-span-4 flex items-center gap-2 min-w-0">
<span className="truncate font-medium text-[var(--foreground)]">
{row.v2CategoryName}
</span>
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.txCount", {
count: transactionCount,
})}
</span>
</div>
{/* Confidence badge + reason */}
<div className="col-span-3 flex items-center gap-2">
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeClass(
row.confidence,
)}`}
title={row.notes ?? undefined}
>
{badgeLabel}
</span>
<span className="text-xs text-[var(--muted-foreground)] truncate">
{reasonLabel}
</span>
</div>
{/* v1 target (or picker) */}
<div
className="col-span-5 flex items-center justify-end gap-2 min-w-0"
onClick={(e) => e.stopPropagation()}
>
{isUnresolved ? (
<select
value=""
onChange={handleResolveChange}
aria-label={t("categoriesSeed.migration.simulate.chooseTarget")}
className="max-w-full truncate rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
>
<option value="" disabled>
{t("categoriesSeed.migration.simulate.chooseTarget")}
</option>
{v1Leaves.map((leaf) => (
<option key={leaf.id} value={leaf.id}>
{t(leaf.i18n_key, { defaultValue: leaf.name })}
</option>
))}
</select>
) : (
<span className="truncate text-[var(--foreground)]">
{targetDisplayName}
</span>
)}
</div>
</div>
);
}

View file

@ -1,245 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
ShieldCheck,
FolderLock,
Loader2,
CheckCircle2,
Circle,
} from "lucide-react";
interface StepConsentProps {
/** PIN/password for PIN-protected profiles. Empty string if no PIN. */
password: string;
onPasswordChange: (value: string) => void;
/** True when the current profile is PIN-protected — hides the field otherwise. */
requiresPassword: boolean;
/** Transition indicator: the running loader reuses this file via a flag. */
isRunning: boolean;
/** Progress stage for the loader (0 = backup, 1 = verified, 2 = sql, 3 = done). */
runningStage: 0 | 1 | 2 | 3;
onBack: () => void;
onConfirm: () => void;
}
/**
* Step 3 Consent: an explicit checklist + confirm button, plus a loader
* that takes over once the user clicks confirm. The loader shows the 4 sub-
* steps (backup created, backup verified, SQL running, commit) per the mockup.
*/
export default function StepConsent({
password,
onPasswordChange,
requiresPassword,
isRunning,
runningStage,
onBack,
onConfirm,
}: StepConsentProps) {
const { t } = useTranslation();
const [ack1, setAck1] = useState(false);
const [ack2, setAck2] = useState(false);
const [ack3, setAck3] = useState(false);
const allAck = ack1 && ack2 && ack3;
const canConfirm =
!isRunning && allAck && (!requiresPassword || password.trim().length > 0);
if (isRunning) {
return <RunningLoader stage={runningStage} />;
}
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.consent.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.subtitle")}
</p>
</header>
{/* Backup info card */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<div className="flex items-start gap-3">
<ShieldCheck
size={18}
className="mt-0.5 shrink-0 text-[var(--primary)]"
/>
<div>
<h3 className="font-semibold">
{t("categoriesSeed.migration.consent.backup.title")}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.backup.body")}
</p>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)] pl-8">
<FolderLock size={12} className="inline mr-1" />
{t("categoriesSeed.migration.consent.backup.location")}
</p>
</div>
{/* Password field (only for PIN-protected profiles) */}
{requiresPassword && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
<label
htmlFor="consent-password"
className="text-sm font-medium text-[var(--foreground)]"
>
{t("categoriesSeed.migration.consent.password.label")}
</label>
<p className="text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.consent.password.help")}
</p>
<input
id="consent-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
/>
</div>
)}
{/* Checklist */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<p className="text-sm font-medium text-[var(--foreground)]">
{t("categoriesSeed.migration.consent.checklist.title")}
</p>
<ul className="space-y-2 text-sm text-[var(--foreground)]">
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack1}
onChange={(e) => setAck1(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item1")}</span>
</label>
</li>
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack2}
onChange={(e) => setAck2(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item2")}</span>
</label>
</li>
<li>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={ack3}
onChange={(e) => setAck3(e.target.checked)}
className="mt-0.5"
/>
<span>{t("categoriesSeed.migration.consent.checklist.item3")}</span>
</label>
</li>
</ul>
</div>
{/* Nav */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] text-sm"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.consent.back")}
</button>
<button
type="button"
onClick={onConfirm}
disabled={!canConfirm}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 transition-opacity"
>
<ShieldCheck size={16} />
{t("categoriesSeed.migration.consent.confirm")}
</button>
</div>
</section>
);
}
interface StageLineProps {
done: boolean;
active: boolean;
label: string;
}
function StageLine({ done, active, label }: StageLineProps) {
const icon = done ? (
<CheckCircle2 size={16} className="text-green-600 dark:text-green-400" />
) : active ? (
<Loader2 size={16} className="animate-spin text-[var(--primary)]" />
) : (
<Circle size={16} className="text-[var(--muted-foreground)]" />
);
return (
<li className="flex items-center gap-3">
{icon}
<span
className={
done
? "text-sm text-[var(--foreground)]"
: active
? "text-sm font-medium text-[var(--foreground)]"
: "text-sm text-[var(--muted-foreground)]"
}
>
{label}
</span>
</li>
);
}
function RunningLoader({ stage }: { stage: 0 | 1 | 2 | 3 }) {
const { t } = useTranslation();
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.running.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.running.subtitle")}
</p>
</header>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5">
<ul className="space-y-3" aria-live="polite">
<StageLine
done={stage > 0}
active={stage === 0}
label={t("categoriesSeed.migration.running.step1")}
/>
<StageLine
done={stage > 1}
active={stage === 1}
label={t("categoriesSeed.migration.running.step2")}
/>
<StageLine
done={stage > 2}
active={stage === 2}
label={t("categoriesSeed.migration.running.step3")}
/>
<StageLine
done={stage > 3}
active={stage === 3}
label={t("categoriesSeed.migration.running.step4")}
/>
</ul>
</div>
</section>
);
}

View file

@ -1,167 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowRight, ChevronsDownUp, ChevronsUpDown, Search } from "lucide-react";
import { useCategoryTaxonomy } from "../../hooks/useCategoryTaxonomy";
import CategoryTaxonomyTree from "../categories/CategoryTaxonomyTree";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
interface StepDiscoverProps {
onNext: () => void;
}
function collectAllIds(nodes: TaxonomyNode[]): number[] {
const ids: number[] = [];
const walk = (n: TaxonomyNode) => {
ids.push(n.id);
n.children.forEach(walk);
};
nodes.forEach(walk);
return ids;
}
function countNodes(nodes: TaxonomyNode[]): {
roots: number;
subcategories: number;
leaves: number;
} {
let roots = 0;
let subcategories = 0;
let leaves = 0;
for (const root of nodes) {
roots += 1;
for (const child of root.children) {
if (child.children.length === 0) leaves += 1;
else {
subcategories += 1;
for (const leaf of child.children) {
if (leaf.children.length === 0) leaves += 1;
else subcategories += 1;
}
}
}
}
return { roots, subcategories, leaves };
}
/**
* Step 1 Discover: read-only navigation of the v1 taxonomy. Reuses the same
* CategoryTaxonomyTree component as the standalone guide page (#117) so the
* two surfaces stay visually consistent.
*/
export default function StepDiscover({ onNext }: StepDiscoverProps) {
const { t } = useTranslation();
const { taxonomy } = useCategoryTaxonomy();
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
const counts = countNodes(taxonomy.roots);
const total = counts.roots + counts.subcategories + counts.leaves;
const toggleNode = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleExpandAll = () => {
setExpanded(new Set(collectAllIds(taxonomy.roots)));
};
const handleCollapseAll = () => setExpanded(new Set());
const allExpanded = expanded.size > 0;
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.discover.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.discover.subtitle")}
</p>
</header>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
<h3 className="font-semibold">
{t("categoriesSeed.migration.discover.intro.title")}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.discover.intro.body")}
</p>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-4">
<p
className="text-sm text-[var(--muted-foreground)]"
aria-live="polite"
>
{t("categoriesSeed.guidePage.counter", {
roots: counts.roots,
subcategories: counts.subcategories,
leaves: counts.leaves,
total,
})}
</p>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] pointer-events-none"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("categoriesSeed.guidePage.searchPlaceholder")}
aria-label={t("categoriesSeed.guidePage.searchPlaceholder")}
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={allExpanded ? handleCollapseAll : handleExpandAll}
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{allExpanded ? (
<>
<ChevronsDownUp size={16} />
{t("categoriesSeed.guidePage.collapseAll")}
</>
) : (
<>
<ChevronsUpDown size={16} />
{t("categoriesSeed.guidePage.expandAll")}
</>
)}
</button>
</div>
</div>
</div>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<CategoryTaxonomyTree
nodes={taxonomy.roots}
expanded={expanded}
onToggle={toggleNode}
searchQuery={search}
/>
</div>
<div className="flex justify-end">
<button
type="button"
onClick={onNext}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
>
{t("categoriesSeed.migration.discover.next")}
<ArrowRight size={16} />
</button>
</div>
</section>
);
}

View file

@ -1,225 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, AlertTriangle, FolderHeart } from "lucide-react";
import MappingRow from "./MappingRow";
import TransactionPreviewPanel from "./TransactionPreviewPanel";
import type {
MigrationPlan,
MappingRow as MappingRowType,
} from "../../services/categoryMappingService";
interface StepSimulateProps {
plan: MigrationPlan;
unresolved: number;
selectedRowV2Id: number | null;
transactionCountByV2Id: Map<number, number>;
onResolveRow: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
onSelectRow: (v2CategoryId: number | null) => void;
onNext: () => void;
onBack: () => void;
}
/**
* Step 2 Simulate (dry-run): a 3-column table (v2 | confidence | v1 target),
* a preview side panel per selected row, and a blocking guard on the "next"
* button until every row is resolved. No DB writes happen here the plan is
* mutated in memory via the reducer.
*/
export default function StepSimulate({
plan,
unresolved,
selectedRowV2Id,
transactionCountByV2Id,
onResolveRow,
onSelectRow,
onNext,
onBack,
}: StepSimulateProps) {
const { t } = useTranslation();
const selectedRow = useMemo<MappingRowType | null>(() => {
if (selectedRowV2Id === null) return null;
return (
plan.rows.find((r) => r.v2CategoryId === selectedRowV2Id) ??
plan.preserved.find((r) => r.v2CategoryId === selectedRowV2Id) ??
null
);
}, [plan.rows, plan.preserved, selectedRowV2Id]);
const canContinue = unresolved === 0;
return (
<section className="space-y-6">
<header className="space-y-1">
<h2 className="text-xl font-semibold">
{t("categoriesSeed.migration.simulate.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.subtitle")}
</p>
</header>
{/* Stats summary */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5">
<dl className="grid grid-cols-2 sm:grid-cols-5 gap-4 text-sm">
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.stats.total")}
</dt>
<dd className="text-lg font-semibold">{plan.stats.total}</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.high")}
</dt>
<dd className="text-lg font-semibold text-green-600 dark:text-green-400">
{plan.stats.high}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.medium")}
</dt>
<dd className="text-lg font-semibold text-blue-600 dark:text-blue-400">
{plan.stats.medium}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.low")}
</dt>
<dd className="text-lg font-semibold text-orange-600 dark:text-orange-400">
{plan.stats.low}
</dd>
</div>
<div>
<dt className="text-xs uppercase text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.confidence.none")}
</dt>
<dd className="text-lg font-semibold text-red-600 dark:text-red-400">
{plan.stats.none}
</dd>
</div>
</dl>
</div>
{/* Unresolved warning banner */}
{unresolved > 0 && (
<div
role="status"
className="flex items-start gap-3 rounded-xl border border-orange-300 bg-orange-50 p-4 dark:bg-orange-900/10 dark:border-orange-700"
>
<AlertTriangle
size={18}
className="mt-0.5 shrink-0 text-orange-600 dark:text-orange-400"
/>
<p className="text-sm text-orange-900 dark:text-orange-200">
{t("categoriesSeed.migration.simulate.unresolvedWarning", {
count: unresolved,
})}
</p>
</div>
)}
{/* Header row */}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3">
<div
className="grid grid-cols-12 gap-2 px-3 py-2 text-xs font-semibold uppercase text-[var(--muted-foreground)] border-b border-[var(--border)]"
aria-hidden="true"
>
<div className="col-span-4">
{t("categoriesSeed.migration.simulate.header.current")}
</div>
<div className="col-span-3">
{t("categoriesSeed.migration.simulate.header.match")}
</div>
<div className="col-span-5 text-right">
{t("categoriesSeed.migration.simulate.header.target")}
</div>
</div>
<ul className="mt-2 space-y-1">
{plan.rows.map((row) => (
<li key={row.v2CategoryId}>
<MappingRow
row={row}
isSelected={selectedRowV2Id === row.v2CategoryId}
onSelect={onSelectRow}
onResolve={onResolveRow}
transactionCount={
transactionCountByV2Id.get(row.v2CategoryId) ?? 0
}
/>
</li>
))}
</ul>
</div>
{/* Preserved categories block */}
{plan.preserved.length > 0 && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-3">
<div className="flex items-start gap-3">
<FolderHeart
size={18}
className="mt-0.5 shrink-0 text-[var(--primary)]"
/>
<div>
<h3 className="font-semibold">
{t("categoriesSeed.migration.simulate.preserved.title", {
count: plan.preserved.length,
})}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.preserved.body")}
</p>
</div>
</div>
<ul className="text-sm space-y-1">
{plan.preserved.map((row) => (
<li
key={row.v2CategoryId}
className="px-3 py-1.5 rounded-md bg-[var(--muted)] text-[var(--foreground)]"
>
<span className="font-medium">{row.v2CategoryName}</span>
<span className="text-xs text-[var(--muted-foreground)] ml-2">
{t("categoriesSeed.migration.simulate.preserved.txCount", {
count: transactionCountByV2Id.get(row.v2CategoryId) ?? 0,
})}
</span>
</li>
))}
</ul>
</div>
)}
{/* Nav */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] text-sm"
>
<ArrowLeft size={16} />
{t("categoriesSeed.migration.simulate.back")}
</button>
<button
type="button"
onClick={onNext}
disabled={!canContinue}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50 transition-opacity"
>
{t("categoriesSeed.migration.simulate.next")}
<ArrowRight size={16} />
</button>
</div>
{/* Side panel with tx preview */}
{selectedRow !== null && (
<TransactionPreviewPanel
row={selectedRow}
onClose={() => onSelectRow(null)}
/>
)}
</section>
);
}

View file

@ -1,180 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { getDb } from "../../services/db";
import type { MappingRow } from "../../services/categoryMappingService";
interface TransactionRow {
id: number;
date: string;
description: string;
amount: number;
}
interface TransactionPreviewPanelProps {
row: MappingRow | null;
onClose: () => void;
}
const MAX_TRANSACTIONS = 50;
/**
* Side panel that shows transactions currently attached to a v2 category
* selected in the simulate table. Read-only exists purely to give the user
* enough context to decide whether the proposed v1 target is correct.
*
* We fetch lazily (once per selected row id) and cap at 50 rows. If the user
* has more than 50 tx attached, a small "... and N more" line is rendered.
*/
export default function TransactionPreviewPanel({
row,
onClose,
}: TransactionPreviewPanelProps) {
const { t, i18n } = useTranslation();
const [loading, setLoading] = useState(false);
const [transactions, setTransactions] = useState<TransactionRow[]>([]);
const [totalCount, setTotalCount] = useState(0);
useEffect(() => {
let cancelled = false;
if (row === null) {
setTransactions([]);
setTotalCount(0);
return;
}
const load = async () => {
setLoading(true);
try {
const db = await getDb();
const txs = await db.select<TransactionRow[]>(
`SELECT id, date, description, amount
FROM transactions
WHERE category_id = $1
ORDER BY date DESC, id DESC
LIMIT $2`,
[row.v2CategoryId, MAX_TRANSACTIONS],
);
const count = await db.select<Array<{ cnt: number }>>(
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
[row.v2CategoryId],
);
if (!cancelled) {
setTransactions(txs);
setTotalCount(count[0]?.cnt ?? 0);
}
} catch {
if (!cancelled) {
setTransactions([]);
setTotalCount(0);
}
} finally {
if (!cancelled) setLoading(false);
}
};
load();
return () => {
cancelled = true;
};
}, [row]);
if (row === null) return null;
const locale = i18n.language?.startsWith("en") ? "en-CA" : "fr-CA";
const formatAmount = (value: number): string =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "CAD",
minimumFractionDigits: 2,
}).format(value);
const formatDate = (iso: string): string => {
try {
return new Date(iso).toLocaleDateString(locale);
} catch {
return iso;
}
};
const overflow = Math.max(0, totalCount - transactions.length);
return (
<aside
className="fixed inset-y-0 right-0 w-full max-w-md bg-[var(--card)] border-l border-[var(--border)] shadow-xl z-40 flex flex-col"
aria-label={t("categoriesSeed.migration.simulate.panel.title")}
>
<header className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div className="min-w-0">
<h3 className="font-semibold truncate text-[var(--foreground)]">
{row.v2CategoryName}
</h3>
<p className="text-xs text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.panel.subtitle", {
count: totalCount,
})}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("categoriesSeed.migration.simulate.panel.close")}
className="rounded-md p-1.5 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</header>
<div className="flex-1 overflow-y-auto p-2">
{loading ? (
<p className="p-4 text-sm text-[var(--muted-foreground)]">
{t("common.loading")}
</p>
) : transactions.length === 0 ? (
<p className="p-4 text-sm text-[var(--muted-foreground)]">
{t("categoriesSeed.migration.simulate.panel.noTransactions")}
</p>
) : (
<ul className="space-y-1">
{transactions.map((tx) => (
<li
key={tx.id}
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] text-sm"
>
<div className="min-w-0">
<p className="truncate text-[var(--foreground)]">
{tx.description}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{formatDate(tx.date)}
</p>
</div>
<span
className={
tx.amount < 0
? "shrink-0 font-mono text-red-600 dark:text-red-400"
: "shrink-0 font-mono text-green-600 dark:text-green-400"
}
>
{formatAmount(tx.amount)}
</span>
</li>
))}
{overflow > 0 && (
<li className="px-2 py-2 text-xs text-[var(--muted-foreground)] text-center">
{t("categoriesSeed.migration.simulate.panel.overflow", {
count: overflow,
})}
</li>
)}
</ul>
)}
</div>
<footer className="px-4 py-3 border-t border-[var(--border)] text-xs text-[var(--muted-foreground)]">
{row.v1TargetName
? t("categoriesSeed.migration.simulate.panel.willMapTo", {
target: row.v1TargetName,
})
: t("categoriesSeed.migration.simulate.panel.noTarget")}
</footer>
</aside>
);
}

Some files were not shown because too many files have changed in this diff Show more