docs(spec): feature-gating decisions + plan + milestone spec-feature-gating (#297-#302)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
195a73596e
commit
99ba147906
3 changed files with 255 additions and 2 deletions
5
STATE.md
5
STATE.md
|
|
@ -1,6 +1,6 @@
|
||||||
# STATE — Simpl'Résultat
|
# STATE — Simpl'Résultat
|
||||||
|
|
||||||
> Derniere MAJ : 2026-07-18 (**v0.14.0 shippée** — milestone `planned-2026-07-15-collapse-multi-niveaux` 4/4 : repli des catégories à **chaque niveau** sur les 3 rapports hiérarchiques + grille Budget + unification des 2 arbres de catégories, persistance par-profil via `user_preferences` (ADR 0016). Pile de 4 PRs #292-#295 → `/pr-review` **APPROVE ×4**, ff-merge (`main` `5a3d87b`), tag `v0.14.0` (`9c18e10`) → CI release #322. Auparavant cette session : **v0.13.0 taggée** (2026-07-13) — le backlog `[Unreleased]` accumulé depuis v0.12.0 (filtres de comptes #272-#279, tendances hiérarchiques #262-#265, collapse niveau-1 #254). **828 vitest** + 98 Rust. Aucune migration DB (v1→v16). #259 mergée (PR #296, `/pr-review` APPROVE) ; epic #260 « rapports uniformes » fermée — 4/5 rapports conformes, 3 déviations documentées entérinées)
|
> Derniere MAJ : 2026-07-19 (**v0.14.0 shippée** — milestone `planned-2026-07-15-collapse-multi-niveaux` 4/4 : repli des catégories à **chaque niveau** sur les 3 rapports hiérarchiques + grille Budget + unification des 2 arbres de catégories, persistance par-profil via `user_preferences` (ADR 0016). Pile de 4 PRs #292-#295 → `/pr-review` **APPROVE ×4**, ff-merge (`main` `5a3d87b`), tag `v0.14.0` (`9c18e10`) → CI release #322. Auparavant cette session : **v0.13.0 taggée** (2026-07-13) — le backlog `[Unreleased]` accumulé depuis v0.12.0 (filtres de comptes #272-#279, tendances hiérarchiques #262-#265, collapse niveau-1 #254). **828 vitest** + 98 Rust. Aucune migration DB (v1→v16). #259 mergée (PR #296, `/pr-review` APPROVE) ; epic #260 « rapports uniformes » fermée — 4/5 rapports conformes, 3 déviations documentées entérinées)
|
||||||
|
|
||||||
## Position actuelle
|
## Position actuelle
|
||||||
|
|
||||||
|
|
@ -16,6 +16,7 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
|
||||||
|
|
||||||
## Decisions recentes
|
## Decisions recentes
|
||||||
|
|
||||||
|
- 2026-07-19 : **Chantier gating par tier planifié via `/spec` → milestone `spec-feature-gating` (#297-#302)**. Origine : réflexion monétisation de Max (« changer le périmètre des abonnements »). Matrice tranchée : **Free** = Dashboard/Import/Transactions/Catégories/**rapport Tendance**/Export chiffré/Changelog (mono-profil) ; **Base** = + les 4 autres rapports/Budget/multi-profils/auto-update ; **Premium** = + Bilan complet (patrimoine + cours). Admin (Max) = licence Premium auto-émise (pas de 4e édition). Décisions clés : upsell **verrouillé** (pas masqué) ; durcissement **sec non destructif** (blocage d'accès, données conservées, récupérables par upgrade) ; entitlements **statique + override `features[]`** ; **enforcement UI-only** (soft-paywall GPL assumé, seul le gate cours reste dur/server-enforced) ; **#271 absorbé** (auto-update Base+, fermé superseded). Re-ancrage Phase 3b : socle déjà là (3 éditions Ed25519, `current_edition`, `check_entitlement`) mais `is_feature_allowed` ignore `features[]` + `useLicense` per-appel → **LicenseProvider** requis pour un `useEntitlement` sync ; rapports = routes distinctes (gate par route) ; #271 = 1 ligne. 6 issues (socle→garde UI→routes/sidebar→multi-profils→#271 Rust→docs), specs force-add (gitignorées, précédent #295), aucune migration DB. Point ouvert : `/adjustments` non classé (défaut Free). (ref spec-feature-gating, #297-#302)
|
||||||
- 2026-07-18 : **Epic #260 « rapports uniformes » fermée + #259 livrée en PR #296 (review)**. #260 fermée après `/analyze` : **4/5 rapports pleinement conformes** (income-statement + filtres partagés + collapse par-profil) ; les 3 écarts restants vs le texte de l'epic sont des **décisions de conception assumées**, entérinés par Max — (1) lignes vides budget non masquées (spec décision 6 : grille = surface d'édition) ; (2) collapse budget replié « comme partout » (#289/ADR 0016 inverse le « sauf budget » de l'epic) ; (3) dashboard convergé sur le modèle Cartes plutôt qu'une table income-statement hiérarchique (décision 5). **Correction STATE** : les entrées 07-08/07-11 « reste de #260 = #259 » sont fausses — #259 est une migration de taxonomie sans lien avec l'epic (recadrée 07-12), et les vraies déviations #260 n'y étaient pas tracées. **#259** (fusion des catégories custom) livrée en PR #296 (`issue-259-merge-custom-categories`) : bloc préservé de `StepSimulate` rendu en `MappingRow`, reducer `RESOLVE_ROW` résout rows+preserved (`unresolved` compté sur seed only → ne bloque pas « Suivant »), writer via helper `isResolvedTarget` (fourre-tout créé seulement s'il reste une custom non fusionnée ; customs fusionnées désactivées au lieu d'être re-parentées). Plan-check pass (6 MINOR, 3 intégrés), **836 vitest** + build tsc/vite propres, régression parent/enfant custom couverte, aucune migration DB. `/pr-review` **APPROVE**, mergée (rebase) le 2026-07-19 → `main` `2314a64`, #259 fermée, branche supprimée. (ref #260, #259 PR #296)
|
- 2026-07-18 : **Epic #260 « rapports uniformes » fermée + #259 livrée en PR #296 (review)**. #260 fermée après `/analyze` : **4/5 rapports pleinement conformes** (income-statement + filtres partagés + collapse par-profil) ; les 3 écarts restants vs le texte de l'epic sont des **décisions de conception assumées**, entérinés par Max — (1) lignes vides budget non masquées (spec décision 6 : grille = surface d'édition) ; (2) collapse budget replié « comme partout » (#289/ADR 0016 inverse le « sauf budget » de l'epic) ; (3) dashboard convergé sur le modèle Cartes plutôt qu'une table income-statement hiérarchique (décision 5). **Correction STATE** : les entrées 07-08/07-11 « reste de #260 = #259 » sont fausses — #259 est une migration de taxonomie sans lien avec l'epic (recadrée 07-12), et les vraies déviations #260 n'y étaient pas tracées. **#259** (fusion des catégories custom) livrée en PR #296 (`issue-259-merge-custom-categories`) : bloc préservé de `StepSimulate` rendu en `MappingRow`, reducer `RESOLVE_ROW` résout rows+preserved (`unresolved` compté sur seed only → ne bloque pas « Suivant »), writer via helper `isResolvedTarget` (fourre-tout créé seulement s'il reste une custom non fusionnée ; customs fusionnées désactivées au lieu d'être re-parentées). Plan-check pass (6 MINOR, 3 intégrés), **836 vitest** + build tsc/vite propres, régression parent/enfant custom couverte, aucune migration DB. `/pr-review` **APPROVE**, mergée (rebase) le 2026-07-19 → `main` `2314a64`, #259 fermée, branche supprimée. (ref #260, #259 PR #296)
|
||||||
- 2026-07-18 : **Milestone collapse multi-niveaux (#288-291) livrée → v0.14.0**. Cycle complet en une session : `/plan-run` (spec 2 fichiers, 8 décisions drainées) → `/review-spec` (3 experts, **verdict 🔴**) → refonte v2 → `/autopilot` (4 workers) → `/pr-review` ×4 → ff-merge → release. **Le point clé** : la revue a tué l'algo v1. Il suivait un curseur de profondeur supposant un **ordre DFS** ; or la grille Budget (`useBudget.ts:361`) trie par **niveau** — les 3 experts l'ont trouvé indépendamment. Refonte v2 : **visibilité par remontée de `parent_id`** (une ligne visible ssi tous ses ancêtres dépliés), order-independant → résout d'un coup l'ordre budget, le tri par type qui sépare parent/enfant, la feuille « (direct) » qui partage la clé du parent, et la contrainte `visible()`-avant-`reorderRows`. **Pile linéaire forcée** (B/C/D dépendent tous du nouveau hook de A → pas de wave parallèle) : PRs #292-#295, chacune basée sur la précédente. `/pr-review` **APPROVE ×3 + 1 REQUEST_CHANGES** (#295 : ligne `- Spec:` de l'ADR 0016 pointant des specs gitignorées → 404 ; corrigé par **force-add des specs à la racine**, précédent repo `spec-refonte-rapports.md` ; au passage la revue s'est trompée en disant que 0015 n'avait pas de ligne Spec — elle en a une, cassée pareil, bug pré-existant signalé). **ff-merge** de la pile (4 issues auto-fermées via `Resolves #N`, milestone 4/4, 4 branches supprimées) → **v0.14.0** taggée. **Persistance migrée `localStorage` → `user_preferences`** (base du profil) : `deleteProfile` ne purge aucun `localStorage` → une clé par-profil y serait un résidu survivant à la suppression, révélant les catégories explorées d'un profil PIN-protégé (exigence privacy-first, **ADR 0016**, frontière tracée : état UI par-profil → DB profil, état UI machine → localStorage). Le hook gagne `defaultExpanded` + `storageKey` nullable, qui **unifie aussi les 2 arbres de catégories** (#290 : `CategoryTree` déplié-par-défaut, guide replié ; corrige le bug `allExpanded = size>0` du guide ; worker a trouvé un **4e consommateur** `StepDiscover` non listé au plan, migré). 828 vitest, aucune migration DB. Écarts protocole tracés au [rapport](reports/DAILY-REPORT-2026-07-15.md) : workers auto-validés **sans forker `/pr-review`** (évite le double-post [[feedback-pr-review-subagent-forks]]) ; #294 sans test (refactor de rendu, non testable sans jsdom) → vérif runtime déléguée à la revue. Reste `spec-ci-build-optimization` (2/4) + `spec-paiements` (#270/#271) + #259. (ref #288-291, PRs #292-#295)
|
- 2026-07-18 : **Milestone collapse multi-niveaux (#288-291) livrée → v0.14.0**. Cycle complet en une session : `/plan-run` (spec 2 fichiers, 8 décisions drainées) → `/review-spec` (3 experts, **verdict 🔴**) → refonte v2 → `/autopilot` (4 workers) → `/pr-review` ×4 → ff-merge → release. **Le point clé** : la revue a tué l'algo v1. Il suivait un curseur de profondeur supposant un **ordre DFS** ; or la grille Budget (`useBudget.ts:361`) trie par **niveau** — les 3 experts l'ont trouvé indépendamment. Refonte v2 : **visibilité par remontée de `parent_id`** (une ligne visible ssi tous ses ancêtres dépliés), order-independant → résout d'un coup l'ordre budget, le tri par type qui sépare parent/enfant, la feuille « (direct) » qui partage la clé du parent, et la contrainte `visible()`-avant-`reorderRows`. **Pile linéaire forcée** (B/C/D dépendent tous du nouveau hook de A → pas de wave parallèle) : PRs #292-#295, chacune basée sur la précédente. `/pr-review` **APPROVE ×3 + 1 REQUEST_CHANGES** (#295 : ligne `- Spec:` de l'ADR 0016 pointant des specs gitignorées → 404 ; corrigé par **force-add des specs à la racine**, précédent repo `spec-refonte-rapports.md` ; au passage la revue s'est trompée en disant que 0015 n'avait pas de ligne Spec — elle en a une, cassée pareil, bug pré-existant signalé). **ff-merge** de la pile (4 issues auto-fermées via `Resolves #N`, milestone 4/4, 4 branches supprimées) → **v0.14.0** taggée. **Persistance migrée `localStorage` → `user_preferences`** (base du profil) : `deleteProfile` ne purge aucun `localStorage` → une clé par-profil y serait un résidu survivant à la suppression, révélant les catégories explorées d'un profil PIN-protégé (exigence privacy-first, **ADR 0016**, frontière tracée : état UI par-profil → DB profil, état UI machine → localStorage). Le hook gagne `defaultExpanded` + `storageKey` nullable, qui **unifie aussi les 2 arbres de catégories** (#290 : `CategoryTree` déplié-par-défaut, guide replié ; corrige le bug `allExpanded = size>0` du guide ; worker a trouvé un **4e consommateur** `StepDiscover` non listé au plan, migré). 828 vitest, aucune migration DB. Écarts protocole tracés au [rapport](reports/DAILY-REPORT-2026-07-15.md) : workers auto-validés **sans forker `/pr-review`** (évite le double-post [[feedback-pr-review-subagent-forks]]) ; #294 sans test (refactor de rendu, non testable sans jsdom) → vérif runtime déléguée à la revue. Reste `spec-ci-build-optimization` (2/4) + `spec-paiements` (#270/#271) + #259. (ref #288-291, PRs #292-#295)
|
||||||
- 2026-07-12 : **#259 recadrée — l'issue décrivait un flux qui n'existe pas** (rectifie les entrées des 2026-07-05 / 07-11 qui la classaient « mapping manuel compte sans similaire auto » et « reliquat de l'epic #260 » : les deux sont faux). Le corps d'origine, rédigé à chaud le 2026-07-05 pendant le run v0.12.0, avait **transposé par analogie** le signalement de Max vers le module Bilan sans ouvrir le fichier : il décrivait une auto-association des comptes standards aux comptes existants du profil dans `StarterAccountsModal`, avec « case désactivée quand aucun similaire n'est auto-identifié ». **Aucune notion de mapping n'existe dans ce flux** — la case est un simple « créer ce compte : oui/non », désactivée **quand une collision EST détectée** (`StarterAccountsModal.tsx:160`), soit la polarité inverse ; le commentaire `l.8` cité (« the matching checkbox ») désignait « la case **correspondante** », pas « la case de matching ». **Le vrai sujet** (confirmé par Max) est la **migration des catégories** : `computeMigrationPlan` range les catégories en 2 seaux et un seul est éditable — `plan.rows` (seed) passe par le moteur d'appariement + type-ahead par ligne (#246/#252), tandis que `plan.preserved` (catégories **custom**) est poussé avec `v1TargetId: null` **sans même être soumis au moteur d'appariement**, rendu en `<li>` texte brut (`StepSimulate.tsx:202-216`, aucun picker) et déversé d'office par le writer sous le fourre-tout « Catégories personnalisées (migration) ». Sémantique tranchée avec Max : **fusion** (choisir une feuille standard réassigne tx/budgets/mots-clés/fournisseurs et fait disparaître la custom ; ne rien choisir = comportement actuel, et ne doit **pas** bloquer le bouton « Suivant »). Re-parentage écarté. Travail = câblage sur 3 couches (UI `StepSimulate` → `MappingRow` réutilisable tel quel ; reducer `RESOLVE_ROW` qui ne voit que `plan.rows` ; 4 retouches du writer) — la machinerie de fusion est **déjà générique** (`buildMappingFromRows` filtre les cibles nulles, étapes 3-7 bouclent sur la Map). Complexité Medium. Piège à couvrir : fusionner une custom **parente ayant des enfants custom** (liste `preserved` plate → l'enfant non résolu retombe au fourre-tout, pas d'orphelin, mais aucun test ne le garantit). Leçon process : une issue rédigée par analogie en fin de run, sans lecture du code, peut inverser la prémisse **et** se tromper de module — le `/analyze` l'a rattrapée 6 jours plus tard. (ref #259)
|
- 2026-07-12 : **#259 recadrée — l'issue décrivait un flux qui n'existe pas** (rectifie les entrées des 2026-07-05 / 07-11 qui la classaient « mapping manuel compte sans similaire auto » et « reliquat de l'epic #260 » : les deux sont faux). Le corps d'origine, rédigé à chaud le 2026-07-05 pendant le run v0.12.0, avait **transposé par analogie** le signalement de Max vers le module Bilan sans ouvrir le fichier : il décrivait une auto-association des comptes standards aux comptes existants du profil dans `StarterAccountsModal`, avec « case désactivée quand aucun similaire n'est auto-identifié ». **Aucune notion de mapping n'existe dans ce flux** — la case est un simple « créer ce compte : oui/non », désactivée **quand une collision EST détectée** (`StarterAccountsModal.tsx:160`), soit la polarité inverse ; le commentaire `l.8` cité (« the matching checkbox ») désignait « la case **correspondante** », pas « la case de matching ». **Le vrai sujet** (confirmé par Max) est la **migration des catégories** : `computeMigrationPlan` range les catégories en 2 seaux et un seul est éditable — `plan.rows` (seed) passe par le moteur d'appariement + type-ahead par ligne (#246/#252), tandis que `plan.preserved` (catégories **custom**) est poussé avec `v1TargetId: null` **sans même être soumis au moteur d'appariement**, rendu en `<li>` texte brut (`StepSimulate.tsx:202-216`, aucun picker) et déversé d'office par le writer sous le fourre-tout « Catégories personnalisées (migration) ». Sémantique tranchée avec Max : **fusion** (choisir une feuille standard réassigne tx/budgets/mots-clés/fournisseurs et fait disparaître la custom ; ne rien choisir = comportement actuel, et ne doit **pas** bloquer le bouton « Suivant »). Re-parentage écarté. Travail = câblage sur 3 couches (UI `StepSimulate` → `MappingRow` réutilisable tel quel ; reducer `RESOLVE_ROW` qui ne voit que `plan.rows` ; 4 retouches du writer) — la machinerie de fusion est **déjà générique** (`buildMappingFromRows` filtre les cibles nulles, étapes 3-7 bouclent sur la Map). Complexité Medium. Piège à couvrir : fusionner une custom **parente ayant des enfants custom** (liste `preserved` plate → l'enfant non résolu retombe au fourre-tout, pas d'orphelin, mais aucun test ne le garantit). Leçon process : une issue rédigée par analogie en fin de run, sans lecture du code, peut inverser la prémisse **et** se tromper de module — le `/analyze` l'a rattrapée 6 jours plus tard. (ref #259)
|
||||||
|
|
@ -30,4 +31,4 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
|
||||||
## Blockers actifs
|
## Blockers actifs
|
||||||
|
|
||||||
- Aucun blocker externe dur. `spec-monetisation` **fermée 12/12** (2026-07-08) — #50/#52/#53/#135/#136 livrées, ne sont plus des blockers.
|
- Aucun blocker externe dur. `spec-monetisation` **fermée 12/12** (2026-07-08) — #50/#52/#53/#135/#136 livrées, ne sont plus des blockers.
|
||||||
- Backlog ouvert (`status:ready`, non bloqué) : `spec-paiements` (#270 activation /v1 + product explicite + URL achat localisée ; #271 re-gate auto-update derrière la licence Base) ; `spec-ci-build-optimization` 2/4 (#234 connectivité cache runner, #232 split workflows).
|
- Backlog ouvert (`status:ready`, non bloqué) : `spec-feature-gating` (#297-#302, gating par tier Free/Base/Premium — planifié 2026-07-19) ; `spec-paiements` (#270 activation /v1 + product explicite + URL achat localisée ; #271 absorbé par #301) ; `spec-ci-build-optimization` 2/4 (#234 connectivité cache runner, #232 split workflows).
|
||||||
|
|
|
||||||
80
spec-decisions-feature-gating.md
Normal file
80
spec-decisions-feature-gating.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Spec Decisions — Gating des fonctionnalités par édition de licence
|
||||||
|
|
||||||
|
> Date: 2026-07-19
|
||||||
|
> Projet: simpl-resultat
|
||||||
|
> Statut: Draft
|
||||||
|
> Slug: feature-gating
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Simpl'Résultat n'applique aujourd'hui **aucun gating de fonctionnalités** : un utilisateur Free a accès à tout, sauf la récupération des cours (seul gate dur, vérifié côté serveur via maximus-api). L'infrastructure de licence est pourtant déjà là — 3 éditions signées Ed25519 (`free`/`base`/`premium`), table `FEATURE_TIERS`, champ `features[]` signé — mais seuls 3 call-sites de gate existent (auto-update, laissé ouvert ; cours, Premium). Max veut définir un vrai périmètre d'abonnement en verrouillant des modules selon le tier.
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Appliquer une matrice d'entitlements Free/Base/Premium à travers l'app : chaque module hors du tier de l'utilisateur reste **visible mais verrouillé**, avec un écran d'upsell. Le gating est piloté par un hook central côté React, adossé à l'édition de licence déjà vérifiée, avec un override par-licence optionnel. Aucune donnée n'est supprimée — le durcissement bloque l'accès, il ne détruit rien.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### IN
|
||||||
|
- Matrice d'entitlements Free/Base/Premium (ci-dessous).
|
||||||
|
- Hook central `useEntitlement(feature)` + garde de navigation (Sidebar) et de route.
|
||||||
|
- Écran/zone d'upsell « verrouillé » (cadenas, CTA upgrade), i18n FR/EN.
|
||||||
|
- Enrichir `FEATURE_TIERS` (Rust) + câbler l'override par-licence (`features[]` consulté par `is_feature_allowed`).
|
||||||
|
- Gating **par onglet** des rapports (`/reports/*`) : Free = Tendance uniquement.
|
||||||
|
- Absorption du re-gate auto-update (#271) : l'auto-update devient Base+.
|
||||||
|
- Durcissement sec **non destructif** : blocage d'accès immédiat, données conservées, récupérables par upgrade.
|
||||||
|
|
||||||
|
### OUT (explicitement exclu)
|
||||||
|
- Le champ `product` explicite (#270) — reste dans spec-paiements.
|
||||||
|
- Le go-live Stripe / le flux d'achat en ligne (l'upsell pointe vers l'achat, dont l'URL localisée relève de #270).
|
||||||
|
- Un vrai système de codes d'invitation (l'émission de clés reste manuelle via l'endpoint admin `generate`).
|
||||||
|
- Tout gate « dur » server-enforced au-delà de l'existant (cours) — le gating local est un soft paywall assumé (GPL).
|
||||||
|
- Un 4e tier « admin » : l'accès total de Max = une licence Premium auto-émise.
|
||||||
|
- Mode lecture-seule des features verrouillées (écarté : durcissement sec, pas de distinction voir/éditer).
|
||||||
|
|
||||||
|
## Matrice d'entitlements
|
||||||
|
|
||||||
|
| Module | Free | Base | Premium |
|
||||||
|
|---|:--:|:--:|:--:|
|
||||||
|
| Dashboard | ✅ | ✅ | ✅ |
|
||||||
|
| Import CSV | ✅ | ✅ | ✅ |
|
||||||
|
| Transactions | ✅ | ✅ | ✅ |
|
||||||
|
| Catégories (+ guide, migration) | ✅ | ✅ | ✅ |
|
||||||
|
| Rapport **Tendance** | ✅ | ✅ | ✅ |
|
||||||
|
| Autres rapports (Highlights, Comparaison, Catégorie, Cartes) | ❌ | ✅ | ✅ |
|
||||||
|
| Budget | ❌ | ✅ | ✅ |
|
||||||
|
| Multi-profils | ❌ | ✅ | ✅ |
|
||||||
|
| Auto-update | ❌ | ✅ | ✅ |
|
||||||
|
| **Bilan complet** (patrimoine + cours) | ❌ | ❌ | ✅ |
|
||||||
|
| Export/Import chiffré | ✅ | ✅ | ✅ |
|
||||||
|
| Changelog / Docs | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
Admin (Max) = licence **Premium** auto-émise (superset).
|
||||||
|
|
||||||
|
## Décisions prises
|
||||||
|
|
||||||
|
| Question | Décision | Raison |
|
||||||
|
|---|---|---|
|
||||||
|
| UX du gate (feature hors tier) | **Verrouillé + upsell** : entrée visible avec cadenas ; clic → écran « Passez à Base/Premium » | Meilleure conversion — l'utilisateur voit ce qu'il rate |
|
||||||
|
| Données existantes lors du durcissement | **Durcissement sec non destructif** : blocage d'accès (vue + édition) immédiat, mais **données conservées**, jamais supprimées, récupérables par upgrade | Simple à coder (pas de mode lecture-seule) ; ne détruit aucune donnée financière (clarifié par Max) |
|
||||||
|
| Modèle d'entitlements | **Statique + override licence** : map `tier → features` en dur, PLUS `license.features[]` consulté en additif (`allowed = feature ∈ tier OU feature ∈ license.features`) | Matrice centralisée et simple, + débloque le champ `features[]` déjà signé pour des licences spéciales sans créer de tier |
|
||||||
|
| Coordination spec-paiements | **Absorber #271** (re-gate auto-update) ici ; **#270** (`product`) reste séparé | L'auto-update est dans la matrice → gaté ici, #271 redondant ; `product`/URL d'achat = go-live, hors scope |
|
||||||
|
| Enforcement | **UI-only** (gardes React : nav + route). `current_edition()` (Rust) reste la source de vérité lue ; les commandes Tauri ne refusent pas (sauf l'existant : cours server-enforced) | Cohérent avec le soft-paywall GPL assumé — un gate Rust serait aussi retirable par un fork, sans gain de sécurité |
|
||||||
|
| Écran d'upsell | **Composant générique paramétré** (feature + tier requis), i18n FR/EN ; CTA « Obtenir Base/Premium » (lien d'achat, dépend de #270) + « J'ai déjà une clé » → Réglages → Licence | Un seul composant réutilisable, pas un écran par feature |
|
||||||
|
| Admin (accès total de Max) | **Licence Premium auto-émise** (endpoint admin `generate`), pas de 4e édition ni mode dev | Premium = superset ; réutilise l'infra existante, zéro code neuf |
|
||||||
|
| Free multi-profils existants | **Profil actif conservé** ; profils supplémentaires verrouillés → upsell (données conservées) | Cohérent avec durcissement sec + conservation des données |
|
||||||
|
|
||||||
|
## Contraintes
|
||||||
|
|
||||||
|
- **GPL-3.0 + privacy-first** : le gating local est un soft paywall **assumé** (contournable par fork). Seules les features adossées à maximus-api (les cours, aujourd'hui) sont dures. Aucune donnée financière ne quitte l'appareil.
|
||||||
|
- **Séquencement** : le gating est livrable et testable **indépendamment de Stripe** (pas encore en live) via des clés admin-émises. Corollaire : sans ce chantier, distribuer des clés n'a aucun effet visible (tout est déjà ouvert).
|
||||||
|
|
||||||
|
## Références
|
||||||
|
|
||||||
|
| Source | Pertinence |
|
||||||
|
|---|---|
|
||||||
|
| `spec-monetisation.md` | Modèle des 3 tiers, contraintes GPL / soft-paywall, format de licence |
|
||||||
|
| `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` + `is_feature_allowed` — table à enrichir, override `features[]` à câbler |
|
||||||
|
| `src-tauri/src/commands/license_commands.rs` | `current_edition()` (source de vérité de l'édition) + `LicenseClaims.features` (champ signé à consulter) |
|
||||||
|
| `src/services/licenseService.ts`, `src/hooks/useLicense.ts`, `src/hooks/useIsPremium.ts` | Base du futur hook `useEntitlement` |
|
||||||
|
| `src/App.tsx` (routes `/reports/*`), `src/components/layout/Sidebar` | Points de garde nav + route |
|
||||||
172
spec-plan-feature-gating.md
Normal file
172
spec-plan-feature-gating.md
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
# 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). |
|
||||||
Loading…
Reference in a new issue