Simpl-Resultat/spec-plan-feature-gating.md
le king fu 4f39fa3434 spec(gating): adjustments -> Base tier + STATE review sync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:44:22 -04:00

205 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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