docs: add WIP specs for OAuth keychain, monetisation, reports, and web

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-04-20 20:41:00 -04:00
parent f3af3d7c1b
commit 4912ae39b0
4 changed files with 2144 additions and 0 deletions

View file

@ -0,0 +1,348 @@
# 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`)

588
spec-monetisation.md Normal file
View file

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

649
spec-refonte-rapports.md Normal file
View file

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

559
spec-simpl-resultat-web.md Normal file
View file

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