Simpl-Resultat/spec-plan-feature-gating.md
le king fu 99ba147906 docs(spec): feature-gating decisions + plan + milestone spec-feature-gating (#297-#302)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:20:50 -04:00

172 lines
13 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
> 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<FeatureKey, Edition[]> = {
"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
- **`<UpsellGate feature requiredTier>`** (nouveau, `src/components/shared/`) : écran plein — cadenas, titre « Fonctionnalité Base/Premium », description paramétrée par feature, deux CTA : « Obtenir <tier> » (lien d'achat — placeholder jusqu'à #270) + « J'ai déjà une clé » → `navigate("/settings/users")`. i18n FR/EN.
- **`<RequireFeature feature>`** (nouveau) : `useEntitlement(feature) ? children : <UpsellGate…>`. 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 `<RequireFeature feature="budget">` 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:["<clé>"]` 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). |