spec(gating): adjustments -> Base tier + STATE review sync

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-07-19 17:44:22 -04:00
parent a100ee287b
commit 4f39fa3434
3 changed files with 16 additions and 13 deletions

View file

@ -16,7 +16,7 @@ Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA
## 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-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. **`/review-spec` 3 experts → verdict 🟡, tout corrigé dans le plan** : 2 🔴 (nav `reports` gaté à tort alors que hub Free ; `advanced-reports` Rust mort contredit `reports-advanced`) + 6 🟡 (override `features[]` **fail-closed en Free** CWE-863 : une clé copiée downgrade free mais expose ses features signées ; `SR_DEV_EDITION` derrière une Cargo feature `dev-override` pas `debug_assertions` CWE-489 ; LicenseProvider récup. d'erreur CWE-703 ; `useEntitlement``{allowed,ready}` anti-flash ; `useIsPremium.test` à migrer ; gate création profil dans `ProfileFormModal`, point unique). Ajustements tranché **Base** par Max. (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 : **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)

View file

@ -43,6 +43,7 @@ Appliquer une matrice d'entitlements Free/Base/Premium à travers l'app : chaque
| Rapport **Tendance** | ✅ | ✅ | ✅ |
| Autres rapports (Highlights, Comparaison, Catégorie, Cartes) | ❌ | ✅ | ✅ |
| Budget | ❌ | ✅ | ✅ |
| Ajustements (écritures manuelles + split de transactions) | ❌ | ✅ | ✅ |
| Multi-profils | ❌ | ✅ | ✅ |
| Auto-update | ❌ | ✅ | ✅ |
| **Bilan complet** (patrimoine + cours) | ❌ | ❌ | ✅ |
@ -63,6 +64,7 @@ Admin (Max) = licence **Premium** auto-émise (superset).
| É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 |
| `/adjustments` (module Ajustements) | **Base** | Outil de correction avancé (écritures + split multi-catégories) → différenciateur payant (tranché post-review 2026-07-19) |
## Contraintes

View file

@ -31,9 +31,10 @@ LicenseProvider (context, chargé 1× au boot, monté AU-DESSUS de ProfileProvid
- **Matrice UI** (`src/shared/entitlements.ts`, nouveau) — source unique côté front :
```ts
export type FeatureKey = "budget" | "reports-advanced" | "multi-profile" | "balance";
export type FeatureKey = "budget" | "adjustments" | "reports-advanced" | "multi-profile" | "balance";
export const ENTITLEMENTS: Record<FeatureKey, Edition[]> = {
"budget": ["base", "premium"],
"adjustments": ["base", "premium"],
"reports-advanced": ["base", "premium"],
"multi-profile": ["base", "premium"],
"balance": ["premium"],
@ -46,7 +47,7 @@ LicenseProvider (context, chargé 1× au boot, monté AU-DESSUS de ProfileProvid
return ENTITLEMENTS[f].includes(edition) || licenseFeatures.includes(f);
}
```
Clés en **kebab-case** (`reports-advanced`) pour rester cohérent avec les strings Rust existants (`auto-update`) — le JWT `features[]` est un **namespace partagé** entre l'override Rust et TS. `auto-update` n'est **pas** ici (géré côté Rust). Modules Free (dashboard, import, transactions, catégories, `reports/trends`, **adjustments**, export, changelog, docs) = pas de clé → jamais gatés.
Clés en **kebab-case** (`reports-advanced`) pour rester cohérent avec les strings Rust existants (`auto-update`) — le JWT `features[]` est un **namespace partagé** entre l'override Rust et TS. `auto-update` n'est **pas** ici (géré côté Rust). Modules Free (dashboard, import, transactions, catégories, `reports/trends`, export, changelog, docs) = pas de clé → jamais gatés. **Ajustements passe en Base** (clé `adjustments`).
- **Enforcement UI-only** (soft-paywall assumé). Le Rust ne change que pour `auto-update` (#271) + le câblage de l'override `check_entitlement`, **fail-closed en Free** comme côté TS.
@ -55,7 +56,7 @@ LicenseProvider (context, chargé 1× au boot, monté AU-DESSUS de ProfileProvid
- **`useEntitlement(f): { allowed: boolean; ready: boolean }`** — `ready = status === "ready"`. Tous les consommateurs (RequireFeature, Sidebar, tuiles ReportsPage, ProfileSwitcher) **suppriment le cadenas/upsell tant que `!ready`** (rendent un placeholder neutre), pour ne jamais flasher « verrouillé » à un utilisateur payant pendant le chargement.
- **`<UpsellGate feature requiredTier>`** (nouveau, `src/components/shared/`) : écran plein — cadenas, titre « Fonctionnalité Base/Premium », description paramétrée, 2 CTA : « Obtenir <tier> » (lien d'achat — placeholder jusqu'à #270) + « J'ai déjà une clé » → `navigate("/settings/users")`. i18n FR/EN.
- **`<RequireFeature feature>`** : `ready ? (allowed ? children : <UpsellGate…>) : <Loader/>`. Wrappe les routes gatées. Les routes de même feature (`/balance*`, les 4 `/reports/*` avancés) sont regroupées sous **une layout-route** `<Route element={<RequireFeature feature=…><Outlet/></RequireFeature>}>` (convention `SettingsLayout`/`AppShell`), pour ne pas répéter le wrapper.
- **Sidebar** : `NavItem` gagne `feature?: FeatureKey`, renseigné sur **budget et balance uniquement**. `reports` reste **non gaté** (pointe vers le hub Free). Un item non autorisé (et `ready`) affiche un cadenas ; le clic mène à la route → `RequireFeature` affiche l'upsell.
- **Sidebar** : `NavItem` gagne `feature?: FeatureKey`, renseigné sur **budget, adjustments et balance**. `reports` reste **non gaté** (pointe vers le hub Free). Un item non autorisé (et `ready`) affiche un cadenas ; le clic mène à la route → `RequireFeature` affiche l'upsell.
- **Hub `/reports`** (Free) : accessible ; 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 `!allowed` ; la **création** est verrouillée au point unique `ProfileFormModal` pour un Free ayant déjà ≥ 1 profil. **Données conservées** : rien n'est supprimé de `profiles.json`.
@ -84,8 +85,8 @@ Dependances : Issue 1
### Issue 3 — Gating des routes + Sidebar [type:feature]
Dependances : Issue 2
- [ ] `App.tsx` : layout-route `<RequireFeature feature="balance"><Outlet/></RequireFeature>` groupant `/balance`, `/balance/accounts`, `/balance/snapshot` ; layout-route `"reports-advanced"` groupant `/reports/highlights|compare|category|cartes` (PAS `/reports/trends`, PAS le hub `/reports`) ; `"budget"` sur `/budget`.
- [ ] `NavItem` (`src/shared/types`) + `feature?: FeatureKey` ; renseigner dans `constants/index.ts` sur **budget et balance uniquement** — **PAS `reports`** (hub Free, non route-wrappé).
- [ ] `App.tsx` : layout-route `<RequireFeature feature="balance"><Outlet/></RequireFeature>` groupant `/balance`, `/balance/accounts`, `/balance/snapshot` ; layout-route `"reports-advanced"` groupant `/reports/highlights|compare|category|cartes` (PAS `/reports/trends`, PAS le hub `/reports`) ; `"budget"` sur `/budget` ; `"adjustments"` sur `/adjustments`.
- [ ] `NavItem` (`src/shared/types`) + `feature?: FeatureKey` ; renseigner dans `constants/index.ts` sur **budget, adjustments et balance** — **PAS `reports`** (hub Free, non route-wrappé).
- [ ] `Sidebar.tsx` : cadenas sur item non autorisé **et `ready`**, reste cliquable.
- [ ] `ReportsPage` (hub) : cadenas sur les tuiles des rapports avancés.
@ -128,8 +129,8 @@ Issues 1-5 → Issue 6
| `src/hooks/useIsPremium.ts` + `.test.ts` | Modifier | Lire le context ; migrer le mock du test |
| `src/components/settings/LicenseCard.tsx` | Modifier | Consommer le context |
| `src/components/shared/UpsellGate.tsx`, `RequireFeature.tsx` | Créer | Écran verrouillé + wrapper (layout-route) |
| `src/App.tsx` | Modifier | Layout-routes budget / reports-advanced / balance |
| `src/shared/types` (NavItem), `src/shared/constants/index.ts` | Modifier | `feature?` sur budget + balance uniquement |
| `src/App.tsx` | Modifier | Wrappers budget / adjustments / reports-advanced / balance |
| `src/shared/types` (NavItem), `src/shared/constants/index.ts` | Modifier | `feature?` sur budget + adjustments + balance |
| `src/components/layout/Sidebar.tsx` | Modifier | Cadenas (si `ready`) |
| `src/pages/ReportsPage.tsx` | Modifier | Cadenas sur tuiles avancées |
| `src/components/profile/ProfileSwitcher.tsx`, `ProfileFormModal.tsx`, `src/pages/ProfileSelectionPage.tsx` | Modifier | Cadenas profils + gate création au point unique |
@ -155,8 +156,8 @@ Issues 1-5 → Issue 6
## 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/Adjustments/Export/Changelog + le hub `/reports`.
- [ ] Un Base débloque Budget, tous les rapports, multi-profils, auto-update ; Bilan reste verrouillé.
- [ ] Un Free voit Budget, Ajustements, les 4 rapports avancés et le Bilan **verrouillés** (cadenas + upsell), garde Dashboard/Import/Transactions/Catégories/Tendance/Export/Changelog + le hub `/reports`.
- [ ] Un Base débloque Budget, Ajustements, tous les rapports, multi-profils, auto-update ; Bilan reste verrouillé.
- [ ] Un Premium a tout.
- [ ] Durcissement **non destructif** : budgets/Bilan/profils réapparaissent intacts après upgrade.
- [ ] Un Free avec 2 profils garde l'actif ; les autres verrouillés (conservés) ; création verrouillée.
@ -169,7 +170,7 @@ Issues 1-5 → Issue 6
| Cas | Mitigation |
|---|---|
| `/adjustments` (écritures manuelles + split de transactions) | **Tranché : Free** (adossé aux Transactions). Pas de clé, pas de gate. |
| `/adjustments` (écritures manuelles + split de transactions) | **Tranché : Base** (outil de correction avancé). Clé `adjustments` — route `/adjustments` + item nav gatés. |
| Max se bloque de son Bilan en dev (dev = `free`) | Cargo feature `dev-override` + `SR_DEV_EDITION=premium` (Issue 5). Sinon poser une clé Premium locale. |
| Flash « verrouillé » pendant le chargement licence | `useEntitlement` renvoie `ready` ; tous les consommateurs suppriment le cadenas tant que `!ready`. |
| Échec de chargement licence (invoke throw) | État neutre + retry/backoff, jamais l'upsell (CWE-703) — un payant n'est pas bloqué par un échec transitoire. |
@ -183,7 +184,7 @@ Issues 1-5 → Issue 6
> Date: 2026-07-19 | Experts: Securite, Architecture, Technique | Corrections intégrées dans cette passe.
### Verdict
🟡 **AMELIORATIONS INTEGREES** — 2 critiques + 6 améliorations trouvées et **corrigées ci-dessus** ; le plan est prêt à exécuter. Aucune décision produit en suspens (sauf `/adjustments`, tranché Free par défaut).
🟡 **AMELIORATIONS INTEGREES** — 2 critiques + 6 améliorations trouvées et **corrigées ci-dessus** ; le plan est prêt à exécuter. Aucune décision produit en suspens (`/adjustments` tranché **Base** par Max le 2026-07-19).
### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points clés |
@ -201,4 +202,4 @@ Issues 1-5 → Issue 6
6. 🟡 **Récupération d'erreur du LicenseProvider** (retry + état neutre, jamais upsell) — CWE-703. ✓
7. 🟡 **Migrer `useIsPremium.test.ts`** (mock du context) — ajouté à Issue 1. ✓
8. 🟡 **Gate création profil dans `ProfileFormModal`** (point unique) — fichiers ajoutés à Issue 4. ✓
9. 🟢 **Kebab-case** `reports-advanced` ; **layout-route** groupant les routes de même feature ; `/adjustments` = Free. ✓
9. 🟢 **Kebab-case** `reports-advanced` ; **layout-route** groupant les routes de même feature ; `/adjustments` = **Base** (tranché par Max). ✓