# 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). |