172 lines
13 KiB
Markdown
172 lines
13 KiB
Markdown
# 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). |
|