# Spec Plan — Gating des fonctionnalités par édition de licence > Date: 2026-07-19 > Projet: simpl-resultat > Statut: Draft (revu — voir Révision — Synthèse) > 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 Free), `/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`) déclare 4 features dont 3 **mortes** (`web-sync`, `cloud-backup`, `advanced-reports` — aucun call-site) ; `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`). ⚠️ `readLicense`/`read_license` **ne vérifie pas** le machine-binding (contrairement à `current_edition`) → l'override doit être fail-closed en Free (voir Sécurité). - **Sidebar** lit `NAV_ITEMS` (`constants/index.ts:6-61`), liste `{key, path, icon, labelKey}`. **`useIsPremium`** (`useIsPremium.ts`) remonte `useLicense` (à rebrancher). **`LicenseCard`** est monté à `/settings/users` (`UsersSettingsPage.tsx:34`). **Création de profil** = `ProfileFormModal` (`createProfile`), ouvert depuis `ProfileSwitcher` ET `ProfileSelectionPage`. ## Design ### Architecture Source de vérité de l'**édition** = inchangée (`current_edition()` Rust, fail-closed, machine-binding). Nouveau : une **couche d'entitlements côté TS** pour un gating UI synchrone. ``` LicenseProvider (context, chargé 1× au boot, monté AU-DESSUS de ProfileProvider dans main.tsx) ├── status, edition, features[], info, refresh, submitKey └── consommé par : useEntitlement(featureKey) → { allowed, ready } — lit ENTITLEMENTS + override features[] useIsPremium() → refactoré pour lire le context ``` - **Matrice UI** (`src/shared/entitlements.ts`, nouveau) — source unique côté front : ```ts export type FeatureKey = "budget" | "adjustments" | "reports-advanced" | "multi-profile" | "balance"; export const ENTITLEMENTS: Record = { "budget": ["base", "premium"], "adjustments": ["base", "premium"], "reports-advanced": ["base", "premium"], "multi-profile": ["base", "premium"], "balance": ["premium"], }; // Override par-licence via le JWT features[] — mais fail-closed en Free : une // license.key copiee sur une autre machine downgrade edition->free (machine-binding), // on ne re-grant PAS ses features[] signees (CWE-863). export function isEntitled(f: FeatureKey, edition: Edition, licenseFeatures: string[]): boolean { if (edition === "free") return false; return ENTITLEMENTS[f].includes(edition) || licenseFeatures.includes(f); } ``` Clés en **kebab-case** (`reports-advanced`) pour rester cohérent avec les strings Rust existants (`auto-update`) — le JWT `features[]` est un **namespace partagé** entre l'override Rust et TS. `auto-update` n'est **pas** ici (géré côté Rust). Modules Free (dashboard, import, transactions, catégories, `reports/trends`, export, changelog, docs) = pas de clé → jamais gatés. **Ajustements passe en Base** (clé `adjustments`). - **Enforcement UI-only** (soft-paywall assumé). Le Rust ne change que pour `auto-update` (#271) + le câblage de l'override `check_entitlement`, **fail-closed en Free** comme côté TS. ### UX / Interface - **`useEntitlement(f): { allowed: boolean; ready: boolean }`** — `ready = status === "ready"`. Tous les consommateurs (RequireFeature, Sidebar, tuiles ReportsPage, ProfileSwitcher) **suppriment le cadenas/upsell tant que `!ready`** (rendent un placeholder neutre), pour ne jamais flasher « verrouillé » à un utilisateur payant pendant le chargement. - **``** (nouveau, `src/components/shared/`) : écran plein — cadenas, titre « Fonctionnalité Base/Premium », description paramétrée, 2 CTA : « Obtenir » (lien d'achat — placeholder jusqu'à #270) + « J'ai déjà une clé » → `navigate("/settings/users")`. i18n FR/EN. - **``** : `ready ? (allowed ? children : ) : `. Wrappe les routes gatées. Les routes de même feature (`/balance*`, les 4 `/reports/*` avancés) sont regroupées sous **une layout-route** `}>` (convention `SettingsLayout`/`AppShell`), pour ne pas répéter le wrapper. - **Sidebar** : `NavItem` gagne `feature?: FeatureKey`, renseigné sur **budget, adjustments et balance**. `reports` reste **non gaté** (pointe vers le hub Free). Un item non autorisé (et `ready`) affiche un cadenas ; le clic mène à la route → `RequireFeature` affiche l'upsell. - **Hub `/reports`** (Free) : accessible ; 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 `!allowed` ; la **création** est verrouillée au point unique `ProfileFormModal` pour un Free ayant déjà ≥ 1 profil. **Données conservées** : rien n'est supprimé de `profiles.json`. ### 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 (modelé sur `ProfileContext` — `createContext`, `useReducer`, hook consommateur qui throw), charge édition + info 1×, expose `{ status, edition, features, info, refresh, submitKey }`. **Monter dans `main.tsx` AU-DESSUS de `ProfileProvider`** (licence = machine-level → survit au remount `BrowserRouter key={refreshKey}` sur changement de profil). - [ ] **Récupération d'erreur** : sur échec de boot (`getEdition`/`readLicense` throw → `status:"error"`), retry avec backoff + `refresh` manuel. Les consommateurs rendent un état neutre pendant `status==="error"` (PAS l'upsell) — sinon un échec transitoire bloque un payant en upsell jusqu'au restart (CWE-703). - [ ] `src/shared/entitlements.ts` : `FeatureKey` (kebab-case), `ENTITLEMENTS`, `isEntitled()` pur **fail-closed en Free**. - [ ] `src/hooks/useEntitlement.ts` : `useEntitlement(f): { allowed, ready }` (sync, lit le context). - [ ] Refactor `useIsPremium.ts` pour lire `LicenseContext` (behavior-preserving, supprime le double-invoke). - [ ] **Migrer `useIsPremium.test.ts`** : il mocke `./useLicense` (`vi.mock`) — le refactor le casse ; le faire mocker le context (ou le module d'entitlements). (`PriceFetchControl`/`PriceFetchConsentToggle` mockent `useIsPremium` directement → non affectés.) - [ ] Refactor `LicenseCard` pour consommer le context. - [ ] Tests : `entitlements.test.ts` (matrice, override `features[]`, **override ignoré en Free**, édition inconnue). ### Issue 2 — Garde UI : RequireFeature + UpsellGate + i18n [type:feature] Dependances : Issue 1 - [ ] `src/components/shared/UpsellGate.tsx` : écran verrouillé paramétré, 2 CTA. - [ ] `src/components/shared/RequireFeature.tsx` : `ready ? (allowed ? children : UpsellGate) : Loader`. Rendre comme layout-route (``) pour regrouper les routes de même feature. - [ ] i18n `{fr,en}.json` : `upsell.*` + `nav.locked`. ### Issue 3 — Gating des routes + Sidebar [type:feature] Dependances : Issue 2 - [ ] `App.tsx` : layout-route `` groupant `/balance`, `/balance/accounts`, `/balance/snapshot` ; layout-route `"reports-advanced"` groupant `/reports/highlights|compare|category|cartes` (PAS `/reports/trends`, PAS le hub `/reports`) ; `"budget"` sur `/budget` ; `"adjustments"` sur `/adjustments`. - [ ] `NavItem` (`src/shared/types`) + `feature?: FeatureKey` ; renseigner dans `constants/index.ts` sur **budget, adjustments et balance** — **PAS `reports`** (hub Free, non route-wrappé). - [ ] `Sidebar.tsx` : cadenas sur item non autorisé **et `ready`**, reste cliquable. - [ ] `ReportsPage` (hub) : cadenas sur les tuiles des rapports avancés. ### Issue 4 — Gating multi-profils (Base+) non destructif [type:feature] Dependances : Issue 1 ET Issue 2 (rend `UpsellGate`) - [ ] `ProfileSwitcher` : cadenas + upsell sur les profils au-delà de l'actif quand `!allowed` (et `ready`). - [ ] `ProfileFormModal` (point unique de création, ouvert depuis `ProfileSwitcher` ET `ProfileSelectionPage`) : verrouillé pour un Free ayant déjà ≥ 1 profil (ou désactiver les 2 boutons d'entrée). - [ ] 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`. - [ ] **Purger les entrées mortes** de `FEATURE_TIERS` : `web-sync`, `cloud-backup`, `advanced-reports` (aucun call-site ; `advanced-reports`→Premium **contredit** la matrice TS `reports-advanced`→Base+). Ne laisser que `auto-update`. - [ ] Câbler l'override `features[]` dans `check_entitlement`, **fail-closed en Free** : résoudre l'édition ET les features via le même chemin machine-binding (features ignorées si `current_edition` downgrade à `free`), puis `is_feature_allowed(feature, edition) || features.contains(feature)`. - [ ] Dev override : gater derrière une **Cargo feature dédiée `dev-override`** (off par défaut, jamais dans le feature-set release), **PAS `debug_assertions`** (activable sur un build release → backdoor Premium, CWE-489). `current_edition` lit `SR_DEV_EDITION` uniquement sous cette feature ; test que `SR_DEV_EDITION` n'a aucun effet quand la feature est off. - [ ] Absorbe #271 (fermé superseded). ### Issue 6 — Docs : ADR + architecture + CHANGELOG [type:feature] Dependances : Issues 1-5 - [ ] ADR `docs/adr/00XX-feature-gating-par-tier.md` : matrice tier→features + override par-licence (fail-closed Free), 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 — modules désormais Base/Premium). ### Ordre d'execution ``` Issue 1 → Issue 2 → Issue 3 → Issue 4 (Issue 4 dépend de 1 ET 2) 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) + récupération d'erreur | | `src/shared/entitlements.ts` | Créer | Matrice `ENTITLEMENTS` + `isEntitled` (pur, fail-closed Free) | | `src/hooks/useEntitlement.ts` | Créer | Hook `{ allowed, ready }` | | `src/hooks/useIsPremium.ts` + `.test.ts` | Modifier | Lire le context ; migrer le mock du test | | `src/components/settings/LicenseCard.tsx` | Modifier | Consommer le context | | `src/components/shared/UpsellGate.tsx`, `RequireFeature.tsx` | Créer | Écran verrouillé + wrapper (layout-route) | | `src/App.tsx` | Modifier | Wrappers budget / adjustments / reports-advanced / balance | | `src/shared/types` (NavItem), `src/shared/constants/index.ts` | Modifier | `feature?` sur budget + adjustments + balance | | `src/components/layout/Sidebar.tsx` | Modifier | Cadenas (si `ready`) | | `src/pages/ReportsPage.tsx` | Modifier | Cadenas sur tuiles avancées | | `src/components/profile/ProfileSwitcher.tsx`, `ProfileFormModal.tsx`, `src/pages/ProfileSelectionPage.tsx` | Modifier | Cadenas profils + gate création au point unique | | `src-tauri/src/commands/entitlements.rs` | Modifier | auto-update Base+ (#271), purge dead rows, override fail-closed | | `src-tauri/src/commands/license_commands.rs` | Modifier | Override features via chemin machine-binding + dev-override (Cargo feature) | | `src-tauri/Cargo.toml` | Modifier | Cargo feature `dev-override` (off par défaut) | | `src/i18n/locales/{fr,en}.json` | Modifier | `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[]`, **override ignoré quand edition==="free"** (régression CWE-863), feature absente. - Rust `entitlements.rs` : auto-update denied Free / allowed Base+Premium ; override fail-closed (features ignorées si édition downgrade free) ; `SR_DEV_EDITION` sans effet quand la feature `dev-override` est off. ### Tests d'integration - Rust : `current_edition` + `check_entitlement` bout-en-bout avec une licence signée de test portant `features:[…]` ET un activation.token machine-mismatch → vérifier que les features ne sont PAS accordées. ### Tests de regression - `useIsPremium` : le refactor vers le context ne change pas le résultat (Premium ⇢ true). Le gate cours (`PriceFetchControl` via `useIsPremium`) ne régresse pas. **Contrainte** : ni jsdom ni @testing-library (confirmé — les `*.test.tsx` existants mockent les hooks, ne rendent pas les composants). 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, Ajustements, les 4 rapports avancés et le Bilan **verrouillés** (cadenas + upsell), garde Dashboard/Import/Transactions/Catégories/Tendance/Export/Changelog + le hub `/reports`. - [ ] Un Base débloque Budget, Ajustements, tous les rapports, multi-profils, auto-update ; Bilan reste verrouillé. - [ ] Un Premium a tout. - [ ] Durcissement **non destructif** : budgets/Bilan/profils réapparaissent intacts après upgrade. - [ ] Un Free avec 2 profils garde l'actif ; les autres verrouillés (conservés) ; création verrouillée. - [ ] Auto-update refusé pour Free (#271 absorbé), autorisé Base+. - [ ] Une licence **valide (machine-bound)** portant `features:[""]` débloque cette feature ; une licence copiée (downgrade free) ne débloque **rien** via `features[]`. - [ ] **Aucun flash « verrouillé »** au boot (Sidebar, tuiles, profils, routes) — `ready` supprime le cadenas pendant le chargement. - [ ] Aucune migration DB ; suite de tests verte. ## Edge cases et risques | Cas | Mitigation | |---|---| | `/adjustments` (écritures manuelles + split de transactions) | **Tranché : Base** (outil de correction avancé). Clé `adjustments` — route `/adjustments` + item nav gatés. | | Max se bloque de son Bilan en dev (dev = `free`) | Cargo feature `dev-override` + `SR_DEV_EDITION=premium` (Issue 5). Sinon poser une clé Premium locale. | | Flash « verrouillé » pendant le chargement licence | `useEntitlement` renvoie `ready` ; tous les consommateurs suppriment le cadenas tant que `!ready`. | | Échec de chargement licence (invoke throw) | État neutre + retry/backoff, jamais l'upsell (CWE-703) — un payant n'est pas bloqué par un échec transitoire. | | Override `features[]` sur une clé copiée (edition downgrade free) | `isEntitled`/`check_entitlement` fail-closed en Free — features[] ignorées quand l'édition est free (CWE-863). | | Désync matrice TS ↔ Rust | Après purge des dead rows (Issue 5), `FEATURE_TIERS` ne garde qu'`auto-update` → matrice UI uniquement en TS. | | Rollout : gating partiel sur `main` entre issues | Sans effet en prod — visible seulement à la prochaine release taggée. Ordre socle→gardes garde `main` cohérent. | | Contournement (fork retire le `if`) | Assumé (GPL, soft-paywall). Seul le gate serveur (cours) est dur. | ## Revision — Synthese > Date: 2026-07-19 | Experts: Securite, Architecture, Technique | Corrections intégrées dans cette passe. ### Verdict 🟡 **AMELIORATIONS INTEGREES** — 2 critiques + 6 améliorations trouvées et **corrigées ci-dessus** ; le plan est prêt à exécuter. Aucune décision produit en suspens (`/adjustments` tranché **Base** par Max le 2026-07-19). ### Resume | Expert | 🔴 | 🟡 | 🟢 | Points clés | |--------|-----|-----|-----|-------------| | Securite | 0 | 3 | 1 | override `features[]` vs machine-binding (CWE-863) ; `SR_DEV_EDITION` sur debug-assertions (CWE-489) ; LicenseProvider SPOF sans récupération d'erreur (CWE-703) | | Architecture | 2 | 2 | 2 | `reports` nav gaté à tort (hub Free) ; `advanced-reports` Rust contredit `reports-advanced` ; casing incohérent ; boolean sans état de chargement ; **mount point validé sain** | | Technique | 0 | 4 | 3 | `useIsPremium.test.ts` cassé par le refactor ; gate création dans `ProfileFormModal` (pas Sidebar) ; `/adjustments` à trancher ; ordre 4→(1,2) | ### Actions requises (toutes intégrées) 1. 🔴 **Nav `reports` non gaté** — `feature` sur budget + balance uniquement ; hub `/reports` reste Free. ✓ 2. 🔴 **Purger `advanced-reports`/`web-sync`/`cloud-backup`** du Rust `FEATURE_TIERS` (Issue 5) ; mitigation « double source » corrigée. ✓ 3. 🟡 **`useEntitlement` → `{ allowed, ready }`** ; tous les consommateurs suppriment le cadenas tant que `!ready`. ✓ 4. 🟡 **Override `features[]` fail-closed en Free** (TS `isEntitled` + Rust `check_entitlement`) — CWE-863. ✓ 5. 🟡 **`SR_DEV_EDITION` derrière une Cargo feature `dev-override`**, pas `debug_assertions` — CWE-489. ✓ 6. 🟡 **Récupération d'erreur du LicenseProvider** (retry + état neutre, jamais upsell) — CWE-703. ✓ 7. 🟡 **Migrer `useIsPremium.test.ts`** (mock du context) — ajouté à Issue 1. ✓ 8. 🟡 **Gate création profil dans `ProfileFormModal`** (point unique) — fichiers ajoutés à Issue 4. ✓ 9. 🟢 **Kebab-case** `reports-advanced` ; **layout-route** groupant les routes de même feature ; `/adjustments` = **Base** (tranché par Max). ✓