diff --git a/STATE.md b/STATE.md index 495d625..de93fbc 100644 --- a/STATE.md +++ b/STATE.md @@ -1,6 +1,6 @@ # STATE — Simpl'Résultat -> Derniere MAJ : 2026-07-18 (**v0.14.0 shippée** — milestone `planned-2026-07-15-collapse-multi-niveaux` 4/4 : repli des catégories à **chaque niveau** sur les 3 rapports hiérarchiques + grille Budget + unification des 2 arbres de catégories, persistance par-profil via `user_preferences` (ADR 0016). Pile de 4 PRs #292-#295 → `/pr-review` **APPROVE ×4**, ff-merge (`main` `5a3d87b`), tag `v0.14.0` (`9c18e10`) → CI release #322. Auparavant cette session : **v0.13.0 taggée** (2026-07-13) — le backlog `[Unreleased]` accumulé depuis v0.12.0 (filtres de comptes #272-#279, tendances hiérarchiques #262-#265, collapse niveau-1 #254). **828 vitest** + 98 Rust. Aucune migration DB (v1→v16). #259 mergée (PR #296, `/pr-review` APPROVE) ; epic #260 « rapports uniformes » fermée — 4/5 rapports conformes, 3 déviations documentées entérinées) +> Derniere MAJ : 2026-07-19 (**v0.14.0 shippée** — milestone `planned-2026-07-15-collapse-multi-niveaux` 4/4 : repli des catégories à **chaque niveau** sur les 3 rapports hiérarchiques + grille Budget + unification des 2 arbres de catégories, persistance par-profil via `user_preferences` (ADR 0016). Pile de 4 PRs #292-#295 → `/pr-review` **APPROVE ×4**, ff-merge (`main` `5a3d87b`), tag `v0.14.0` (`9c18e10`) → CI release #322. Auparavant cette session : **v0.13.0 taggée** (2026-07-13) — le backlog `[Unreleased]` accumulé depuis v0.12.0 (filtres de comptes #272-#279, tendances hiérarchiques #262-#265, collapse niveau-1 #254). **828 vitest** + 98 Rust. Aucune migration DB (v1→v16). #259 mergée (PR #296, `/pr-review` APPROVE) ; epic #260 « rapports uniformes » fermée — 4/5 rapports conformes, 3 déviations documentées entérinées) ## Position actuelle @@ -16,6 +16,7 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA ## Decisions recentes +- 2026-07-19 : **Chantier gating par tier planifié via `/spec` → milestone `spec-feature-gating` (#297-#302)**. Origine : réflexion monétisation de Max (« changer le périmètre des abonnements »). Matrice tranchée : **Free** = Dashboard/Import/Transactions/Catégories/**rapport Tendance**/Export chiffré/Changelog (mono-profil) ; **Base** = + les 4 autres rapports/Budget/multi-profils/auto-update ; **Premium** = + Bilan complet (patrimoine + cours). Admin (Max) = licence Premium auto-émise (pas de 4e édition). Décisions clés : upsell **verrouillé** (pas masqué) ; durcissement **sec non destructif** (blocage d'accès, données conservées, récupérables par upgrade) ; entitlements **statique + override `features[]`** ; **enforcement UI-only** (soft-paywall GPL assumé, seul le gate cours reste dur/server-enforced) ; **#271 absorbé** (auto-update Base+, fermé superseded). Re-ancrage Phase 3b : socle déjà là (3 éditions Ed25519, `current_edition`, `check_entitlement`) mais `is_feature_allowed` ignore `features[]` + `useLicense` per-appel → **LicenseProvider** requis pour un `useEntitlement` sync ; rapports = routes distinctes (gate par route) ; #271 = 1 ligne. 6 issues (socle→garde UI→routes/sidebar→multi-profils→#271 Rust→docs), specs force-add (gitignorées, précédent #295), aucune migration DB. Point ouvert : `/adjustments` non classé (défaut Free). (ref spec-feature-gating, #297-#302) - 2026-07-18 : **Epic #260 « rapports uniformes » fermée + #259 livrée en PR #296 (review)**. #260 fermée après `/analyze` : **4/5 rapports pleinement conformes** (income-statement + filtres partagés + collapse par-profil) ; les 3 écarts restants vs le texte de l'epic sont des **décisions de conception assumées**, entérinés par Max — (1) lignes vides budget non masquées (spec décision 6 : grille = surface d'édition) ; (2) collapse budget replié « comme partout » (#289/ADR 0016 inverse le « sauf budget » de l'epic) ; (3) dashboard convergé sur le modèle Cartes plutôt qu'une table income-statement hiérarchique (décision 5). **Correction STATE** : les entrées 07-08/07-11 « reste de #260 = #259 » sont fausses — #259 est une migration de taxonomie sans lien avec l'epic (recadrée 07-12), et les vraies déviations #260 n'y étaient pas tracées. **#259** (fusion des catégories custom) livrée en PR #296 (`issue-259-merge-custom-categories`) : bloc préservé de `StepSimulate` rendu en `MappingRow`, reducer `RESOLVE_ROW` résout rows+preserved (`unresolved` compté sur seed only → ne bloque pas « Suivant »), writer via helper `isResolvedTarget` (fourre-tout créé seulement s'il reste une custom non fusionnée ; customs fusionnées désactivées au lieu d'être re-parentées). Plan-check pass (6 MINOR, 3 intégrés), **836 vitest** + build tsc/vite propres, régression parent/enfant custom couverte, aucune migration DB. `/pr-review` **APPROVE**, mergée (rebase) le 2026-07-19 → `main` `2314a64`, #259 fermée, branche supprimée. (ref #260, #259 PR #296) - 2026-07-18 : **Milestone collapse multi-niveaux (#288-291) livrée → v0.14.0**. Cycle complet en une session : `/plan-run` (spec 2 fichiers, 8 décisions drainées) → `/review-spec` (3 experts, **verdict 🔴**) → refonte v2 → `/autopilot` (4 workers) → `/pr-review` ×4 → ff-merge → release. **Le point clé** : la revue a tué l'algo v1. Il suivait un curseur de profondeur supposant un **ordre DFS** ; or la grille Budget (`useBudget.ts:361`) trie par **niveau** — les 3 experts l'ont trouvé indépendamment. Refonte v2 : **visibilité par remontée de `parent_id`** (une ligne visible ssi tous ses ancêtres dépliés), order-independant → résout d'un coup l'ordre budget, le tri par type qui sépare parent/enfant, la feuille « (direct) » qui partage la clé du parent, et la contrainte `visible()`-avant-`reorderRows`. **Pile linéaire forcée** (B/C/D dépendent tous du nouveau hook de A → pas de wave parallèle) : PRs #292-#295, chacune basée sur la précédente. `/pr-review` **APPROVE ×3 + 1 REQUEST_CHANGES** (#295 : ligne `- Spec:` de l'ADR 0016 pointant des specs gitignorées → 404 ; corrigé par **force-add des specs à la racine**, précédent repo `spec-refonte-rapports.md` ; au passage la revue s'est trompée en disant que 0015 n'avait pas de ligne Spec — elle en a une, cassée pareil, bug pré-existant signalé). **ff-merge** de la pile (4 issues auto-fermées via `Resolves #N`, milestone 4/4, 4 branches supprimées) → **v0.14.0** taggée. **Persistance migrée `localStorage` → `user_preferences`** (base du profil) : `deleteProfile` ne purge aucun `localStorage` → une clé par-profil y serait un résidu survivant à la suppression, révélant les catégories explorées d'un profil PIN-protégé (exigence privacy-first, **ADR 0016**, frontière tracée : état UI par-profil → DB profil, état UI machine → localStorage). Le hook gagne `defaultExpanded` + `storageKey` nullable, qui **unifie aussi les 2 arbres de catégories** (#290 : `CategoryTree` déplié-par-défaut, guide replié ; corrige le bug `allExpanded = size>0` du guide ; worker a trouvé un **4e consommateur** `StepDiscover` non listé au plan, migré). 828 vitest, aucune migration DB. Écarts protocole tracés au [rapport](reports/DAILY-REPORT-2026-07-15.md) : workers auto-validés **sans forker `/pr-review`** (évite le double-post [[feedback-pr-review-subagent-forks]]) ; #294 sans test (refactor de rendu, non testable sans jsdom) → vérif runtime déléguée à la revue. Reste `spec-ci-build-optimization` (2/4) + `spec-paiements` (#270/#271) + #259. (ref #288-291, PRs #292-#295) - 2026-07-12 : **#259 recadrée — l'issue décrivait un flux qui n'existe pas** (rectifie les entrées des 2026-07-05 / 07-11 qui la classaient « mapping manuel compte sans similaire auto » et « reliquat de l'epic #260 » : les deux sont faux). Le corps d'origine, rédigé à chaud le 2026-07-05 pendant le run v0.12.0, avait **transposé par analogie** le signalement de Max vers le module Bilan sans ouvrir le fichier : il décrivait une auto-association des comptes standards aux comptes existants du profil dans `StarterAccountsModal`, avec « case désactivée quand aucun similaire n'est auto-identifié ». **Aucune notion de mapping n'existe dans ce flux** — la case est un simple « créer ce compte : oui/non », désactivée **quand une collision EST détectée** (`StarterAccountsModal.tsx:160`), soit la polarité inverse ; le commentaire `l.8` cité (« the matching checkbox ») désignait « la case **correspondante** », pas « la case de matching ». **Le vrai sujet** (confirmé par Max) est la **migration des catégories** : `computeMigrationPlan` range les catégories en 2 seaux et un seul est éditable — `plan.rows` (seed) passe par le moteur d'appariement + type-ahead par ligne (#246/#252), tandis que `plan.preserved` (catégories **custom**) est poussé avec `v1TargetId: null` **sans même être soumis au moteur d'appariement**, rendu en `
  • ` texte brut (`StepSimulate.tsx:202-216`, aucun picker) et déversé d'office par le writer sous le fourre-tout « Catégories personnalisées (migration) ». Sémantique tranchée avec Max : **fusion** (choisir une feuille standard réassigne tx/budgets/mots-clés/fournisseurs et fait disparaître la custom ; ne rien choisir = comportement actuel, et ne doit **pas** bloquer le bouton « Suivant »). Re-parentage écarté. Travail = câblage sur 3 couches (UI `StepSimulate` → `MappingRow` réutilisable tel quel ; reducer `RESOLVE_ROW` qui ne voit que `plan.rows` ; 4 retouches du writer) — la machinerie de fusion est **déjà générique** (`buildMappingFromRows` filtre les cibles nulles, étapes 3-7 bouclent sur la Map). Complexité Medium. Piège à couvrir : fusionner une custom **parente ayant des enfants custom** (liste `preserved` plate → l'enfant non résolu retombe au fourre-tout, pas d'orphelin, mais aucun test ne le garantit). Leçon process : une issue rédigée par analogie en fin de run, sans lecture du code, peut inverser la prémisse **et** se tromper de module — le `/analyze` l'a rattrapée 6 jours plus tard. (ref #259) @@ -30,4 +31,4 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA ## Blockers actifs - Aucun blocker externe dur. `spec-monetisation` **fermée 12/12** (2026-07-08) — #50/#52/#53/#135/#136 livrées, ne sont plus des blockers. -- Backlog ouvert (`status:ready`, non bloqué) : `spec-paiements` (#270 activation /v1 + product explicite + URL achat localisée ; #271 re-gate auto-update derrière la licence Base) ; `spec-ci-build-optimization` 2/4 (#234 connectivité cache runner, #232 split workflows). +- Backlog ouvert (`status:ready`, non bloqué) : `spec-feature-gating` (#297-#302, gating par tier Free/Base/Premium — planifié 2026-07-19) ; `spec-paiements` (#270 activation /v1 + product explicite + URL achat localisée ; #271 absorbé par #301) ; `spec-ci-build-optimization` 2/4 (#234 connectivité cache runner, #232 split workflows). diff --git a/spec-decisions-feature-gating.md b/spec-decisions-feature-gating.md new file mode 100644 index 0000000..04e725e --- /dev/null +++ b/spec-decisions-feature-gating.md @@ -0,0 +1,80 @@ +# Spec Decisions — Gating des fonctionnalités par édition de licence + +> Date: 2026-07-19 +> Projet: simpl-resultat +> Statut: Draft +> Slug: feature-gating + +## Contexte + +Simpl'Résultat n'applique aujourd'hui **aucun gating de fonctionnalités** : un utilisateur Free a accès à tout, sauf la récupération des cours (seul gate dur, vérifié côté serveur via maximus-api). L'infrastructure de licence est pourtant déjà là — 3 éditions signées Ed25519 (`free`/`base`/`premium`), table `FEATURE_TIERS`, champ `features[]` signé — mais seuls 3 call-sites de gate existent (auto-update, laissé ouvert ; cours, Premium). Max veut définir un vrai périmètre d'abonnement en verrouillant des modules selon le tier. + +## Objectif + +Appliquer une matrice d'entitlements Free/Base/Premium à travers l'app : chaque module hors du tier de l'utilisateur reste **visible mais verrouillé**, avec un écran d'upsell. Le gating est piloté par un hook central côté React, adossé à l'édition de licence déjà vérifiée, avec un override par-licence optionnel. Aucune donnée n'est supprimée — le durcissement bloque l'accès, il ne détruit rien. + +## Scope + +### IN +- Matrice d'entitlements Free/Base/Premium (ci-dessous). +- Hook central `useEntitlement(feature)` + garde de navigation (Sidebar) et de route. +- Écran/zone d'upsell « verrouillé » (cadenas, CTA upgrade), i18n FR/EN. +- Enrichir `FEATURE_TIERS` (Rust) + câbler l'override par-licence (`features[]` consulté par `is_feature_allowed`). +- Gating **par onglet** des rapports (`/reports/*`) : Free = Tendance uniquement. +- Absorption du re-gate auto-update (#271) : l'auto-update devient Base+. +- Durcissement sec **non destructif** : blocage d'accès immédiat, données conservées, récupérables par upgrade. + +### OUT (explicitement exclu) +- Le champ `product` explicite (#270) — reste dans spec-paiements. +- Le go-live Stripe / le flux d'achat en ligne (l'upsell pointe vers l'achat, dont l'URL localisée relève de #270). +- Un vrai système de codes d'invitation (l'émission de clés reste manuelle via l'endpoint admin `generate`). +- Tout gate « dur » server-enforced au-delà de l'existant (cours) — le gating local est un soft paywall assumé (GPL). +- Un 4e tier « admin » : l'accès total de Max = une licence Premium auto-émise. +- Mode lecture-seule des features verrouillées (écarté : durcissement sec, pas de distinction voir/éditer). + +## Matrice d'entitlements + +| Module | Free | Base | Premium | +|---|:--:|:--:|:--:| +| Dashboard | ✅ | ✅ | ✅ | +| Import CSV | ✅ | ✅ | ✅ | +| Transactions | ✅ | ✅ | ✅ | +| Catégories (+ guide, migration) | ✅ | ✅ | ✅ | +| Rapport **Tendance** | ✅ | ✅ | ✅ | +| Autres rapports (Highlights, Comparaison, Catégorie, Cartes) | ❌ | ✅ | ✅ | +| Budget | ❌ | ✅ | ✅ | +| Multi-profils | ❌ | ✅ | ✅ | +| Auto-update | ❌ | ✅ | ✅ | +| **Bilan complet** (patrimoine + cours) | ❌ | ❌ | ✅ | +| Export/Import chiffré | ✅ | ✅ | ✅ | +| Changelog / Docs | ✅ | ✅ | ✅ | + +Admin (Max) = licence **Premium** auto-émise (superset). + +## Décisions prises + +| Question | Décision | Raison | +|---|---|---| +| UX du gate (feature hors tier) | **Verrouillé + upsell** : entrée visible avec cadenas ; clic → écran « Passez à Base/Premium » | Meilleure conversion — l'utilisateur voit ce qu'il rate | +| Données existantes lors du durcissement | **Durcissement sec non destructif** : blocage d'accès (vue + édition) immédiat, mais **données conservées**, jamais supprimées, récupérables par upgrade | Simple à coder (pas de mode lecture-seule) ; ne détruit aucune donnée financière (clarifié par Max) | +| Modèle d'entitlements | **Statique + override licence** : map `tier → features` en dur, PLUS `license.features[]` consulté en additif (`allowed = feature ∈ tier OU feature ∈ license.features`) | Matrice centralisée et simple, + débloque le champ `features[]` déjà signé pour des licences spéciales sans créer de tier | +| Coordination spec-paiements | **Absorber #271** (re-gate auto-update) ici ; **#270** (`product`) reste séparé | L'auto-update est dans la matrice → gaté ici, #271 redondant ; `product`/URL d'achat = go-live, hors scope | +| Enforcement | **UI-only** (gardes React : nav + route). `current_edition()` (Rust) reste la source de vérité lue ; les commandes Tauri ne refusent pas (sauf l'existant : cours server-enforced) | Cohérent avec le soft-paywall GPL assumé — un gate Rust serait aussi retirable par un fork, sans gain de sécurité | +| Écran d'upsell | **Composant générique paramétré** (feature + tier requis), i18n FR/EN ; CTA « Obtenir Base/Premium » (lien d'achat, dépend de #270) + « J'ai déjà une clé » → Réglages → Licence | Un seul composant réutilisable, pas un écran par feature | +| Admin (accès total de Max) | **Licence Premium auto-émise** (endpoint admin `generate`), pas de 4e édition ni mode dev | Premium = superset ; réutilise l'infra existante, zéro code neuf | +| Free multi-profils existants | **Profil actif conservé** ; profils supplémentaires verrouillés → upsell (données conservées) | Cohérent avec durcissement sec + conservation des données | + +## Contraintes + +- **GPL-3.0 + privacy-first** : le gating local est un soft paywall **assumé** (contournable par fork). Seules les features adossées à maximus-api (les cours, aujourd'hui) sont dures. Aucune donnée financière ne quitte l'appareil. +- **Séquencement** : le gating est livrable et testable **indépendamment de Stripe** (pas encore en live) via des clés admin-émises. Corollaire : sans ce chantier, distribuer des clés n'a aucun effet visible (tout est déjà ouvert). + +## Références + +| Source | Pertinence | +|---|---| +| `spec-monetisation.md` | Modèle des 3 tiers, contraintes GPL / soft-paywall, format de licence | +| `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` + `is_feature_allowed` — table à enrichir, override `features[]` à câbler | +| `src-tauri/src/commands/license_commands.rs` | `current_edition()` (source de vérité de l'édition) + `LicenseClaims.features` (champ signé à consulter) | +| `src/services/licenseService.ts`, `src/hooks/useLicense.ts`, `src/hooks/useIsPremium.ts` | Base du futur hook `useEntitlement` | +| `src/App.tsx` (routes `/reports/*`), `src/components/layout/Sidebar` | Points de garde nav + route | diff --git a/spec-plan-feature-gating.md b/spec-plan-feature-gating.md new file mode 100644 index 0000000..4cd4c29 --- /dev/null +++ b/spec-plan-feature-gating.md @@ -0,0 +1,172 @@ +# Spec Plan — Gating des fonctionnalités par édition de licence + +> Date: 2026-07-19 +> Projet: simpl-resultat +> Statut: Draft +> Slug: feature-gating +> Decisions: [spec-decisions-feature-gating.md](./spec-decisions-feature-gating.md) + +## Ancrage code (vérifié 2026-07-19) + +- **Rapports = routes distinctes** (`App.tsx:117-122`) : `/reports` (hub), `/reports/highlights|trends|compare|category|cartes`. Le gating « par onglet » = **gating par route**. +- **Édition + features déjà en mémoire** : `useLicense` (`useLicense.ts:59`) charge `edition` + `info.features[]` au boot (`getEdition` + `readLicense`). ⇒ `useEntitlement` peut être **synchrone**. MAIS `useLicense` est un état **par-appel** (chaque montage refait 2 invokes) → il faut un **Context** partagé. +- **`FEATURE_TIERS` Rust** (`entitlements.rs:14-21`) ne déclare que 4 features ; `is_feature_allowed` ne regarde **que l'édition** (`entitlements.rs:24-30`) — l'override `features[]` n'est pas câblé. Une feature absente = deny-all. +- **Auto-update déjà branché** sur `check_entitlement("auto-update")` (`useUpdater.ts:79`), UI gère l'état `notEntitled`. ⇒ #271 = **1 ligne** dans `FEATURE_TIERS`. +- **`LicenseInfo.features[]`** est peuplé depuis le JWT (`license_commands.rs:132`) et exposé au front (`licenseService.ts:8`) → l'override par-licence est **gratuit côté TS**. +- **Sidebar** lit `NAV_ITEMS` (`constants/index.ts:6-61`), liste de config `{key, path, icon, labelKey}`. **`useIsPremium`** (`useIsPremium.ts`) remonte `useLicense` (à rebrancher sur le Context). **`LicenseCard`** est monté à `/settings/users` (`UsersSettingsPage.tsx:34`) → cible du lien « J'ai déjà une clé ». + +## Design + +### Architecture + +Source de vérité de l'**édition** = inchangée (`current_edition()` Rust, fail-closed). Nouveau : une **couche d'entitlements côté TS** pour un gating UI synchrone. + +``` +LicenseProvider (context, chargé 1× au boot) + ├── edition, features[], info, refresh, submitKey + └── consommé par : + useEntitlement(featureKey) → sync, lit ENTITLEMENTS + override features[] + useIsPremium() → refactoré pour lire le context (au lieu d'un useLicense par-appel) +``` + +- **Matrice UI** (`src/shared/entitlements.ts`, nouveau) — source unique côté front : + ```ts + export type FeatureKey = "budget" | "reports.advanced" | "multi-profile" | "balance"; + export const ENTITLEMENTS: Record = { + "budget": ["base", "premium"], + "reports.advanced":["base", "premium"], + "multi-profile": ["base", "premium"], + "balance": ["premium"], + }; + export function isEntitled(f: FeatureKey, edition: Edition, licenseFeatures: string[]): boolean { + return ENTITLEMENTS[f].includes(edition) || licenseFeatures.includes(f); + } + ``` + `auto-update` n'est **pas** ici : il reste géré côté Rust (`FEATURE_TIERS`), car c'est le seul gate consulté par du code Rust. Les modules Free (dashboard, import, transactions, catégories, `reports/trends`, export, changelog, docs) n'ont **pas** de clé → jamais gatés. + +- **Enforcement UI-only** (soft-paywall assumé) : aucune commande Tauri ne refuse (sauf l'existant : cours server-enforced). Le Rust ne change que pour `auto-update` (#271) + le câblage de l'override dans `check_entitlement`. + +### UX / Interface + +- **``** (nouveau, `src/components/shared/`) : écran plein — cadenas, titre « Fonctionnalité Base/Premium », description paramétrée par feature, deux CTA : « Obtenir » (lien d'achat — placeholder jusqu'à #270) + « J'ai déjà une clé » → `navigate("/settings/users")`. i18n FR/EN. +- **``** (nouveau) : `useEntitlement(feature) ? children : `. Wrappe les routes gatées. +- **Sidebar** : `NavItem` gagne `feature?: FeatureKey`. Un item non autorisé reste **visible avec un cadenas** (icône `Lock`) ; le clic mène à la route → `RequireFeature` affiche l'upsell (décision « verrouillé + upsell », pas masqué). +- **Hub `/reports`** (Free) : accessible, mais les tuiles vers les rapports avancés portent un cadenas. +- **Multi-profils** : `ProfileSwitcher` marque d'un cadenas les profils au-delà de l'actif quand `!useEntitlement("multi-profile")` ; « créer un profil » est verrouillé (upsell) pour un Free ayant déjà ≥ 1 profil. **Données conservées** : rien n'est supprimé de `profiles.json`, seul l'accès est bloqué. + +### Données + +Aucune nouvelle table, **aucune migration DB** — le gating lit la licence (fichier `license.key`), pas la base profil. `NavItem` (type TS) gagne un champ optionnel `feature`. + +## Plan de travail + +### Issue 1 — Socle : LicenseProvider + matrice d'entitlements + useEntitlement [type:feature] +Dependances : aucune +- [ ] `src/contexts/LicenseContext.tsx` : provider encapsulant la logique de `useLicense` (charge édition + info 1×), expose `{ status, edition, features, info, refresh, submitKey }`. Monter dans `main.tsx` au niveau racine (licence = machine-level, au-dessus de `ProfileProvider`). +- [ ] `src/shared/entitlements.ts` : type `FeatureKey`, `ENTITLEMENTS`, `isEntitled()` (pur). +- [ ] `src/hooks/useEntitlement.ts` : `useEntitlement(f: FeatureKey): boolean` (sync, lit le context). +- [ ] Refactorer `src/hooks/useIsPremium.ts` pour lire `LicenseContext` (behavior-preserving) → supprime le double-invoke sur `PriceFetchControl`/`PriceFetchConsentToggle`. +- [ ] Refactorer `LicenseCard` pour consommer le context (au lieu de son `useLicense` local). +- [ ] Tests : `entitlements.test.ts` (matrice, override `features[]`, édition inconnue). + +### Issue 2 — Garde UI : RequireFeature + UpsellGate + i18n [type:feature] +Dependances : Issue 1 +- [ ] `src/components/shared/UpsellGate.tsx` : écran verrouillé paramétré (feature, tier requis), 2 CTA. +- [ ] `src/components/shared/RequireFeature.tsx` : wrapper de route. +- [ ] i18n `src/i18n/locales/{fr,en}.json` : clés `upsell.*` (titres/descriptions par feature, CTA) + `nav.locked` (aria cadenas). + +### Issue 3 — Gating des routes + Sidebar [type:feature] +Dependances : Issue 2 +- [ ] `App.tsx` : wrapper `` sur `/budget` ; `"reports.advanced"` sur `/reports/highlights|compare|category|cartes` (PAS `/reports/trends`, PAS `/reports` hub) ; `"balance"` sur `/balance`, `/balance/accounts`, `/balance/snapshot`. +- [ ] `src/shared/types` : `NavItem` + `feature?: FeatureKey`. `constants/index.ts` : renseigner `feature` sur budget/reports/balance. +- [ ] `Sidebar.tsx` : cadenas sur item non autorisé (via `useEntitlement`). +- [ ] `ReportsPage` (hub) : cadenas sur les tuiles des rapports avancés. + +### Issue 4 — Gating multi-profils [type:feature] +Dependances : Issue 1 (+ Issue 2 pour l'upsell) +- [ ] `ProfileSwitcher` : cadenas + upsell sur profils au-delà de l'actif si `!multi-profile`. +- [ ] Bouton « créer un profil » (ProfileSwitcher / ProfileSelectionPage) : verrouillé pour Free ayant ≥ 1 profil. +- [ ] Garde non-destructive : ne rien retirer de `profiles.json`. + +### Issue 5 — Absorber #271 : auto-update Base+ + override licence (Rust) [type:feature] +Dependances : aucune (Rust indépendant — parallélisable) +- [ ] `entitlements.rs:17` : `("auto-update", &[EDITION_BASE, EDITION_PREMIUM])`, retirer le commentaire « temporarily open », ajuster le test `free_allows_auto_update_temporarily` → `free_denied_auto_update`. +- [ ] Câbler l'**override `features[]`** dans `check_entitlement` : `is_feature_allowed(feature, edition) || license_features.contains(feature)` (charger les features de la licence courante). +- [ ] Dev override (confort de test) : en `#[cfg(debug_assertions)]`, `current_edition` lit `SR_DEV_EDITION` (jamais en build release) pour tester les 3 tiers sans jongler avec des clés. +- [ ] Ferme/rescope l'issue Forgejo #271. + +### Issue 6 — Docs : ADR + architecture + CHANGELOG [type:feature] +Dependances : Issues 1-5 +- [ ] ADR `docs/adr/00XX-feature-gating-par-tier.md` : modèle (matrice tier→features + override par-licence, enforcement UI-only, soft-paywall GPL assumé). +- [ ] `docs/architecture.md` : nouveau context/hook/module d'entitlements. +- [ ] `docs/guide-utilisateur.md` + i18n `docs.*` : ce que débloque chaque tier. +- [ ] CHANGELOG.md + CHANGELOG.fr.md : **une** entrée globale (durcissement : liste des modules désormais Base/Premium). + +### Ordre d'execution +``` +Issue 1 → Issue 2 → Issue 3 +Issue 1 → Issue 4 +Issue 5 (indépendant, parallèle) +Issues 1-5 → Issue 6 +``` + +## Fichiers concernes + +| Fichier | Action | Raison | +|---|---|---| +| `src/contexts/LicenseContext.tsx` | Créer | Provider licence partagé (1 chargement) | +| `src/shared/entitlements.ts` | Créer | Matrice `ENTITLEMENTS` + `isEntitled` (pur, testable) | +| `src/hooks/useEntitlement.ts` | Créer | Hook sync de check | +| `src/hooks/useIsPremium.ts` | Modifier | Lire le context (dé-duplique l'invoke) | +| `src/components/settings/LicenseCard.tsx` | Modifier | Consommer le context | +| `src/components/shared/UpsellGate.tsx` | Créer | Écran verrouillé + CTA | +| `src/components/shared/RequireFeature.tsx` | Créer | Wrapper de route | +| `src/App.tsx` | Modifier | Wrapper les routes budget / reports avancés / balance | +| `src/shared/types` (NavItem) | Modifier | Champ `feature?` | +| `src/shared/constants/index.ts` | Modifier | Renseigner `feature` sur les items gatés | +| `src/components/layout/Sidebar.tsx` | Modifier | Cadenas sur item non autorisé | +| `src/pages/ReportsPage.tsx` | Modifier | Cadenas sur tuiles avancées | +| `src/components/profile/ProfileSwitcher.tsx` | Modifier | Cadenas profils + gate création | +| `src-tauri/src/commands/entitlements.rs` | Modifier | auto-update Base+ (#271) + override `features[]` | +| `src-tauri/src/commands/license_commands.rs` | Modifier | Dev override `SR_DEV_EDITION` (debug only) | +| `src/i18n/locales/{fr,en}.json` | Modifier | Clés `upsell.*`, `nav.locked` | +| `docs/adr/00XX-*.md`, `docs/architecture.md`, `CHANGELOG*.md` | Créer/Modifier | Documentation | + +## Plan de tests + +### Tests unitaires +- `entitlements.ts` : `isEntitled` — chaque feature × chaque édition, override `features[]` (une licence Free avec `features:["budget"]` débloque budget), feature absente. +- Rust `entitlements.rs` : auto-update denied Free / allowed Base+Premium ; override (`is_feature_allowed` OR `license.features`). + +### Tests d'integration +- Rust : `current_edition` + `check_entitlement` bout-en-bout avec une licence signée de test (le harness de test de `license_commands.rs` signe déjà des JWT) portant `features:[…]`. + +### Tests de regression +- `useIsPremium` : le refactor vers le context ne change pas le résultat (Premium ⇢ true) — figer via un test de `isEntitled`/edition avant de toucher `PriceFetchControl`. +- Suite existante verte (le gate cours via `useIsPremium` ne doit pas régresser). + +**Contrainte** : ni jsdom ni @testing-library → les composants React (`RequireFeature`, `UpsellGate`, `Sidebar`, `ProfileSwitcher`) ne sont pas rendables en test. La logique testable vit dans `isEntitled` (TS pur) et les checks Rust ; le rendu est délégué à la vérif runtime / `/pr-review`. + +## Criteres d'acceptation + +- [ ] Un Free voit Budget, les 4 rapports avancés et le Bilan **verrouillés** (cadenas + upsell), garde Dashboard/Import/Transactions/Catégories/Tendance/Export/Changelog. +- [ ] Un Base débloque Budget, tous les rapports, multi-profils, auto-update ; Bilan reste verrouillé. +- [ ] Un Premium a tout. +- [ ] Le durcissement est **non destructif** : les budgets/Bilan/profils déjà créés réapparaissent intacts après upgrade. +- [ ] Un Free avec 2 profils garde l'actif ; les autres sont verrouillés (conservés) ; « créer profil » est verrouillé. +- [ ] Auto-update refusé pour Free (#271 absorbé), autorisé Base+. +- [ ] Une licence portant `features:[""]` débloque cette feature quel que soit le tier (override). +- [ ] `useEntitlement` est synchrone (pas de flash « verrouillé » au chargement une fois la licence lue). +- [ ] Aucune migration DB ; suite de tests verte. + +## Edge cases et risques + +| Cas | Mitigation | +|---|---| +| `/adjustments` non classé dans la matrice | **À trancher** — défaut proposé : Free (proche des Transactions), pas de gate. Confirmer avec Max. | +| Max se bloque de son propre Bilan en dev (dev = édition `free`) | Dev override `SR_DEV_EDITION=premium` (`#[cfg(debug_assertions)]`) — Issue 5. Sinon, poser une vraie clé Premium locale. | +| Flash « verrouillé » pendant le chargement de la licence au boot | `useEntitlement` renvoie un état pendant `status !== "ready"` : rendre un loader (pas l'upsell) tant que la licence n'est pas résolue. | +| Désync matrice TS ↔ Rust | Non applicable : la matrice UI vit **uniquement** en TS ; Rust ne gère qu'`auto-update`. Pas de double source pour les features UI. | +| Rollout : gating partiel sur `main` entre les issues | Sans effet en prod — le durcissement ne devient visible qu'à la prochaine **release** taggée. Ordre des issues garde `main` cohérent (socle avant gardes). | +| Contournement (fork retire le `if`) | Assumé (GPL, soft-paywall). Hors périmètre — seul le gate serveur (cours) est dur. | +| Override `features[]` sur une Premium-via-compte (pas de license.key) | L'override vient du JWT `license.key` ; un Premium-via-abonnement n'a pas de `features[]` custom → comportement normal (tier seul). |