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

13 KiB
Raw Blame History

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) 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 :

    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 » (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_temporarilyfree_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).