# 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<FeatureKey, Edition[]> = {
    "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.
- **`<UpsellGate feature requiredTier>`** (nouveau, `src/components/shared/`) : écran plein — cadenas, titre « Fonctionnalité Base/Premium », description paramétrée, 2 CTA : « Obtenir <tier> » (lien d'achat — placeholder jusqu'à #270) + « J'ai déjà une clé » → `navigate("/settings/users")`. i18n FR/EN.
- **`<RequireFeature feature>`** : `ready ? (allowed ? children : <UpsellGate…>) : <Loader/>`. Wrappe les routes gatées. Les routes de même feature (`/balance*`, les 4 `/reports/*` avancés) sont regroupées sous **une layout-route** `<Route element={<RequireFeature feature=…><Outlet/></RequireFeature>}>` (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<T|null>`, `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 (`<Outlet/>`) 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 `<RequireFeature feature="balance"><Outlet/></RequireFeature>` 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:["<clé>"]` 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). ✓
