docs(gating): ADR 0017 + architecture + guide + CHANGELOG #308
8 changed files with 256 additions and 9 deletions
|
|
@ -6,6 +6,10 @@
|
||||||
|
|
||||||
- Migration des catégories : les catégories personnalisées sans correspondance standard peuvent désormais être **fusionnées** dans une catégorie standard, au lieu d'être seulement mises de côté. Chaque catégorie personnalisée du bloc « Catégories personnalisées » reçoit le même sélecteur de cible que les lignes du seed — choisissez une feuille standard et ses transactions, budgets, mots-clés et fournisseurs y sont réassignés, puis la catégorie personnalisée est retirée. Laisser une catégorie personnalisée non mappée conserve le comportement précédent (regroupée sous « Catégories personnalisées (migration) ») et ne bloque jamais la migration (#259).
|
- Migration des catégories : les catégories personnalisées sans correspondance standard peuvent désormais être **fusionnées** dans une catégorie standard, au lieu d'être seulement mises de côté. Chaque catégorie personnalisée du bloc « Catégories personnalisées » reçoit le même sélecteur de cible que les lignes du seed — choisissez une feuille standard et ses transactions, budgets, mots-clés et fournisseurs y sont réassignés, puis la catégorie personnalisée est retirée. Laisser une catégorie personnalisée non mappée conserve le comportement précédent (regroupée sous « Catégories personnalisées (migration) ») et ne bloque jamais la migration (#259).
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- L'application déverrouille désormais ses modules par édition — **Gratuite**, **Base** ou **Premium**, résolue depuis votre clé de licence. La Gratuite conserve le Tableau de bord, l'Import CSV, les Transactions, les Catégories, le rapport Tendances (et le hub Rapports), l'export/import chiffré et le journal des modifications, avec un seul profil. La **Base** débloque en plus le Budget, les Ajustements, les quatre rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes), les profils multiples et les mises à jour automatiques. La **Premium** débloque en plus le module Bilan complet (patrimoine, détail par titre, cours du marché). Les modules verrouillés restent visibles — un cadenas apparaît dans la barre latérale et sur les tuiles de rapports — et les ouvrir affiche un écran de déverrouillage avec un raccourci « J'ai déjà une clé » vers la carte de licence ; le bouton d'achat en ligne « Obtenir Base / Premium » est affiché mais désactivé pour l'instant (« bientôt disponible »). Le verrouillage n'est jamais destructif : quelle que soit l'édition, vos données sont conservées intactes et tout réapparaît dès qu'une clé valide est entrée — en Gratuite votre profil actif reste toujours accessible, seuls la création d'un profil supplémentaire ou le passage à un autre profil sont verrouillés (#297, #298, #299, #300, #301).
|
||||||
|
|
||||||
## [0.14.0] - 2026-07-18
|
## [0.14.0] - 2026-07-18
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
|
|
||||||
- Category migration: custom categories that have no standard match can now be **merged** into a standard category instead of only being set aside. Each custom category in the "Custom categories" block gets the same target picker as the seeded rows — choose a standard leaf and its transactions, budgets, keywords and suppliers are reassigned to it, then the custom category is removed. Leaving a custom category unmapped keeps the previous behaviour (grouped under "Custom categories (migration)") and never blocks the migration (#259).
|
- Category migration: custom categories that have no standard match can now be **merged** into a standard category instead of only being set aside. Each custom category in the "Custom categories" block gets the same target picker as the seeded rows — choose a standard leaf and its transactions, budgets, keywords and suppliers are reassigned to it, then the custom category is removed. Leaving a custom category unmapped keeps the previous behaviour (grouped under "Custom categories (migration)") and never blocks the migration (#259).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The application now unlocks its modules per edition — **Free**, **Base** or **Premium**, resolved from your license key. Free keeps the Dashboard, CSV Import, Transactions, Categories, the Trends report (and the Reports hub), encrypted export/import and the changelog, with a single profile. **Base** additionally unlocks Budget, Adjustments, the four advanced reports (Highlights, Compare, Category analysis, Cards), multiple profiles and automatic updates. **Premium** additionally unlocks the full Balance module (net worth, per-security detail, market prices). Locked modules stay visible — a lock badge shows in the sidebar and on the report tiles — and opening one shows an unlock screen with an "I already have a key" shortcut to the license card; the online "Get Base / Premium" purchase button is shown but disabled for now ("coming soon"). Locking is never destructive: whatever the edition, your data is kept untouched and everything reappears as soon as a valid key is entered — on Free your active profile always stays accessible, only creating or switching to another profile is locked (#297, #298, #299, #300, #301).
|
||||||
|
|
||||||
## [0.14.0] - 2026-07-18
|
## [0.14.0] - 2026-07-18
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
129
docs/adr/0017-feature-gating-par-tier.md
Normal file
129
docs/adr/0017-feature-gating-par-tier.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# ADR 0017 — Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé
|
||||||
|
|
||||||
|
- Status: **Accepted**
|
||||||
|
- Date: 2026-07-20
|
||||||
|
- Issues: #297 (socle LicenseContext + matrice + `useEntitlement`), #298 (garde `RequireFeature` + `UpsellGate`), #299 (routes gatées + cadenas Sidebar/tuiles), #300 (multi-profils non destructif), #301 (Rust : `auto-update` Base+, `features[]`, dev-override), #302 (cette doc)
|
||||||
|
- Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md)
|
||||||
|
- S'appuie sur la licence JWT Ed25519 machine-bindée (Phase 3b monétisation) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 liée à la machine (ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
|
||||||
|
|
||||||
|
Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
|
||||||
|
|
||||||
|
| Module | Gratuite | Base | Premium |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ |
|
||||||
|
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
|
||||||
|
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
|
||||||
|
| Profil (un seul) | ✓ | ✓ | ✓ |
|
||||||
|
| Budget | — | ✓ | ✓ |
|
||||||
|
| Ajustements | — | ✓ | ✓ |
|
||||||
|
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
|
||||||
|
| Profils multiples | — | ✓ | ✓ |
|
||||||
|
| Mises à jour automatiques | — | ✓ | ✓ |
|
||||||
|
| Bilan complet (patrimoine, détail par titre, cours) | — | — | ✓ |
|
||||||
|
|
||||||
|
Pas de quatrième édition « admin » : Max s'auto-émet une licence Premium.
|
||||||
|
|
||||||
|
Trois contraintes structurent la solution :
|
||||||
|
|
||||||
|
1. **L'app est GPL-3.0.** Tout enforcement embarqué dans le client est contournable par recompilation — un « durcissement » côté Rust n'apporterait aucune garantie réelle, seulement de la complexité (un aller-retour IPC par gate, gestion d'erreur, latence au boot d'une app offline-first).
|
||||||
|
2. **La licence est machine-bindée.** Une clé `license.key` copiée sur une autre machine est déclassée en `free` par le chemin de validation (token d'activation ↔ machine id). Mais le JWT copié porte toujours son tableau signé `features[]` (overrides par-licence) : sans précaution, un check « matrice OU features » re-donnerait à une clé déclassée les fonctionnalités qu'elle liste — c'est une autorisation incorrecte (CWE-863).
|
||||||
|
3. **Le contexte licence n'existait pas côté React.** `useLicense` invoquait `get_edition` à chaque appel (asynchrone, par composant) — inutilisable pour gater des routes et des items de navigation sans flash ni cascade d'IPC.
|
||||||
|
|
||||||
|
## Décision
|
||||||
|
|
||||||
|
### 1. Matrice statique côté UI + override signé `features[]`, fail-closed en Free
|
||||||
|
|
||||||
|
La source de vérité front est `src/shared/entitlements.ts` : un type `FeatureKey` fermé (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance` — kebab-case car le namespace est partagé avec le `features[]` du JWT et avec Rust) et une table `ENTITLEMENTS: Record<FeatureKey, Edition[]>`. Les modules Free n'ont **pas** de clé : ce qui n'est pas dans la matrice n'est jamais gaté.
|
||||||
|
|
||||||
|
`isEntitled(feature, edition, licenseFeatures)` combine la matrice et l'override signé par-licence, avec un court-circuit **fail-closed en Free** (CWE-863) :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (edition === "free") return false; // l'override ne ressuscite JAMAIS une clé déclassée
|
||||||
|
return tiers.includes(edition) || licenseFeatures.includes(feature);
|
||||||
|
```
|
||||||
|
|
||||||
|
L'override `features[]` sert à débloquer une fonctionnalité au-dessus du tier d'une licence payante (ex. une licence Base portant `balance`), jamais à secourir une édition `free` — qu'elle soit native ou issue d'un déclassement machine-binding. Le même court-circuit existe côté Rust (`entitlements::is_entitled`), en défense en profondeur : `current_entitlements` retourne déjà `("free", [])` sur tout échec de validation, donc les features signées sont perdues dès le déclassement, et le court-circuit les refuse même si un chemin futur les laissait passer. `requiredTierFor(feature)` (dérivé de l'appartenance à la matrice, pas de l'ordre du tableau) alimente le libellé d'upsell « Obtenir Base / Premium ».
|
||||||
|
|
||||||
|
### 2. Enforcement UI-only — soft-paywall GPL assumé
|
||||||
|
|
||||||
|
Le gating est appliqué **uniquement dans l'interface** (routes, navigation, points d'entrée). C'est un choix explicite, pas un oubli : sous GPL, un utilisateur qui recompile l'app sans les gardes est un cas assumé — le paywall s'adresse à l'utilisateur des binaires officiels, pas à un adversaire. La seule fonctionnalité réellement enforced l'est **côté serveur** : la récupération de cours (Premium) passe par maximus-api qui vérifie la licence à chaque requête ([ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Côté Rust, `FEATURE_TIERS` ne conserve que `auto-update` → Base+ (absorbe l'issue #271) — la matrice UI vit uniquement en TS, les deux tables ont des rôles disjoints.
|
||||||
|
|
||||||
|
### 3. `LicenseProvider` machine-level + `useEntitlement` synchrone anti-flash
|
||||||
|
|
||||||
|
`LicenseContext` est monté **au-dessus** de `ProfileProvider` : la licence est une propriété de la machine, pas du profil actif, et le provider survit au remount `BrowserRouter key={refreshKey}` déclenché par un changement de profil. Il charge édition + licence **une fois au boot**, puis :
|
||||||
|
|
||||||
|
- **Récupération d'erreur (CWE-703)** : le provider est un point unique de défaillance ; si l'invoke de boot échoue, l'état passe à `status: "error"` (édition précédente préservée) et un retry à backoff exponentiel plafonné (1 s → 30 s) relance le chargement. Les consommateurs rendent un placeholder neutre tant que `status !== "ready"` — jamais l'upsell — pour qu'une panne IPC transitoire ne verrouille pas un client payant.
|
||||||
|
- **Validation de clé orthogonale** : un `submitKey` rejeté (clé mal saisie) alimente `validationError` sans toucher `status` — pas de flash de verrouillage sur une typo, et la boucle de retry ne peut pas s'armer sur une erreur de validation.
|
||||||
|
|
||||||
|
`useEntitlement(feature)` retourne `{ allowed, ready }` (pas un booléen nu) : `allowed` est fail-closed pendant le boot (édition par défaut `free`), `ready` permet aux surfaces de supprimer le cadenas/upsell tant que la licence n'est pas résolue.
|
||||||
|
|
||||||
|
### 4. Upsell verrouillé visible, jamais masqué
|
||||||
|
|
||||||
|
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**.
|
||||||
|
|
||||||
|
### 5. Durcissement sec, non destructif
|
||||||
|
|
||||||
|
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, machine changée) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
|
||||||
|
|
||||||
|
### 6. Dev-override compilé hors des builds normaux
|
||||||
|
|
||||||
|
Pour tester les trois éditions sans forger de licences, `SR_DEV_EDITION` force l'édition résolue — mais **uniquement** dans un build compilé avec la Cargo feature `dev-override` (off par défaut, `cargo test --features dev-override`). Le choix d'une feature explicite plutôt que `debug_assertions` évite qu'un artefact debug distribué par erreur embarque la porte dérobée (CWE-489) : l'activation est un acte opt-in, jamais un effet de profil de build.
|
||||||
|
|
||||||
|
## Alternatives considérées
|
||||||
|
|
||||||
|
### A. Enforcement dur côté Rust pour tous les modules — rejeté
|
||||||
|
|
||||||
|
Faire passer chaque gate par `check_entitlement` (IPC) et refuser les données côté commandes. Rejeté : sous GPL le client reste recompilable, donc la garantie est illusoire ; le coût est réel (latence, gestion d'erreur par gate, couplage des services SQL — qui n'appellent aucune commande Rust par convention — au module licence). Le seul enforcement qui vaut quelque chose est côté serveur, et il existe déjà pour les cours.
|
||||||
|
|
||||||
|
### B. Masquer les fonctionnalités non licenciées — rejeté
|
||||||
|
|
||||||
|
Retirer de la Sidebar et du hub ce que l'édition ne couvre pas. Rejeté : l'utilisateur Gratuite doit **voir** ce que Base et Premium offrent (découvrabilité = le canal de vente d'une app sans télémétrie) ; un module invisible ne se vend pas. D'où l'upsell verrouillé : cadenas + écran explicite.
|
||||||
|
|
||||||
|
### C. Downgrade destructif ou données en lecture seule exportable — rejeté
|
||||||
|
|
||||||
|
Purger ou geler les données des modules perdus au déclassement. Rejeté sans débat : contraire au principe privacy-first « vos données vous appartiennent », et transforme toute expiration de licence en incident. Le blocage d'accès réversible donne le même incitatif d'upgrade sans risque de perte.
|
||||||
|
|
||||||
|
### D. Édition admin dédiée — rejetée
|
||||||
|
|
||||||
|
Une quatrième édition pour l'usage interne de Max. Rejetée : une licence Premium auto-émise donne le même résultat sans quatrième branche dans la matrice, les tests et l'UI.
|
||||||
|
|
||||||
|
### E. `features[]` seul, sans matrice statique — rejeté
|
||||||
|
|
||||||
|
Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : chaque licence devrait énumérer toutes ses fonctionnalités (fragile à l'ajout d'un module — les licences déjà émises ne le porteraient pas), et le serveur d'émission deviendrait la seule source de vérité d'un comportement client. La matrice donne le défaut par édition ; l'override signé reste l'exception par-licence.
|
||||||
|
|
||||||
|
## Conséquences
|
||||||
|
|
||||||
|
### Positives
|
||||||
|
|
||||||
|
- **Un point de vérité par couche** : `ENTITLEMENTS` (TS) pour l'UI, `FEATURE_TIERS` (Rust) réduit à `auto-update` — rôles disjoints, namespace kebab-case partagé (`features[]` JWT lisible par les deux).
|
||||||
|
- **Fail-closed partout** : édition par défaut `free` au boot, override refusé en Free (CWE-863) des deux côtés, échec de résolution Rust → `("free", [])`.
|
||||||
|
- **Pas de flash de verrouillage** : `{ allowed, ready }` + loader neutre dans `RequireFeature` + retry backoff dans le provider — un client payant ne voit jamais l'upsell sur une erreur transitoire.
|
||||||
|
- **Zéro migration, zéro perte** : aucune table, aucun changement de schéma ; le déclassement est purement un état d'affichage réversible.
|
||||||
|
- **#271 absorbé** : `auto-update` passe Base+ par une ligne de `FEATURE_TIERS`, sans code nouveau.
|
||||||
|
|
||||||
|
### Négatives / risques actés
|
||||||
|
|
||||||
|
- **Contournable par build local** : assumé (GPL, soft-paywall). Ne jamais présenter ce gating comme une protection — la seule barrière réelle est serveur (cours).
|
||||||
|
- **CTA d'achat inerte** : « Obtenir \<tier\> » est affiché désactivé (« bientôt ») tant que #270 (activation en ligne + URL d'achat) n'est pas livré. Fenêtre où l'upsell promet sans vendre — la voie « J'ai déjà une clé » reste fonctionnelle.
|
||||||
|
- **Deux tables à ne pas confondre** : un futur gate ajouté côté Rust dans `FEATURE_TIERS` ne gaterait rien dans l'UI, et réciproquement. La règle est documentaire (cet ADR + commentaires des deux modules).
|
||||||
|
- **`dev-override` à surveiller en release** : la feature Cargo ne doit jamais apparaître dans un build publié ; le choix opt-in la rend improbable, pas impossible.
|
||||||
|
|
||||||
|
### Neutre
|
||||||
|
|
||||||
|
- Le chemin abonnement Compte Maximus (Premium) ne porte pas de `features[]` — l'override est propre aux licences JWT ; c'est cohérent, Premium débloque déjà toute la matrice.
|
||||||
|
- `useIsPremium` subsiste comme raccourci d'affichage (badge licence) au-dessus de `LicenseContext` ; `useLicense` (invoke par appel) est supprimé.
|
||||||
|
|
||||||
|
## Liens
|
||||||
|
|
||||||
|
- `src/shared/entitlements.ts` — matrice `ENTITLEMENTS`, `isEntitled` (court-circuit Free), `requiredTierFor`
|
||||||
|
- `src/contexts/LicenseContext.tsx` — provider machine-level, retry backoff (CWE-703), `validationError` orthogonal
|
||||||
|
- `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
|
||||||
|
- `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé
|
||||||
|
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
|
||||||
|
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (machine-binding), `dev_override_edition` (CWE-489)
|
||||||
|
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
|
||||||
|
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (câblage du CTA d'achat, à venir)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Architecture technique — Simpl'Résultat
|
# Architecture technique — Simpl'Résultat
|
||||||
|
|
||||||
> Document mis à jour le 2026-04-25 — Version 0.8.x (Bilan)
|
> Document mis à jour le 2026-07-20 — Version 0.14.x (gating par édition)
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
|
|
||||||
|
|
@ -37,13 +37,13 @@ simpl-resultat/
|
||||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||||
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
||||||
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
|
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
|
||||||
│ │ ├── shared/ # 6 composants réutilisables
|
│ │ ├── shared/ # 9 composants réutilisables (dont RequireFeature, UpsellGate)
|
||||||
│ │ └── transactions/ # 5 composants
|
│ │ └── transactions/ # 5 composants
|
||||||
│ ├── contexts/ # ProfileContext (état global profil)
|
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
|
||||||
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
|
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
|
||||||
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
||||||
│ ├── services/ # 14 services métier
|
│ ├── services/ # 14 services métier
|
||||||
│ ├── shared/ # Types et constantes partagés
|
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts)
|
||||||
│ ├── utils/ # 4 utilitaires (parsing, CSV, charts)
|
│ ├── utils/ # 4 utilitaires (parsing, CSV, charts)
|
||||||
│ ├── i18n/ # Config i18next + locales FR/EN
|
│ ├── i18n/ # Config i18next + locales FR/EN
|
||||||
│ ├── App.tsx # Router principal
|
│ ├── App.tsx # Router principal
|
||||||
|
|
@ -217,8 +217,9 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
| `useBalanceOverview` | Bilan — page `/balance` : sélecteur de période (`3M / 6M / 1A / 3A / Tout`), série temporelle agrégée, mode chart (`line` / `stacked`), tableau des comptes avec valeurs courantes et Δ% sur la période. Les rendements multi-horizons sont chargés *lazily* dans `BalanceAccountsTable` (un appel `compute_account_return` par cellule) |
|
| `useBalanceOverview` | Bilan — page `/balance` : sélecteur de période (`3M / 6M / 1A / 3A / Tout`), série temporelle agrégée, mode chart (`line` / `stacked`), tableau des comptes avec valeurs courantes et Δ% sur la période. Les rendements multi-horizons sont chargés *lazily* dans `BalanceAccountsTable` (un appel `compute_account_return` par cellule) |
|
||||||
| `useDataExport` | Export de données |
|
| `useDataExport` | Export de données |
|
||||||
| `useTheme` | Thème clair/sombre |
|
| `useTheme` | Thème clair/sombre |
|
||||||
| `useUpdater` | Mise à jour de l'application (gated par entitlement licence) |
|
| `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
|
||||||
| `useLicense` | État de la licence et entitlements |
|
| `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext` — `ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
|
||||||
|
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
|
||||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||||
|
|
||||||
### Hook transverse — `useCollapsibleGroups`
|
### Hook transverse — `useCollapsibleGroups`
|
||||||
|
|
@ -228,6 +229,23 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
||||||
- `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible.
|
- `storageKey` **non nul** → l'état de repli est **persisté par profil** dans `user_preferences` (via `userPreferenceService`), donc détruit avec le profil, sans résidu `localStorage` ([ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Quatre surfaces persistées (les 3 rapports + budget). Hydratation **asynchrone** : le défaut (« tout replié » pour rapports/budget) est rendu d'abord, un `useEffect` hydrate ensuite → pas de flash visible.
|
||||||
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
|
- `storageKey === null` → état **purement en mémoire**, réinitialisé à chaque montage (les 2 arbres de catégories : navigation éphémère, rien à conserver ni à révéler).
|
||||||
|
|
||||||
|
## Gating par édition (licence)
|
||||||
|
|
||||||
|
Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de licence (`free` / `base` / `premium`) **côté interface uniquement** — un soft-paywall assumé (l'app est GPL ; le seul gate appliqué côté serveur reste la récupération de cours Premium via maximus-api). La matrice complète tier → modules, le rationale et les alternatives rejetées sont dans l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
||||||
|
|
||||||
|
| Brique | Fichier | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé copiée déclassée par le machine-binding ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
|
||||||
|
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status` |
|
||||||
|
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }` — `allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
|
||||||
|
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
|
||||||
|
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé : CTA « Obtenir \<tier\> » **désactivé** avec mention « bientôt » (câblage boutique par #270) + « J'ai déjà une clé » → `/settings/users` (prop `onNavigate?` pour les hôtes modaux) |
|
||||||
|
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
|
||||||
|
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell compact) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
|
||||||
|
| Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` |
|
||||||
|
|
||||||
|
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
|
||||||
|
|
||||||
## Commandes Tauri (36)
|
## Commandes Tauri (36)
|
||||||
|
|
||||||
### `fs_commands.rs` — Système de fichiers (6)
|
### `fs_commands.rs` — Système de fichiers (6)
|
||||||
|
|
@ -296,9 +314,10 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
|
||||||
|
|
||||||
### `entitlements.rs` — Entitlements (1)
|
### `entitlements.rs` — Entitlements (1)
|
||||||
|
|
||||||
- `check_entitlement` — Vérifie si une feature est autorisée selon l'édition
|
- `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé copiée, déclassée `free` par le machine-binding, ne récupère jamais ses features signées)
|
||||||
- Source de vérité : `FEATURE_TIERS` dans `entitlements.rs`. Modifier cette constante pour changer les gates, jamais ailleurs dans le code
|
- Source de vérité Rust : `FEATURE_TIERS` dans `entitlements.rs`, réduit depuis #301 à `auto-update` → `[base, premium]` (absorbe l'issue #271). Les gates UI (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`) vivent dans la matrice TS `src/shared/entitlements.ts` — voir la section « Gating par édition » et l'[ADR 0017](adr/0017-feature-gating-par-tier.md)
|
||||||
- Temporaire : `auto-update` est ouvert à `free` en attendant le serveur de licences (issue #49). À re-gater à `[base, premium]` quand l'activation payante sera live
|
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin machine-binding que `get_edition`, tout échec de validation retourne `("free", [])`
|
||||||
|
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
|
||||||
|
|
||||||
### `balance_commands.rs` — Bilan (1)
|
### `balance_commands.rs` — Bilan (1)
|
||||||
|
|
||||||
|
|
@ -346,6 +365,8 @@ Fichiers : `src-tauri/src/lib.rs` (wiring), `src-tauri/src/commands/auth_command
|
||||||
|
|
||||||
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
|
Le routing est défini dans `App.tsx`. Toutes les pages sont englobées par `AppShell` (sidebar + layout). L'accès est contrôlé par `ProfileContext` (gate).
|
||||||
|
|
||||||
|
Les modules payants sont enveloppés dans des **layout-routes pathless `RequireFeature`** (une par feature, soft paywall — voir la section « Gating par édition ») : `/adjustments` (`adjustments`), `/budget` (`budget`), `/reports/highlights` + `/reports/compare` + `/reports/category` + `/reports/cartes` (`reports-advanced` — le hub `/reports` et `/reports/trends` restent Free, hors de tout gate), `/balance` + `/balance/accounts` + `/balance/snapshot` (`balance`).
|
||||||
|
|
||||||
### Gestion d'erreurs
|
### Gestion d'erreurs
|
||||||
|
|
||||||
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
|
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
|
||||||
|
|
@ -437,3 +458,4 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
|
||||||
| [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted |
|
| [0014](adr/0014-balance-vehicule-attribut.md) | Bilan : le véhicule fiscal est un attribut du compte (Étape 1) | 2026-06-01 | Accepted |
|
||||||
| [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted |
|
| [0015](adr/0015-balance-detail-par-titre.md) | Bilan : détail par titre (holdings par snapshot, Étape 2) | 2026-06-06 | Accepted |
|
||||||
| [0016](adr/0016-persistance-etat-ui-par-profil.md) | Persistance de l'état UI par profil : repli des catégories dans `user_preferences` | 2026-07-15 | Accepted |
|
| [0016](adr/0016-persistance-etat-ui-par-profil.md) | Persistance de l'état UI par profil : repli des catégories dans `user_preferences` | 2026-07-15 | Accepted |
|
||||||
|
| [0017](adr/0017-feature-gating-par-tier.md) | Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé | 2026-07-20 | Accepted |
|
||||||
|
|
|
||||||
|
|
@ -501,3 +501,47 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
||||||
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
|
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
|
||||||
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
|
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
|
||||||
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
|
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Éditions
|
||||||
|
|
||||||
|
Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium**. L'édition détermine quels modules sont accessibles — elle ne touche jamais à vos données, qui restent locales et complètes quelle que soit l'édition active.
|
||||||
|
|
||||||
|
### Ce que débloque chaque édition
|
||||||
|
|
||||||
|
| Module | Gratuite | Base | Premium |
|
||||||
|
|---|:---:|:---:|:---:|
|
||||||
|
| Tableau de bord | ✓ | ✓ | ✓ |
|
||||||
|
| Import CSV | ✓ | ✓ | ✓ |
|
||||||
|
| Transactions | ✓ | ✓ | ✓ |
|
||||||
|
| Catégories | ✓ | ✓ | ✓ |
|
||||||
|
| Rapports — hub et Tendances | ✓ | ✓ | ✓ |
|
||||||
|
| Export / import chiffré | ✓ | ✓ | ✓ |
|
||||||
|
| Journal des modifications | ✓ | ✓ | ✓ |
|
||||||
|
| Profils | 1 profil | ✓ multiples | ✓ multiples |
|
||||||
|
| Ajustements | — | ✓ | ✓ |
|
||||||
|
| Budget | — | ✓ | ✓ |
|
||||||
|
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
|
||||||
|
| Mises à jour automatiques | — | ✓ | ✓ |
|
||||||
|
| Bilan (patrimoine, détail par titre, cours du marché) | — | — | ✓ |
|
||||||
|
|
||||||
|
### Comment ça se présente
|
||||||
|
|
||||||
|
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan) et sur les tuiles de rapports avancés du hub Rapports. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
|
||||||
|
|
||||||
|
- **Obtenir Base / Premium** — l'achat en ligne arrive bientôt ; le bouton est affiché mais désactivé en attendant
|
||||||
|
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
|
||||||
|
|
||||||
|
### Comment faire
|
||||||
|
|
||||||
|
1. Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition
|
||||||
|
2. Cliquez sur un module verrouillé pour voir l'édition requise
|
||||||
|
3. Si vous avez une clé de licence, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
|
||||||
|
4. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire
|
||||||
|
|
||||||
|
### Astuces
|
||||||
|
|
||||||
|
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, changement de machine), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
|
||||||
|
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
|
||||||
|
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Footprints,
|
Footprints,
|
||||||
Printer,
|
Printer,
|
||||||
Users,
|
Users,
|
||||||
|
KeyRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
|
|
@ -31,6 +32,7 @@ const SECTIONS = [
|
||||||
{ key: "reports", icon: BarChart3 },
|
{ key: "reports", icon: BarChart3 },
|
||||||
{ key: "balance", icon: Wallet },
|
{ key: "balance", icon: Wallet },
|
||||||
{ key: "settings", icon: Settings },
|
{ key: "settings", icon: Settings },
|
||||||
|
{ key: "editions", icon: KeyRound },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function DocsContent() {
|
export default function DocsContent() {
|
||||||
|
|
|
||||||
|
|
@ -1041,6 +1041,27 @@
|
||||||
"If you encounter an issue, copy the logs and attach them to your report",
|
"If you encounter an issue, copy the logs and attach them to your report",
|
||||||
"Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
|
"Feedback is the only feature that talks to a server besides updates and Maximus sign-in — every submission is explicit, no automatic telemetry"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"editions": {
|
||||||
|
"title": "Editions",
|
||||||
|
"overview": "Simpl'Résultat comes in three editions — Free, Base and Premium. The edition determines which modules are accessible; it never touches your data, which stays local and complete whatever the active edition.",
|
||||||
|
"features": [
|
||||||
|
"Free — Dashboard, CSV Import, Transactions, Categories, the Trends report (and the Reports hub), encrypted export/import and the changelog, with a single profile",
|
||||||
|
"Base — everything in Free, plus Budget, Adjustments, the advanced reports (Highlights, Compare, Category analysis, Cards), multiple profiles and automatic updates",
|
||||||
|
"Premium — everything in Base, plus the full Balance module (net worth, per-security detail, market prices)",
|
||||||
|
"Modules above your edition stay visible but locked: a lock badge shows in the sidebar and on the report tiles — opening one shows the unlock screen"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Look for the lock badge in the sidebar or on the Reports hub tiles: it marks the modules above your edition",
|
||||||
|
"Click a locked module to see which edition it requires",
|
||||||
|
"If you have a license key, click \"I already have a key\" (or go to Settings → Users) and enter it",
|
||||||
|
"Modules unlock immediately — no reinstall or restart needed; online purchase is coming soon, the \"Get\" button will be enabled once the store is live"
|
||||||
|
],
|
||||||
|
"tips": [
|
||||||
|
"Locking is never destructive: if your edition goes down (expired key, machine change), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
|
||||||
|
"On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked",
|
||||||
|
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|
|
||||||
|
|
@ -1041,6 +1041,27 @@
|
||||||
"En cas de problème, copiez les journaux et joignez-les à votre signalement",
|
"En cas de problème, copiez les journaux et joignez-les à votre signalement",
|
||||||
"Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
|
"Le feedback est la seule fonctionnalité qui communique avec un serveur hors mises à jour et connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"editions": {
|
||||||
|
"title": "Éditions",
|
||||||
|
"overview": "Simpl'Résultat existe en trois éditions — Gratuite, Base et Premium. L'édition détermine quels modules sont accessibles ; elle ne touche jamais à vos données, qui restent locales et complètes quelle que soit l'édition active.",
|
||||||
|
"features": [
|
||||||
|
"Gratuite — Tableau de bord, Import CSV, Transactions, Catégories, rapport Tendances (et le hub Rapports), export/import chiffré et journal des modifications, avec un profil",
|
||||||
|
"Base — tout de la Gratuite, plus Budget, Ajustements, les rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes), les profils multiples et les mises à jour automatiques",
|
||||||
|
"Premium — tout de la Base, plus le module Bilan complet (patrimoine, détail par titre, cours du marché)",
|
||||||
|
"Les modules au-dessus de votre édition restent visibles mais verrouillés : cadenas dans la barre latérale et sur les tuiles de rapports — les ouvrir affiche l'écran de déverrouillage"
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition",
|
||||||
|
"Cliquez sur un module verrouillé pour voir l'édition requise",
|
||||||
|
"Si vous avez une clé de licence, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la",
|
||||||
|
"Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire ; l'achat en ligne arrive bientôt, le bouton « Obtenir » sera activé quand la boutique sera en ligne"
|
||||||
|
],
|
||||||
|
"tips": [
|
||||||
|
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, changement de machine), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée",
|
||||||
|
"En édition Gratuite, votre profil actif reste toujours accessible — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés",
|
||||||
|
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue