Compare commits

..

No commits in common. "main" and "issue-46-license-commands-entitlements" have entirely different histories.

155 changed files with 2045 additions and 21583 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

@ -25,8 +25,7 @@ jobs:
apt-get update apt-get update
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
curl wget git ca-certificates build-essential pkg-config \ curl wget git ca-certificates build-essential pkg-config \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \ libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev
libdbus-1-dev
# Node.js is required by actions/checkout and actions/cache (they # Node.js is required by actions/checkout and actions/cache (they
# are JavaScript actions and need `node` in the container PATH). # are JavaScript actions and need `node` in the container PATH).
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
@ -64,16 +63,6 @@ jobs:
- name: cargo test - name: cargo test
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets 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: frontend:
runs-on: ubuntu runs-on: ubuntu
container: ubuntu:22.04 container: ubuntu:22.04

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: |

6
.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
@ -52,8 +51,3 @@ public/CHANGELOG.fr.md
# Tauri generated # Tauri generated
src-tauri/gen/ src-tauri/gen/
# Claude Code local state
.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees/

View file

@ -2,101 +2,12 @@
## [Non publié] ## [Non publié]
### 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é ### 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) - 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 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é ### Modifié
- Les mises à jour automatiques sont maintenant réservées à l'édition Base ; l'édition Gratuite affiche un message invitant à activer une licence (#48) - 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 ## [0.6.7] - 2026-03-29

View file

@ -2,101 +2,12 @@
## [Unreleased] ## [Unreleased]
### 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 ### 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) - 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) - 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 ### Changed
- Automatic updates are now gated behind the Base edition entitlement; the Free edition shows an upgrade hint instead of fetching updates (#48) - 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 ## [0.6.7] - 2026-03-29

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,6 +1,6 @@
# Architecture technique — Simpl'Résultat # Architecture technique — Simpl'Résultat
> Document mis à jour le 2026-04-13 — Version 0.7.3 > Document mis à jour le 2026-03-07 — Version 0.6.3
## Stack technique ## Stack technique
@ -26,7 +26,7 @@
``` ```
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
│ │ ├── budget/ # 5 composants │ │ ├── budget/ # 5 composants
│ │ ├── categories/ # 5 composants │ │ ├── categories/ # 5 composants
@ -34,13 +34,13 @@ simpl-resultat/
│ │ ├── 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)
@ -49,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
@ -110,7 +107,7 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
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 (17) ## Services TypeScript (15)
| Service | Responsabilité | | Service | Responsabilité |
|---------|---------------| |---------|---------------|
@ -121,18 +118,16 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
| `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_*) |
## Hooks (14) ## Hooks (12)
Chaque hook encapsule la logique d'état via `useReducer` : Chaque hook encapsule la logique d'état via `useReducer` :
@ -146,19 +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`) |
| `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 (35) ## Commandes Tauri (18)
### `fs_commands.rs` — Système de fichiers (6) ### `fs_commands.rs` — Système de fichiers (6)
@ -183,87 +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
## 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).
@ -285,12 +196,7 @@ 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) |
| `/reports/trends` | `ReportsTrendsPage` | Tendances (flux global + par catégorie) |
| `/reports/compare` | `ReportsComparePage` | Comparables (MoM / YoY / Réel vs budget) |
| `/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é |
| `/settings` | `SettingsPage` | Paramètres | | `/settings` | `SettingsPage` | Paramètres |
| `/docs` | `DocsPage` | Documentation in-app | | `/docs` | `DocsPage` | Documentation in-app |
| `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) | | `/changelog` | `ChangelogPage` | Historique des versions (bilingue FR/EN) |
@ -307,24 +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

View file

@ -246,112 +246,51 @@ 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 ### Fonctionnalités
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. - Tendances mensuelles : revenus vs dépenses dans le temps (graphique en barres)
- Dépenses par catégorie : répartition des dépenses (graphique circulaire)
- Catégories dans le temps : suivez l'évolution de chaque catégorie (graphique en barres empilées), avec filtre par type (dépense/revenu/transfert)
- Budget vs Réel : tableau comparatif mensuel et cumul annuel
- Rapport dynamique : tableau croisé dynamique (pivot table) personnalisable
- Motifs SVG (lignes, points, hachures) pour distinguer les catégories
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
- Détail des transactions par catégorie avec tri par colonne (date, description, montant)
- Toggle pour afficher ou masquer les montants dans le détail des transactions
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. ### Comment faire
### Rapport Faits saillants (`/reports/highlights`) 1. Utilisez les onglets pour basculer entre Tendances, Par catégorie, Dans le temps et Budget vs Réel
2. Ajustez la période avec le sélecteur de période
- Tuiles de soldes mois courant + YTD avec sparklines 12 mois 3. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
- 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) 4. Les catégories masquées apparaissent comme pastilles au-dessus du graphique — cliquez dessus pour les réafficher
- Liste des **plus grosses transactions récentes** avec fenêtre configurable 30 / 60 / 90 jours 5. Dans Budget vs Réel, basculez entre les vues Mensuel et Cumul annuel
6. Dans le détail d'une catégorie, cliquez sur un en-tête de colonne pour trier les transactions
### Rapport Cartes (`/reports/cartes`) 7. Utilisez l'icône œil dans le détail pour masquer ou afficher la colonne des montants
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 ### Astuces
- Le toggle **graphique / tableau** est mémorisé par sous-rapport (vos préférences restent même après redémarrage) - Les catégories masquées sont mémorisées tant que vous restez sur la page — cliquez sur Tout afficher pour réinitialiser
- Les mots-clés doivent faire entre 2 et 64 caractères (protection contre les regex explosives) - Le sélecteur de période s'applique à tous les onglets de graphiques simultanément
- Le zoom catégorie est **protégé contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app - Budget vs Réel affiche l'écart en dollars et en pourcentage pour chaque catégorie
- Les motifs SVG aident les personnes daltoniennes à distinguer les catégories dans les graphiques
### 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
--- ---
@ -365,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
@ -375,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
@ -387,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

71
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.8.3", "version": "0.6.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"version": "0.8.3", "version": "0.6.6",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -1431,66 +1431,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",
@ -3357,11 +3297,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",

View file

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

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 |

841
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.8.3" version = "0.6.7"
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,23 +34,9 @@ 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" jsonwebtoken = "9"
machine-uid = "0.5" machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] }
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] [dev-dependencies]
# Used in license_commands.rs tests to sign test JWTs. We avoid the `pem` # Used in license_commands.rs tests to sign test JWTs. We avoid the `pem`

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

@ -12,9 +12,7 @@ pub const EDITION_PREMIUM: &str = "premium";
/// Maps feature name → list of editions allowed to use it. /// Maps feature name → list of editions allowed to use it.
/// A feature absent from this list is denied for all editions. /// A feature absent from this list is denied for all editions.
const FEATURE_TIERS: &[(&str, &[&str])] = &[ const FEATURE_TIERS: &[(&str, &[&str])] = &[
// auto-update is temporarily open to FREE until the license server (issue #49) ("auto-update", &[EDITION_BASE, EDITION_PREMIUM]),
// 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]), ("web-sync", &[EDITION_PREMIUM]),
("cloud-backup", &[EDITION_PREMIUM]), ("cloud-backup", &[EDITION_PREMIUM]),
("advanced-reports", &[EDITION_PREMIUM]), ("advanced-reports", &[EDITION_PREMIUM]),
@ -40,9 +38,8 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn free_allows_auto_update_temporarily() { fn free_blocks_auto_update() {
// Temporary: auto-update is open to FREE until the license server is live. assert!(!is_feature_allowed("auto-update", EDITION_FREE));
assert!(is_feature_allowed("auto-update", EDITION_FREE));
} }
#[test] #[test]

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

@ -22,10 +22,12 @@ use super::entitlements::{EDITION_BASE, EDITION_FREE, EDITION_PREMIUM};
// Ed25519 public key for license verification. // Ed25519 public key for license verification.
// //
// Production key generated 2026-04-10. The corresponding private key lives ONLY // IMPORTANT: this PEM is a development placeholder taken from RFC 8410 §10.3 test vectors.
// on the license server (Issue #49) as env var ED25519_PRIVATE_KEY_PEM. // The matching private key is publicly known, so any license signed with it offers no real
// protection. Replace this constant with the production public key before shipping a paid
// release. The corresponding private key MUST live only on the license server (Issue #49).
const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\ const PUBLIC_KEY_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
MCowBQYDK2VwAyEAZKoo8eeiSdpxBIVTQXemggOGRUX0+xpiqtOYZfAFeuM=\n\ MCowBQYDK2VwAyEAGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE=\n\
-----END PUBLIC KEY-----\n"; -----END PUBLIC KEY-----\n";
const LICENSE_FILE: &str = "license.key"; const LICENSE_FILE: &str = "license.key";
@ -222,16 +224,7 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any /// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any
/// failure resolves to "free" so feature gates fail closed. /// 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 { 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 { let Ok(path) = license_path(app) else {
return EDITION_FREE.to_string(); return EDITION_FREE.to_string();
}; };
@ -267,22 +260,6 @@ pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
info.edition 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 /// 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). /// or hardware migration, in which case the user must re-activate (handled in Issue #53).
#[tauri::command] #[tauri::command]
@ -294,202 +271,6 @@ fn machine_id_internal() -> Result<String, String> {
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e)) machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e))
} }
// License server API base URL. Overridable via SIMPL_API_URL env var for development.
fn api_base_url() -> String {
std::env::var("SIMPL_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 ==================================================================================== // === Tests ====================================================================================
#[cfg(test)] #[cfg(test)]

View file

@ -1,20 +1,11 @@
pub mod account_cache;
pub mod auth_commands;
pub mod backup_commands;
pub mod entitlements; pub mod entitlements;
pub mod export_import_commands; pub mod export_import_commands;
pub mod feedback_commands;
pub mod fs_commands; pub mod fs_commands;
pub mod license_commands; pub mod license_commands;
pub mod profile_commands; pub mod profile_commands;
pub mod token_store;
pub use auth_commands::*;
pub use backup_commands::*;
pub use entitlements::*; pub use entitlements::*;
pub use export_import_commands::*; pub use export_import_commands::*;
pub use feedback_commands::*;
pub use fs_commands::*; pub use fs_commands::*;
pub use license_commands::*; pub use license_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() % 2 != 0 {
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,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,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,477 +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 (new profiles ship with the v1 IPC taxonomy) -- Default preferences
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');
-- ============================================================================
-- 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,9 +1,6 @@
mod commands; mod commands;
mod database; mod database;
use std::sync::Mutex;
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_sql::{Migration, MigrationKind}; use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -82,79 +79,15 @@ pub fn run() {
UPDATE keywords SET category_id = 312 WHERE keyword = 'INS/ASS' AND category_id = 31;", UPDATE keywords SET category_id = 312 WHERE keyword = 'INS/ASS' AND category_id = 31;",
kind: MigrationKind::Up, kind: MigrationKind::Up,
}, },
// Migration v8 — additive: tag existing profiles with the v2 categories
// taxonomy and add a nullable i18n_key column on categories so the v1
// IPC seed (applied only to brand-new profiles via consolidated_schema)
// can store translation keys. Existing v2 profiles are untouched: the
// column defaults to NULL (falling back to the category's `name`) and
// the preference is a no-op INSERT OR IGNORE so re-runs are safe.
Migration {
version: 8,
description: "add i18n_key to categories and categories_schema_version preference",
sql: "ALTER TABLE categories ADD COLUMN i18n_key TEXT;
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v2');",
kind: MigrationKind::Up,
},
]; ];
tauri::Builder::default() tauri::Builder::default()
// Single-instance plugin MUST be registered first. With the `deep-link`
// feature, it forwards `simpl-resultat://` URLs to the running instance
// so the OAuth2 callback reaches the process that holds the PKCE verifier.
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}))
.manage(commands::auth_commands::OAuthState {
code_verifier: Mutex::new(None),
})
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_deep_link::init())
.setup(|app| { .setup(|app| {
#[cfg(desktop)] #[cfg(desktop)]
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Register the custom scheme at runtime on Linux (the .desktop file
// handles it in prod, but register_all is a no-op there and required
// for AppImage/dev builds).
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
{
let _ = app.deep_link().register_all();
}
// Canonical Tauri v2 pattern: on_open_url fires for both initial-launch
// URLs and subsequent URLs forwarded by tauri-plugin-single-instance
// (with the `deep-link` feature).
let handle = app.handle().clone();
app.deep_link().on_open_url(move |event| {
for url in event.urls() {
let url_str = url.as_str();
let h = handle.clone();
if let Some(code) = extract_auth_code(url_str) {
tauri::async_runtime::spawn(async move {
match commands::handle_auth_callback(h.clone(), code).await {
Ok(account) => {
let _ = h.emit("auth-callback-success", &account);
}
Err(err) => {
let _ = h.emit("auth-callback-error", &err);
}
}
});
} else {
// No `code` param — likely an OAuth error response. Surface
// it to the frontend instead of leaving the UI stuck in
// "loading" forever.
let err_msg = extract_auth_error(url_str)
.unwrap_or_else(|| "OAuth callback did not include a code".to_string());
let _ = h.emit("auth-callback-error", &err_msg);
}
}
});
Ok(()) Ok(())
}) })
.plugin( .plugin(
@ -188,55 +121,7 @@ pub fn run() {
commands::get_edition, commands::get_edition,
commands::get_machine_id, commands::get_machine_id,
commands::check_entitlement, commands::check_entitlement,
commands::activate_machine,
commands::deactivate_machine,
commands::list_activated_machines,
commands::get_activation_status,
commands::start_oauth,
commands::refresh_auth_token,
commands::get_account_info,
commands::check_subscription_status,
commands::logout,
commands::get_token_store_mode,
commands::send_feedback,
commands::get_feedback_user_agent,
commands::ensure_backup_dir,
commands::get_file_size,
commands::file_exists,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
/// Extract the `code` query parameter from a deep-link callback URL.
/// e.g. "simpl-resultat://auth/callback?code=abc123&state=xyz" → Some("abc123")
fn extract_auth_code(url: &str) -> Option<String> {
extract_query_param(url, "code")
}
/// Extract an OAuth error description from a callback URL. Returns a
/// formatted string combining `error` and `error_description` when present.
fn extract_auth_error(url: &str) -> Option<String> {
let error = extract_query_param(url, "error")?;
match extract_query_param(url, "error_description") {
Some(desc) => Some(format!("{}: {}", error, desc)),
None => Some(error),
}
}
fn extract_query_param(url: &str, key: &str) -> Option<String> {
let url = url.trim();
if !url.starts_with("simpl-resultat://auth/callback") {
return None;
}
let query = url.split('?').nth(1)?;
for pair in query.split('&') {
let mut kv = pair.splitn(2, '=');
if kv.next()? == key {
return kv.next().map(|v| {
urlencoding::decode(v).map(|s| s.into_owned()).unwrap_or_else(|_| v.to_string())
});
}
}
None
}

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.8.3", "version": "0.6.7",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@ -18,12 +18,12 @@
} }
], ],
"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/128x128.png", "icons/128x128.png",
@ -34,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,14 +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 ReportsTrendsPage from "./pages/ReportsTrendsPage";
import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
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";
@ -108,20 +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="/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={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<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,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>
);
}

View file

@ -1,278 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
import {
KEYWORD_MAX_LENGTH,
KEYWORD_MIN_LENGTH,
KEYWORD_PREVIEW_LIMIT,
applyKeywordWithReassignment,
previewKeywordMatches,
validateKeyword,
} from "../../services/categorizationService";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
interface CategoryOption {
id: number;
name: string;
}
export interface AddKeywordDialogProps {
initialKeyword: string;
initialCategoryId?: number | null;
onClose: () => void;
onApplied?: () => void;
}
type PreviewState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; visible: RecentTransaction[]; totalMatches: number }
| { kind: "error"; message: string };
export default function AddKeywordDialog({
initialKeyword,
initialCategoryId = null,
onClose,
onApplied,
}: AddKeywordDialogProps) {
const { t } = useTranslation();
const [keyword, setKeyword] = useState(initialKeyword);
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId);
const [categories, setCategories] = useState<CategoryOption[]>([]);
const [checked, setChecked] = useState<Set<number>>(new Set());
const [applyToHidden, setApplyToHidden] = useState(false);
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
const [replacePrompt, setReplacePrompt] = useState<string | null>(null);
const [allowReplaceExisting, setAllowReplaceExisting] = useState(false);
const validation = useMemo(() => validateKeyword(keyword), [keyword]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name }))))
.catch(() => setCategories([]));
}, []);
useEffect(() => {
if (!validation.ok) {
setPreview({ kind: "idle" });
return;
}
let cancelled = false;
setPreview({ kind: "loading" });
previewKeywordMatches(validation.value, KEYWORD_PREVIEW_LIMIT)
.then(({ visible, totalMatches }) => {
if (cancelled) return;
setPreview({ kind: "ready", visible, totalMatches });
setChecked(new Set(visible.map((tx) => tx.id)));
})
.catch((e: unknown) => {
if (cancelled) return;
setPreview({ kind: "error", message: e instanceof Error ? e.message : String(e) });
});
return () => {
cancelled = true;
};
}, [validation]);
const toggleRow = (id: number) => {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const canApply =
validation.ok &&
categoryId !== null &&
preview.kind === "ready" &&
!isApplying &&
checked.size > 0;
const handleApply = async () => {
if (!validation.ok || categoryId === null) return;
setIsApplying(true);
setApplyError(null);
try {
await applyKeywordWithReassignment({
keyword: validation.value,
categoryId,
transactionIds: Array.from(checked),
allowReplaceExisting,
});
if (onApplied) onApplied();
onClose();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === "keyword_already_exists") {
setReplacePrompt(t("reports.keyword.alreadyExists", { category: "" }));
} else {
setApplyError(msg);
}
} finally {
setIsApplying(false);
}
};
const preventBackdropClose = (e: React.MouseEvent) => e.stopPropagation();
return (
<div
onClick={onClose}
className="fixed inset-0 z-[200] bg-black/40 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div
onClick={preventBackdropClose}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl max-w-xl w-full max-h-[90vh] flex flex-col"
>
<header className="px-5 py-3 border-b border-[var(--border)]">
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
</header>
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto">
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.keyword.dialogTitle")}
</span>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
maxLength={KEYWORD_MAX_LENGTH * 2 /* allow user to paste longer then see error */}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
/>
{!validation.ok && (
<span className="text-xs text-[var(--negative)]">
{validation.reason === "tooShort"
? t("reports.keyword.tooShort", { min: KEYWORD_MIN_LENGTH })
: t("reports.keyword.tooLong", { max: KEYWORD_MAX_LENGTH })}
</span>
)}
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.category")}
</span>
<select
value={categoryId ?? ""}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<section>
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
{t("reports.keyword.willMatch")}
</h3>
{preview.kind === "idle" && (
<p className="text-sm text-[var(--muted-foreground)] italic"></p>
)}
{preview.kind === "loading" && (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("common.loading")}</p>
)}
{preview.kind === "error" && (
<p className="text-sm text-[var(--negative)]">{preview.message}</p>
)}
{preview.kind === "ready" && (
<>
<p className="text-sm mb-2">
{t("reports.keyword.nMatches", { count: preview.totalMatches })}
</p>
<ul className="divide-y divide-[var(--border)] max-h-[220px] overflow-y-auto border border-[var(--border)] rounded-lg">
{preview.visible.map((tx) => (
<li key={tx.id} className="flex items-center gap-2 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={checked.has(tx.id)}
onChange={() => toggleRow(tx.id)}
className="accent-[var(--primary)]"
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
<span className="tabular-nums font-medium flex-shrink-0">
{new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
}).format(tx.amount)}
</span>
</li>
))}
</ul>
{preview.totalMatches > preview.visible.length && (
<label className="flex items-center gap-2 text-xs mt-2 text-[var(--muted-foreground)]">
<input
type="checkbox"
checked={applyToHidden}
onChange={(e) => setApplyToHidden(e.target.checked)}
className="accent-[var(--primary)]"
/>
{t("reports.keyword.applyToHidden", {
count: preview.totalMatches - preview.visible.length,
})}
</label>
)}
</>
)}
</section>
{replacePrompt && (
<div className="bg-[var(--muted)]/50 border border-[var(--border)] rounded-lg p-3 text-sm flex flex-col gap-2">
<p>{replacePrompt}</p>
<button
type="button"
onClick={() => {
setAllowReplaceExisting(true);
setReplacePrompt(null);
void handleApply();
}}
className="self-start px-3 py-1.5 rounded-lg bg-[var(--primary)] text-white text-sm"
>
{t("common.confirm")}
</button>
</div>
)}
{applyError && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-lg p-3 text-sm">
{applyError}
</div>
)}
</div>
<footer className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-2 rounded-lg text-sm bg-[var(--card)] border border-[var(--border)] hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleApply}
disabled={!canApply}
className="px-3 py-2 rounded-lg text-sm bg-[var(--primary)] text-white font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isApplying ? t("common.loading") : t("reports.keyword.applyAndRecategorize")}
</button>
</footer>
</div>
</div>
);
}

View file

@ -1,214 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react";
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
interface CategoryTaxonomyTreeProps {
nodes: TaxonomyNode[];
expanded: Set<number>;
onToggle: (id: number) => void;
searchQuery: string;
}
interface NodeRowProps {
node: TaxonomyNode;
depth: number;
expanded: Set<number>;
onToggle: (id: number) => void;
visibleIds: Set<number> | null;
}
function NodeRow({
node,
depth,
expanded,
onToggle,
visibleIds,
}: NodeRowProps) {
const { t } = useTranslation();
const label = t(node.i18n_key, { defaultValue: node.name });
const hasChildren = node.children.length > 0;
const isExpanded = expanded.has(node.id);
// Filter children by visibility set (search mode) if provided.
const visibleChildren = useMemo(() => {
if (visibleIds === null) return node.children;
return node.children.filter((child) => visibleIds.has(child.id));
}, [node.children, visibleIds]);
const typeLabel =
node.type === "income"
? t("categoriesSeed.guidePage.type.income")
: node.type === "transfer"
? t("categoriesSeed.guidePage.type.transfer")
: t("categoriesSeed.guidePage.type.expense");
const tooltipText = [
`${t("categoriesSeed.guidePage.tooltip.key")}: ${node.i18n_key}`,
`${t("categoriesSeed.guidePage.tooltip.type")}: ${typeLabel}`,
`${t("categoriesSeed.guidePage.tooltip.id")}: ${node.id}`,
].join("\n");
// On screen: show children only if expanded.
// In print: @media print in styles.css overrides display:none to show everything.
const childrenHidden = !isExpanded;
return (
<li className="taxonomy-node">
<div
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-[var(--muted)] transition-colors"
style={{ paddingLeft: `${depth * 1.25 + 0.5}rem` }}
title={tooltipText}
>
{hasChildren ? (
<button
type="button"
onClick={() => onToggle(node.id)}
aria-label={
isExpanded
? t("categoriesSeed.guidePage.collapseAll")
: t("categoriesSeed.guidePage.expandAll")
}
aria-expanded={isExpanded}
className="print:hidden shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
) : (
<span className="shrink-0 w-[14px]" aria-hidden="true" />
)}
<span
className="shrink-0 inline-block h-3 w-3 rounded-sm border border-[var(--border)]"
style={{ backgroundColor: node.color }}
aria-hidden="true"
/>
<span
className={
depth === 0
? "font-semibold text-[var(--foreground)]"
: depth === 1
? "font-medium text-[var(--foreground)]"
: "text-[var(--foreground)]"
}
>
{label}
</span>
{depth === 0 && (
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
({node.children.length})
</span>
)}
</div>
{visibleChildren.length > 0 && (
<ul
className={`list-none m-0 p-0 taxonomy-children ${
childrenHidden ? "taxonomy-children-collapsed" : ""
}`}
>
{visibleChildren.map((child) => (
<NodeRow
key={child.id}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
visibleIds={visibleIds}
/>
))}
</ul>
)}
</li>
);
}
/**
* Build the set of node IDs whose visible subtree matches the query.
* A node is kept if its translated name contains the query OR any of its descendants match.
*/
export function collectVisibleIds(
roots: TaxonomyNode[],
normalizedQuery: string,
translate: (key: string, fallback: string) => string,
): Set<number> {
const visible = new Set<number>();
if (normalizedQuery.length === 0) {
const walk = (n: TaxonomyNode) => {
visible.add(n.id);
n.children.forEach(walk);
};
roots.forEach(walk);
return visible;
}
const walk = (node: TaxonomyNode): boolean => {
const label = translate(node.i18n_key, node.name);
const selfMatches = normalize(label).includes(normalizedQuery);
let anyChildMatches = false;
for (const child of node.children) {
if (walk(child)) anyChildMatches = true;
}
if (selfMatches || anyChildMatches) {
visible.add(node.id);
return true;
}
return false;
};
roots.forEach(walk);
return visible;
}
// Case-and-accent insensitive normalization for search.
export function normalize(s: string): string {
return s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
export default function CategoryTaxonomyTree({
nodes,
expanded,
onToggle,
searchQuery,
}: CategoryTaxonomyTreeProps) {
const { t } = useTranslation();
const normalizedQuery = normalize(searchQuery.trim());
const visibleIds = useMemo(() => {
if (normalizedQuery.length === 0) return null;
return collectVisibleIds(nodes, normalizedQuery, (key, fallback) =>
t(key, { defaultValue: fallback }),
);
}, [nodes, normalizedQuery, t]);
const visibleRoots =
visibleIds === null ? nodes : nodes.filter((r) => visibleIds.has(r.id));
if (visibleRoots.length === 0) {
return (
<p className="text-sm text-[var(--muted-foreground)] p-4">
{t("categoriesSeed.guidePage.noResults")}
</p>
);
}
return (
<ul className="list-none m-0 p-0">
{visibleRoots.map((root) => (
<NodeRow
key={root.id}
node={root}
depth={0}
expanded={expanded}
onToggle={onToggle}
visibleIds={visibleIds}
/>
))}
</ul>
);
}

View file

@ -129,9 +129,7 @@ function TreeRowContent({
className="w-3 h-3 rounded-full flex-shrink-0" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: node.color ?? "#9ca3af" }} style={{ backgroundColor: node.color ?? "#9ca3af" }}
/> />
<span className="flex-1 truncate"> <span className="flex-1 truncate">{node.name}</span>
{node.i18n_key ? t(node.i18n_key, { defaultValue: node.name }) : node.name}
</span>
<TypeBadge type={node.type} /> <TypeBadge type={node.type} />
{node.keyword_count > 0 && ( {node.keyword_count > 0 && (
<span className="text-[11px] text-[var(--muted-foreground)]"> <span className="text-[11px] text-[var(--muted-foreground)]">

View file

@ -1,104 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Sparkles, X, ArrowRight } from "lucide-react";
import {
getPreference,
setPreference,
} from "../../services/userPreferenceService";
// Preference keys used to decide whether to show the banner.
// - CATEGORIES_SCHEMA_VERSION is set by migration v8 (see src-tauri/src/lib.rs):
// existing profiles are tagged 'v2' (legacy seed), new profiles are 'v1'
// (IPC taxonomy). Only v2 profiles are invited to discover the new v1 guide.
// - BANNER_DISMISSED is a persistent flag set when the user dismisses the
// banner; once set, the banner never reappears for this profile.
const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version";
const BANNER_DISMISSED_KEY = "categories_v1_banner_dismissed";
type Visibility = "loading" | "visible" | "hidden";
/**
* Dashboard banner that invites users on the legacy v2 category seed to
* discover the new v1 IPC taxonomy guide. It is:
* - Non-destructive (read-only CTA that navigates to the guide page).
* - Dismissable the dismissal is persisted in `user_preferences` and
* survives app restarts / profile reloads.
* - Only visible on profiles tagged `categories_schema_version='v2'`.
* Profiles on the new v1 seed never see it (they already have the IPC
* taxonomy).
*/
export default function CategoriesV1DiscoveryBanner() {
const { t } = useTranslation();
const [visibility, setVisibility] = useState<Visibility>("loading");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [schemaVersion, dismissed] = await Promise.all([
getPreference(CATEGORIES_SCHEMA_VERSION_KEY),
getPreference(BANNER_DISMISSED_KEY),
]);
if (cancelled) return;
const shouldShow = schemaVersion === "v2" && dismissed !== "1";
setVisibility(shouldShow ? "visible" : "hidden");
} catch {
// If prefs cannot be read (e.g. DB not ready), hide the banner
// silently — it is a purely informational, non-critical UI element.
if (!cancelled) setVisibility("hidden");
}
})();
return () => {
cancelled = true;
};
}, []);
const dismiss = async () => {
// Optimistically hide the banner, then persist the flag. If persistence
// fails we still keep it hidden for this session.
setVisibility("hidden");
try {
await setPreference(BANNER_DISMISSED_KEY, "1");
} catch {
// Ignore — the banner will reappear on next launch if the write failed,
// which is an acceptable degradation.
}
};
if (visibility !== "visible") return null;
return (
<div
role="status"
className="flex items-start gap-3 rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 p-4 mb-6"
>
<div className="mt-0.5 shrink-0 rounded-lg bg-[var(--primary)]/10 p-2 text-[var(--primary)]">
<Sparkles size={18} />
</div>
<div className="flex-1 space-y-2 text-sm">
<p className="font-semibold text-[var(--foreground)]">
{t("dashboard.categoriesBanner.title")}
</p>
<p className="text-[var(--muted-foreground)]">
{t("dashboard.categoriesBanner.description")}
</p>
<Link
to="/settings/categories/standard"
className="inline-flex items-center gap-1.5 font-medium text-[var(--primary)] hover:underline"
>
{t("dashboard.categoriesBanner.cta")}
<ArrowRight size={14} />
</Link>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("dashboard.categoriesBanner.dismiss")}
className="shrink-0 rounded-md p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
</button>
</div>
);
}

View file

@ -6,7 +6,7 @@ import { verifyPin } from "../../services/profileService";
interface Props { interface Props {
profileName: string; profileName: string;
storedHash: string; storedHash: string;
onSuccess: (rehashed?: string | null) => void; onSuccess: () => void;
onCancel: () => void; onCancel: () => void;
} }
@ -41,9 +41,9 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
if (value && filledCount === index + 1) { if (value && filledCount === index + 1) {
setChecking(true); setChecking(true);
try { try {
const result = await verifyPin(pin.replace(/\s/g, ""), storedHash); const valid = await verifyPin(pin.replace(/\s/g, ""), storedHash);
if (result.valid) { if (valid) {
onSuccess(result.rehashed); onSuccess();
} else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) { } else if (filledCount >= 6 || (filledCount >= 4 && index === filledCount - 1 && !value)) {
setError(true); setError(true);
setDigits(["", "", "", "", "", ""]); setDigits(["", "", "", "", "", ""]);
@ -67,10 +67,10 @@ export default function PinDialog({ profileName, storedHash, onSuccess, onCancel
const pin = digits.join(""); const pin = digits.join("");
if (pin.length >= 4) { if (pin.length >= 4) {
setChecking(true); setChecking(true);
verifyPin(pin, storedHash).then((result) => { verifyPin(pin, storedHash).then((valid) => {
setChecking(false); setChecking(false);
if (result.valid) { if (valid) {
onSuccess(result.rehashed); onSuccess();
} else { } else {
setError(true); setError(true);
setDigits(["", "", "", "", "", ""]); setDigits(["", "", "", "", "", ""]);

View file

@ -8,7 +8,7 @@ import type { Profile } from "../../services/profileService";
export default function ProfileSwitcher() { export default function ProfileSwitcher() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile(); const { profiles, activeProfile, switchProfile } = useProfile();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pinProfile, setPinProfile] = useState<Profile | null>(null); const [pinProfile, setPinProfile] = useState<Profile | null>(null);
const [showManage, setShowManage] = useState(false); const [showManage, setShowManage] = useState(false);
@ -36,15 +36,8 @@ export default function ProfileSwitcher() {
} }
}; };
const handlePinSuccess = async (rehashed?: string | null) => { const handlePinSuccess = () => {
if (pinProfile) { if (pinProfile) {
if (rehashed) {
try {
await updateProfile(pinProfile.id, { pin_hash: rehashed });
} catch {
// Best-effort rehash: don't block profile switch if persistence fails
}
}
switchProfile(pinProfile.id); switchProfile(pinProfile.id);
setPinProfile(null); setPinProfile(null);
} }

View file

@ -1,72 +0,0 @@
import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import type { CategoryZoomChild } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
export interface CategoryDonutChartProps {
byChild: CategoryZoomChild[];
centerLabel: string;
centerValue: string;
}
export default function CategoryDonutChart({
byChild,
centerLabel,
centerValue,
}: CategoryDonutChartProps) {
const { t } = useTranslation();
if (byChild.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 relative">
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<ChartPatternDefs
prefix="cat-donut"
categories={byChild.map((c, index) => ({ color: c.categoryColor, index }))}
/>
<Pie
data={byChild}
dataKey="total"
nameKey="categoryName"
cx="50%"
cy="50%"
innerRadius={55}
outerRadius={95}
paddingAngle={2}
>
{byChild.map((entry, index) => (
<Cell
key={entry.categoryId}
fill={getPatternFill("cat-donut", index, entry.categoryColor)}
/>
))}
</Pie>
<Tooltip
formatter={(value) =>
typeof value === "number"
? new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(value)
: String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<span className="text-xs text-[var(--muted-foreground)]">{centerLabel}</span>
<span className="text-lg font-bold">{centerValue}</span>
</div>
</div>
);
}

View file

@ -1,82 +0,0 @@
import { useTranslation } from "react-i18next";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import type { CategoryZoomEvolutionPoint } from "../../shared/types";
export interface CategoryEvolutionChartProps {
data: CategoryZoomEvolutionPoint[];
color?: string;
}
function formatMonth(month: string): string {
const [year, m] = month.split("-");
const date = new Date(Number(year), Number(m) - 1);
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
}
export default function CategoryEvolutionChart({
data,
color = "var(--primary)",
}: CategoryEvolutionChartProps) {
const { t, i18n } = useTranslation();
if (data.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<h3 className="text-sm font-semibold mb-2">{t("reports.category.evolution")}</h3>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={data} margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v: number) =>
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(v)
}
/>
<Tooltip
formatter={(value) =>
typeof value === "number"
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(value)
: String(value)
}
labelFormatter={(label) => (typeof label === "string" ? formatMonth(label) : String(label ?? ""))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Area type="monotone" dataKey="total" stroke={color} fill={color} fillOpacity={0.2} />
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -3,8 +3,6 @@ import { useTranslation } from "react-i18next";
import { import {
BarChart, BarChart,
Bar, Bar,
AreaChart,
Area,
XAxis, XAxis,
YAxis, YAxis,
Tooltip, Tooltip,
@ -27,8 +25,6 @@ function formatMonth(month: string): string {
return date.toLocaleDateString("default", { month: "short", year: "2-digit" }); return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
} }
export type CategoryOverTimeChartType = "bar" | "area";
interface CategoryOverTimeChartProps { interface CategoryOverTimeChartProps {
data: CategoryOverTimeData; data: CategoryOverTimeData;
hiddenCategories: Set<string>; hiddenCategories: Set<string>;
@ -36,13 +32,6 @@ interface CategoryOverTimeChartProps {
onShowAll: () => void; onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void; onViewDetails: (item: CategoryBreakdownItem) => void;
showAmounts?: boolean; showAmounts?: boolean;
/**
* Visual rendering mode. `bar` (default) keeps the legacy stacked-bar
* composition. `area` stacks Recharts <Area> layers (stackId="1") for a
* smoother flow view. Both modes share the same palette and SVG grayscale
* patterns (existing signature visual).
*/
chartType?: CategoryOverTimeChartType;
} }
export default function CategoryOverTimeChart({ export default function CategoryOverTimeChart({
@ -52,7 +41,6 @@ export default function CategoryOverTimeChart({
onShowAll, onShowAll,
onViewDetails, onViewDetails,
showAmounts, showAmounts,
chartType = "bar",
}: CategoryOverTimeChartProps) { }: CategoryOverTimeChartProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const hoveredRef = useRef<string | null>(null); const hoveredRef = useRef<string | null>(null);
@ -80,16 +68,37 @@ export default function CategoryOverTimeChart({
); );
} }
// Shared chart configuration used by both Bar and Area variants. return (
const patternPrefix = "cat-time"; <div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
const patternDefs = ( {hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
{Array.from(hiddenCategories).map((name) => (
<button
key={name}
onClick={() => onToggleHidden(name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
>
<Eye size={12} />
{name}
</button>
))}
<button
onClick={onShowAll}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("charts.showAll")}
</button>
</div>
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<ChartPatternDefs <ChartPatternDefs
prefix={patternPrefix} prefix="cat-time"
categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))} categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))}
/> />
);
const commonAxes = (
<>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" /> <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis <XAxis
dataKey="month" dataKey="month"
@ -129,64 +138,12 @@ export default function CategoryOverTimeChart({
wrapperStyle={{ cursor: "pointer" }} wrapperStyle={{ cursor: "pointer" }}
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>} formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
/> />
</>
);
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
{Array.from(hiddenCategories).map((name) => (
<button
key={name}
onClick={() => onToggleHidden(name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
>
<Eye size={12} />
{name}
</button>
))}
<button
onClick={onShowAll}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("charts.showAll")}
</button>
</div>
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={400}>
{chartType === "area" ? (
<AreaChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
{patternDefs}
{commonAxes}
{categoryEntries.map((c) => (
<Area
key={c.name}
type="monotone"
dataKey={c.name}
stackId="1"
stroke={c.color}
fill={getPatternFill(patternPrefix, c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
style={{ transition: "fill-opacity 150ms", cursor: "context-menu" }}
/>
))}
</AreaChart>
) : (
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
{patternDefs}
{commonAxes}
{categoryEntries.map((c) => ( {categoryEntries.map((c) => (
<Bar <Bar
key={c.name} key={c.name}
dataKey={c.name} dataKey={c.name}
stackId="stack" stackId="stack"
fill={getPatternFill(patternPrefix, c.index, c.color)} fill={getPatternFill("cat-time", c.index, c.color)}
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2} fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }} onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }} onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
@ -204,7 +161,6 @@ export default function CategoryOverTimeChart({
</Bar> </Bar>
))} ))}
</BarChart> </BarChart>
)}
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View file

@ -1,135 +0,0 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Tag } from "lucide-react";
import type { RecentTransaction } from "../../shared/types";
import ContextMenu from "../shared/ContextMenu";
export interface CategoryTransactionsTableProps {
transactions: RecentTransaction[];
onAddKeyword?: (transaction: RecentTransaction) => void;
}
type SortKey = "date" | "description" | "amount";
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function CategoryTransactionsTable({
transactions,
onAddKeyword,
}: CategoryTransactionsTableProps) {
const { t, i18n } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>("amount");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
const sorted = useMemo(() => {
const copy = [...transactions];
copy.sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "date":
cmp = a.date.localeCompare(b.date);
break;
case "description":
cmp = a.description.localeCompare(b.description);
break;
case "amount":
cmp = Math.abs(a.amount) - Math.abs(b.amount);
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
return copy;
}, [transactions, sortKey, sortDir]);
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir("desc");
}
}
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
if (!onAddKeyword) return;
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, tx });
};
const header = (key: SortKey, label: string, align: "left" | "right") => (
<th
onClick={() => toggleSort(key)}
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
>
{label}
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
</th>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-[var(--card)]">
<tr className="border-b border-[var(--border)]">
{header("date", t("transactions.date"), "left")}
{header("description", t("transactions.description"), "left")}
{header("amount", t("transactions.amount"), "right")}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={3} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
sorted.map((tx) => (
<tr
key={tx.id}
onContextMenu={(e) => handleContextMenu(e, tx)}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2 tabular-nums text-[var(--muted-foreground)]">{tx.date}</td>
<td className="px-3 py-2 truncate max-w-[280px]">{tx.description}</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)",
}}
>
{formatAmount(tx.amount, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{menu && onAddKeyword && (
<ContextMenu
x={menu.x}
y={menu.y}
header={menu.tx.description}
onClose={() => setMenu(null)}
items={[
{
icon: <Tag size={14} />,
label: t("reports.keyword.addFromTransaction"),
onClick: () => {
onAddKeyword(menu.tx);
},
},
]}
/>
)}
</div>
);
}

View file

@ -1,73 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAllCategoriesWithCounts } from "../../services/categoryService";
import CategoryCombobox from "../shared/CategoryCombobox";
import type { Category } from "../../shared/types";
export interface CategoryZoomHeaderProps {
categoryId: number | null;
includeSubcategories: boolean;
onCategoryChange: (id: number | null) => void;
onIncludeSubcategoriesChange: (flag: boolean) => void;
}
export default function CategoryZoomHeader({
categoryId,
includeSubcategories,
onCategoryChange,
onIncludeSubcategoriesChange,
}: CategoryZoomHeaderProps) {
const { t } = useTranslation();
const [categories, setCategories] = useState<Category[]>([]);
useEffect(() => {
getAllCategoriesWithCounts()
.then((rows) =>
setCategories(
rows.map((r) => ({
id: r.id,
name: r.name,
parent_id: r.parent_id ?? undefined,
color: r.color ?? undefined,
icon: r.icon ?? undefined,
type: r.type,
is_active: r.is_active,
is_inputable: r.is_inputable,
sort_order: r.sort_order,
created_at: "",
})),
),
)
.catch(() => setCategories([]));
}, []);
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<label className="flex flex-col gap-1 flex-1 min-w-0">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.category.selectCategory")}
</span>
<CategoryCombobox
categories={categories}
value={categoryId}
onChange={onCategoryChange}
placeholder={t("reports.category.searchPlaceholder")}
ariaLabel={t("reports.category.selectCategory")}
/>
</label>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSubcategories}
onChange={(e) => onIncludeSubcategoriesChange(e.target.checked)}
className="accent-[var(--primary)]"
/>
<span>
{includeSubcategories
? t("reports.category.includeSubcategories")
: t("reports.category.directOnly")}
</span>
</label>
</div>
);
}

View file

@ -1,47 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import BudgetVsActualTable from "./BudgetVsActualTable";
import { getBudgetVsActualData } from "../../services/budgetService";
import type { BudgetVsActualRow } from "../../shared/types";
export interface CompareBudgetViewProps {
year: number;
month: number;
}
export default function CompareBudgetView({ year, month }: CompareBudgetViewProps) {
const { t } = useTranslation();
const [rows, setRows] = useState<BudgetVsActualRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setError(null);
getBudgetVsActualData(year, month)
.then((data) => {
if (!cancelled) setRows(data);
})
.catch((e: unknown) => {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
});
return () => {
cancelled = true;
};
}, [year, month]);
if (error) {
return (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4">{error}</div>
);
}
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.bva.noData")}
</div>
);
}
return <BudgetVsActualTable data={rows} />;
}

View file

@ -1,37 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CompareMode } from "../../hooks/useCompare";
export interface CompareModeTabsProps {
value: CompareMode;
onChange: (mode: CompareMode) => void;
}
export default function CompareModeTabs({ value, onChange }: CompareModeTabsProps) {
const { t } = useTranslation();
const modes: { id: CompareMode; labelKey: string }[] = [
{ id: "actual", labelKey: "reports.compare.modeActual" },
{ id: "budget", labelKey: "reports.compare.modeBudget" },
];
return (
<div className="inline-flex gap-1" role="tablist">
{modes.map(({ id, labelKey }) => (
<button
key={id}
type="button"
role="tab"
onClick={() => onChange(id)}
aria-selected={value === id}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === id
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(labelKey)}
</button>
))}
</div>
);
}

View file

@ -1,111 +0,0 @@
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
CartesianGrid,
} from "recharts";
import type { CategoryDelta } from "../../shared/types";
export interface ComparePeriodChartProps {
rows: CategoryDelta[];
previousLabel: string;
currentLabel: string;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
export default function ComparePeriodChart({
rows,
previousLabel,
currentLabel,
}: ComparePeriodChartProps) {
const { t, i18n } = useTranslation();
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
// Sort by current-period amount (largest spending first) so the user's eye
// lands on the biggest categories, then reverse so the biggest appears at
// the top of the vertical bar chart.
const chartData = [...rows]
.sort((a, b) => b.currentAmount - a.currentAmount)
.map((r) => ({
name: r.categoryName,
previousAmount: r.previousAmount,
currentAmount: r.currentAmount,
color: r.categoryColor,
}));
const previousFill = "var(--muted-foreground)";
const currentFill = "var(--primary)";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={Math.max(280, chartData.length * 44 + 60)}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 10, right: 24, bottom: 10, left: 10 }}
barCategoryGap="20%"
>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
<XAxis
type="number"
tickFormatter={(v) => formatCurrency(v, i18n.language)}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis
type="category"
dataKey="name"
width={140}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
cursor={{ fill: "var(--muted)", fillOpacity: 0.2 }}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<Bar
dataKey="previousAmount"
name={previousLabel}
fill={previousFill}
radius={[0, 4, 4, 0]}
/>
<Bar
dataKey="currentAmount"
name={currentLabel}
fill={currentFill}
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,253 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CategoryDelta } from "../../shared/types";
export interface ComparePeriodTableProps {
rows: CategoryDelta[];
/** Label for the "previous" monthly column (e.g. "March 2026" or "2025"). */
previousLabel: string;
/** Label for the "current" monthly column (e.g. "April 2026" or "2026"). */
currentLabel: string;
/** Optional label for the previous cumulative window (YTD). Falls back to previousLabel. */
cumulativePreviousLabel?: string;
/** Optional label for the current cumulative window (YTD). Falls back to currentLabel. */
cumulativeCurrentLabel?: string;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
function variationColor(value: number): string {
// Compare report deals with expenses (abs values): spending more is negative
// for the user, spending less is positive. Mirror that colour convention
// consistently so the eye parses the delta sign at a glance.
if (value > 0) return "var(--negative, #ef4444)";
if (value < 0) return "var(--positive, #10b981)";
return "";
}
export default function ComparePeriodTable({
rows,
previousLabel,
currentLabel,
cumulativePreviousLabel,
cumulativeCurrentLabel,
}: ComparePeriodTableProps) {
const { t, i18n } = useTranslation();
const monthPrevLabel = previousLabel;
const monthCurrLabel = currentLabel;
const ytdPrevLabel = cumulativePreviousLabel ?? previousLabel;
const ytdCurrLabel = cumulativeCurrentLabel ?? currentLabel;
// Totals across all rows (there is no parent/child structure in compare mode).
const totals = rows.reduce(
(acc, r) => ({
monthCurrent: acc.monthCurrent + r.currentAmount,
monthPrevious: acc.monthPrevious + r.previousAmount,
monthDelta: acc.monthDelta + r.deltaAbs,
ytdCurrent: acc.ytdCurrent + r.cumulativeCurrentAmount,
ytdPrevious: acc.ytdPrevious + r.cumulativePreviousAmount,
ytdDelta: acc.ytdDelta + r.cumulativeDeltaAbs,
}),
{ monthCurrent: 0, monthPrevious: 0, monthDelta: 0, ytdCurrent: 0, ytdPrevious: 0, ytdDelta: 0 },
);
const totalMonthPct =
totals.monthPrevious !== 0 ? (totals.monthDelta / Math.abs(totals.monthPrevious)) * 100 : null;
const totalYtdPct =
totals.ytdPrevious !== 0 ? (totals.ytdDelta / Math.abs(totals.ytdPrevious)) * 100 : null;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th
rowSpan={2}
className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] align-bottom sticky left-0 bg-[var(--card)] z-30 min-w-[180px]"
>
{t("reports.highlights.category")}
</th>
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.monthly")}
</th>
<th
colSpan={4}
className="text-center px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]"
>
{t("reports.bva.ytd")}
</th>
</tr>
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthCurrLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{monthPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
<div>{t("reports.compare.currentAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdCurrLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
<div>{t("reports.compare.previousAmount")}</div>
<div className="text-[10px] font-normal opacity-70">{ytdPrevLabel}</div>
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.dollarVar")}
</th>
<th className="text-right px-3 py-1 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.bva.pctVar")}
</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td
colSpan={9}
className="px-3 py-4 text-center text-[var(--muted-foreground)] italic"
>
{t("reports.empty.noData")}
</td>
</tr>
) : (
<>
{rows.map((row) => (
<tr
key={`${row.categoryId ?? "uncat"}-${row.categoryName}`}
className="border-b border-[var(--border)]/50 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-1.5 sticky left-0 bg-[var(--card)] z-10">
<span className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.categoryColor }}
/>
{row.categoryName}
</span>
</td>
{/* Monthly block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.currentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.previousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatSignedCurrency(row.deltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.deltaAbs) }}
>
{formatPct(row.deltaPct, i18n.language)}
</td>
{/* Cumulative YTD block */}
<td className="text-right px-3 py-1.5 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(row.cumulativeCurrentAmount, i18n.language)}
</td>
<td className="text-right px-3 py-1.5 tabular-nums">
{formatCurrency(row.cumulativePreviousAmount, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums font-medium"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatSignedCurrency(row.cumulativeDeltaAbs, i18n.language)}
</td>
<td
className="text-right px-3 py-1.5 tabular-nums"
style={{ color: variationColor(row.cumulativeDeltaAbs) }}
>
{formatPct(row.cumulativeDeltaPct, i18n.language)}
</td>
</tr>
))}
{/* Grand totals row */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))]">
<td className="px-3 py-3 sticky left-0 bg-[color-mix(in_srgb,var(--muted)_20%,var(--card))] z-10">
{t("reports.compare.totalRow")}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.monthCurrent, i18n.language)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.monthPrevious, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatSignedCurrency(totals.monthDelta, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.monthDelta) }}
>
{formatPct(totalMonthPct, i18n.language)}
</td>
<td className="text-right px-3 py-3 border-l border-[var(--border)]/50 tabular-nums">
{formatCurrency(totals.ytdCurrent, i18n.language)}
</td>
<td className="text-right px-3 py-3 tabular-nums">
{formatCurrency(totals.ytdPrevious, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatSignedCurrency(totals.ytdDelta, i18n.language)}
</td>
<td
className="text-right px-3 py-3 tabular-nums"
style={{ color: variationColor(totals.ytdDelta) }}
>
{formatPct(totalYtdPct, i18n.language)}
</td>
</tr>
</>
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,93 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
export interface CompareReferenceMonthPickerProps {
year: number;
month: number;
onChange: (year: number, month: number) => void;
/** Number of recent months to show in the dropdown. Default: 24. */
monthCount?: number;
/** "today" override for tests. */
today?: Date;
}
interface Option {
value: string; // "YYYY-MM"
year: number;
month: number;
label: string;
}
function formatMonth(year: number, month: number, language: string): string {
const date = new Date(year, month - 1, 1);
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(date);
}
export default function CompareReferenceMonthPicker({
year,
month,
onChange,
monthCount = 24,
today = new Date(),
}: CompareReferenceMonthPickerProps) {
const { t, i18n } = useTranslation();
const options = useMemo<Option[]>(() => {
const list: Option[] = [];
let y = today.getFullYear();
let m = today.getMonth() + 1;
for (let i = 0; i < monthCount; i++) {
list.push({
value: `${y}-${String(m).padStart(2, "0")}`,
year: y,
month: m,
label: formatMonth(y, m, i18n.language),
});
m -= 1;
if (m === 0) {
m = 12;
y -= 1;
}
}
return list;
}, [today, monthCount, i18n.language]);
const currentValue = `${year}-${String(month).padStart(2, "0")}`;
const isKnown = options.some((o) => o.value === currentValue);
const displayOptions = isKnown
? options
: [
{
value: currentValue,
year,
month,
label: formatMonth(year, month, i18n.language),
},
...options,
];
return (
<label className="inline-flex items-center gap-2">
<span className="text-sm text-[var(--muted-foreground)]">
{t("reports.compare.referenceMonth")}
</span>
<select
value={currentValue}
onChange={(e) => {
const opt = displayOptions.find((o) => o.value === e.target.value);
if (opt) onChange(opt.year, opt.month);
}}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] px-3 py-2 text-sm hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
>
{displayOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}

View file

@ -1,40 +0,0 @@
import { useTranslation } from "react-i18next";
import type { CompareSubMode } from "../../hooks/useCompare";
export interface CompareSubModeToggleProps {
value: CompareSubMode;
onChange: (subMode: CompareSubMode) => void;
}
export default function CompareSubModeToggle({ value, onChange }: CompareSubModeToggleProps) {
const { t } = useTranslation();
const items: { id: CompareSubMode; labelKey: string }[] = [
{ id: "mom", labelKey: "reports.compare.subModeMoM" },
{ id: "yoy", labelKey: "reports.compare.subModeYoY" },
];
return (
<div
className="inline-flex rounded-lg border border-[var(--border)] bg-[var(--card)] p-0.5"
role="group"
aria-label={t("reports.compare.subModeAria")}
>
{items.map(({ id, labelKey }) => (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-pressed={value === id}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
value === id
? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{t(labelKey)}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,106 @@
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Table, BarChart3, Columns, Maximize2, Minimize2 } from "lucide-react";
import type { PivotConfig, PivotResult } from "../../shared/types";
import DynamicReportPanel from "./DynamicReportPanel";
import DynamicReportTable from "./DynamicReportTable";
import DynamicReportChart from "./DynamicReportChart";
type ViewMode = "table" | "chart" | "both";
interface DynamicReportProps {
config: PivotConfig;
result: PivotResult;
onConfigChange: (config: PivotConfig) => void;
}
export default function DynamicReport({ config, result, onConfigChange }: DynamicReportProps) {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>("table");
const [fullscreen, setFullscreen] = useState(false);
const toggleFullscreen = useCallback(() => setFullscreen((prev) => !prev), []);
// Escape key exits fullscreen
useEffect(() => {
if (!fullscreen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setFullscreen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [fullscreen]);
const hasConfig = (config.rows.length > 0 || config.columns.length > 0) && config.values.length > 0;
const viewButtons: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
{ mode: "table", icon: <Table size={14} />, label: t("reports.pivot.viewTable") },
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.pivot.viewChart") },
{ mode: "both", icon: <Columns size={14} />, label: t("reports.pivot.viewBoth") },
];
return (
<div
className={
fullscreen
? "fixed inset-0 z-50 bg-[var(--background)] overflow-auto p-6"
: ""
}
>
<div className="flex gap-4 items-start">
{/* Content area */}
<div className="flex-1 min-w-0 space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-1">
{hasConfig && viewButtons.map(({ mode, icon, label }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
mode === viewMode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
<div className="flex-1" />
<button
onClick={toggleFullscreen}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
title={fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
>
{fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
{fullscreen ? t("reports.pivot.exitFullscreen") : t("reports.pivot.fullscreen")}
</button>
</div>
{/* Empty state */}
{!hasConfig && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-12 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noConfig")}
</div>
)}
{/* Table */}
{hasConfig && (viewMode === "table" || viewMode === "both") && (
<DynamicReportTable config={config} result={result} />
)}
{/* Chart */}
{hasConfig && (viewMode === "chart" || viewMode === "both") && (
<DynamicReportChart config={config} result={result} />
)}
</div>
{/* Side panel */}
<DynamicReportPanel
config={config}
onChange={onConfigChange}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Legend,
CartesianGrid,
} from "recharts";
import type { PivotConfig, PivotResult } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
// Generate distinct colors for series
const SERIES_COLORS = [
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
"#d946ef", "#0ea5e9", "#eab308", "#22c55e", "#e11d48",
];
interface DynamicReportChartProps {
config: PivotConfig;
result: PivotResult;
}
export default function DynamicReportChart({ config, result }: DynamicReportChartProps) {
const { t } = useTranslation();
const { chartData, seriesKeys, seriesColors } = useMemo(() => {
if (result.rows.length === 0) {
return { chartData: [], seriesKeys: [], seriesColors: {} };
}
const colDims = config.columns;
const rowDim = config.rows[0];
const measure = config.values[0] || "periodic";
// X-axis = composite column key (or first row dimension if no columns)
const hasColDims = colDims.length > 0;
if (!hasColDims && !rowDim) return { chartData: [], seriesKeys: [], seriesColors: {} };
// Build composite column key per row
const getColKey = (r: typeof result.rows[0]) =>
colDims.map((d) => r.keys[d] || "").join(" — ");
// Series = first row dimension (or no stacking if no rows, or first row if columns exist)
const seriesDim = hasColDims ? rowDim : undefined;
// Collect unique x and series values
const xValues = hasColDims
? [...new Set(result.rows.map(getColKey))].sort()
: [...new Set(result.rows.map((r) => r.keys[rowDim]))].sort();
const seriesVals = seriesDim
? [...new Set(result.rows.map((r) => r.keys[seriesDim]))].sort()
: [measure];
// Build chart data: one entry per x value
const data = xValues.map((xVal) => {
const entry: Record<string, string | number> = { name: xVal };
if (seriesDim) {
for (const sv of seriesVals) {
const matchingRows = result.rows.filter(
(r) => (hasColDims ? getColKey(r) : r.keys[rowDim]) === xVal && r.keys[seriesDim] === sv
);
entry[sv] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
} else {
const matchingRows = result.rows.filter((r) =>
hasColDims ? getColKey(r) === xVal : r.keys[rowDim] === xVal
);
entry[measure] = matchingRows.reduce((sum, r) => sum + (r.measures[measure] || 0), 0);
}
return entry;
});
const colors: Record<string, string> = {};
seriesVals.forEach((sv, i) => {
colors[sv] = SERIES_COLORS[i % SERIES_COLORS.length];
});
return { chartData: data, seriesKeys: seriesVals, seriesColors: colors };
}, [config, result]);
if (chartData.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<p className="text-center text-[var(--muted-foreground)] py-8">{t("reports.pivot.noData")}</p>
</div>
);
}
const categoryEntries = seriesKeys.map((key, index) => ({
color: seriesColors[key],
index,
}));
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<ChartPatternDefs prefix="pivot-chart" categories={categoryEntries} />
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="name"
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
{seriesKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
stackId="stack"
fill={getPatternFill("pivot-chart", index, seriesColors[key])}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -0,0 +1,306 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import type { PivotConfig, PivotFieldId, PivotFilterEntry, PivotMeasureId, PivotZone } from "../../shared/types";
import { getDynamicFilterValues } from "../../services/reportService";
const ALL_FIELDS: PivotFieldId[] = ["year", "month", "type", "level1", "level2", "level3"];
const ALL_MEASURES: PivotMeasureId[] = ["periodic", "ytd"];
interface DynamicReportPanelProps {
config: PivotConfig;
onChange: (config: PivotConfig) => void;
}
export default function DynamicReportPanel({ config, onChange }: DynamicReportPanelProps) {
const { t } = useTranslation();
const [menuTarget, setMenuTarget] = useState<{ id: string; type: "field" | "measure"; x: number; y: number } | null>(null);
const [filterValues, setFilterValues] = useState<Record<string, string[]>>({});
const menuRef = useRef<HTMLDivElement>(null);
// A field is only "exhausted" if it's in all 3 zones (rows + columns + filters)
const inRows = new Set(config.rows);
const inColumns = new Set(config.columns);
const inFilters = new Set(Object.keys(config.filters) as PivotFieldId[]);
const assignedFields = new Set(
ALL_FIELDS.filter((f) => inRows.has(f) && inColumns.has(f) && inFilters.has(f))
);
const assignedMeasures = new Set(config.values);
const availableFields = ALL_FIELDS.filter((f) => !assignedFields.has(f));
const availableMeasures = ALL_MEASURES.filter((m) => !assignedMeasures.has(m));
// Load filter values when a field is added to filters
const filterFieldIds = Object.keys(config.filters) as PivotFieldId[];
useEffect(() => {
for (const fieldId of filterFieldIds) {
if (!filterValues[fieldId]) {
getDynamicFilterValues(fieldId as PivotFieldId).then((vals) => {
setFilterValues((prev) => ({ ...prev, [fieldId]: vals }));
});
}
}
}, [filterFieldIds.join(",")]);
// Close menu on outside click
useEffect(() => {
if (!menuTarget) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuTarget(null);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [menuTarget]);
const handleFieldClick = (id: string, type: "field" | "measure", e: React.MouseEvent) => {
setMenuTarget({ id, type, x: e.clientX, y: e.clientY });
};
const assignTo = useCallback((zone: PivotZone) => {
if (!menuTarget) return;
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
if (menuTarget.type === "measure") {
if (zone === "values") {
next.values = [...next.values, menuTarget.id as PivotMeasureId];
}
} else {
const fieldId = menuTarget.id as PivotFieldId;
if (zone === "rows") next.rows = [...next.rows, fieldId];
else if (zone === "columns") next.columns = [...next.columns, fieldId];
else if (zone === "filters") next.filters = { ...next.filters, [fieldId]: { include: [], exclude: [] } };
}
setMenuTarget(null);
onChange(next);
}, [menuTarget, config, onChange]);
const removeFrom = (zone: PivotZone, id: string) => {
const next = { ...config, rows: [...config.rows], columns: [...config.columns], filters: { ...config.filters }, values: [...config.values] };
if (zone === "rows") next.rows = next.rows.filter((f) => f !== id);
else if (zone === "columns") next.columns = next.columns.filter((f) => f !== id);
else if (zone === "filters") {
const { [id]: _, ...rest } = next.filters;
next.filters = rest;
} else if (zone === "values") next.values = next.values.filter((m) => m !== id);
onChange(next);
};
const toggleFilterInclude = (fieldId: string, value: string) => {
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
const isIncluded = entry.include.includes(value);
const newInclude = isIncluded ? entry.include.filter((v) => v !== value) : [...entry.include, value];
// Remove from exclude if adding to include
const newExclude = isIncluded ? entry.exclude : entry.exclude.filter((v) => v !== value);
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
};
const toggleFilterExclude = (fieldId: string, value: string) => {
const entry: PivotFilterEntry = config.filters[fieldId] || { include: [], exclude: [] };
const isExcluded = entry.exclude.includes(value);
const newExclude = isExcluded ? entry.exclude.filter((v) => v !== value) : [...entry.exclude, value];
// Remove from include if adding to exclude
const newInclude = isExcluded ? entry.include : entry.include.filter((v) => v !== value);
onChange({ ...config, filters: { ...config.filters, [fieldId]: { include: newInclude, exclude: newExclude } } });
};
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "level3" ? "level3" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
// Context menu only shows zones where the field is NOT already assigned
const getAvailableZones = (fieldId: string): PivotZone[] => {
const zones: PivotZone[] = [];
if (!inRows.has(fieldId as PivotFieldId)) zones.push("rows");
if (!inColumns.has(fieldId as PivotFieldId)) zones.push("columns");
if (!inFilters.has(fieldId as PivotFieldId)) zones.push("filters");
return zones;
};
const zoneLabels: Record<PivotZone, string> = {
rows: t("reports.pivot.rows"),
columns: t("reports.pivot.columns"),
filters: t("reports.pivot.filters"),
values: t("reports.pivot.values"),
};
return (
<div className="w-64 shrink-0 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 space-y-4 text-sm h-fit sticky top-4">
{/* Available Fields */}
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-2">{t("reports.pivot.availableFields")}</h3>
<div className="flex flex-wrap gap-1.5">
{availableFields.map((f) => (
<button
key={f}
onClick={(e) => handleFieldClick(f, "field", e)}
className="px-2.5 py-1 rounded-lg bg-[var(--muted)] text-[var(--foreground)] hover:bg-[var(--border)] transition-colors text-xs"
>
{fieldLabel(f)}
</button>
))}
{availableMeasures.map((m) => (
<button
key={m}
onClick={(e) => handleFieldClick(m, "measure", e)}
className="px-2.5 py-1 rounded-lg bg-[var(--primary)]/10 text-[var(--primary)] hover:bg-[var(--primary)]/20 transition-colors text-xs"
>
{measureLabel(m)}
</button>
))}
{availableFields.length === 0 && availableMeasures.length === 0 && (
<span className="text-xs text-[var(--muted-foreground)]"></span>
)}
</div>
</div>
{/* Rows */}
<ZoneSection
label={t("reports.pivot.rows")}
items={config.rows}
getLabel={fieldLabel}
onRemove={(id) => removeFrom("rows", id)}
/>
{/* Columns */}
<ZoneSection
label={t("reports.pivot.columns")}
items={config.columns}
getLabel={fieldLabel}
onRemove={(id) => removeFrom("columns", id)}
/>
{/* Filters */}
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{t("reports.pivot.filters")}</h3>
{filterFieldIds.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="space-y-2">
{filterFieldIds.map((fieldId) => {
const entry = config.filters[fieldId] || { include: [], exclude: [] };
const hasActive = entry.include.length > 0 || entry.exclude.length > 0;
return (
<div key={fieldId}>
<div className="flex items-center gap-1 mb-1">
<span className="text-xs font-medium">{fieldLabel(fieldId)}</span>
<button onClick={() => removeFrom("filters", fieldId)} className="text-[var(--muted-foreground)] hover:text-[var(--negative)]">
<X size={12} />
</button>
</div>
<div className="flex flex-wrap gap-1">
{(filterValues[fieldId] || []).map((val) => {
const isIncluded = entry.include.includes(val);
const isExcluded = entry.exclude.includes(val);
return (
<button
key={val}
onClick={() => toggleFilterInclude(fieldId, val)}
onContextMenu={(e) => {
e.preventDefault();
toggleFilterExclude(fieldId, val);
}}
className={`px-2 py-0.5 rounded text-xs transition-colors ${
isIncluded
? "bg-[var(--primary)] text-white"
: isExcluded
? "bg-[var(--negative)] text-white line-through"
: hasActive
? "bg-[var(--muted)] text-[var(--muted-foreground)] opacity-50"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
title={t("reports.pivot.rightClickExclude")}
>
{val}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Values */}
<ZoneSection
label={t("reports.pivot.values")}
items={config.values}
getLabel={measureLabel}
onRemove={(id) => removeFrom("values", id)}
accent
/>
{/* Context menu */}
{menuTarget && (
<div
ref={menuRef}
className="fixed z-50 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1 min-w-[140px]"
style={{ left: menuTarget.x, top: menuTarget.y }}
>
<div className="px-3 py-1 text-xs text-[var(--muted-foreground)]">{t("reports.pivot.addTo")}</div>
{menuTarget.type === "measure" ? (
<button
onClick={() => assignTo("values")}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{zoneLabels.values}
</button>
) : (
getAvailableZones(menuTarget.id).map((zone) => (
<button
key={zone}
onClick={() => assignTo(zone)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-[var(--muted)] transition-colors"
>
{zoneLabels[zone]}
</button>
))
)}
</div>
)}
</div>
);
}
function ZoneSection({
label,
items,
getLabel,
onRemove,
accent,
}: {
label: string;
items: string[];
getLabel: (id: string) => string;
onRemove: (id: string) => void;
accent?: boolean;
}) {
return (
<div>
<h3 className="font-medium text-[var(--muted-foreground)] mb-1">{label}</h3>
{items.length === 0 ? (
<span className="text-xs text-[var(--muted-foreground)]"></span>
) : (
<div className="flex flex-wrap gap-1.5">
{items.map((id) => (
<span
key={id}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-xs ${
accent
? "bg-[var(--primary)]/10 text-[var(--primary)]"
: "bg-[var(--muted)] text-[var(--foreground)]"
}`}
>
{getLabel(id)}
<button onClick={() => onRemove(id)} className="hover:text-[var(--negative)]">
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,295 @@
import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpDown } from "lucide-react";
import type { PivotConfig, PivotResult, PivotResultRow } from "../../shared/types";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
const STORAGE_KEY = "pivot-subtotals-position";
interface DynamicReportTableProps {
config: PivotConfig;
result: PivotResult;
}
/** A pivoted row: one per unique combination of row dimensions */
interface PivotedRow {
rowKeys: Record<string, string>; // row-dimension values
cells: Record<string, Record<string, number>>; // colValue → measure → value
}
/** Pivot raw result rows into one PivotedRow per unique row-key combination */
function pivotRows(
rows: PivotResultRow[],
rowDims: string[],
colDims: string[],
measures: string[],
): PivotedRow[] {
const map = new Map<string, PivotedRow>();
for (const row of rows) {
const rowKey = rowDims.map((d) => row.keys[d] || "").join("\0");
let pivoted = map.get(rowKey);
if (!pivoted) {
const rowKeys: Record<string, string> = {};
for (const d of rowDims) rowKeys[d] = row.keys[d] || "";
pivoted = { rowKeys, cells: {} };
map.set(rowKey, pivoted);
}
const colKey = colDims.length > 0
? colDims.map((d) => row.keys[d] || "").join("\0")
: "__all__";
if (!pivoted.cells[colKey]) pivoted.cells[colKey] = {};
for (const m of measures) {
pivoted.cells[colKey][m] = (pivoted.cells[colKey][m] || 0) + (row.measures[m] || 0);
}
}
return Array.from(map.values());
}
interface GroupNode {
key: string;
label: string;
pivotedRows: PivotedRow[];
children: GroupNode[];
}
function buildGroups(rows: PivotedRow[], rowDims: string[], depth: number): GroupNode[] {
if (depth >= rowDims.length) return [];
const dim = rowDims[depth];
const map = new Map<string, PivotedRow[]>();
for (const row of rows) {
const key = row.rowKeys[dim] || "";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(row);
}
const groups: GroupNode[] = [];
for (const [key, groupRows] of map) {
groups.push({
key,
label: key,
pivotedRows: groupRows,
children: buildGroups(groupRows, rowDims, depth + 1),
});
}
return groups;
}
function computeSubtotals(
rows: PivotedRow[],
measures: string[],
colValues: string[],
): Record<string, Record<string, number>> {
const result: Record<string, Record<string, number>> = {};
for (const colVal of colValues) {
result[colVal] = {};
for (const m of measures) {
result[colVal][m] = rows.reduce((sum, r) => sum + (r.cells[colVal]?.[m] || 0), 0);
}
}
return result;
}
export default function DynamicReportTable({ config, result }: DynamicReportTableProps) {
const { t } = useTranslation();
const [subtotalsOnTop, setSubtotalsOnTop] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === null ? true : stored === "top";
});
const toggleSubtotals = () => {
setSubtotalsOnTop((prev) => {
const next = !prev;
localStorage.setItem(STORAGE_KEY, next ? "top" : "bottom");
return next;
});
};
const rowDims = config.rows;
const colDims = config.columns;
const colValues = colDims.length > 0 ? result.columnValues : ["__all__"];
const measures = config.values;
// Display label for a composite column key (joined with \0)
const colLabel = (compositeKey: string) => compositeKey.split("\0").join(" — ");
// Pivot the flat SQL rows into one PivotedRow per unique row-key combo
const pivotedRows = useMemo(
() => pivotRows(result.rows, rowDims, colDims, measures),
[result.rows, rowDims, colDims, measures],
);
if (pivotedRows.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.pivot.noData")}
</div>
);
}
const groups = rowDims.length > 0 ? buildGroups(pivotedRows, rowDims, 0) : [];
const grandTotals = computeSubtotals(pivotedRows, measures, colValues);
const fieldLabel = (id: string) => t(`reports.pivot.${id === "level1" ? "level1" : id === "level2" ? "level2" : id === "type" ? "categoryType" : id}`);
const measureLabel = (id: string) => t(`reports.pivot.${id}`);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
{rowDims.length > 1 && (
<div className="flex justify-end px-3 py-2 border-b border-[var(--border)]">
<button
onClick={toggleSubtotals}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ArrowUpDown size={13} />
{subtotalsOnTop ? t("reports.subtotalsOnTop") : t("reports.subtotalsOnBottom")}
</button>
</div>
)}
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight: "calc(100vh - 220px)" }}>
<table className="w-full text-sm">
<thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]">
{rowDims.map((dim) => (
<th key={dim} className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{fieldLabel(dim)}
</th>
))}
{colValues.map((colVal) =>
measures.map((m) => (
<th key={`${colVal}-${m}`} className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] border-l border-[var(--border)] bg-[var(--card)]">
{colDims.length > 0 ? (measures.length > 1 ? `${colLabel(colVal)}${measureLabel(m)}` : colLabel(colVal)) : measureLabel(m)}
</th>
))
)}
</tr>
</thead>
<tbody>
{rowDims.length === 0 ? (
<tr className="border-b border-[var(--border)]/50">
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
) : (
groups.map((group) => (
<GroupRows
key={group.key}
group={group}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={0}
subtotalsOnTop={subtotalsOnTop}
/>
))
)}
{/* Grand total */}
<tr className="border-t-2 border-[var(--border)] font-bold text-sm bg-[var(--muted)]/20">
<td colSpan={rowDims.length || 1} className="px-3 py-3">
{t("reports.pivot.total")}
</td>
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`total-${colVal}-${m}`} className="text-right px-3 py-3 border-l border-[var(--border)]/50">
{cadFormatter(grandTotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
</tbody>
</table>
</div>
</div>
);
}
function GroupRows({
group,
colValues,
measures,
rowDims,
depth,
subtotalsOnTop,
}: {
group: GroupNode;
colValues: string[];
measures: string[];
rowDims: string[];
depth: number;
subtotalsOnTop: boolean;
}) {
const isLeafLevel = depth === rowDims.length - 1;
const subtotals = computeSubtotals(group.pivotedRows, measures, colValues);
const subtotalRow = rowDims.length > 1 && !isLeafLevel ? (
<tr className="bg-[var(--muted)]/30 font-semibold border-b border-[var(--border)]/50">
<td className="px-3 py-1.5" style={{ paddingLeft: `${depth * 16 + 12}px` }}>
{group.label}
</td>
{depth < rowDims.length - 1 && <td colSpan={rowDims.length - depth - 1} />}
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`sub-${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(subtotals[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
) : null;
if (isLeafLevel) {
// Render one table row per pivoted row (already deduplicated by row keys)
return (
<>
{group.pivotedRows.map((pRow, i) => (
<tr key={i} className="border-b border-[var(--border)]/50">
{rowDims.map((dim, di) => (
<td
key={dim}
className="px-3 py-1.5"
style={di === 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
>
{pRow.rowKeys[dim] || ""}
</td>
))}
{colValues.map((colVal) =>
measures.map((m) => (
<td key={`${colVal}-${m}`} className="text-right px-3 py-1.5 border-l border-[var(--border)]/50">
{cadFormatter(pRow.cells[colVal]?.[m] || 0)}
</td>
))
)}
</tr>
))}
</>
);
}
const childContent = group.children.map((child) => (
<GroupRows
key={child.key}
group={child}
colValues={colValues}
measures={measures}
rowDims={rowDims}
depth={depth + 1}
subtotalsOnTop={subtotalsOnTop}
/>
));
return (
<Fragment>
{subtotalsOnTop && subtotalRow}
{childContent}
{!subtotalsOnTop && subtotalRow}
</Fragment>
);
}

View file

@ -1,76 +0,0 @@
import { useTranslation } from "react-i18next";
import { BarChart, Bar, XAxis, YAxis, Cell, ReferenceLine, Tooltip, ResponsiveContainer } from "recharts";
import type { HighlightMover } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
export interface HighlightsTopMoversChartProps {
movers: HighlightMover[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
export default function HighlightsTopMoversChart({ movers }: HighlightsTopMoversChartProps) {
const { t, i18n } = useTranslation();
if (movers.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const chartData = movers
.map((m, i) => ({
name: m.categoryName,
color: m.categoryColor,
delta: m.deltaAbs,
index: i,
}))
.sort((a, b) => a.delta - b.delta);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<ResponsiveContainer width="100%" height={Math.max(200, chartData.length * 36 + 40)}>
<BarChart data={chartData} layout="vertical" margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
<ChartPatternDefs
prefix="highlights-movers"
categories={chartData.map((d) => ({ color: d.color, index: d.index }))}
/>
<XAxis
type="number"
tickFormatter={(v) => formatCurrency(v, i18n.language)}
stroke="var(--muted-foreground)"
fontSize={11}
/>
<YAxis type="category" dataKey="name" width={120} stroke="var(--muted-foreground)" fontSize={11} />
<ReferenceLine x={0} stroke="var(--border)" />
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Bar dataKey="delta">
{chartData.map((entry) => (
<Cell
key={entry.name}
fill={getPatternFill("highlights-movers", entry.index, entry.color)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,148 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { HighlightMover } from "../../shared/types";
type SortKey = "categoryName" | "previous" | "current" | "deltaAbs" | "deltaPct";
export interface HighlightsTopMoversTableProps {
movers: HighlightMover[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
export default function HighlightsTopMoversTable({ movers }: HighlightsTopMoversTableProps) {
const { t, i18n } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>("deltaAbs");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const sorted = [...movers].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "categoryName":
cmp = a.categoryName.localeCompare(b.categoryName);
break;
case "previous":
cmp = a.previousAmount - b.previousAmount;
break;
case "current":
cmp = a.currentAmount - b.currentAmount;
break;
case "deltaAbs":
cmp = Math.abs(a.deltaAbs) - Math.abs(b.deltaAbs);
break;
case "deltaPct":
cmp = (a.deltaPct ?? 0) - (b.deltaPct ?? 0);
break;
}
return sortDir === "asc" ? cmp : -cmp;
});
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
}
const headerCell = (key: SortKey, label: string, align: "left" | "right") => (
<th
onClick={() => toggleSort(key)}
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
>
{label}
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
</th>
);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
{headerCell("categoryName", t("reports.highlights.category"), "left")}
{headerCell("previous", t("reports.highlights.previousAmount"), "right")}
{headerCell("current", t("reports.highlights.currentAmount"), "right")}
{headerCell("deltaAbs", t("reports.highlights.variationAbs"), "right")}
{headerCell("deltaPct", t("reports.highlights.variationPct"), "right")}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</td>
</tr>
) : (
sorted.map((mover) => (
<tr
key={`${mover.categoryId ?? "uncat"}-${mover.categoryName}`}
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2">
<span className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: mover.categoryColor }}
/>
{mover.categoryName}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(mover.previousAmount, i18n.language)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatCurrency(mover.currentAmount, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums font-medium"
style={{
color:
mover.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatSignedCurrency(mover.deltaAbs, i18n.language)}
</td>
<td
className="px-3 py-2 text-right tabular-nums"
style={{
color:
mover.deltaAbs >= 0 ? "var(--negative, #ef4444)" : "var(--positive, #10b981)",
}}
>
{formatPct(mover.deltaPct, i18n.language)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,85 +0,0 @@
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
export interface HighlightsTopTransactionsListProps {
transactions: RecentTransaction[];
windowDays: 30 | 60 | 90;
onWindowChange: (days: 30 | 60 | 90) => void;
onContextMenuRow?: (event: React.MouseEvent, transaction: RecentTransaction) => void;
}
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function HighlightsTopTransactionsList({
transactions,
windowDays,
onWindowChange,
onContextMenuRow,
}: HighlightsTopTransactionsListProps) {
const { t, i18n } = useTranslation();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<h3 className="text-sm font-semibold">{t("reports.highlights.topTransactions")}</h3>
<div className="flex gap-1">
{([30, 60, 90] as const).map((days) => (
<button
key={days}
type="button"
onClick={() => onWindowChange(days)}
aria-pressed={windowDays === days}
className={`text-xs px-2 py-1 rounded ${
windowDays === days
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
{t(`reports.highlights.windowDays${days}`)}
</button>
))}
</div>
</div>
{transactions.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</p>
) : (
<ul className="divide-y divide-[var(--border)]">
{transactions.map((tx) => (
<li
key={tx.id}
onContextMenu={onContextMenuRow ? (e) => onContextMenuRow(e, tx) : undefined}
className="flex items-center gap-3 px-4 py-2 text-sm"
>
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="flex-1 min-w-0 truncate">{tx.description}</span>
{tx.category_name && (
<span className="text-xs text-[var(--muted-foreground)] flex-shrink-0">
{tx.category_name}
</span>
)}
<span
className="tabular-nums font-medium flex-shrink-0"
style={{ color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)" }}
>
{formatAmount(tx.amount, i18n.language)}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -1,55 +0,0 @@
import { useTranslation } from "react-i18next";
import type { HighlightsData } from "../../shared/types";
import HubNetBalanceTile from "./HubNetBalanceTile";
import HubTopMoversTile from "./HubTopMoversTile";
import HubTopTransactionsTile from "./HubTopTransactionsTile";
export interface HubHighlightsPanelProps {
data: HighlightsData | null;
isLoading: boolean;
error: string | null;
}
export default function HubHighlightsPanel({ data, isLoading, error }: HubHighlightsPanelProps) {
const { t } = useTranslation();
if (error) {
return (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
);
}
if (!data) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 mb-6 text-center text-[var(--muted-foreground)]">
{isLoading ? t("common.loading") : t("reports.empty.noData")}
</div>
);
}
const series = data.monthlyBalanceSeries.map((m) => m.netBalance);
return (
<section className={`mb-6 ${isLoading ? "opacity-60" : ""}`} aria-busy={isLoading}>
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
{t("reports.hub.highlights")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<HubNetBalanceTile
label={t("reports.highlights.netBalanceCurrent")}
amount={data.netBalanceCurrent}
series={series}
/>
<HubNetBalanceTile
label={t("reports.highlights.netBalanceYtd")}
amount={data.netBalanceYtd}
series={series}
/>
<HubTopMoversTile movers={data.topMovers} />
<HubTopTransactionsTile transactions={data.topTransactions} />
</div>
</section>
);
}

View file

@ -1,35 +0,0 @@
import { useTranslation } from "react-i18next";
import Sparkline from "./Sparkline";
export interface HubNetBalanceTileProps {
label: string;
amount: number;
series: number[];
}
function formatSigned(amount: number, language: string): string {
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
}).format(amount);
return formatted;
}
export default function HubNetBalanceTile({ label, amount, series }: HubNetBalanceTileProps) {
const { i18n } = useTranslation();
const positive = amount >= 0;
const color = positive ? "var(--positive, #10b981)" : "var(--negative, #ef4444)";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{label}
</span>
<span className="text-2xl font-bold" style={{ color }}>
{formatSigned(amount, i18n.language)}
</span>
<Sparkline data={series} color={color} height={28} />
</div>
);
}

View file

@ -1,24 +0,0 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
export interface HubReportNavCardProps {
to: string;
icon: ReactNode;
title: string;
description: string;
}
export default function HubReportNavCard({ to, icon, title, description }: HubReportNavCardProps) {
return (
<Link
to={to}
className="group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
>
<div className="text-[var(--primary)]">{icon}</div>
<h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]">
{title}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
</Link>
);
}

View file

@ -1,105 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpRight, ArrowDownRight } from "lucide-react";
import type { HighlightMover } from "../../shared/types";
export interface HubTopMoversTileProps {
movers: HighlightMover[];
limit?: number;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
signDisplay: "always",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(pct / 100);
}
export default function HubTopMoversTile({ movers, limit = 3 }: HubTopMoversTileProps) {
const { t, i18n } = useTranslation();
const [mode, setMode] = useState<"abs" | "pct">("abs");
const visible = movers.slice(0, limit);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.topMovers")}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setMode("abs")}
aria-pressed={mode === "abs"}
className={`text-xs px-2 py-0.5 rounded ${
mode === "abs"
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
$
</button>
<button
type="button"
onClick={() => setMode("pct")}
aria-pressed={mode === "pct"}
className={`text-xs px-2 py-0.5 rounded ${
mode === "pct"
? "bg-[var(--primary)] text-white"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)]"
}`}
>
%
</button>
</div>
</div>
{visible.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("reports.empty.noData")}</p>
) : (
<ul className="flex flex-col gap-1">
{visible.map((mover) => {
const isUp = mover.deltaAbs >= 0;
const Icon = isUp ? ArrowUpRight : ArrowDownRight;
const color = isUp ? "var(--negative, #ef4444)" : "var(--positive, #10b981)";
return (
<li
key={`${mover.categoryId ?? "uncat"}-${mover.categoryName}`}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: mover.categoryColor }}
/>
<span className="truncate">{mover.categoryName}</span>
</span>
<span className="flex items-center gap-1 flex-shrink-0" style={{ color }}>
<Icon size={14} />
<span className="tabular-nums font-medium">
{mode === "abs"
? formatCurrency(mover.deltaAbs, i18n.language)
: formatPct(mover.deltaPct, i18n.language)}
</span>
</span>
</li>
);
})}
</ul>
)}
<p className="text-[10px] text-[var(--muted-foreground)]">
{t("reports.highlights.vsLastMonth")}
</p>
</div>
);
}

View file

@ -1,54 +0,0 @@
import { useTranslation } from "react-i18next";
import type { RecentTransaction } from "../../shared/types";
export interface HubTopTransactionsTileProps {
transactions: RecentTransaction[];
limit?: number;
}
function formatAmount(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
}).format(amount);
}
export default function HubTopTransactionsTile({
transactions,
limit = 5,
}: HubTopTransactionsTileProps) {
const { t, i18n } = useTranslation();
const visible = transactions.slice(0, limit);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-2">
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
{t("reports.highlights.topTransactions")}
</span>
{visible.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)] italic">{t("reports.empty.noData")}</p>
) : (
<ul className="flex flex-col gap-1.5">
{visible.map((tx) => (
<li key={tx.id} className="flex items-center gap-2 text-sm">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: tx.category_color ?? "#9ca3af" }}
/>
<span className="text-[var(--muted-foreground)] tabular-nums flex-shrink-0">
{tx.date}
</span>
<span className="truncate flex-1 min-w-0">{tx.description}</span>
<span
className="tabular-nums font-medium flex-shrink-0"
style={{ color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)" }}
>
{formatAmount(tx.amount, i18n.language)}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -37,7 +37,7 @@ export default function MonthlyTrendsTable({ data }: MonthlyTrendsTableProps) {
<thead className="sticky top-0 z-20"> <thead className="sticky top-0 z-20">
<tr className="border-b border-[var(--border)] bg-[var(--card)]"> <tr className="border-b border-[var(--border)] bg-[var(--card)]">
<th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-left px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("reports.month")} {t("reports.pivot.month")}
</th> </th>
<th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]"> <th className="text-right px-3 py-2 font-medium text-[var(--muted-foreground)] bg-[var(--card)]">
{t("dashboard.income")} {t("dashboard.income")}

View file

@ -0,0 +1,166 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, Search } from "lucide-react";
import type { ImportSource } from "../../shared/types";
import type { CategoryTypeFilter } from "../../hooks/useReports";
interface ReportFilterPanelProps {
categories: { name: string; color: string }[];
hiddenCategories: Set<string>;
onToggleHidden: (name: string) => void;
onShowAll: () => void;
sources: ImportSource[];
selectedSourceId: number | null;
onSourceChange: (id: number | null) => void;
categoryType?: CategoryTypeFilter;
onCategoryTypeChange?: (type: CategoryTypeFilter) => void;
}
export default function ReportFilterPanel({
categories,
hiddenCategories,
onToggleHidden,
onShowAll,
sources,
selectedSourceId,
onSourceChange,
categoryType,
onCategoryTypeChange,
}: ReportFilterPanelProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [collapsed, setCollapsed] = useState(false);
const filtered = search
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: categories;
const allVisible = hiddenCategories.size === 0;
const allHidden = hiddenCategories.size === categories.length;
return (
<div className="w-56 shrink-0 sticky top-4 self-start space-y-3">
{/* Source filter */}
{sources.length > 1 && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("transactions.table.source")}
</div>
<div className="border-t border-[var(--border)] px-2 py-2">
<select
value={selectedSourceId ?? ""}
onChange={(e) => onSourceChange(e.target.value ? Number(e.target.value) : null)}
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("transactions.filters.allSources")}</option>
{sources.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
</div>
)}
{/* Type filter */}
{onCategoryTypeChange && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<div className="px-3 py-2.5 text-sm font-medium text-[var(--foreground)] flex items-center gap-2">
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("categories.type")}
</div>
<div className="border-t border-[var(--border)] px-2 py-2">
<select
value={categoryType ?? ""}
onChange={(e) => {
const v = e.target.value;
const valid: CategoryTypeFilter[] = ["expense", "income", "transfer"];
onCategoryTypeChange(valid.includes(v as CategoryTypeFilter) ? (v as CategoryTypeFilter) : null);
}}
className="w-full px-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("reports.filters.allTypes")}</option>
<option value="expense">{t("categories.expense")}</option>
<option value="income">{t("categories.income")}</option>
<option value="transfer">{t("categories.transfer")}</option>
</select>
</div>
</div>
)}
{/* Category filter */}
{categories.length > 0 && <div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm font-medium text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<Filter size={14} className="text-[var(--muted-foreground)]" />
{t("reports.filters.title")}
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
{categories.length - hiddenCategories.size}/{categories.length}
</span>
</button>
{!collapsed && (
<div className="border-t border-[var(--border)]">
<div className="px-2 py-2">
<div className="relative">
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("reports.filters.search")}
className="w-full pl-7 pr-2 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
</div>
<div className="px-2 pb-1 flex gap-1">
<button
onClick={onShowAll}
disabled={allVisible}
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
>
{t("reports.filters.all")}
</button>
<button
onClick={() => categories.forEach((c) => { if (!hiddenCategories.has(c.name)) onToggleHidden(c.name); })}
disabled={allHidden}
className="text-xs px-2 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors disabled:opacity-40"
>
{t("reports.filters.none")}
</button>
</div>
<div className="max-h-64 overflow-y-auto px-2 pb-2 space-y-0.5">
{filtered.map((cat) => {
const visible = !hiddenCategories.has(cat.name);
return (
<label
key={cat.name}
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-[var(--muted)] cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={visible}
onChange={() => onToggleHidden(cat.name)}
className="rounded border-[var(--border)] text-[var(--primary)] focus:ring-[var(--primary)] h-3.5 w-3.5"
/>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: cat.color }}
/>
<span className={`text-xs truncate ${visible ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)] line-through"}`}>
{cat.name}
</span>
</label>
);
})}
</div>
</div>
)}
</div>}
</div>
);
}

View file

@ -1,39 +0,0 @@
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
export interface SparklineProps {
data: number[];
color?: string;
width?: number | `${number}%`;
height?: number;
strokeWidth?: number;
}
export default function Sparkline({
data,
color = "var(--primary)",
width = "100%",
height = 32,
strokeWidth = 1.5,
}: SparklineProps) {
if (data.length === 0) {
return <div style={{ width, height }} />;
}
const chartData = data.map((value, index) => ({ index, value }));
return (
<ResponsiveContainer width={width} height={height}>
<LineChart data={chartData} margin={{ top: 2, right: 2, bottom: 2, left: 2 }}>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={strokeWidth}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -1,55 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readTrendsChartType, TRENDS_CHART_TYPE_STORAGE_KEY } from "./TrendsChartTypeToggle";
describe("readTrendsChartType", () => {
const store = new Map<string, string>();
const mockLocalStorage = {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => store.clear()),
key: vi.fn(),
length: 0,
};
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", mockLocalStorage);
});
it("returns 'bar' fallback when key is missing", () => {
expect(readTrendsChartType()).toBe("bar");
});
it("returns 'bar' when stored value is 'bar'", () => {
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "bar");
expect(readTrendsChartType()).toBe("bar");
});
it("migrates legacy 'line' stored value to 'bar'", () => {
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "line");
expect(readTrendsChartType()).toBe("bar");
});
it("returns 'area' when stored value is 'area'", () => {
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "area");
expect(readTrendsChartType()).toBe("area");
});
it("ignores invalid stored values and returns fallback", () => {
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "bogus");
expect(readTrendsChartType(TRENDS_CHART_TYPE_STORAGE_KEY, "area")).toBe("area");
});
it("respects custom fallback when provided", () => {
expect(readTrendsChartType(TRENDS_CHART_TYPE_STORAGE_KEY, "area")).toBe("area");
});
it("uses the expected storage key", () => {
expect(TRENDS_CHART_TYPE_STORAGE_KEY).toBe("reports-trends-category-charttype");
});
});

View file

@ -1,62 +0,0 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3 as BarIcon, AreaChart as AreaIcon } from "lucide-react";
import type { CategoryOverTimeChartType } from "./CategoryOverTimeChart";
export const TRENDS_CHART_TYPE_STORAGE_KEY = "reports-trends-category-charttype";
export interface TrendsChartTypeToggleProps {
value: CategoryOverTimeChartType;
onChange: (value: CategoryOverTimeChartType) => void;
/** localStorage key used to persist the preference. */
storageKey?: string;
}
export function readTrendsChartType(
storageKey: string = TRENDS_CHART_TYPE_STORAGE_KEY,
fallback: CategoryOverTimeChartType = "bar",
): CategoryOverTimeChartType {
if (typeof localStorage === "undefined") return fallback;
const saved = localStorage.getItem(storageKey);
// Back-compat: "line" was the historical key for the bar chart.
if (saved === "line") return "bar";
return saved === "bar" || saved === "area" ? saved : fallback;
}
export default function TrendsChartTypeToggle({
value,
onChange,
storageKey = TRENDS_CHART_TYPE_STORAGE_KEY,
}: TrendsChartTypeToggleProps) {
const { t } = useTranslation();
useEffect(() => {
if (storageKey) localStorage.setItem(storageKey, value);
}, [value, storageKey]);
const options: { type: CategoryOverTimeChartType; icon: React.ReactNode; label: string }[] = [
{ type: "bar", icon: <BarIcon size={14} />, label: t("reports.trends.chartBar") },
{ type: "area", icon: <AreaIcon size={14} />, label: t("reports.trends.chartArea") },
];
return (
<div className="inline-flex gap-1" role="group" aria-label={t("reports.trends.chartTypeAria")}>
{options.map(({ type, icon, label }) => (
<button
key={type}
type="button"
onClick={() => onChange(type)}
aria-pressed={value === type}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === type
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
</div>
);
}

View file

@ -1,46 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { readViewMode } from "./ViewModeToggle";
describe("readViewMode", () => {
const store = new Map<string, string>();
const mockLocalStorage = {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => store.clear()),
key: vi.fn(),
length: 0,
};
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", mockLocalStorage);
});
it("returns fallback when key is missing", () => {
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
});
it("returns 'chart' when stored value is 'chart'", () => {
store.set("reports-viewmode-highlights", "chart");
expect(readViewMode("reports-viewmode-highlights")).toBe("chart");
});
it("returns 'table' when stored value is 'table'", () => {
store.set("reports-viewmode-highlights", "table");
expect(readViewMode("reports-viewmode-highlights")).toBe("table");
});
it("ignores invalid stored values and returns fallback", () => {
store.set("reports-viewmode-highlights", "bogus");
expect(readViewMode("reports-viewmode-highlights", "table")).toBe("table");
});
it("respects custom fallback when provided", () => {
expect(readViewMode("reports-viewmode-compare", "table")).toBe("table");
});
});

View file

@ -1,52 +0,0 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Table } from "lucide-react";
export type ViewMode = "chart" | "table";
export interface ViewModeToggleProps {
value: ViewMode;
onChange: (mode: ViewMode) => void;
/** localStorage key used to persist the preference per section. */
storageKey?: string;
}
export function readViewMode(storageKey: string, fallback: ViewMode = "chart"): ViewMode {
if (typeof localStorage === "undefined") return fallback;
const saved = localStorage.getItem(storageKey);
return saved === "chart" || saved === "table" ? saved : fallback;
}
export default function ViewModeToggle({ value, onChange, storageKey }: ViewModeToggleProps) {
const { t } = useTranslation();
useEffect(() => {
if (storageKey) localStorage.setItem(storageKey, value);
}, [value, storageKey]);
const options: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [
{ mode: "chart", icon: <BarChart3 size={14} />, label: t("reports.viewMode.chart") },
{ mode: "table", icon: <Table size={14} />, label: t("reports.viewMode.table") },
];
return (
<div className="inline-flex gap-1" role="group" aria-label={t("reports.viewMode.chart")}>
{options.map(({ mode, icon, label }) => (
<button
key={mode}
type="button"
onClick={() => onChange(mode)}
aria-pressed={value === mode}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === mode
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
</div>
);
}

View file

@ -1,111 +0,0 @@
import { useTranslation } from "react-i18next";
import { Target } from "lucide-react";
import type { CartesBudgetAdherence } from "../../../shared/types";
export interface BudgetAdherenceCardProps {
adherence: CartesBudgetAdherence;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(pct / 100);
}
export default function BudgetAdherenceCard({ adherence }: BudgetAdherenceCardProps) {
const { t, i18n } = useTranslation();
const { categoriesInTarget, categoriesTotal, worstOverruns } = adherence;
const score = categoriesTotal === 0 ? null : (categoriesInTarget / categoriesTotal) * 100;
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Target size={16} className="text-[var(--primary)]" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
{t("reports.cartes.budgetAdherenceTitle")}
</h3>
</div>
{categoriesTotal === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.cartes.budgetAdherenceEmpty")}
</div>
) : (
<>
<div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{categoriesInTarget}
<span className="text-sm text-[var(--muted-foreground)] font-normal">
{" / "}
{categoriesTotal}
</span>
</div>
<div className="text-xs text-[var(--muted-foreground)]">
{t("reports.cartes.budgetAdherenceSubtitle", {
score:
score !== null
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 0,
}).format(score / 100)
: "—",
})}
</div>
</div>
{worstOverruns.length > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-[var(--border)]">
<div className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{t("reports.cartes.budgetAdherenceWorst")}
</div>
{worstOverruns.map((r) => {
const progressPct = r.budget > 0 ? Math.min((r.actual / r.budget) * 100, 200) : 0;
return (
<div key={r.categoryId} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: r.categoryColor }}
/>
<span className="truncate text-[var(--foreground)]">{r.categoryName}</span>
</span>
<span className="tabular-nums text-[var(--muted-foreground)]">
{formatCurrency(r.actual, i18n.language)}
{" / "}
{formatCurrency(r.budget, i18n.language)}
<span className="text-[var(--negative)] ml-1 font-medium">
{formatPct(r.overrunPct, i18n.language)}
</span>
</span>
</div>
<div
className="h-1.5 rounded-full bg-[var(--muted)] overflow-hidden"
aria-hidden
>
<div
className="h-full bg-[var(--negative)]"
style={{ width: `${Math.min(progressPct, 100)}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</>
)}
</div>
);
}

View file

@ -1,60 +0,0 @@
import { useTranslation } from "react-i18next";
import { Calendar as MonthIcon, CalendarRange as YtdIcon } from "lucide-react";
import type { CartesKpiPeriodMode } from "../../../shared/types";
export interface CartesPeriodModeToggleProps {
value: CartesKpiPeriodMode;
onChange: (value: CartesKpiPeriodMode) => void;
}
/**
* Segmented toggle that flips the four Cartes KPI cards between the reference
* month (current default) and a Year-to-Date view. The 13-month sparkline and
* the Seasonality / Top Movers / Budget Adherence widgets are unaffected they
* always remain monthly by design. Persistence is owned by the parent hook
* (`useCartes`); this component is a controlled input only.
*/
export default function CartesPeriodModeToggle({
value,
onChange,
}: CartesPeriodModeToggleProps) {
const { t } = useTranslation();
const options: { type: CartesKpiPeriodMode; icon: React.ReactNode; label: string }[] = [
{
type: "month",
icon: <MonthIcon size={14} />,
label: t("reports.cartes.periodMode.month"),
},
{
type: "ytd",
icon: <YtdIcon size={14} />,
label: t("reports.cartes.periodMode.ytd"),
},
];
return (
<div
className="inline-flex gap-1"
role="group"
aria-label={t("reports.cartes.periodMode.aria")}
>
{options.map(({ type, icon, label }) => (
<button
key={type}
type="button"
onClick={() => onChange(type)}
aria-pressed={value === type}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
value === type
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
{icon}
{label}
</button>
))}
</div>
);
}

View file

@ -1,97 +0,0 @@
import { useTranslation } from "react-i18next";
import {
ComposedChart,
Bar,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
} from "recharts";
import type { CartesMonthFlow } from "../../../shared/types";
export interface IncomeExpenseOverlayChartProps {
flow: CartesMonthFlow[];
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatMonthShort(month: string, language: string): string {
const [y, m] = month.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return month;
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "short",
year: "2-digit",
}).format(new Date(y, m - 1, 1));
}
export default function IncomeExpenseOverlayChart({ flow }: IncomeExpenseOverlayChartProps) {
const { t, i18n } = useTranslation();
if (flow.length === 0) {
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
{t("reports.empty.noData")}
</div>
);
}
const data = flow.map((p) => ({
...p,
label: formatMonthShort(p.month, i18n.language),
}));
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
<div className="text-sm font-medium text-[var(--foreground)] mb-3">
{t("reports.cartes.flowChartTitle")}
</div>
<ResponsiveContainer width="100%" height={260}>
<ComposedChart data={data} margin={{ top: 10, right: 20, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="label" stroke="var(--muted-foreground)" fontSize={11} />
<YAxis
stroke="var(--muted-foreground)"
fontSize={11}
tickFormatter={(v) => formatCurrency(v, i18n.language)}
width={80}
/>
<Tooltip
formatter={(value) =>
typeof value === "number" ? formatCurrency(value, i18n.language) : String(value)
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
}}
/>
<Legend
wrapperStyle={{ paddingTop: 8, fontSize: 12, color: "var(--muted-foreground)" }}
/>
<ReferenceLine y={0} stroke="var(--border)" />
<Bar dataKey="income" name={t("reports.cartes.income")} fill="var(--positive)" />
<Bar dataKey="expenses" name={t("reports.cartes.expenses")} fill="var(--negative)" />
<Line
type="monotone"
dataKey="net"
name={t("reports.cartes.net")}
stroke="var(--primary)"
strokeWidth={2}
dot={{ r: 2 }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,154 +0,0 @@
import { useTranslation } from "react-i18next";
import { HelpCircle } from "lucide-react";
import KpiSparkline from "./KpiSparkline";
import type { CartesKpi, CartesKpiId } from "../../../shared/types";
export interface KpiCardProps {
id: CartesKpiId;
title: string;
kpi: CartesKpi;
format: "currency" | "percent";
/** When true, positive deltas are rendered in red (e.g. rising expenses). */
deltaIsBadWhenUp?: boolean;
/** Optional help text shown on hover of a (?) icon next to the title. */
tooltip?: string;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPercent(value: number, language: string, signed = false): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: signed ? "always" : "auto",
}).format(value / 100);
}
function formatValue(value: number, format: "currency" | "percent", language: string): string {
return format === "currency" ? formatCurrency(value, language) : formatPercent(value, language);
}
function formatDeltaAbs(
value: number,
format: "currency" | "percent",
language: string,
): string {
if (format === "currency") {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(value);
}
// Savings rate delta in percentage points — not a % of %
const formatted = new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
maximumFractionDigits: 1,
signDisplay: "always",
}).format(value);
return `${formatted} pt`;
}
interface DeltaBadgeProps {
abs: number | null;
pct: number | null;
label: string;
format: "currency" | "percent";
language: string;
deltaIsBadWhenUp: boolean;
}
function DeltaBadge({ abs, pct, label, format, language, deltaIsBadWhenUp }: DeltaBadgeProps) {
if (abs === null) {
return (
<div className="flex flex-col items-start">
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</span>
<span className="text-xs text-[var(--muted-foreground)] italic"></span>
</div>
);
}
const isUp = abs >= 0;
const isBad = deltaIsBadWhenUp ? isUp : !isUp;
// Treat near-zero as neutral
const isNeutral = abs === 0;
const colorClass = isNeutral
? "text-[var(--muted-foreground)]"
: isBad
? "text-[var(--negative)]"
: "text-[var(--positive)]";
const absText = formatDeltaAbs(abs, format, language);
const pctText = pct === null ? "" : ` (${formatPercent(pct, language, true)})`;
return (
<div className="flex flex-col items-start">
<span className="text-[10px] uppercase tracking-wide text-[var(--muted-foreground)]">
{label}
</span>
<span className={`text-xs font-medium tabular-nums ${colorClass}`}>
{absText}
{pctText}
</span>
</div>
);
}
export default function KpiCard({
id,
title,
kpi,
format,
deltaIsBadWhenUp = false,
tooltip,
}: KpiCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.language;
return (
<div
data-kpi={id}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3"
>
<div className="text-sm text-[var(--muted-foreground)] flex items-center gap-1">
<span>{title}</span>
{tooltip && (
<span
title={tooltip}
aria-label={tooltip}
className="inline-flex items-center text-[var(--muted-foreground)] cursor-help"
>
<HelpCircle size={12} aria-hidden="true" />
</span>
)}
</div>
<div className="text-2xl font-bold tabular-nums text-[var(--foreground)]">
{kpi.current === null ? "—" : formatValue(kpi.current, format, language)}
</div>
<KpiSparkline data={kpi.sparkline} />
<div className="flex items-start justify-between gap-2 pt-1 border-t border-[var(--border)]">
<DeltaBadge
abs={kpi.deltaMoMAbs}
pct={kpi.deltaMoMPct}
label={t("reports.cartes.deltaMoMLabel")}
format={format}
language={language}
deltaIsBadWhenUp={deltaIsBadWhenUp}
/>
<DeltaBadge
abs={kpi.deltaYoYAbs}
pct={kpi.deltaYoYPct}
label={t("reports.cartes.deltaYoYLabel")}
format={format}
language={language}
deltaIsBadWhenUp={deltaIsBadWhenUp}
/>
</div>
</div>
);
}

View file

@ -1,50 +0,0 @@
import { LineChart, Line, ResponsiveContainer, YAxis, ReferenceDot } from "recharts";
import type { CartesSparklinePoint } from "../../../shared/types";
export interface KpiSparklineProps {
data: CartesSparklinePoint[];
color?: string;
height?: number;
}
/**
* Compact line chart with the reference month (the last point) highlighted
* by a filled dot. Rendered inside the KPI cards on the Cartes page.
*/
export default function KpiSparkline({
data,
color = "var(--primary)",
height = 40,
}: KpiSparklineProps) {
if (data.length === 0) {
return <div style={{ width: "100%", height }} />;
}
const chartData = data.map((p, index) => ({ index, value: p.value, month: p.month }));
const lastIndex = chartData.length - 1;
const lastValue = chartData[lastIndex]?.value ?? 0;
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={chartData} margin={{ top: 4, right: 6, bottom: 2, left: 2 }}>
<YAxis hide domain={["dataMin", "dataMax"]} />
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={1.75}
dot={false}
isAnimationActive={false}
/>
<ReferenceDot
x={lastIndex}
y={lastValue}
r={3.5}
fill={color}
stroke="var(--card)"
strokeWidth={1.5}
/>
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -1,106 +0,0 @@
import { useTranslation } from "react-i18next";
import { CalendarClock } from "lucide-react";
import type { CartesSeasonality } from "../../../shared/types";
export interface SeasonalityCardProps {
seasonality: CartesSeasonality;
referenceYear: number;
referenceMonth: number;
}
function formatCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(amount);
}
function formatPct(pct: number, language: string, signed = true): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: signed ? "always" : "auto",
}).format(pct / 100);
}
function formatMonthYear(year: number, month: number, language: string): string {
return new Intl.DateTimeFormat(language === "fr" ? "fr-CA" : "en-CA", {
month: "long",
year: "numeric",
}).format(new Date(year, month - 1, 1));
}
export default function SeasonalityCard({
seasonality,
referenceYear,
referenceMonth,
}: SeasonalityCardProps) {
const { t, i18n } = useTranslation();
const language = i18n.language;
const { referenceAmount, historicalYears, historicalAverage, deviationPct } = seasonality;
const refLabel = formatMonthYear(referenceYear, referenceMonth, language);
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<CalendarClock size={16} className="text-[var(--primary)]" />
<h3 className="text-sm font-medium text-[var(--foreground)]">
{t("reports.cartes.seasonalityTitle")}
</h3>
</div>
{historicalYears.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.cartes.seasonalityEmpty")}
</div>
) : (
<div className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs text-[var(--muted-foreground)]">{refLabel}</span>
<span className="text-lg font-bold tabular-nums text-[var(--foreground)]">
{formatCurrency(referenceAmount, language)}
</span>
</div>
<div className="flex flex-col gap-1 text-xs">
{historicalYears.map((y) => (
<div
key={y.year}
className="flex items-center justify-between text-[var(--muted-foreground)]"
>
<span>{y.year}</span>
<span className="tabular-nums">{formatCurrency(y.amount, language)}</span>
</div>
))}
{historicalAverage !== null && (
<div className="flex items-center justify-between border-t border-[var(--border)] pt-1 mt-1 text-[var(--foreground)]">
<span>{t("reports.cartes.seasonalityAverage")}</span>
<span className="tabular-nums font-medium">
{formatCurrency(historicalAverage, language)}
</span>
</div>
)}
</div>
{deviationPct !== null && (
<div
className={`text-xs font-medium ${
deviationPct > 5
? "text-[var(--negative)]"
: deviationPct < -5
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
>
{t("reports.cartes.seasonalityDeviation", {
pct: formatPct(deviationPct, language),
})}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -1,86 +0,0 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TrendingUp, TrendingDown } from "lucide-react";
import type { CartesTopMover } from "../../../shared/types";
export interface TopMoversListProps {
movers: CartesTopMover[];
direction: "up" | "down";
}
function formatSignedCurrency(amount: number, language: string): string {
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
signDisplay: "always",
}).format(amount);
}
function formatPct(pct: number | null, language: string): string {
if (pct === null) return "—";
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "always",
}).format(pct / 100);
}
function categoryHref(categoryId: number | null): string {
if (categoryId === null) return "/transactions";
const params = new URLSearchParams(window.location.search);
params.set("cat", String(categoryId));
return `/reports/category?${params.toString()}`;
}
export default function TopMoversList({ movers, direction }: TopMoversListProps) {
const { t, i18n } = useTranslation();
const title =
direction === "up"
? t("reports.cartes.topMoversUp")
: t("reports.cartes.topMoversDown");
const Icon = direction === "up" ? TrendingUp : TrendingDown;
const accentClass = direction === "up" ? "text-[var(--negative)]" : "text-[var(--positive)]";
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Icon size={16} className={accentClass} />
<h3 className="text-sm font-medium text-[var(--foreground)]">{title}</h3>
</div>
{movers.length === 0 ? (
<div className="text-xs italic text-[var(--muted-foreground)] py-2">
{t("reports.empty.noData")}
</div>
) : (
<ul className="flex flex-col gap-1">
{movers.map((m) => (
<li key={`${m.categoryId ?? "uncat"}-${m.categoryName}`}>
<Link
to={categoryHref(m.categoryId)}
className="flex items-center justify-between gap-3 px-2 py-1.5 rounded-md hover:bg-[var(--muted)] transition-colors"
>
<span className="flex items-center gap-2 min-w-0">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: m.categoryColor }}
/>
<span className="truncate text-sm text-[var(--foreground)]">
{m.categoryName}
</span>
</span>
<span className={`text-xs font-medium tabular-nums ${accentClass}`}>
{formatSignedCurrency(m.deltaAbs, i18n.language)}
<span className="text-[var(--muted-foreground)] ml-1">
{formatPct(m.deltaPct, i18n.language)}
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -1,79 +0,0 @@
import { useTranslation } from "react-i18next";
import { User, LogIn, LogOut, Loader2, AlertCircle } from "lucide-react";
import { useAuth } from "../../hooks/useAuth";
export default function AccountCard() {
const { t } = useTranslation();
const { state, login, logout } = useAuth();
return (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<User size={18} />
{t("account.title")}
<span className="text-xs font-normal text-[var(--muted-foreground)]">
{t("account.optional")}
</span>
</h2>
{state.status === "error" && state.error && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<p>{state.error}</p>
</div>
)}
{state.status === "authenticated" && state.account && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[var(--primary)] text-white flex items-center justify-center font-semibold text-sm">
{(state.account.name || state.account.email).charAt(0).toUpperCase()}
</div>
<div>
<p className="font-medium">
{state.account.name || state.account.email}
</p>
{state.account.name && (
<p className="text-sm text-[var(--muted-foreground)]">
{state.account.email}
</p>
)}
</div>
</div>
<button
type="button"
onClick={logout}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors text-sm"
>
<LogOut size={14} />
{t("account.signOut")}
</button>
</div>
)}
{(state.status === "unauthenticated" || state.status === "idle") && (
<div className="space-y-3">
<p className="text-sm text-[var(--muted-foreground)]">
{t("account.description")}
</p>
<button
type="button"
onClick={login}
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-sm"
>
<LogIn size={14} />
{t("account.signIn")}
</button>
</div>
)}
{state.status === "loading" && (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 size={14} className="animate-spin" />
{t("common.loading")}
</div>
)}
</div>
);
}

View file

@ -1,158 +0,0 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FolderTree, ChevronRight, MoveRight, RotateCcw } from "lucide-react";
import { getPreference } from "../../services/userPreferenceService";
import {
readLastMigrationJournal,
type RestorableMigrationJournal,
} from "../../services/categoryRestoreService";
import CategoriesMigrationBackupBanner from "./CategoriesMigrationBackupBanner";
import CategoriesMigrationRestoreModal from "./CategoriesMigrationRestoreModal";
/**
* Card that surfaces category-related entries in the Settings page.
*
* Entries, decided by the profile's categories_schema_version and whether a
* migration was previously recorded:
* - "Standard categories guide" (always visible) read-only tree (#117).
* - "Migrate to the standard structure" (v2 only) 3-step migration (#121).
* - "Restore a backup" (when a migration journal exists, regardless of age)
* opens the same modal as the 90-day banner (#122).
*
* Profiles already on v1 never see the migrate entry (they're done) but they
* keep the Restore entry as long as a journal is present, so they can always
* roll back even after the banner has expired.
*/
export default function CategoriesCard() {
const { t } = useTranslation();
const [showMigrate, setShowMigrate] = useState(false);
const [journal, setJournal] = useState<RestorableMigrationJournal | null>(
null,
);
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [version, loadedJournal] = await Promise.all([
getPreference("categories_schema_version"),
readLastMigrationJournal(),
]);
if (cancelled) return;
setShowMigrate(version === "v2");
setJournal(loadedJournal);
} catch {
if (!cancelled) {
setShowMigrate(false);
setJournal(null);
}
}
})();
return () => {
cancelled = true;
};
}, []);
// The permanent Restore entry is shown whenever a journal exists, even past
// the 90-day banner window AND even after the user already reverted (a user
// who wants to re-import the backup a second time is free to do so — the
// SREF file is still valid as long as it is on disk).
const showRestoreEntry = journal !== null;
return (
<div className="space-y-3">
{/* Post-migration 90-day banner renders only within window and when
un-dismissed and not-yet-reverted. */}
<CategoriesMigrationBackupBanner />
<Link
to="/settings/categories/standard"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<FolderTree size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">
{t("settings.categoriesCard.standardGuideTitle")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.categoriesCard.standardGuideDescription")}
</p>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
/>
</div>
</Link>
{showMigrate && (
<Link
to="/settings/categories/migrate"
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<MoveRight size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">
{t("settings.categoriesCard.migrateTitle")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.categoriesCard.migrateDescription")}
</p>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
/>
</div>
</Link>
)}
{showRestoreEntry && journal && (
<button
type="button"
onClick={() => setIsRestoreModalOpen(true)}
className="block w-full text-left bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
<RotateCcw size={22} />
</div>
<div>
<h2 className="text-lg font-semibold">
{t("settings.categoriesCard.restoreEntry.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.categoriesCard.restoreEntry.description")}
</p>
</div>
</div>
<ChevronRight
size={18}
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
/>
</div>
</button>
)}
{isRestoreModalOpen && journal && (
<CategoriesMigrationRestoreModal
journal={journal}
onClose={() => setIsRestoreModalOpen(false)}
/>
)}
</div>
);
}

View file

@ -1,133 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldCheck, X, RotateCcw } from "lucide-react";
import {
CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY,
readLastMigrationJournal,
shouldShowBanner,
type RestorableMigrationJournal,
} from "../../services/categoryRestoreService";
import { getPreference, setPreference } from "../../services/userPreferenceService";
import CategoriesMigrationRestoreModal from "./CategoriesMigrationRestoreModal";
// -----------------------------------------------------------------------------
// Post-migration backup banner (shown in Settings > Categories for 90 days).
//
// Decides visibility client-side using the `shouldShowBanner` pure helper and
// three prefs:
// - `last_categories_migration` JSON (written by categoryMigrationService).
// - `categories_migration_banner_dismissed` (flag, "1" = dismissed).
// - `reverted_at` (merged into the journal once a restore succeeds).
//
// The banner never surfaces the restore action directly — it opens a confirm
// modal that enforces a two-step consent before the destructive wipe.
// -----------------------------------------------------------------------------
type Visibility = "loading" | "visible" | "hidden";
export default function CategoriesMigrationBackupBanner() {
const { t } = useTranslation();
const [visibility, setVisibility] = useState<Visibility>("loading");
const [journal, setJournal] = useState<RestorableMigrationJournal | null>(
null,
);
const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [loadedJournal, dismissed] = await Promise.all([
readLastMigrationJournal(),
getPreference(CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY),
]);
if (cancelled) return;
setJournal(loadedJournal);
setVisibility(
shouldShowBanner(loadedJournal, dismissed) ? "visible" : "hidden",
);
} catch {
if (!cancelled) setVisibility("hidden");
}
})();
return () => {
cancelled = true;
};
}, []);
async function dismiss() {
// Optimistically hide, then persist. Matches the pattern used in
// CategoriesV1DiscoveryBanner — a failed write just means the banner will
// reappear on next launch, which is an acceptable degradation.
setVisibility("hidden");
try {
await setPreference(CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY, "1");
} catch {
// Swallow — see comment above.
}
}
if (visibility !== "visible" || !journal) return null;
// Compute the 90-day deadline for display. Falls back to the raw timestamp
// if the journal date cannot be parsed (the banner is still useful in that
// edge case — the CTA does not rely on this value).
const expiryLabel = formatExpiry(journal.timestamp);
return (
<>
<div
role="status"
className="flex items-start gap-3 rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 p-4"
>
<div className="mt-0.5 shrink-0 rounded-lg bg-[var(--primary)]/10 p-2 text-[var(--primary)]">
<ShieldCheck size={18} />
</div>
<div className="flex-1 space-y-2 text-sm">
<p className="font-semibold text-[var(--foreground)]">
{t("settings.categoriesCard.restoreBanner.title")}
</p>
<p className="text-[var(--muted-foreground)]">
{t("settings.categoriesCard.restoreBanner.description", {
expiry: expiryLabel,
})}
</p>
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center gap-1.5 font-medium text-[var(--primary)] hover:underline"
>
<RotateCcw size={14} />
{t("settings.categoriesCard.restoreBanner.cta")}
</button>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("settings.categoriesCard.restoreBanner.dismiss")}
className="shrink-0 rounded-md p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
</button>
</div>
{isModalOpen && (
<CategoriesMigrationRestoreModal
journal={journal}
onClose={() => setIsModalOpen(false)}
/>
)}
</>
);
}
/**
* Produce an end-of-window label: backup timestamp + 90 days, formatted in
* the browser locale. Returns the raw journal timestamp on parse failure.
*/
function formatExpiry(journalTimestamp: string): string {
const ts = Date.parse(journalTimestamp);
if (Number.isNaN(ts)) return journalTimestamp;
const deadline = new Date(ts + 90 * 24 * 60 * 60 * 1000);
return deadline.toLocaleDateString();
}

View file

@ -1,360 +0,0 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { AlertTriangle, FolderOpen, Loader2, X } from "lucide-react";
import {
backupFileExists,
isFileEncrypted,
pickBackupFile,
restoreFromBackup,
RestoreError,
type RestorableMigrationJournal,
} from "../../services/categoryRestoreService";
// -----------------------------------------------------------------------------
// Confirmation modal used by both the in-window banner and the permanent
// Settings entry to roll back a category migration. Mounted via a portal on
// document.body so it escapes any ancestor stacking context.
//
// High-level flow:
// 1. Mount with the recorded `journal` — show the date + path and two CTAs.
// 2. On "Restore" click: verify the recorded file still exists on disk. If
// not, flip to the "missing file" state and let the user pick a file.
// 3. If the file is SREF-encrypted, prompt the PIN; otherwise proceed.
// 4. Run `restoreFromBackup`, reload the app on success so every page sees
// the restored state.
//
// The UX intentionally keeps destructive action behind an explicit "Restore"
// button — a single click does not restore; the user must read the warning
// and click again. The banner and the permanent Settings entry both route
// to this same component.
// -----------------------------------------------------------------------------
type Phase =
| "confirm" // initial screen with the data/path + Restore/Cancel buttons
| "missing" // recorded path no longer on disk — user must pick a file
| "password" // detected SREF encryption — ask for the PIN
| "restoring" // in-flight restore call
| "error"; // failed — surface the error and allow retry
interface CategoriesMigrationRestoreModalProps {
journal: RestorableMigrationJournal;
onClose: () => void;
/**
* Called after the restore finishes successfully. By default, the app is
* reloaded to refresh all data-bound views. Consumers may override this
* (e.g. in tests) but the default is the safe path.
*/
onRestored?: () => void;
}
export default function CategoriesMigrationRestoreModal({
journal,
onClose,
onRestored,
}: CategoriesMigrationRestoreModalProps) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("confirm");
const [effectivePath, setEffectivePath] = useState<string>(journal.backupPath);
const [password, setPassword] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [isEncrypted, setIsEncrypted] = useState<boolean>(false);
// Close on Escape (disabled while restoring to avoid interrupting the wipe).
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape" && phase !== "restoring") onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose, phase]);
const formattedDate = formatJournalDate(journal.timestamp);
// Centralised error mapping — keeps the rendered messages i18n-driven.
function messageForError(e: unknown): string {
if (e instanceof RestoreError) {
return t(
`settings.categoriesCard.restoreModal.errors.${e.code}`,
t("settings.categoriesCard.restoreModal.errors.read_failed"),
);
}
return e instanceof Error ? e.message : String(e);
}
async function beginRestore(pathToUse: string) {
setError(null);
// 1. Verify the file is still on disk.
const exists = await backupFileExists(pathToUse);
if (!exists) {
setPhase("missing");
return;
}
// 2. If it is SREF-encrypted, branch to the password prompt.
let encrypted = false;
try {
encrypted = await isFileEncrypted(pathToUse);
} catch {
// If we cannot even inspect the file, treat it as unreadable.
setPhase("error");
setError(t("settings.categoriesCard.restoreModal.errors.read_failed"));
return;
}
setIsEncrypted(encrypted);
setEffectivePath(pathToUse);
if (encrypted) {
setPhase("password");
return;
}
await runRestore(pathToUse, null);
}
async function runRestore(pathToUse: string, pw: string | null) {
setPhase("restoring");
setError(null);
try {
await restoreFromBackup(pathToUse, pw);
if (onRestored) {
onRestored();
} else {
// Full reload so every page (dashboard, transactions, etc.) reflects
// the restored v2 state instead of stale v1 derived data.
window.location.reload();
}
} catch (e) {
setError(messageForError(e));
setPhase("error");
}
}
async function handlePickFile() {
setError(null);
try {
const picked = await pickBackupFile();
if (!picked) {
// User cancelled; stay in the "missing" state so they can retry.
return;
}
// Reset password between attempts on different files.
setPassword("");
await beginRestore(picked);
} catch (e) {
setError(messageForError(e));
setPhase("error");
}
}
function handlePasswordSubmit(e: React.FormEvent) {
e.preventDefault();
void runRestore(effectivePath, password.length > 0 ? password : null);
}
const isBusy = phase === "restoring";
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 p-4"
onClick={(e) => {
if (e.target === e.currentTarget && !isBusy) onClose();
}}
role="dialog"
aria-modal="true"
aria-labelledby="restore-modal-title"
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-lg">
{/* Header */}
<div className="flex items-start justify-between px-6 py-4 border-b border-[var(--border)]">
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0 rounded-lg bg-[var(--negative)]/10 p-2 text-[var(--negative)]">
<AlertTriangle size={18} />
</div>
<div>
<h2 id="restore-modal-title" className="text-lg font-semibold">
{t("settings.categoriesCard.restoreModal.title")}
</h2>
<p className="text-sm text-[var(--muted-foreground)] mt-0.5">
{t("settings.categoriesCard.restoreModal.subtitle", {
date: formattedDate,
})}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
aria-label={t("settings.categoriesCard.restoreModal.close")}
className="shrink-0 rounded-md p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] transition-colors disabled:opacity-50"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 space-y-4">
{/* Destructive action warning — kept visible across every phase */}
<p className="text-sm text-[var(--foreground)]">
{t("settings.categoriesCard.restoreModal.warning")}
</p>
{phase === "confirm" && (
<div className="rounded-lg bg-[var(--muted)] px-3 py-2 text-xs text-[var(--muted-foreground)] break-all">
<p className="font-medium text-[var(--foreground)] mb-1">
{t("settings.categoriesCard.restoreModal.filePath")}
</p>
<code>{journal.backupPath}</code>
</div>
)}
{phase === "missing" && (
<div className="rounded-lg border border-[var(--negative)]/30 bg-[var(--negative)]/5 px-3 py-3 text-sm text-[var(--foreground)] space-y-2">
<p className="font-medium text-[var(--negative)]">
{t("settings.categoriesCard.restoreModal.fileMissingTitle")}
</p>
<p className="text-[var(--muted-foreground)] text-xs break-all">
{journal.backupPath}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{t("settings.categoriesCard.restoreModal.fileMissingHint")}
</p>
</div>
)}
{phase === "password" && (
<form onSubmit={handlePasswordSubmit} className="space-y-3">
<label className="block text-sm font-medium">
{t("settings.categoriesCard.restoreModal.passwordLabel")}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:border-[var(--primary)] focus:outline-none"
/>
<p className="text-xs text-[var(--muted-foreground)]">
{t("settings.categoriesCard.restoreModal.passwordHelp")}
</p>
</form>
)}
{phase === "restoring" && (
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.categoriesCard.restoreModal.restoring")}
</div>
)}
{phase === "error" && error && (
<div className="rounded-lg border border-[var(--negative)]/30 bg-[var(--negative)]/5 px-3 py-3 text-sm text-[var(--negative)]">
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
{phase === "confirm" && (
<>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("settings.categoriesCard.restoreModal.cancel")}
</button>
<button
type="button"
onClick={() => beginRestore(journal.backupPath)}
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
>
{t("settings.categoriesCard.restoreModal.confirm")}
</button>
</>
)}
{phase === "missing" && (
<>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("settings.categoriesCard.restoreModal.cancel")}
</button>
<button
type="button"
onClick={handlePickFile}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
>
<FolderOpen size={16} />
{t("settings.categoriesCard.restoreModal.pickFile")}
</button>
</>
)}
{phase === "password" && (
<>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("settings.categoriesCard.restoreModal.cancel")}
</button>
<button
type="button"
onClick={() =>
runRestore(effectivePath, password.length > 0 ? password : null)
}
disabled={password.length === 0}
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("settings.categoriesCard.restoreModal.confirm")}
</button>
</>
)}
{phase === "error" && (
<>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("settings.categoriesCard.restoreModal.close")}
</button>
<button
type="button"
onClick={() =>
isEncrypted
? setPhase("password")
: void beginRestore(effectivePath)
}
className="px-4 py-2 text-sm font-medium rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
>
{t("settings.categoriesCard.restoreModal.retry")}
</button>
</>
)}
</div>
</div>
</div>,
document.body,
);
}
/**
* Format the backup ISO timestamp as a human-readable local date+time.
* Falls back to the raw string on invalid input.
*/
function formatJournalDate(iso: string): string {
const ts = Date.parse(iso);
if (Number.isNaN(ts)) return iso;
return new Date(ts).toLocaleString();
}

View file

@ -1,67 +0,0 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Globe } from "lucide-react";
interface FeedbackConsentDialogProps {
onAccept: () => void;
onCancel: () => void;
}
export default function FeedbackConsentDialog({
onAccept,
onCancel,
}: FeedbackConsentDialogProps) {
const { t } = useTranslation();
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onCancel();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onCancel]);
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Globe size={18} />
{t("feedback.consent.title")}
</h2>
<button
onClick={onCancel}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
aria-label={t("feedback.dialog.cancel")}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4 space-y-3 text-sm text-[var(--muted-foreground)]">
<p>{t("feedback.consent.body")}</p>
</div>
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
<button
onClick={onCancel}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
{t("feedback.dialog.cancel")}
</button>
<button
onClick={onAccept}
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
{t("feedback.consent.accept")}
</button>
</div>
</div>
</div>,
document.body,
);
}

View file

@ -1,239 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { X, MessageSquarePlus, CheckCircle, AlertCircle } from "lucide-react";
import { useFeedback } from "../../hooks/useFeedback";
import { useAuth } from "../../hooks/useAuth";
import { getRecentErrorLogs } from "../../services/logService";
import {
getFeedbackUserAgent,
type FeedbackContext,
} from "../../services/feedbackService";
const MAX_CONTENT_LENGTH = 2000;
const LOGS_SUFFIX_MAX = 800;
const RECENT_ERROR_LOGS_N = 20;
const AUTO_CLOSE_DELAY_MS = 2000;
interface FeedbackDialogProps {
onClose: () => void;
}
export default function FeedbackDialog({ onClose }: FeedbackDialogProps) {
const { t, i18n } = useTranslation();
const location = useLocation();
const { state: authState } = useAuth();
const { state: feedbackState, submit, reset } = useFeedback();
const [content, setContent] = useState("");
const [includeContext, setIncludeContext] = useState(false);
const [includeLogs, setIncludeLogs] = useState(false);
const [identify, setIdentify] = useState(false);
const isAuthenticated = authState.status === "authenticated";
const userEmail = authState.account?.email ?? null;
const trimmed = content.trim();
const isSending = feedbackState.status === "sending";
const isSuccess = feedbackState.status === "success";
const canSubmit = trimmed.length > 0 && !isSending && !isSuccess;
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape" && !isSending) onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose, isSending]);
// Auto-close after success
useEffect(() => {
if (!isSuccess) return;
const timer = setTimeout(() => {
onClose();
reset();
}, AUTO_CLOSE_DELAY_MS);
return () => clearTimeout(timer);
}, [isSuccess, onClose, reset]);
const errorMessage = useMemo(() => {
if (feedbackState.status !== "error" || !feedbackState.errorCode) return null;
switch (feedbackState.errorCode) {
case "rate_limit":
return t("feedback.toast.error.429");
case "invalid":
return t("feedback.toast.error.400");
case "network_error":
case "server_error":
default:
return t("feedback.toast.error.generic");
}
}, [feedbackState, t]);
async function handleSubmit() {
if (!canSubmit) return;
// Compose content with optional logs suffix, keeping total ≤ 2000 chars
let body = trimmed;
if (includeLogs) {
const rawLogs = getRecentErrorLogs(RECENT_ERROR_LOGS_N);
if (rawLogs.length > 0) {
const trimmedLogs = rawLogs.slice(-LOGS_SUFFIX_MAX);
const suffix = `\n\n---\n${t("feedback.logsHeading")}\n${trimmedLogs}`;
const available = MAX_CONTENT_LENGTH - body.length;
if (available > suffix.length) {
body = body + suffix;
} else if (available > 50) {
body = body + suffix.slice(0, available);
}
}
}
// Compose context if opted in
let context: FeedbackContext | undefined;
if (includeContext) {
let userAgent = "Simpl'Résultat";
try {
userAgent = await getFeedbackUserAgent();
} catch {
// fall back to a basic string if the Rust helper fails
}
context = {
page: location.pathname,
locale: i18n.language,
theme: document.documentElement.classList.contains("dark") ? "dark" : "light",
viewport: `${window.innerWidth}x${window.innerHeight}`,
userAgent,
timestamp: new Date().toISOString(),
};
}
const userId = identify && isAuthenticated ? userEmail : null;
await submit({ content: body, userId, context });
}
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget && !isSending) onClose();
}}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-lg mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold flex items-center gap-2">
<MessageSquarePlus size={18} />
{t("feedback.dialog.title")}
</h2>
<button
onClick={onClose}
disabled={isSending}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] disabled:opacity-50"
aria-label={t("feedback.dialog.cancel")}
>
<X size={18} />
</button>
</div>
<div className="px-6 py-4 space-y-3">
{isSuccess ? (
<div className="flex items-center gap-2 text-[var(--positive)] py-8 justify-center">
<CheckCircle size={20} />
<span>{t("feedback.toast.success")}</span>
</div>
) : (
<>
<textarea
value={content}
onChange={(e) =>
setContent(e.target.value.slice(0, MAX_CONTENT_LENGTH))
}
placeholder={t("feedback.dialog.placeholder")}
rows={6}
disabled={isSending}
className="w-full px-3 py-2 rounded-lg bg-[var(--background)] border border-[var(--border)] text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--primary)] disabled:opacity-50"
/>
<div className="flex justify-end text-xs text-[var(--muted-foreground)]">
{content.length}/{MAX_CONTENT_LENGTH}
</div>
<div className="space-y-2 text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeContext}
onChange={(e) => setIncludeContext(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>{t("feedback.checkbox.context")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeLogs}
onChange={(e) => setIncludeLogs(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>{t("feedback.checkbox.logs")}</span>
</label>
{isAuthenticated && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={identify}
onChange={(e) => setIdentify(e.target.checked)}
disabled={isSending}
className="h-4 w-4"
/>
<span>
{t("feedback.checkbox.identify")}
{userEmail && (
<span className="text-[var(--muted-foreground)]">
{" "}
({userEmail})
</span>
)}
</span>
</label>
)}
</div>
{errorMessage && (
<div className="flex items-start gap-2 text-sm text-[var(--negative)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
</>
)}
</div>
{!isSuccess && (
<div className="flex justify-end gap-2 px-6 py-4 border-t border-[var(--border)]">
<button
onClick={onClose}
disabled={isSending}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{t("feedback.dialog.cancel")}
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="px-4 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isSending
? t("feedback.dialog.sending")
: t("feedback.dialog.submit")}
</button>
</div>
)}
</div>
</div>,
document.body,
);
}

View file

@ -1,16 +1,8 @@
import { useState, useEffect, useCallback } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { openUrl } from "@tauri-apps/plugin-opener"; import { openUrl } from "@tauri-apps/plugin-opener";
import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { KeyRound, CheckCircle, AlertCircle, Loader2, ExternalLink } from "lucide-react";
import { useLicense } from "../../hooks/useLicense"; import { useLicense } from "../../hooks/useLicense";
import {
MachineInfo,
ActivationStatus,
activateMachine,
deactivateMachine,
listActivatedMachines,
getActivationStatus,
} from "../../services/licenseService";
const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat"; const PURCHASE_URL = "https://lacompagniemaximus.com/simpl-resultat";
@ -19,75 +11,6 @@ export default function LicenseCard() {
const { state, submitKey } = useLicense(); const { state, submitKey } = useLicense();
const [keyInput, setKeyInput] = useState(""); const [keyInput, setKeyInput] = useState("");
const [showInput, setShowInput] = useState(false); const [showInput, setShowInput] = useState(false);
const [showMachines, setShowMachines] = useState(false);
const [machines, setMachines] = useState<MachineInfo[]>([]);
const [activation, setActivation] = useState<ActivationStatus | null>(null);
const [machineLoading, setMachineLoading] = useState(false);
const [deactivatingId, setDeactivatingId] = useState<string | null>(null);
const [machineError, setMachineError] = useState<string | null>(null);
const hasLicense = state.edition !== "free";
const loadActivation = useCallback(async () => {
if (!hasLicense) return;
try {
const status = await getActivationStatus();
setActivation(status);
} catch {
// Ignore — activation status is best-effort
}
}, [hasLicense]);
const loadMachines = useCallback(async () => {
setMachineLoading(true);
setMachineError(null);
try {
const list = await listActivatedMachines();
setMachines(list);
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setMachineLoading(false);
}
}, []);
useEffect(() => {
void loadActivation();
}, [loadActivation]);
const handleActivate = async () => {
setMachineLoading(true);
setMachineError(null);
try {
await activateMachine();
await loadActivation();
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setMachineLoading(false);
}
};
const handleDeactivate = async (machineId: string) => {
setDeactivatingId(machineId);
try {
await deactivateMachine(machineId);
await loadActivation();
await loadMachines();
} catch (e) {
setMachineError(e instanceof Error ? e.message : String(e));
} finally {
setDeactivatingId(null);
}
};
const toggleMachines = async () => {
const next = !showMachines;
setShowMachines(next);
if (next && machines.length === 0) {
await loadMachines();
}
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -200,100 +123,6 @@ export default function LicenseCard() {
</div> </div>
</form> </form>
)} )}
{hasLicense && (
<div className="border-t border-[var(--border)] pt-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<Monitor size={16} />
{t("license.machines.title")}
</h3>
<div className="flex items-center gap-2">
{activation && !activation.is_activated && (
<button
type="button"
onClick={handleActivate}
disabled={machineLoading}
className="flex items-center gap-1 px-3 py-1 bg-[var(--primary)] text-white rounded-lg hover:opacity-90 transition-opacity text-xs disabled:opacity-50"
>
{machineLoading && <Loader2 size={12} className="animate-spin" />}
{t("license.activate")}
</button>
)}
{activation?.is_activated && (
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
<CheckCircle size={12} />
{t("license.machines.activated")}
</span>
)}
<button
type="button"
onClick={toggleMachines}
className="p-1 hover:bg-[var(--border)] rounded transition-colors"
>
{showMachines ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
{machineError && (
<div className="flex items-start gap-2 text-xs text-[var(--negative)]">
<AlertCircle size={14} className="mt-0.5 shrink-0" />
<p>{machineError}</p>
</div>
)}
{showMachines && (
<div className="space-y-2">
{machineLoading && machines.length === 0 && (
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<Loader2 size={12} className="animate-spin" />
{t("common.loading")}
</div>
)}
{!machineLoading && machines.length === 0 && (
<p className="text-xs text-[var(--muted-foreground)]">
{t("license.machines.noMachines")}
</p>
)}
{machines.map((m) => {
const isThis = activation?.machine_id === m.machine_id;
return (
<div
key={m.machine_id}
className="flex items-center justify-between px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-sm"
>
<div>
<span className="font-medium">
{m.machine_name || m.machine_id.slice(0, 12)}
</span>
{isThis && (
<span className="ml-2 text-xs text-[var(--positive)]">
({t("license.machines.thisMachine")})
</span>
)}
<p className="text-xs text-[var(--muted-foreground)]">
{new Date(m.activated_at).toLocaleDateString()}
</p>
</div>
<button
type="button"
onClick={() => handleDeactivate(m.machine_id)}
disabled={deactivatingId === m.machine_id}
className="flex items-center gap-1 px-2 py-1 text-xs border border-[var(--border)] rounded hover:bg-[var(--border)] transition-colors disabled:opacity-50"
>
{deactivatingId === m.machine_id && (
<Loader2 size={10} className="animate-spin" />
)}
{t("license.machines.deactivate")}
</button>
</div>
);
})}
</div>
)}
</div>
)}
</div> </div>
); );
} }

View file

@ -1,37 +1,16 @@
import { useState, useEffect, useRef, useSyncExternalStore } from "react"; import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollText, Trash2, Copy, Check, MessageSquarePlus } from "lucide-react"; import { ScrollText, Trash2, Copy, Check } from "lucide-react";
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService"; import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
import FeedbackDialog from "./FeedbackDialog";
import FeedbackConsentDialog from "./FeedbackConsentDialog";
type Filter = "all" | LogLevel; type Filter = "all" | LogLevel;
const FEEDBACK_CONSENT_KEY = "feedbackConsentAccepted";
export default function LogViewerCard() { export default function LogViewerCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const [filter, setFilter] = useState<Filter>("all"); const [filter, setFilter] = useState<Filter>("all");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [consentOpen, setConsentOpen] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const openFeedback = () => {
const accepted = localStorage.getItem(FEEDBACK_CONSENT_KEY) === "true";
if (accepted) {
setFeedbackOpen(true);
} else {
setConsentOpen(true);
}
};
const acceptConsent = () => {
localStorage.setItem(FEEDBACK_CONSENT_KEY, "true");
setConsentOpen(false);
setFeedbackOpen(true);
};
const logs = useSyncExternalStore(subscribe, getLogs, getLogs); const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter); const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
@ -75,13 +54,6 @@ export default function LogViewerCard() {
{t("settings.logs.title")} {t("settings.logs.title")}
</h2> </h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={openFeedback}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
>
<MessageSquarePlus size={14} />
{t("feedback.button")}
</button>
<button <button
onClick={handleCopy} onClick={handleCopy}
disabled={filtered.length === 0} disabled={filtered.length === 0}
@ -139,14 +111,6 @@ export default function LogViewerCard() {
)) ))
)} )}
</div> </div>
{consentOpen && (
<FeedbackConsentDialog
onAccept={acceptConsent}
onCancel={() => setConsentOpen(false)}
/>
)}
{feedbackOpen && <FeedbackDialog onClose={() => setFeedbackOpen(false)} />}
</div> </div>
); );
} }

View file

@ -1,69 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert, X } from "lucide-react";
import { getTokenStoreMode, TokenStoreMode } from "../../services/authService";
// Per-session dismissal flag. Kept in sessionStorage so the banner
// returns on the next app launch if the fallback condition still
// holds — this matches the acceptance criteria from issue #81.
const DISMISS_KEY = "tokenStoreFallbackBannerDismissed";
export default function TokenStoreFallbackBanner() {
const { t } = useTranslation();
const [mode, setMode] = useState<TokenStoreMode | null>(null);
const [dismissed, setDismissed] = useState<boolean>(() => {
try {
return sessionStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
});
useEffect(() => {
let cancelled = false;
getTokenStoreMode()
.then((m) => {
if (!cancelled) setMode(m);
})
.catch(() => {
if (!cancelled) setMode(null);
});
return () => {
cancelled = true;
};
}, []);
if (mode !== "file" || dismissed) return null;
const dismiss = () => {
try {
sessionStorage.setItem(DISMISS_KEY, "1");
} catch {
// Ignore storage errors — the banner will simply hide for the
// remainder of this render cycle via state.
}
setDismissed(true);
};
return (
<div className="flex items-start gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4">
<ShieldAlert size={20} className="mt-0.5 shrink-0 text-amber-500" />
<div className="flex-1 space-y-1 text-sm">
<p className="font-semibold text-[var(--foreground)]">
{t("account.tokenStore.fallback.title")}
</p>
<p className="text-[var(--muted-foreground)]">
{t("account.tokenStore.fallback.description")}
</p>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("common.close")}
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
</button>
</div>
);
}

View file

@ -1,5 +1,4 @@
import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { Category } from "../../shared/types"; import type { Category } from "../../shared/types";
interface CategoryComboboxProps { interface CategoryComboboxProps {
@ -8,7 +7,6 @@ interface CategoryComboboxProps {
onChange: (id: number | null) => void; onChange: (id: number | null) => void;
placeholder?: string; placeholder?: string;
compact?: boolean; compact?: boolean;
ariaLabel?: string;
/** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */ /** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */
extraOptions?: Array<{ value: string; label: string }>; extraOptions?: Array<{ value: string; label: string }>;
/** Called when an extra option is selected */ /** Called when an extra option is selected */
@ -17,75 +15,38 @@ interface CategoryComboboxProps {
activeExtra?: string | null; activeExtra?: string | null;
} }
// Strip accents + lowercase for accent-insensitive matching
function normalize(s: string): string {
return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
// Compute depth of each category based on parent_id chain
function computeDepths(categories: Category[]): Map<number, number> {
const byId = new Map<number, Category>();
for (const c of categories) byId.set(c.id, c);
const depths = new Map<number, number>();
function depthOf(id: number, seen: Set<number>): number {
if (depths.has(id)) return depths.get(id)!;
if (seen.has(id)) return 0;
seen.add(id);
const cat = byId.get(id);
if (!cat || cat.parent_id == null) {
depths.set(id, 0);
return 0;
}
const d = depthOf(cat.parent_id, seen) + 1;
depths.set(id, d);
return d;
}
for (const c of categories) depthOf(c.id, new Set());
return depths;
}
export default function CategoryCombobox({ export default function CategoryCombobox({
categories, categories,
value, value,
onChange, onChange,
placeholder = "", placeholder = "",
compact = false, compact = false,
ariaLabel,
extraOptions, extraOptions,
onExtraSelect, onExtraSelect,
activeExtra, activeExtra,
}: CategoryComboboxProps) { }: CategoryComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [highlightIndex, setHighlightIndex] = useState(0); const [highlightIndex, setHighlightIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null); const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const baseId = useId();
const listboxId = `${baseId}-listbox`;
const optionId = (i: number) => `${baseId}-option-${i}`;
const depths = useMemo(() => computeDepths(categories), [categories]);
// Resolve the display name for a category: seed rows carry an i18n_key that
// we translate with name as defaultValue; user-created rows just use name.
const displayName = useCallback(
(c: Category) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name),
[t]
);
// Build display label
const selectedCategory = categories.find((c) => c.id === value); const selectedCategory = categories.find((c) => c.id === value);
const displayLabel = const displayLabel =
activeExtra != null activeExtra != null
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? "" ? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
: selectedCategory : selectedCategory?.name ?? "";
? displayName(selectedCategory)
: "";
// Strip accents + lowercase for accent-insensitive matching
const normalize = (s: string) =>
s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
// Filter categories
const normalizedQuery = normalize(query); const normalizedQuery = normalize(query);
const filtered = query const filtered = query
? categories.filter((c) => normalize(displayName(c)).includes(normalizedQuery)) ? categories.filter((c) => normalize(c.name).includes(normalizedQuery))
: categories; : categories;
const filteredExtras = extraOptions const filteredExtras = extraOptions
@ -96,6 +57,7 @@ export default function CategoryCombobox({
const totalItems = filteredExtras.length + filtered.length; const totalItems = filteredExtras.length + filtered.length;
// Scroll highlighted item into view
useEffect(() => { useEffect(() => {
if (open && listRef.current) { if (open && listRef.current) {
const el = listRef.current.children[highlightIndex] as HTMLElement | undefined; const el = listRef.current.children[highlightIndex] as HTMLElement | undefined;
@ -103,6 +65,7 @@ export default function CategoryCombobox({
} }
}, [highlightIndex, open]); }, [highlightIndex, open]);
// Close on outside click
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@ -165,19 +128,11 @@ export default function CategoryCombobox({
const py = compact ? "py-1" : "py-2"; const py = compact ? "py-1" : "py-2";
const px = compact ? "px-2" : "px-3"; const px = compact ? "px-2" : "px-3";
const activeId = open && totalItems > 0 ? optionId(highlightIndex) : undefined;
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
role="combobox"
aria-label={ariaLabel}
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeId}
value={open ? query : displayLabel} value={open ? query : displayLabel}
placeholder={placeholder || displayLabel} placeholder={placeholder || displayLabel}
onChange={(e) => { onChange={(e) => {
@ -196,16 +151,11 @@ export default function CategoryCombobox({
{open && totalItems > 0 && ( {open && totalItems > 0 && (
<ul <ul
ref={listRef} ref={listRef}
id={listboxId}
role="listbox"
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg" className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-[var(--border)] bg-[var(--card)] shadow-lg"
> >
{filteredExtras.map((opt, i) => ( {filteredExtras.map((opt, i) => (
<li <li
key={`extra-${opt.value}`} key={`extra-${opt.value}`}
id={optionId(i)}
role="option"
aria-selected={i === highlightIndex}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => selectItem(i)} onClick={() => selectItem(i)}
onMouseEnter={() => setHighlightIndex(i)} onMouseEnter={() => setHighlightIndex(i)}
@ -220,14 +170,9 @@ export default function CategoryCombobox({
))} ))}
{filtered.map((cat, i) => { {filtered.map((cat, i) => {
const idx = filteredExtras.length + i; const idx = filteredExtras.length + i;
const depth = depths.get(cat.id) ?? 0;
const indent = depth > 0 ? " ".repeat(depth) : "";
return ( return (
<li <li
key={cat.id} key={cat.id}
id={optionId(idx)}
role="option"
aria-selected={idx === highlightIndex}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => selectItem(idx)} onClick={() => selectItem(idx)}
onMouseEnter={() => setHighlightIndex(idx)} onMouseEnter={() => setHighlightIndex(idx)}
@ -236,10 +181,8 @@ export default function CategoryCombobox({
? "bg-[var(--primary)] text-white" ? "bg-[var(--primary)] text-white"
: "text-[var(--foreground)] hover:bg-[var(--muted)]" : "text-[var(--foreground)] hover:bg-[var(--muted)]"
}`} }`}
style={depth > 0 ? { paddingLeft: `calc(${compact ? "0.5rem" : "0.75rem"} + ${depth * 1}rem)` } : undefined}
> >
<span className="whitespace-pre">{indent}</span> {cat.name}
{displayName(cat)}
</li> </li>
); );
})} })}

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { EyeOff, List } from "lucide-react"; import { EyeOff, List } from "lucide-react";
import ContextMenu from "./ContextMenu";
export interface ChartContextMenuProps { export interface ChartContextMenuProps {
x: number; x: number;
@ -20,25 +20,60 @@ export default function ChartContextMenu({
onClose, onClose,
}: ChartContextMenuProps) { }: ChartContextMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
// Adjust position to stay within viewport
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return ( return (
<ContextMenu <div
x={x} ref={menuRef}
y={y} className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
header={categoryName} style={{ left: x, top: y }}
onClose={onClose} >
items={[ <div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{ {categoryName}
icon: <List size={14} />, </div>
label: t("charts.viewTransactions"), <button
onClick: onViewDetails, onClick={() => { onViewDetails(); onClose(); }}
}, className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
{ >
icon: <EyeOff size={14} />, <List size={14} />
label: t("charts.hideCategory"), {t("charts.viewTransactions")}
onClick: onHide, </button>
}, <button
]} onClick={() => { onHide(); onClose(); }}
/> className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<EyeOff size={14} />
{t("charts.hideCategory")}
</button>
</div>
); );
} }

View file

@ -1,77 +0,0 @@
import { useEffect, useRef, type ReactNode } from "react";
export interface ContextMenuItem {
icon?: ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
}
export interface ContextMenuProps {
x: number;
y: number;
header?: ReactNode;
items: ContextMenuItem[];
onClose: () => void;
}
export default function ContextMenu({ x, y, header, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
style={{ left: x, top: y }}
>
{header && (
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{header}
</div>
)}
{items.map((item, i) => (
<button
key={i}
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick();
onClose();
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{item.icon}
{item.label}
</button>
))}
</div>
);
}

View file

@ -21,7 +21,6 @@ interface TransactionTableProps {
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>; onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>; onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>; onDeleteSplit: (parentId: number) => Promise<void>;
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
} }
function SortIcon({ function SortIcon({
@ -51,7 +50,6 @@ export default function TransactionTable({
onLoadSplitChildren, onLoadSplitChildren,
onSaveSplit, onSaveSplit,
onDeleteSplit, onDeleteSplit,
onRowContextMenu,
}: TransactionTableProps) { }: TransactionTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
@ -137,7 +135,6 @@ export default function TransactionTable({
{rows.map((row) => ( {rows.map((row) => (
<Fragment key={row.id}> <Fragment key={row.id}>
<tr <tr
onContextMenu={onRowContextMenu ? (e) => onRowContextMenu(e, row) : undefined}
className="hover:bg-[var(--muted)] transition-colors" className="hover:bg-[var(--muted)] transition-colors"
> >
<td className="px-3 py-2 whitespace-nowrap">{row.date}</td> <td className="px-3 py-2 whitespace-nowrap">{row.date}</td>

View file

@ -46,7 +46,7 @@ interface ProfileContextValue {
error: string | null; error: string | null;
switchProfile: (id: string) => Promise<void>; switchProfile: (id: string) => Promise<void>;
createProfile: (name: string, color: string, pin?: string) => Promise<void>; createProfile: (name: string, color: string, pin?: string) => Promise<void>;
updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => Promise<void>; updateProfile: (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => Promise<void>;
deleteProfile: (id: string) => Promise<void>; deleteProfile: (id: string) => Promise<void>;
setPin: (id: string, pin: string | null) => Promise<void>; setPin: (id: string, pin: string | null) => Promise<void>;
connectActiveProfile: () => Promise<void>; connectActiveProfile: () => Promise<void>;
@ -151,7 +151,7 @@ export function ProfileProvider({ children }: { children: ReactNode }) {
} }
}, [state.config]); }, [state.config]);
const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color" | "pin_hash">>) => { const updateProfile = useCallback(async (id: string, updates: Partial<Pick<Profile, "name" | "color">>) => {
if (!state.config) return; if (!state.config) return;
const newProfiles = state.config.profiles.map((p) => const newProfiles = state.config.profiles.map((p) =>

View file

@ -1,498 +0,0 @@
{
"version": "v1",
"description": "IPC Statistique Canada-aligned category taxonomy, 3 levels, Canada/Québec. Derived from src-tauri/src/database/consolidated_schema.sql — keep the two in sync when rolling a new seed version.",
"roots": [
{
"id": 1000,
"name": "Revenus",
"i18n_key": "categoriesSeed.revenus.root",
"type": "income",
"color": "#16a34a",
"sort_order": 1,
"children": [
{
"id": 1010,
"name": "Emploi",
"i18n_key": "categoriesSeed.revenus.emploi.root",
"type": "income",
"color": "#22c55e",
"sort_order": 1,
"children": [
{ "id": 1011, "name": "Paie régulière", "i18n_key": "categoriesSeed.revenus.emploi.paie", "type": "income", "color": "#22c55e", "sort_order": 1, "children": [] },
{ "id": 1012, "name": "Primes & bonus", "i18n_key": "categoriesSeed.revenus.emploi.primes", "type": "income", "color": "#4ade80", "sort_order": 2, "children": [] },
{ "id": 1013, "name": "Travail autonome", "i18n_key": "categoriesSeed.revenus.emploi.autonome", "type": "income", "color": "#86efac", "sort_order": 3, "children": [] }
]
},
{
"id": 1020,
"name": "Gouvernemental",
"i18n_key": "categoriesSeed.revenus.gouvernemental.root",
"type": "income",
"color": "#16a34a",
"sort_order": 2,
"children": [
{ "id": 1021, "name": "Remboursement impôt", "i18n_key": "categoriesSeed.revenus.gouvernemental.remboursementImpot", "type": "income", "color": "#16a34a", "sort_order": 1, "children": [] },
{ "id": 1022, "name": "Allocations familiales", "i18n_key": "categoriesSeed.revenus.gouvernemental.allocations", "type": "income", "color": "#15803d", "sort_order": 2, "children": [] },
{ "id": 1023, "name": "Crédits TPS/TVQ", "i18n_key": "categoriesSeed.revenus.gouvernemental.credits", "type": "income", "color": "#166534", "sort_order": 3, "children": [] },
{ "id": 1024, "name": "Assurance-emploi / RQAP", "i18n_key": "categoriesSeed.revenus.gouvernemental.assuranceEmploi", "type": "income", "color": "#14532d", "sort_order": 4, "children": [] }
]
},
{
"id": 1030,
"name": "Revenus de placement",
"i18n_key": "categoriesSeed.revenus.placement.root",
"type": "income",
"color": "#10b981",
"sort_order": 3,
"children": [
{ "id": 1031, "name": "Intérêts & dividendes", "i18n_key": "categoriesSeed.revenus.placement.interets", "type": "income", "color": "#10b981", "sort_order": 1, "children": [] },
{ "id": 1032, "name": "Gains en capital", "i18n_key": "categoriesSeed.revenus.placement.capital", "type": "income", "color": "#059669", "sort_order": 2, "children": [] },
{ "id": 1033, "name": "Revenus locatifs", "i18n_key": "categoriesSeed.revenus.placement.locatifs", "type": "income", "color": "#047857", "sort_order": 3, "children": [] }
]
},
{ "id": 1090, "name": "Autres revenus", "i18n_key": "categoriesSeed.revenus.autres", "type": "income", "color": "#84cc16", "sort_order": 9, "children": [] }
]
},
{
"id": 1100,
"name": "Alimentation",
"i18n_key": "categoriesSeed.alimentation.root",
"type": "expense",
"color": "#ea580c",
"sort_order": 2,
"children": [
{
"id": 1110,
"name": "Épicerie & marché",
"i18n_key": "categoriesSeed.alimentation.epicerie.root",
"type": "expense",
"color": "#ea580c",
"sort_order": 1,
"children": [
{ "id": 1111, "name": "Épicerie régulière", "i18n_key": "categoriesSeed.alimentation.epicerie.reguliere", "type": "expense", "color": "#ea580c", "sort_order": 1, "children": [] },
{ "id": 1112, "name": "Boucherie & poissonnerie", "i18n_key": "categoriesSeed.alimentation.epicerie.boucherie", "type": "expense", "color": "#c2410c", "sort_order": 2, "children": [] },
{ "id": 1113, "name": "Boulangerie & pâtisserie", "i18n_key": "categoriesSeed.alimentation.epicerie.boulangerie", "type": "expense", "color": "#9a3412", "sort_order": 3, "children": [] },
{ "id": 1114, "name": "Dépanneur", "i18n_key": "categoriesSeed.alimentation.epicerie.depanneur", "type": "expense", "color": "#7c2d12", "sort_order": 4, "children": [] },
{ "id": 1115, "name": "Marché & produits spécialisés", "i18n_key": "categoriesSeed.alimentation.epicerie.marche", "type": "expense", "color": "#fb923c", "sort_order": 5, "children": [] }
]
},
{
"id": 1120,
"name": "Restauration",
"i18n_key": "categoriesSeed.alimentation.restauration.root",
"type": "expense",
"color": "#f97316",
"sort_order": 2,
"children": [
{ "id": 1121, "name": "Restaurant", "i18n_key": "categoriesSeed.alimentation.restauration.restaurant", "type": "expense", "color": "#f97316", "sort_order": 1, "children": [] },
{ "id": 1122, "name": "Café & boulangerie rapide", "i18n_key": "categoriesSeed.alimentation.restauration.cafe", "type": "expense", "color": "#fb923c", "sort_order": 2, "children": [] },
{ "id": 1123, "name": "Restauration rapide", "i18n_key": "categoriesSeed.alimentation.restauration.fastfood", "type": "expense", "color": "#fdba74", "sort_order": 3, "children": [] },
{ "id": 1124, "name": "Livraison à domicile", "i18n_key": "categoriesSeed.alimentation.restauration.livraison", "type": "expense", "color": "#fed7aa", "sort_order": 4, "children": [] },
{ "id": 1125, "name": "Cantine & cafétéria", "i18n_key": "categoriesSeed.alimentation.restauration.cantine", "type": "expense", "color": "#ffedd5", "sort_order": 5, "children": [] }
]
}
]
},
{
"id": 1200,
"name": "Logement",
"i18n_key": "categoriesSeed.logement.root",
"type": "expense",
"color": "#dc2626",
"sort_order": 3,
"children": [
{
"id": 1210,
"name": "Habitation principale",
"i18n_key": "categoriesSeed.logement.habitation.root",
"type": "expense",
"color": "#dc2626",
"sort_order": 1,
"children": [
{ "id": 1211, "name": "Loyer", "i18n_key": "categoriesSeed.logement.habitation.loyer", "type": "expense", "color": "#dc2626", "sort_order": 1, "children": [] },
{ "id": 1212, "name": "Hypothèque", "i18n_key": "categoriesSeed.logement.habitation.hypotheque", "type": "expense", "color": "#b91c1c", "sort_order": 2, "children": [] },
{ "id": 1213, "name": "Taxes municipales & scolaires", "i18n_key": "categoriesSeed.logement.habitation.taxes", "type": "expense", "color": "#991b1b", "sort_order": 3, "children": [] },
{ "id": 1214, "name": "Charges de copropriété", "i18n_key": "categoriesSeed.logement.habitation.copropriete", "type": "expense", "color": "#7f1d1d", "sort_order": 4, "children": [] }
]
},
{
"id": 1220,
"name": "Services publics",
"i18n_key": "categoriesSeed.logement.services.root",
"type": "expense",
"color": "#ef4444",
"sort_order": 2,
"children": [
{ "id": 1221, "name": "Électricité", "i18n_key": "categoriesSeed.logement.services.electricite", "type": "expense", "color": "#ef4444", "sort_order": 1, "children": [] },
{ "id": 1222, "name": "Gaz naturel", "i18n_key": "categoriesSeed.logement.services.gaz", "type": "expense", "color": "#f87171", "sort_order": 2, "children": [] },
{ "id": 1223, "name": "Chauffage (mazout, propane)", "i18n_key": "categoriesSeed.logement.services.chauffage", "type": "expense", "color": "#fca5a5", "sort_order": 3, "children": [] },
{ "id": 1224, "name": "Eau & égouts", "i18n_key": "categoriesSeed.logement.services.eau", "type": "expense", "color": "#fecaca", "sort_order": 4, "children": [] }
]
},
{
"id": 1230,
"name": "Communications",
"i18n_key": "categoriesSeed.logement.communications.root",
"type": "expense",
"color": "#6366f1",
"sort_order": 3,
"children": [
{ "id": 1231, "name": "Internet résidentiel", "i18n_key": "categoriesSeed.logement.communications.internet", "type": "expense", "color": "#6366f1", "sort_order": 1, "children": [] },
{ "id": 1232, "name": "Téléphonie mobile", "i18n_key": "categoriesSeed.logement.communications.mobile", "type": "expense", "color": "#818cf8", "sort_order": 2, "children": [] },
{ "id": 1233, "name": "Téléphonie résidentielle", "i18n_key": "categoriesSeed.logement.communications.residentielle", "type": "expense", "color": "#a5b4fc", "sort_order": 3, "children": [] },
{ "id": 1234, "name": "Câblodistribution & streaming TV", "i18n_key": "categoriesSeed.logement.communications.streamingTv", "type": "expense", "color": "#c7d2fe", "sort_order": 4, "children": [] }
]
},
{
"id": 1240,
"name": "Entretien & réparations",
"i18n_key": "categoriesSeed.logement.entretien.root",
"type": "expense",
"color": "#e11d48",
"sort_order": 4,
"children": [
{ "id": 1241, "name": "Entretien général", "i18n_key": "categoriesSeed.logement.entretien.general", "type": "expense", "color": "#e11d48", "sort_order": 1, "children": [] },
{ "id": 1242, "name": "Rénovations", "i18n_key": "categoriesSeed.logement.entretien.renovations", "type": "expense", "color": "#be123c", "sort_order": 2, "children": [] },
{ "id": 1243, "name": "Matériaux & outils", "i18n_key": "categoriesSeed.logement.entretien.materiaux", "type": "expense", "color": "#9f1239", "sort_order": 3, "children": [] },
{ "id": 1244, "name": "Aménagement paysager", "i18n_key": "categoriesSeed.logement.entretien.paysager", "type": "expense", "color": "#881337", "sort_order": 4, "children": [] }
]
},
{ "id": 1250, "name": "Assurance habitation", "i18n_key": "categoriesSeed.logement.assurance", "type": "expense", "color": "#14b8a6", "sort_order": 5, "children": [] }
]
},
{
"id": 1300,
"name": "Ménage & ameublement",
"i18n_key": "categoriesSeed.menage.root",
"type": "expense",
"color": "#ca8a04",
"sort_order": 4,
"children": [
{
"id": 1310,
"name": "Ameublement",
"i18n_key": "categoriesSeed.menage.ameublement.root",
"type": "expense",
"color": "#ca8a04",
"sort_order": 1,
"children": [
{ "id": 1311, "name": "Meubles", "i18n_key": "categoriesSeed.menage.ameublement.meubles", "type": "expense", "color": "#ca8a04", "sort_order": 1, "children": [] },
{ "id": 1312, "name": "Électroménagers", "i18n_key": "categoriesSeed.menage.ameublement.electromenagers", "type": "expense", "color": "#a16207", "sort_order": 2, "children": [] },
{ "id": 1313, "name": "Décoration", "i18n_key": "categoriesSeed.menage.ameublement.decoration", "type": "expense", "color": "#854d0e", "sort_order": 3, "children": [] }
]
},
{
"id": 1320,
"name": "Fournitures ménagères",
"i18n_key": "categoriesSeed.menage.fournitures.root",
"type": "expense",
"color": "#eab308",
"sort_order": 2,
"children": [
{ "id": 1321, "name": "Produits d'entretien", "i18n_key": "categoriesSeed.menage.fournitures.entretien", "type": "expense", "color": "#eab308", "sort_order": 1, "children": [] },
{ "id": 1322, "name": "Literie & linge de maison", "i18n_key": "categoriesSeed.menage.fournitures.literie", "type": "expense", "color": "#facc15", "sort_order": 2, "children": [] },
{ "id": 1323, "name": "Vaisselle & ustensiles", "i18n_key": "categoriesSeed.menage.fournitures.vaisselle", "type": "expense", "color": "#fde047", "sort_order": 3, "children": [] }
]
},
{
"id": 1330,
"name": "Services domestiques",
"i18n_key": "categoriesSeed.menage.services.root",
"type": "expense",
"color": "#fbbf24",
"sort_order": 3,
"children": [
{ "id": 1331, "name": "Ménage & nettoyage", "i18n_key": "categoriesSeed.menage.services.nettoyage", "type": "expense", "color": "#fbbf24", "sort_order": 1, "children": [] },
{ "id": 1332, "name": "Buanderie & pressing", "i18n_key": "categoriesSeed.menage.services.buanderie", "type": "expense", "color": "#fcd34d", "sort_order": 2, "children": [] }
]
}
]
},
{
"id": 1400,
"name": "Vêtements & chaussures",
"i18n_key": "categoriesSeed.vetements.root",
"type": "expense",
"color": "#d946ef",
"sort_order": 5,
"children": [
{ "id": 1410, "name": "Vêtements adultes", "i18n_key": "categoriesSeed.vetements.adultes", "type": "expense", "color": "#d946ef", "sort_order": 1, "children": [] },
{ "id": 1420, "name": "Vêtements enfants", "i18n_key": "categoriesSeed.vetements.enfants", "type": "expense", "color": "#c026d3", "sort_order": 2, "children": [] },
{ "id": 1430, "name": "Chaussures", "i18n_key": "categoriesSeed.vetements.chaussures", "type": "expense", "color": "#a21caf", "sort_order": 3, "children": [] },
{ "id": 1440, "name": "Accessoires & bijoux", "i18n_key": "categoriesSeed.vetements.accessoires", "type": "expense", "color": "#86198f", "sort_order": 4, "children": [] }
]
},
{
"id": 1500,
"name": "Transport",
"i18n_key": "categoriesSeed.transport.root",
"type": "expense",
"color": "#2563eb",
"sort_order": 6,
"children": [
{
"id": 1510,
"name": "Véhicule personnel",
"i18n_key": "categoriesSeed.transport.vehicule.root",
"type": "expense",
"color": "#2563eb",
"sort_order": 1,
"children": [
{ "id": 1511, "name": "Achat / location véhicule", "i18n_key": "categoriesSeed.transport.vehicule.achat", "type": "expense", "color": "#2563eb", "sort_order": 1, "children": [] },
{ "id": 1512, "name": "Essence", "i18n_key": "categoriesSeed.transport.vehicule.essence", "type": "expense", "color": "#1d4ed8", "sort_order": 2, "children": [] },
{ "id": 1513, "name": "Entretien & réparations auto", "i18n_key": "categoriesSeed.transport.vehicule.entretien", "type": "expense", "color": "#1e40af", "sort_order": 3, "children": [] },
{ "id": 1514, "name": "Immatriculation & permis", "i18n_key": "categoriesSeed.transport.vehicule.immatriculation", "type": "expense", "color": "#1e3a8a", "sort_order": 4, "children": [] },
{ "id": 1515, "name": "Stationnement & péages", "i18n_key": "categoriesSeed.transport.vehicule.stationnement", "type": "expense", "color": "#3b82f6", "sort_order": 5, "children": [] },
{ "id": 1516, "name": "Assurance auto", "i18n_key": "categoriesSeed.transport.vehicule.assurance", "type": "expense", "color": "#60a5fa", "sort_order": 6, "children": [] }
]
},
{
"id": 1520,
"name": "Transport public",
"i18n_key": "categoriesSeed.transport.public.root",
"type": "expense",
"color": "#0ea5e9",
"sort_order": 2,
"children": [
{ "id": 1521, "name": "Autobus & métro", "i18n_key": "categoriesSeed.transport.public.autobus", "type": "expense", "color": "#0ea5e9", "sort_order": 1, "children": [] },
{ "id": 1522, "name": "Train de banlieue", "i18n_key": "categoriesSeed.transport.public.train", "type": "expense", "color": "#0284c7", "sort_order": 2, "children": [] },
{ "id": 1523, "name": "Taxi & covoiturage", "i18n_key": "categoriesSeed.transport.public.taxi", "type": "expense", "color": "#0369a1", "sort_order": 3, "children": [] }
]
},
{
"id": 1530,
"name": "Voyages longue distance",
"i18n_key": "categoriesSeed.transport.voyages.root",
"type": "expense",
"color": "#38bdf8",
"sort_order": 3,
"children": [
{ "id": 1531, "name": "Avion", "i18n_key": "categoriesSeed.transport.voyages.avion", "type": "expense", "color": "#38bdf8", "sort_order": 1, "children": [] },
{ "id": 1532, "name": "Train & autocar", "i18n_key": "categoriesSeed.transport.voyages.trainAutocar", "type": "expense", "color": "#7dd3fc", "sort_order": 2, "children": [] },
{ "id": 1533, "name": "Hébergement", "i18n_key": "categoriesSeed.transport.voyages.hebergement", "type": "expense", "color": "#bae6fd", "sort_order": 3, "children": [] },
{ "id": 1534, "name": "Location véhicule voyage", "i18n_key": "categoriesSeed.transport.voyages.location", "type": "expense", "color": "#e0f2fe", "sort_order": 4, "children": [] }
]
}
]
},
{
"id": 1600,
"name": "Santé & soins personnels",
"i18n_key": "categoriesSeed.sante.root",
"type": "expense",
"color": "#f43f5e",
"sort_order": 7,
"children": [
{
"id": 1610,
"name": "Soins médicaux",
"i18n_key": "categoriesSeed.sante.medicaux.root",
"type": "expense",
"color": "#f43f5e",
"sort_order": 1,
"children": [
{ "id": 1611, "name": "Pharmacie", "i18n_key": "categoriesSeed.sante.medicaux.pharmacie", "type": "expense", "color": "#f43f5e", "sort_order": 1, "children": [] },
{ "id": 1612, "name": "Consultations médicales", "i18n_key": "categoriesSeed.sante.medicaux.consultations", "type": "expense", "color": "#e11d48", "sort_order": 2, "children": [] },
{ "id": 1613, "name": "Dentiste & orthodontiste", "i18n_key": "categoriesSeed.sante.medicaux.dentiste", "type": "expense", "color": "#be123c", "sort_order": 3, "children": [] },
{ "id": 1614, "name": "Optométrie & lunettes", "i18n_key": "categoriesSeed.sante.medicaux.optometrie", "type": "expense", "color": "#9f1239", "sort_order": 4, "children": [] },
{ "id": 1615, "name": "Thérapies (physio, psycho, etc.)", "i18n_key": "categoriesSeed.sante.medicaux.therapies", "type": "expense", "color": "#881337", "sort_order": 5, "children": [] },
{ "id": 1616, "name": "Assurance santé complémentaire", "i18n_key": "categoriesSeed.sante.medicaux.assurance", "type": "expense", "color": "#fb7185", "sort_order": 6, "children": [] }
]
},
{
"id": 1620,
"name": "Soins personnels",
"i18n_key": "categoriesSeed.sante.personnels.root",
"type": "expense",
"color": "#fb7185",
"sort_order": 2,
"children": [
{ "id": 1621, "name": "Coiffure & esthétique", "i18n_key": "categoriesSeed.sante.personnels.coiffure", "type": "expense", "color": "#fb7185", "sort_order": 1, "children": [] },
{ "id": 1622, "name": "Produits de soins corporels", "i18n_key": "categoriesSeed.sante.personnels.soins", "type": "expense", "color": "#fda4af", "sort_order": 2, "children": [] }
]
},
{ "id": 1630, "name": "Assurance vie & invalidité", "i18n_key": "categoriesSeed.sante.assuranceVie", "type": "expense", "color": "#14b8a6", "sort_order": 3, "children": [] }
]
},
{
"id": 1700,
"name": "Loisirs, formation & lecture",
"i18n_key": "categoriesSeed.loisirs.root",
"type": "expense",
"color": "#8b5cf6",
"sort_order": 8,
"children": [
{
"id": 1710,
"name": "Divertissement",
"i18n_key": "categoriesSeed.loisirs.divertissement.root",
"type": "expense",
"color": "#8b5cf6",
"sort_order": 1,
"children": [
{ "id": 1711, "name": "Cinéma & spectacles", "i18n_key": "categoriesSeed.loisirs.divertissement.cinema", "type": "expense", "color": "#8b5cf6", "sort_order": 1, "children": [] },
{ "id": 1712, "name": "Jeux vidéo & consoles", "i18n_key": "categoriesSeed.loisirs.divertissement.jeux", "type": "expense", "color": "#a78bfa", "sort_order": 2, "children": [] },
{ "id": 1713, "name": "Streaming vidéo", "i18n_key": "categoriesSeed.loisirs.divertissement.streamingVideo", "type": "expense", "color": "#c4b5fd", "sort_order": 3, "children": [] },
{ "id": 1714, "name": "Streaming musique & audio", "i18n_key": "categoriesSeed.loisirs.divertissement.streamingMusique", "type": "expense", "color": "#ddd6fe", "sort_order": 4, "children": [] },
{ "id": 1715, "name": "Jouets & passe-temps", "i18n_key": "categoriesSeed.loisirs.divertissement.jouets", "type": "expense", "color": "#ede9fe", "sort_order": 5, "children": [] }
]
},
{
"id": 1720,
"name": "Sports & plein air",
"i18n_key": "categoriesSeed.loisirs.sports.root",
"type": "expense",
"color": "#22c55e",
"sort_order": 2,
"children": [
{ "id": 1721, "name": "Abonnements sportifs", "i18n_key": "categoriesSeed.loisirs.sports.abonnements", "type": "expense", "color": "#22c55e", "sort_order": 1, "children": [] },
{ "id": 1722, "name": "Équipement sportif", "i18n_key": "categoriesSeed.loisirs.sports.equipement", "type": "expense", "color": "#4ade80", "sort_order": 2, "children": [] },
{ "id": 1723, "name": "Parcs & activités plein air", "i18n_key": "categoriesSeed.loisirs.sports.parcs", "type": "expense", "color": "#86efac", "sort_order": 3, "children": [] }
]
},
{
"id": 1730,
"name": "Formation & éducation",
"i18n_key": "categoriesSeed.loisirs.formation.root",
"type": "expense",
"color": "#6366f1",
"sort_order": 3,
"children": [
{ "id": 1731, "name": "Scolarité (frais)", "i18n_key": "categoriesSeed.loisirs.formation.scolarite", "type": "expense", "color": "#6366f1", "sort_order": 1, "children": [] },
{ "id": 1732, "name": "Matériel scolaire", "i18n_key": "categoriesSeed.loisirs.formation.materiel", "type": "expense", "color": "#818cf8", "sort_order": 2, "children": [] },
{ "id": 1733, "name": "Cours & certifications", "i18n_key": "categoriesSeed.loisirs.formation.cours", "type": "expense", "color": "#a5b4fc", "sort_order": 3, "children": [] },
{ "id": 1734, "name": "Abonnements professionnels", "i18n_key": "categoriesSeed.loisirs.formation.abonnements", "type": "expense", "color": "#c7d2fe", "sort_order": 4, "children": [] }
]
},
{
"id": 1740,
"name": "Lecture & médias",
"i18n_key": "categoriesSeed.loisirs.lecture.root",
"type": "expense",
"color": "#ec4899",
"sort_order": 4,
"children": [
{ "id": 1741, "name": "Livres", "i18n_key": "categoriesSeed.loisirs.lecture.livres", "type": "expense", "color": "#ec4899", "sort_order": 1, "children": [] },
{ "id": 1742, "name": "Journaux & magazines", "i18n_key": "categoriesSeed.loisirs.lecture.journaux", "type": "expense", "color": "#f472b6", "sort_order": 2, "children": [] }
]
},
{
"id": 1750,
"name": "Animaux de compagnie",
"i18n_key": "categoriesSeed.loisirs.animaux.root",
"type": "expense",
"color": "#a855f7",
"sort_order": 5,
"children": [
{ "id": 1751, "name": "Nourriture & accessoires animaux", "i18n_key": "categoriesSeed.loisirs.animaux.nourriture", "type": "expense", "color": "#a855f7", "sort_order": 1, "children": [] },
{ "id": 1752, "name": "Vétérinaire", "i18n_key": "categoriesSeed.loisirs.animaux.veterinaire", "type": "expense", "color": "#c084fc", "sort_order": 2, "children": [] }
]
}
]
},
{
"id": 1800,
"name": "Boissons, tabac & cannabis",
"i18n_key": "categoriesSeed.consommation.root",
"type": "expense",
"color": "#7c3aed",
"sort_order": 9,
"children": [
{ "id": 1810, "name": "Alcool (SAQ, microbrasseries)", "i18n_key": "categoriesSeed.consommation.alcool", "type": "expense", "color": "#7c3aed", "sort_order": 1, "children": [] },
{ "id": 1820, "name": "Cannabis (SQDC)", "i18n_key": "categoriesSeed.consommation.cannabis", "type": "expense", "color": "#6d28d9", "sort_order": 2, "children": [] },
{ "id": 1830, "name": "Tabac", "i18n_key": "categoriesSeed.consommation.tabac", "type": "expense", "color": "#5b21b6", "sort_order": 3, "children": [] }
]
},
{
"id": 1900,
"name": "Finances & obligations",
"i18n_key": "categoriesSeed.finances.root",
"type": "expense",
"color": "#6b7280",
"sort_order": 10,
"children": [
{
"id": 1910,
"name": "Frais bancaires",
"i18n_key": "categoriesSeed.finances.fraisBancaires.root",
"type": "expense",
"color": "#6b7280",
"sort_order": 1,
"children": [
{ "id": 1911, "name": "Frais de compte", "i18n_key": "categoriesSeed.finances.fraisBancaires.compte", "type": "expense", "color": "#6b7280", "sort_order": 1, "children": [] },
{ "id": 1912, "name": "Intérêts & frais de crédit", "i18n_key": "categoriesSeed.finances.fraisBancaires.interets", "type": "expense", "color": "#9ca3af", "sort_order": 2, "children": [] },
{ "id": 1913, "name": "Frais de change", "i18n_key": "categoriesSeed.finances.fraisBancaires.change", "type": "expense", "color": "#d1d5db", "sort_order": 3, "children": [] }
]
},
{
"id": 1920,
"name": "Impôts & taxes",
"i18n_key": "categoriesSeed.finances.impots.root",
"type": "expense",
"color": "#dc2626",
"sort_order": 2,
"children": [
{ "id": 1921, "name": "Impôt fédéral", "i18n_key": "categoriesSeed.finances.impots.federal", "type": "expense", "color": "#dc2626", "sort_order": 1, "children": [] },
{ "id": 1922, "name": "Impôt provincial", "i18n_key": "categoriesSeed.finances.impots.provincial", "type": "expense", "color": "#b91c1c", "sort_order": 2, "children": [] },
{ "id": 1923, "name": "Acomptes provisionnels", "i18n_key": "categoriesSeed.finances.impots.acomptes", "type": "expense", "color": "#991b1b", "sort_order": 3, "children": [] }
]
},
{
"id": 1930,
"name": "Dons & cotisations",
"i18n_key": "categoriesSeed.finances.dons.root",
"type": "expense",
"color": "#ec4899",
"sort_order": 3,
"children": [
{ "id": 1931, "name": "Dons de charité", "i18n_key": "categoriesSeed.finances.dons.charite", "type": "expense", "color": "#ec4899", "sort_order": 1, "children": [] },
{ "id": 1932, "name": "Cotisations professionnelles", "i18n_key": "categoriesSeed.finances.dons.professionnelles", "type": "expense", "color": "#f472b6", "sort_order": 2, "children": [] },
{ "id": 1933, "name": "Cotisations syndicales", "i18n_key": "categoriesSeed.finances.dons.syndicales", "type": "expense", "color": "#f9a8d4", "sort_order": 3, "children": [] }
]
},
{ "id": 1940, "name": "Cadeaux", "i18n_key": "categoriesSeed.finances.cadeaux", "type": "expense", "color": "#f43f5e", "sort_order": 4, "children": [] },
{ "id": 1945, "name": "Retrait cash", "i18n_key": "categoriesSeed.finances.cash", "type": "expense", "color": "#57534e", "sort_order": 5, "children": [] },
{ "id": 1946, "name": "Achats divers non catégorisés", "i18n_key": "categoriesSeed.finances.divers", "type": "expense", "color": "#78716c", "sort_order": 6, "children": [] }
]
},
{
"id": 1950,
"name": "Transferts & placements",
"i18n_key": "categoriesSeed.transferts.root",
"type": "transfer",
"color": "#0ea5e9",
"sort_order": 11,
"children": [
{
"id": 1960,
"name": "Épargne & placements",
"i18n_key": "categoriesSeed.transferts.epargne.root",
"type": "transfer",
"color": "#0ea5e9",
"sort_order": 1,
"children": [
{ "id": 1961, "name": "REER / RRSP", "i18n_key": "categoriesSeed.transferts.epargne.reer", "type": "transfer", "color": "#0ea5e9", "sort_order": 1, "children": [] },
{ "id": 1962, "name": "CELI / TFSA", "i18n_key": "categoriesSeed.transferts.epargne.celi", "type": "transfer", "color": "#0284c7", "sort_order": 2, "children": [] },
{ "id": 1963, "name": "REEE / RESP", "i18n_key": "categoriesSeed.transferts.epargne.reee", "type": "transfer", "color": "#0369a1", "sort_order": 3, "children": [] },
{ "id": 1964, "name": "Compte non-enregistré", "i18n_key": "categoriesSeed.transferts.epargne.nonEnregistre", "type": "transfer", "color": "#075985", "sort_order": 4, "children": [] },
{ "id": 1965, "name": "Fonds d'urgence", "i18n_key": "categoriesSeed.transferts.epargne.urgence", "type": "transfer", "color": "#0c4a6e", "sort_order": 5, "children": [] }
]
},
{
"id": 1970,
"name": "Remboursement de dette",
"i18n_key": "categoriesSeed.transferts.dette.root",
"type": "transfer",
"color": "#7c3aed",
"sort_order": 2,
"children": [
{ "id": 1971, "name": "Paiement carte crédit", "i18n_key": "categoriesSeed.transferts.dette.carteCredit", "type": "transfer", "color": "#7c3aed", "sort_order": 1, "children": [] },
{ "id": 1972, "name": "Remboursement prêt étudiant", "i18n_key": "categoriesSeed.transferts.dette.etudiant", "type": "transfer", "color": "#8b5cf6", "sort_order": 2, "children": [] },
{ "id": 1973, "name": "Remboursement prêt perso", "i18n_key": "categoriesSeed.transferts.dette.personnel", "type": "transfer", "color": "#a78bfa", "sort_order": 3, "children": [] }
]
},
{ "id": 1980, "name": "Transferts internes", "i18n_key": "categoriesSeed.transferts.internes", "type": "transfer", "color": "#64748b", "sort_order": 3, "children": [] }
]
}
]
}

View file

@ -1,126 +0,0 @@
import { useCallback, useEffect, useReducer } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { listen } from "@tauri-apps/api/event";
import {
AccountInfo,
startOAuth,
getAccountInfo,
checkSubscriptionStatus,
logoutAccount,
} from "../services/authService";
type AuthStatus = "idle" | "loading" | "authenticated" | "unauthenticated" | "error";
interface AuthState {
status: AuthStatus;
account: AccountInfo | null;
error: string | null;
}
type AuthAction =
| { type: "LOAD_START" }
| { type: "LOAD_DONE"; account: AccountInfo | null }
| { type: "LOGIN_START" }
| { type: "LOGOUT" }
| { type: "ERROR"; error: string };
const initialState: AuthState = {
status: "idle",
account: null,
error: null,
};
function reducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "LOAD_START":
return { ...state, status: "loading", error: null };
case "LOAD_DONE":
return {
status: action.account ? "authenticated" : "unauthenticated",
account: action.account,
error: null,
};
case "LOGIN_START":
return { ...state, status: "loading", error: null };
case "LOGOUT":
return { status: "unauthenticated", account: null, error: null };
case "ERROR":
return { ...state, status: "error", error: action.error };
}
}
export function useAuth() {
const [state, dispatch] = useReducer(reducer, initialState);
const refresh = useCallback(async () => {
dispatch({ type: "LOAD_START" });
try {
// checkSubscriptionStatus refreshes the token if last check > 24h,
// otherwise returns cached account info. Graceful on network errors.
const account = await checkSubscriptionStatus();
dispatch({ type: "LOAD_DONE", account });
} catch (e) {
// Fallback to cached account info if the check command itself fails
try {
const cached = await getAccountInfo();
dispatch({ type: "LOAD_DONE", account: cached });
} catch {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}
}, []);
const login = useCallback(async () => {
dispatch({ type: "LOGIN_START" });
try {
const url = await startOAuth();
await openUrl(url);
// The actual auth completion happens via the deep-link callback,
// which triggers handle_auth_callback on the Rust side.
// The UI should call refresh() after the callback completes.
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
const logout = useCallback(async () => {
try {
await logoutAccount();
dispatch({ type: "LOGOUT" });
} catch (e) {
dispatch({
type: "ERROR",
error: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Listen for deep-link auth callback events from the Rust backend
useEffect(() => {
const unlisten: Array<() => void> = [];
listen<AccountInfo>("auth-callback-success", (event) => {
dispatch({ type: "LOAD_DONE", account: event.payload });
}).then((fn) => unlisten.push(fn));
listen<string>("auth-callback-error", (event) => {
dispatch({ type: "ERROR", error: event.payload });
}).then((fn) => unlisten.push(fn));
return () => {
unlisten.forEach((fn) => fn());
};
}, []);
return { state, refresh, login, logout };
}

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