13 KiB
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
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) chargeedition+info.features[]au boot (getEdition+readLicense). ⇒useEntitlementpeut être synchrone. MAISuseLicenseest un état par-appel (chaque montage refait 2 invokes) → il faut un Context partagé. FEATURE_TIERSRust (entitlements.rs:14-21) ne déclare que 4 features ;is_feature_allowedne regarde que l'édition (entitlements.rs:24-30) — l'overridefeatures[]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'étatnotEntitled. ⇒ #271 = 1 ligne dansFEATURE_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) remonteuseLicense(à rebrancher sur le Context).LicenseCardest 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 :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-updaten'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 danscheck_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 » (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 :
NavItemgagnefeature?: FeatureKey. Un item non autorisé reste visible avec un cadenas (icôneLock) ; le clic mène à la route →RequireFeatureaffiche 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 :
ProfileSwitchermarque 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é deprofiles.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 deuseLicense(charge édition + info 1×), expose{ status, edition, features, info, refresh, submitKey }. Monter dansmain.tsxau niveau racine (licence = machine-level, au-dessus deProfileProvider).src/shared/entitlements.ts: typeFeatureKey,ENTITLEMENTS,isEntitled()(pur).src/hooks/useEntitlement.ts:useEntitlement(f: FeatureKey): boolean(sync, lit le context).- Refactorer
src/hooks/useIsPremium.tspour lireLicenseContext(behavior-preserving) → supprime le double-invoke surPriceFetchControl/PriceFetchConsentToggle. - Refactorer
LicenseCardpour consommer le context (au lieu de sonuseLicenselocal). - Tests :
entitlements.test.ts(matrice, overridefeatures[], é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ésupsell.*(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/reportshub) ;"balance"sur/balance,/balance/accounts,/balance/snapshot.src/shared/types:NavItem+feature?: FeatureKey.constants/index.ts: renseignerfeaturesur budget/reports/balance.Sidebar.tsx: cadenas sur item non autorisé (viauseEntitlement).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 testfree_allows_auto_update_temporarily→free_denied_auto_update.- Câbler l'override
features[]danscheck_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_editionlitSR_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+ i18ndocs.*: 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, overridefeatures[](une licence Free avecfeatures:["budget"]débloque budget), feature absente.- Rust
entitlements.rs: auto-update denied Free / allowed Base+Premium ; override (is_feature_allowedORlicense.features).
Tests d'integration
- Rust :
current_edition+check_entitlementbout-en-bout avec une licence signée de test (le harness de test delicense_commands.rssigne déjà des JWT) portantfeatures:[…].
Tests de regression
useIsPremium: le refactor vers le context ne change pas le résultat (Premium ⇢ true) — figer via un test deisEntitled/edition avant de toucherPriceFetchControl.- Suite existante verte (le gate cours via
useIsPremiumne 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). useEntitlementest 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). |