Compare commits

...

6 commits

Author SHA1 Message Date
le king fu
6de96174de fix(i18n): FR typo in docs.editions tier descriptions (#302)
"tout la Gratuite/Base" -> "tout de la Gratuite/Base", flagged as the
one user-facing correction in the /pr-review pass on PR #308.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 20:20:11 -04:00
le king fu
01da65c215 docs(gating): ADR 0017 + architecture + user guide + CHANGELOG
Document the edition-gating work (#297-#301):

- ADR 0017 (accepted): tier->features matrix, signed features[] override
  fail-closed in Free (CWE-863), UI-only enforcement as an assumed GPL
  soft-paywall (server-enforced price fetching stays the only hard gate),
  non-destructive downgrade, dev-override behind an explicit Cargo
  feature (CWE-489), rejected alternatives.
- architecture.md: new 'Gating par edition' section (entitlements matrix,
  LicenseContext, useEntitlement, RequireFeature/UpsellGate, NavLock,
  profileGate, Rust side), rewritten entitlements.rs section (auto-update
  now Base+, stale 'open to free' note removed), gated routes listed in
  the routing section, hooks table updated (useLicense removed in #297 ->
  useEntitlement/useIsPremium), ADR index + header refreshed.
- guide-utilisateur.md + docs.editions.* i18n keys (FR/EN) wired into
  DocsContent: new 'Editions' section with the Free/Base/Premium table,
  unlock flow and non-destructive locking tips.
- CHANGELOG.md + CHANGELOG.fr.md: one global [Unreleased] entry listing
  the modules now gated Base (Budget, Adjustments, advanced reports,
  multi-profile, auto-update) and Premium (Balance), the visible-but-
  locked upsell with disabled 'coming soon' purchase CTA, and the
  data-preserving behaviour.

Resolves #302

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:48:55 -04:00
le king fu
17833cf942 feat(gating): auto-update Base+, features[] override fail-closed, dev-override (Rust)
Re-gate auto-update to Base+Premium now that paid activation works
end-to-end (absorbs #271), and align the Rust entitlement layer with the
TS matrix shipped in #297:

- FEATURE_TIERS: auto-update -> [base, premium]; the 'temporarily open'
  carve-out and its test are gone (free_allows_auto_update_temporarily
  -> free_denied_auto_update). Dead rows web-sync, cloud-backup and
  advanced-reports are purged (no call-site anywhere; advanced-reports
  -> Premium contradicted the TS reports-advanced -> Base+ matrix).
  Only auto-update remains on the Rust side.
- features[] override, fail-closed in Free (CWE-863): new
  current_entitlements() resolves the edition AND the signed features[]
  through the same machine-binding path — every downgrade path returns
  ('free', []) so a copied license.key can never keep its signed
  features. check_entitlement combines them via the new pure
  is_entitled(): is_feature_allowed(feature, edition) ||
  features.contains(feature), with a defense-in-depth free short-circuit
  mirroring the TS isEntitled. current_edition() now delegates to
  current_entitlements() — single resolution path, no drift possible.
- dev-override: new Cargo feature (off by default, never in a release
  feature set — CWE-489: debug_assertions could be flipped on a custom
  release build and become a Premium backdoor). Only when compiled in,
  SR_DEV_EDITION forces the edition (free|base|premium) to test tiers
  locally. A feature-off test proves the env var has zero effect in
  normal builds; feature-on companions (env access serialized by a
  mutex) cover cargo test --features dev-override.

No Tauri command signature changes: check_entitlement keeps its
(feature: String) -> Result<bool, String> contract for useUpdater.ts
and ErrorPage.tsx.

Validation: cargo check + cargo test (106 passed, feature off) +
cargo test --features dev-override + npm test (871) + npm run build.

Resolves #301

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:35:16 -04:00
le king fu
b89074e6c6 feat(gating): multi-profile gate (Base+), non-destructive
A Free user keeps full access to their active profile; profiles beyond
it show a lock in ProfileSwitcher and open an upsell dialog instead of
switching. Creating a profile beyond the first is locked at the single
creation point, ProfileFormModal (reached from both ProfileSwitcher and
ProfileSelectionPage), with a race guard in handleSave covering the
license boot window. Both creation entries stay visible with a lock
(locked-not-hidden). Nothing is ever removed from profiles.json — an
upgrade to Base/Premium makes every profile reappear untouched.

- New pure predicates in src/shared/profileGate.ts
  (isProfileSwitchLocked, isProfileCreationLocked) + 10 vitest
- ProfileFormModal upsell panel reuses upsell.* keys WITHOUT UpsellGate:
  the modal also opens from ProfileSelectionPage, which renders outside
  BrowserRouter, where UpsellGate's useNavigate would throw
- UpsellGate gains an optional onNavigate callback so the
  ProfileSwitcher upsell dialog can close itself after navigation
- No lock while the license is loading (anti-flash, ready guard);
  zero new i18n keys; no DB migration

Resolves #300

Generated autonomously by /autopilot run of 2026-07-20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:24:23 -04:00
le king fu
553da0ce8c feat(gating): gate routes and Sidebar for budget, advanced reports, balance
Apply the tier gating to routes and navigation on top of the #298 UI
guard:

- App.tsx: pathless RequireFeature layout-routes grouping /balance,
  /balance/accounts, /balance/snapshot under "balance"; /reports/
  highlights|compare|category|cartes under "reports-advanced"; /budget
  under "budget"; /adjustments under "adjustments". The /reports hub and
  /reports/trends stay Free and ungated.
- NavItem gains an optional `feature?: FeatureKey`; set in NAV_ITEMS on
  budget, adjustments and balance only — NOT on reports (Free hub).
- Sidebar: local NavLock child component (hook at component top level)
  renders a lock badge only when the license is ready AND the feature is
  not allowed — no locked flash at boot; items stay clickable and lead
  to the upsell via the gated route. Tooltip/aria reuse nav.locked.
- ReportsPage hub: single useEntitlement("reports-advanced") call
  drives a `locked` badge on the 4 advanced tiles via a new additive
  HubReportNavCard `locked?` prop; the Trends tile is never locked.
- Pure contract test on NAV_ITEMS (gated trio present, reports/Free
  items ungated, exactly 3 of 9 gated).

No new i18n strings (nav.locked shipped with #298), no DB migration.
Changelog centralized in #302.

Resolves #299

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:12:51 -04:00
le king fu
554373e7d8 feat(gating): UI guard — RequireFeature + UpsellGate + i18n
All checks were successful
PR Check / rust (pull_request) Successful in 21m44s
PR Check / frontend (pull_request) Successful in 2m28s
Add the reusable gating guard components on top of the #297 foundation:

- UpsellGate: full locked screen (lock icon, tier title, per-feature
  description). Two CTAs: "Get <tier>" rendered VISIBLE but DISABLED with
  an "online purchase coming soon" note (per planning decision — #270 will
  activate it), and "I already have a key" navigating to /settings/users
  (LicenseCard).
- RequireFeature: renders a neutral loader while the license is not ready
  (no upsell flash at boot), then children or UpsellGate. Renders <Outlet/>
  when children are omitted so it also works as a layout route grouping
  all routes of one feature.
- requiredTierFor() pure helper in shared/entitlements.ts derives the
  minimum unlocking tier from matrix membership (not array order).
- i18n: upsell.* (title, per-feature descriptions, CTAs) + nav.locked in
  BOTH locales; tier labels reuse the existing license.editions.* keys.
- Tests: requiredTierFor mapping/minimality + upsell i18n coverage for
  every FeatureKey in fr and en (the components themselves are not
  testable without jsdom).

Resolves #298

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:05:04 -04:00
27 changed files with 1209 additions and 117 deletions

View file

@ -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é

View file

@ -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

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

View file

@ -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 |

View file

@ -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

View file

@ -66,3 +66,13 @@ hmac = "0.12"
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
# HTTP mock server for balance_commands fetch_price tests (Issue #155). # HTTP mock server for balance_commands fetch_price tests (Issue #155).
mockito = "1.6" mockito = "1.6"
[features]
# Dev-only escape hatch: when enabled, the SR_DEV_EDITION env var forces the
# resolved edition (free|base|premium) so the three license tiers can be tested
# without real license files. MUST stay out of `default` and of any release
# feature set — gating this on debug_assertions instead would let a custom
# release build honor the env var and become a Premium backdoor (CWE-489).
# Usage: cargo test --features dev-override, or `tauri dev` with
# `-- --features dev-override`.
dev-override = []

View file

@ -1,8 +1,15 @@
// Centralized feature → tier mapping for license entitlements. // Centralized feature → tier mapping for license entitlements.
// //
// This module is the single source of truth for which features are gated by which tier. // This module is the single source of truth for which features are gated by which tier
// To change what is gated where, modify FEATURE_TIERS only — never sprinkle edition checks // on the Rust side. To change what is gated where, modify FEATURE_TIERS only — never
// throughout the codebase. // sprinkle edition checks throughout the codebase.
//
// Since the tier-gating work (#297-#301), UI-level gates (budget, adjustments,
// reports-advanced, multi-profile, balance) live in the TS matrix
// `src/shared/entitlements.ts` (soft-paywall, UI-only enforcement). This table
// only keeps the features actually checked through `check_entitlement` — the
// JWT `features[]` array is a namespace shared with that TS layer, so keys are
// kebab-case on both sides.
/// Editions, ordered from least to most privileged. /// Editions, ordered from least to most privileged.
pub const EDITION_FREE: &str = "free"; pub const EDITION_FREE: &str = "free";
@ -10,17 +17,11 @@ pub const EDITION_BASE: &str = "base";
pub const EDITION_PREMIUM: &str = "premium"; pub const EDITION_PREMIUM: &str = "premium";
/// Maps feature name → list of editions allowed to use it. /// Maps feature name → list of editions allowed to use it.
/// A feature absent from this list is denied for all editions. /// A feature absent from this list is denied for all editions, unless the
const FEATURE_TIERS: &[(&str, &[&str])] = &[ /// license carries it in its signed `features[]` override (see [`is_entitled`]).
// auto-update is temporarily open to FREE until the license server (issue #49) const FEATURE_TIERS: &[(&str, &[&str])] = &[("auto-update", &[EDITION_BASE, EDITION_PREMIUM])];
// is live. Re-gate to [BASE, PREMIUM] once paid activation works end-to-end.
("auto-update", &[EDITION_FREE, EDITION_BASE, EDITION_PREMIUM]),
("web-sync", &[EDITION_PREMIUM]),
("cloud-backup", &[EDITION_PREMIUM]),
("advanced-reports", &[EDITION_PREMIUM]),
];
/// Pure check: does `edition` grant access to `feature`? /// Pure check: does `edition` grant access to `feature` via the static matrix?
pub fn is_feature_allowed(feature: &str, edition: &str) -> bool { pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
FEATURE_TIERS FEATURE_TIERS
.iter() .iter()
@ -29,10 +30,28 @@ pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
/// Static matrix check OR signed per-license `features[]` override.
///
/// Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
/// resolves to "free" through the machine-binding path, which already drops the
/// signed features (see `license_commands::current_entitlements`). As defense
/// in depth we also refuse the override here whenever the edition is free, so
/// signed features can never rescue a downgraded license.
pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool {
if edition == EDITION_FREE {
return is_feature_allowed(feature, edition);
}
is_feature_allowed(feature, edition) || features.iter().any(|f| f == feature)
}
/// Tauri command: is `feature` available right now? Edition AND signed
/// per-license feature overrides are resolved through the same machine-binding
/// path (`license_commands::current_entitlements`), then combined by
/// [`is_entitled`].
#[tauri::command] #[tauri::command]
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> { pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
let edition = crate::commands::license_commands::current_edition(&app); let (edition, features) = crate::commands::license_commands::current_entitlements(&app);
Ok(is_feature_allowed(&feature, &edition)) Ok(is_entitled(&feature, &edition, &features))
} }
#[cfg(test)] #[cfg(test)]
@ -40,9 +59,8 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn free_allows_auto_update_temporarily() { fn free_denied_auto_update() {
// Temporary: auto-update is open to FREE until the license server is live. assert!(!is_feature_allowed("auto-update", EDITION_FREE));
assert!(is_feature_allowed("auto-update", EDITION_FREE));
} }
#[test] #[test]
@ -51,20 +69,43 @@ mod tests {
} }
#[test] #[test]
fn premium_unlocks_everything() { fn premium_unlocks_auto_update() {
assert!(is_feature_allowed("auto-update", EDITION_PREMIUM)); assert!(is_feature_allowed("auto-update", EDITION_PREMIUM));
assert!(is_feature_allowed("web-sync", EDITION_PREMIUM));
assert!(is_feature_allowed("cloud-backup", EDITION_PREMIUM));
}
#[test]
fn base_does_not_unlock_premium_features() {
assert!(!is_feature_allowed("web-sync", EDITION_BASE));
assert!(!is_feature_allowed("cloud-backup", EDITION_BASE));
} }
#[test] #[test]
fn unknown_feature_denied() { fn unknown_feature_denied() {
assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM)); assert!(!is_feature_allowed("nonexistent", EDITION_PREMIUM));
} }
#[test]
fn matrix_grants_without_override() {
assert!(is_entitled("auto-update", EDITION_BASE, &[]));
assert!(is_entitled("auto-update", EDITION_PREMIUM, &[]));
}
#[test]
fn unlisted_feature_denied_without_override() {
// "balance" lives in the TS matrix only — absent from FEATURE_TIERS,
// so the static path denies it for every edition.
assert!(!is_entitled("balance", EDITION_BASE, &[]));
assert!(!is_entitled("balance", EDITION_PREMIUM, &[]));
}
#[test]
fn override_grants_unlisted_feature_for_paid_editions() {
let features = vec!["balance".to_string()];
assert!(is_entitled("balance", EDITION_BASE, &features));
assert!(is_entitled("balance", EDITION_PREMIUM, &features));
}
#[test]
fn override_ignored_in_free() {
// CWE-863: signed features[] must never rescue a license downgraded to
// free (copied key / machine mismatch) — not even for a feature that a
// paid edition would get from the static matrix.
let features = vec!["balance".to_string(), "auto-update".to_string()];
assert!(!is_entitled("balance", EDITION_FREE, &features));
assert!(!is_entitled("auto-update", EDITION_FREE, &features));
}
} }

View file

@ -221,51 +221,117 @@ pub fn get_edition(app: tauri::AppHandle) -> Result<String, String> {
Ok(current_edition(&app)) Ok(current_edition(&app))
} }
/// Internal helper used by `entitlements::check_entitlement`. Never returns an error — any /// Dev-only edition override, compiled in ONLY under the `dev-override` Cargo
/// failure resolves to "free" so feature gates fail closed. /// feature (off by default and absent from any release feature set). Gating on
/// /// `debug_assertions` instead would be CWE-489: a custom release build could
/// Priority: Premium (via Compte Maximus with active subscription) > Base (offline license) > Free. /// flip it on and the env var would become a Premium backdoor. When compiled
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String { /// in, `SR_DEV_EDITION` forces the resolved edition (free|base|premium) so the
// Check Compte Maximus subscription first — Premium overrides Base /// three tiers can be tested without real licenses; unrecognized values are
if let Some(edition) = check_account_edition(app) { /// ignored and resolution falls through to the normal path.
if edition == EDITION_PREMIUM { fn dev_override_edition() -> Option<String> {
return edition; #[cfg(feature = "dev-override")]
} {
} if let Ok(edition) = std::env::var("SR_DEV_EDITION") {
if edition == EDITION_FREE || edition == EDITION_BASE || edition == EDITION_PREMIUM {
let Ok(path) = license_path(app) else { return Some(edition);
return EDITION_FREE.to_string();
};
if !path.exists() {
return EDITION_FREE.to_string();
}
let Ok(key) = fs::read_to_string(&path) else {
return EDITION_FREE.to_string();
};
let Ok(decoding_key) = embedded_decoding_key() else {
return EDITION_FREE.to_string();
};
let Ok(info) = validate_with_key(&key, &decoding_key) else {
return EDITION_FREE.to_string();
};
// If an activation token exists, it must match the local machine. A missing token is
// accepted (graceful pre-activation).
if let Ok(activation_path) = activation_path(app) {
if activation_path.exists() {
let Ok(token) = fs::read_to_string(&activation_path) else {
return EDITION_FREE.to_string();
};
let Ok(local_id) = machine_id_internal() else {
return EDITION_FREE.to_string();
};
if validate_activation_with_key(&token, &local_id, &decoding_key).is_err() {
return EDITION_FREE.to_string();
} }
} }
} }
None
}
info.edition /// Pure resolution of `(edition, signed features)` from license material.
///
/// Single choke point for the machine-binding rule: every downgrade path
/// returns `("free", [])`, so the signed `features[]` of a copied license can
/// never be honored once the edition is downgraded (CWE-863). Separated from
/// the fs/AppHandle plumbing so tests can exercise it with in-memory keys.
fn resolve_license_entitlements(
license_key: &str,
activation_token: Option<&str>,
local_machine_id: &str,
decoding_key: &DecodingKey,
) -> (String, Vec<String>) {
let Ok(info) = validate_with_key(license_key, decoding_key) else {
return (EDITION_FREE.to_string(), Vec::new());
};
// If an activation token exists, it must match the local machine. A missing
// token is accepted (graceful pre-activation state).
if let Some(token) = activation_token {
if validate_activation_with_key(token, local_machine_id, decoding_key).is_err() {
return (EDITION_FREE.to_string(), Vec::new());
}
}
(info.edition, info.features)
}
/// Internal helper used by `entitlements::check_entitlement`. Resolves the
/// effective edition AND the signed per-license feature overrides through the
/// SAME machine-binding path. Never returns an error — any failure resolves to
/// `("free", [])` so feature gates fail closed, features included (CWE-863: a
/// copied `license.key` must not keep its signed `features[]` once downgraded).
///
/// Priority: dev override (dev-override builds only) > Premium (via Compte
/// Maximus with active subscription) > Base (offline license) > Free.
pub(crate) fn current_entitlements(app: &tauri::AppHandle) -> (String, Vec<String>) {
// Dev-only tier testing — compiled out of normal builds (see the helper).
if let Some(edition) = dev_override_edition() {
return (edition, Vec::new());
}
// Check Compte Maximus subscription first — Premium overrides Base. This
// path never reads the license JWT, so it carries no signed features.
if let Some(edition) = check_account_edition(app) {
if edition == EDITION_PREMIUM {
return (edition, Vec::new());
}
}
let free = || (EDITION_FREE.to_string(), Vec::new());
let Ok(path) = license_path(app) else {
return free();
};
if !path.exists() {
return free();
}
let Ok(key) = fs::read_to_string(&path) else {
return free();
};
let Ok(decoding_key) = embedded_decoding_key() else {
return free();
};
// Read the activation token when present. An unreadable token or machine
// id resolves to free, matching the strict posture of `current_edition`
// before this refactor.
let mut activation_token: Option<String> = None;
let mut local_machine_id = String::new();
if let Ok(act_path) = activation_path(app) {
if act_path.exists() {
let Ok(token) = fs::read_to_string(&act_path) else {
return free();
};
let Ok(local_id) = machine_id_internal() else {
return free();
};
activation_token = Some(token);
local_machine_id = local_id;
}
}
resolve_license_entitlements(
&key,
activation_token.as_deref(),
&local_machine_id,
&decoding_key,
)
}
/// Edition-only view of [`current_entitlements`], used by `get_edition` and any
/// caller that does not need the feature overrides.
pub(crate) fn current_edition(app: &tauri::AppHandle) -> String {
current_entitlements(app).0
} }
/// Read the HMAC-verified account cache to check for an active Premium /// Read the HMAC-verified account cache to check for an active Premium
@ -677,4 +743,118 @@ mod tests {
// Sanity check that the production PEM constant is well-formed. // Sanity check that the production PEM constant is well-formed.
assert!(embedded_decoding_key().is_ok()); assert!(embedded_decoding_key().is_ok());
} }
// === Entitlements resolution (edition + signed features, machine binding) =================
fn base_license_with_features(enc: &EncodingKey, features: Vec<String>) -> String {
let claims = LicenseClaims {
sub: "user@example.com".to_string(),
iss: "lacompagniemaximus.com".to_string(),
iat: now(),
exp: now() + 86400,
edition: EDITION_BASE.to_string(),
features,
machine_limit: 3,
};
let jwt = make_token(enc, &claims);
format!("{}{}", KEY_PREFIX_BASE, jwt)
}
fn activation_for(enc: &EncodingKey, machine_id: &str) -> String {
let claims = ActivationClaims {
sub: "license-id".to_string(),
iat: now(),
exp: now() + 86400,
machine_id: machine_id.to_string(),
};
make_token(enc, &claims)
}
#[test]
fn machine_match_keeps_edition_and_features() {
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let token = activation_for(&enc, "machine-A");
let (edition, features) =
resolve_license_entitlements(&key, Some(&token), "machine-A", &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
}
#[test]
fn machine_mismatch_downgrades_to_free_and_drops_features() {
// CWE-863: a copied license.key + activation.token still carries its
// signed features[] — they must vanish together with the downgrade.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let token = activation_for(&enc, "machine-A");
let (edition, features) =
resolve_license_entitlements(&key, Some(&token), "machine-B", &dec);
assert_eq!(edition, EDITION_FREE);
assert!(
features.is_empty(),
"signed features must not survive the machine-binding downgrade"
);
}
#[test]
fn missing_activation_token_keeps_edition_and_features() {
// Graceful pre-activation state: no token yet, the license still counts.
let (enc, dec) = default_keys();
let key = base_license_with_features(&enc, vec!["balance".to_string()]);
let (edition, features) = resolve_license_entitlements(&key, None, "machine-A", &dec);
assert_eq!(edition, EDITION_BASE);
assert_eq!(features, vec!["balance".to_string()]);
}
#[test]
fn invalid_license_resolves_free_without_features() {
let (_enc, dec) = default_keys();
let (edition, features) =
resolve_license_entitlements("SR-BASE-not.a.jwt", None, "machine-A", &dec);
assert_eq!(edition, EDITION_FREE);
assert!(features.is_empty());
}
// === Dev override =========================================================================
/// The dev override must be dead code in normal builds: even with
/// SR_DEV_EDITION set, nothing reads it when the `dev-override` Cargo
/// feature is off (CWE-489 — a release binary must never honor the env
/// var). Env-var note: under this feature-off build NO code path reads
/// SR_DEV_EDITION, so setting it here cannot race with parallel tests.
#[cfg(not(feature = "dev-override"))]
#[test]
fn sr_dev_edition_has_no_effect_when_feature_off() {
std::env::set_var("SR_DEV_EDITION", "premium");
assert_eq!(dev_override_edition(), None);
std::env::remove_var("SR_DEV_EDITION");
}
// Companion coverage for `cargo test --features dev-override` (not part of
// the normal CI run). SR_DEV_EDITION is process-global and cargo test runs
// tests on parallel threads, so every env manipulation serializes on a lock.
#[cfg(feature = "dev-override")]
mod dev_override_on {
use super::super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn sr_dev_edition_forces_edition() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("SR_DEV_EDITION", "premium");
assert_eq!(dev_override_edition(), Some("premium".to_string()));
std::env::remove_var("SR_DEV_EDITION");
}
#[test]
fn sr_dev_edition_unknown_value_ignored() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::set_var("SR_DEV_EDITION", "enterprise");
assert_eq!(dev_override_edition(), None);
std::env::remove_var("SR_DEV_EDITION");
}
}
} }

View file

@ -29,6 +29,7 @@ import DocsPage from "./pages/DocsPage";
import ChangelogPage from "./pages/ChangelogPage"; import ChangelogPage from "./pages/ChangelogPage";
import ProfileSelectionPage from "./pages/ProfileSelectionPage"; import ProfileSelectionPage from "./pages/ProfileSelectionPage";
import ErrorPage from "./components/shared/ErrorPage"; import ErrorPage from "./components/shared/ErrorPage";
import RequireFeature from "./components/shared/RequireFeature";
const STARTUP_TIMEOUT_MS = 10_000; const STARTUP_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
@ -112,23 +113,35 @@ export default function App() {
<Route path="/import" element={<ImportPage />} /> <Route path="/import" element={<ImportPage />} />
<Route path="/transactions" element={<TransactionsPage />} /> <Route path="/transactions" element={<TransactionsPage />} />
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
<Route path="/adjustments" element={<AdjustmentsPage />} /> {/* Gated routes (soft paywall): pathless layout-routes render
<Route path="/budget" element={<BudgetPage />} /> RequireFeature's <Outlet/> one group per feature, mirroring the
SettingsLayout convention. The /reports hub and /reports/trends
stay Free and OUTSIDE any gate. */}
<Route element={<RequireFeature feature="adjustments" />}>
<Route path="/adjustments" element={<AdjustmentsPage />} />
</Route>
<Route element={<RequireFeature feature="budget" />}>
<Route path="/budget" element={<BudgetPage />} />
</Route>
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
<Route path="/reports/trends" element={<ReportsTrendsPage />} /> <Route path="/reports/trends" element={<ReportsTrendsPage />} />
<Route path="/reports/compare" element={<ReportsComparePage />} /> <Route element={<RequireFeature feature="reports-advanced" />}>
<Route path="/reports/category" element={<ReportsCategoryPage />} /> <Route path="/reports/highlights" element={<ReportsHighlightsPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} /> <Route path="/reports/compare" element={<ReportsComparePage />} />
<Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
</Route>
<Route path="/settings" element={<SettingsLayout />}> <Route path="/settings" element={<SettingsLayout />}>
<Route index element={<SettingsHomePage />} /> <Route index element={<SettingsHomePage />} />
<Route path="users" element={<UsersSettingsPage />} /> <Route path="users" element={<UsersSettingsPage />} />
<Route path="data" element={<DataSettingsPage />} /> <Route path="data" element={<DataSettingsPage />} />
<Route path="systems" element={<SystemsSettingsPage />} /> <Route path="systems" element={<SystemsSettingsPage />} />
</Route> </Route>
<Route path="/balance" element={<BalancePage />} /> <Route element={<RequireFeature feature="balance" />}>
<Route path="/balance/accounts" element={<AccountsPage />} /> <Route path="/balance" element={<BalancePage />} />
<Route path="/balance/snapshot" element={<SnapshotEditPage />} /> <Route path="/balance/accounts" element={<AccountsPage />} />
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
</Route>
<Route <Route
path="/settings/categories/standard" path="/settings/categories/standard"
element={<CategoriesStandardGuidePage />} element={<CategoriesStandardGuidePage />}

View file

@ -11,11 +11,14 @@ import {
Wallet, Wallet,
Settings, Settings,
Languages, Languages,
Lock,
Moon, Moon,
Sun, Sun,
} from "lucide-react"; } from "lucide-react";
import { NAV_ITEMS, APP_NAME } from "../../shared/constants"; import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
import { useEntitlement } from "../../hooks/useEntitlement";
import { useTheme } from "../../hooks/useTheme"; import { useTheme } from "../../hooks/useTheme";
import type { FeatureKey } from "../../shared/entitlements";
import ProfileSwitcher from "../profile/ProfileSwitcher"; import ProfileSwitcher from "../profile/ProfileSwitcher";
const iconMap: Record<string, React.ComponentType<{ size?: number }>> = { const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
@ -30,6 +33,31 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
Settings, Settings,
}; };
/**
* Lock badge next to a gated nav item. Rendered as a child component so the
* useEntitlement hook runs at a component top level (never inside the
* NAV_ITEMS.map callback). Shown ONLY when the license is `ready` AND the
* feature is not allowed never during boot, so a paying user sees no
* "locked" flash. The item itself stays clickable (route shows the upsell).
*/
function NavLock({ feature }: { feature: FeatureKey }) {
const { t } = useTranslation();
const { allowed, ready } = useEntitlement(feature);
if (!ready || allowed) return null;
return (
<span
className="ml-auto opacity-60"
title={t("nav.locked")}
aria-label={t("nav.locked")}
role="img"
>
<Lock size={14} aria-hidden="true" />
</span>
);
}
export default function Sidebar() { export default function Sidebar() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
@ -64,6 +92,7 @@ export default function Sidebar() {
> >
{Icon && <Icon size={18} />} {Icon && <Icon size={18} />}
<span>{t(item.labelKey)}</span> <span>{t(item.labelKey)}</span>
{item.feature && <NavLock feature={item.feature} />}
</NavLink> </NavLink>
); );
})} })}

View file

@ -1,7 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X, Trash2, Lock, LockOpen, Plus } from "lucide-react"; import { X, Trash2, Lock, LockOpen, Plus, ShoppingCart } from "lucide-react";
import { useProfile } from "../../contexts/ProfileContext"; import { useProfile } from "../../contexts/ProfileContext";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor } from "../../shared/entitlements";
import { isProfileCreationLocked } from "../../shared/profileGate";
const PRESET_COLORS = [ const PRESET_COLORS = [
"#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6", "#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6",
@ -16,12 +19,20 @@ interface Props {
export default function ProfileFormModal({ onClose, editProfileId }: Props) { export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile(); const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile();
// Multi-profile gate (Base+, #300) — this modal is the SINGLE creation
// point (`createProfile`), reached from ProfileSwitcher AND
// ProfileSelectionPage, so gating here covers both entries. Managing
// existing profiles (rename/PIN/delete) stays available: the gate is
// non-destructive and only blocks creating a profile beyond the first.
const gate = useEntitlement("multi-profile");
const creationLocked = isProfileCreationLocked(profiles.length, gate);
const upsellTier = t(`license.editions.${requiredTierFor("multi-profile")}`);
const editProfile = editProfileId const editProfile = editProfileId
? profiles.find((p) => p.id === editProfileId) ? profiles.find((p) => p.id === editProfileId)
: null; : null;
const [mode, setMode] = useState<"list" | "create" | "edit">( const [mode, setMode] = useState<"list" | "create" | "edit" | "upsell">(
editProfileId ? "edit" : "list" editProfileId ? "edit" : "list"
); );
const [selectedId, setSelectedId] = useState<string | null>(editProfileId ?? null); const [selectedId, setSelectedId] = useState<string | null>(editProfileId ?? null);
@ -31,6 +42,10 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const handleCreate = () => { const handleCreate = () => {
if (creationLocked) {
setMode("upsell");
return;
}
setMode("create"); setMode("create");
setName(""); setName("");
setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]); setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]);
@ -50,6 +65,13 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
const handleSave = async () => { const handleSave = async () => {
if (!name.trim()) return; if (!name.trim()) return;
// Race guard: the create form is reachable while the license is still
// loading (`!ready` shows no lock, anti-flash). If the gate became active
// in the meantime, never call createProfile — show the upsell instead.
if (mode === "create" && creationLocked) {
setMode("upsell");
return;
}
setSaving(true); setSaving(true);
try { try {
if (mode === "create") { if (mode === "create") {
@ -89,7 +111,7 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]"> <div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]"> <div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
<h2 className="font-semibold text-[var(--foreground)]"> <h2 className="font-semibold text-[var(--foreground)]">
{mode === "create" {mode === "create" || mode === "upsell"
? t("profile.create") ? t("profile.create")
: mode === "edit" : mode === "edit"
? t("profile.edit") ? t("profile.edit")
@ -142,12 +164,54 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
))} ))}
<button <button
onClick={handleCreate} onClick={handleCreate}
className="flex items-center gap-2 w-full p-3 rounded-lg border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] text-[var(--muted-foreground)] text-sm transition-colors" title={creationLocked ? t("nav.locked") : undefined}
className={`flex items-center gap-2 w-full p-3 rounded-lg border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] text-[var(--muted-foreground)] text-sm transition-colors ${
creationLocked ? "opacity-60" : ""
}`}
> >
<Plus size={16} /> {/* Locked-not-hidden: the entry stays visible with a lock; the
click leads to the upsell panel (spec decision). */}
{creationLocked ? <Lock size={16} /> : <Plus size={16} />}
{t("profile.create")} {t("profile.create")}
</button> </button>
</div> </div>
) : mode === "upsell" ? (
/* Compact upsell panel — NOT the shared <UpsellGate/>: this modal
also opens from ProfileSelectionPage, which renders OUTSIDE
BrowserRouter (App.tsx mounts the router only once a profile is
active), so UpsellGate's unconditional useNavigate would throw.
Same i18n keys; the "I already have a key" CTA is omitted here
the ProfileSwitcher upsell dialog (always in-router) exposes it. */
<div className="space-y-4 py-2 text-center">
<Lock className="mx-auto h-12 w-12 text-[var(--muted-foreground)]" />
<div className="space-y-1">
<h3 className="font-semibold text-[var(--foreground)]">
{t("upsell.title", { tier: upsellTier })}
</h3>
<p className="text-sm text-[var(--muted-foreground)]">
{t("upsell.features.multi-profile")}
</p>
</div>
<div>
<button
type="button"
disabled
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] opacity-50 cursor-not-allowed"
>
<ShoppingCart className="h-4 w-4" />
{t("upsell.ctaGet", { tier: upsellTier })}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("upsell.ctaGetSoon")}
</p>
</div>
<button
onClick={() => setMode("list")}
className="w-full px-4 py-2 rounded-lg border border-[var(--border)] text-sm text-[var(--foreground)] hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>

View file

@ -1,7 +1,11 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, Lock, Settings } from "lucide-react"; import { ChevronDown, Lock, Settings, X } from "lucide-react";
import { useProfile } from "../../contexts/ProfileContext"; import { useProfile } from "../../contexts/ProfileContext";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor } from "../../shared/entitlements";
import { isProfileSwitchLocked } from "../../shared/profileGate";
import UpsellGate from "../shared/UpsellGate";
import PinDialog from "./PinDialog"; import PinDialog from "./PinDialog";
import ProfileFormModal from "./ProfileFormModal"; import ProfileFormModal from "./ProfileFormModal";
import type { Profile } from "../../services/profileService"; import type { Profile } from "../../services/profileService";
@ -9,9 +13,14 @@ import type { Profile } from "../../services/profileService";
export default function ProfileSwitcher() { export default function ProfileSwitcher() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile(); const { profiles, activeProfile, switchProfile, updateProfile } = useProfile();
// Multi-profile gate (Base+, #300): profiles beyond the active one are
// LOCKED for a Free user — non-destructive, switching is blocked but nothing
// is removed from profiles.json. No lock while `!ready` (anti-flash).
const gate = useEntitlement("multi-profile");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pinProfile, setPinProfile] = useState<Profile | null>(null); const [pinProfile, setPinProfile] = useState<Profile | null>(null);
const [showManage, setShowManage] = useState(false); const [showManage, setShowManage] = useState(false);
const [showUpsell, setShowUpsell] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// Close on outside click // Close on outside click
@ -25,10 +34,18 @@ export default function ProfileSwitcher() {
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, [open]); }, [open]);
const isLocked = (profile: Profile) =>
isProfileSwitchLocked(profile.id, activeProfile?.id ?? null, gate);
const handleSelect = (profile: Profile) => { const handleSelect = (profile: Profile) => {
setOpen(false); setOpen(false);
if (profile.id === activeProfile?.id) return; if (profile.id === activeProfile?.id) return;
if (isLocked(profile)) {
setShowUpsell(true);
return;
}
if (profile.pin_hash) { if (profile.pin_hash) {
setPinProfile(profile); setPinProfile(profile);
} else { } else {
@ -67,24 +84,34 @@ export default function ProfileSwitcher() {
{open && ( {open && (
<div className="absolute left-3 right-3 top-full mt-1 z-50 rounded-lg bg-[var(--sidebar-bg)] border border-white/10 shadow-lg overflow-hidden"> <div className="absolute left-3 right-3 top-full mt-1 z-50 rounded-lg bg-[var(--sidebar-bg)] border border-white/10 shadow-lg overflow-hidden">
{profiles.map((profile) => ( {profiles.map((profile) => {
<button const locked = isLocked(profile);
key={profile.id} return (
onClick={() => handleSelect(profile)} <button
className={`flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors ${ key={profile.id}
profile.id === activeProfile?.id onClick={() => handleSelect(profile)}
? "bg-[var(--sidebar-active)] text-white" title={locked ? t("nav.locked") : undefined}
: "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]" className={`flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors ${
}`} profile.id === activeProfile?.id
> ? "bg-[var(--sidebar-active)] text-white"
<span : "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
className="w-2.5 h-2.5 rounded-full flex-shrink-0" } ${locked ? "opacity-60" : ""}`}
style={{ backgroundColor: profile.color }} >
/> <span
<span className="truncate flex-1 text-left">{profile.name}</span> className="w-2.5 h-2.5 rounded-full flex-shrink-0"
{profile.pin_hash && <Lock size={12} className="opacity-50" />} style={{ backgroundColor: profile.color }}
</button> />
))} <span className="truncate flex-1 text-left">{profile.name}</span>
{/* One lock only: the gating lock replaces the PIN lock on
locked rows (the PIN dialog is unreachable there anyway). */}
{locked ? (
<Lock size={12} />
) : (
profile.pin_hash && <Lock size={12} className="opacity-50" />
)}
</button>
);
})}
<button <button
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
@ -111,6 +138,28 @@ export default function ProfileSwitcher() {
{showManage && ( {showManage && (
<ProfileFormModal onClose={() => setShowManage(false)} /> <ProfileFormModal onClose={() => setShowManage(false)} />
)} )}
{showUpsell && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
<div className="flex justify-end p-3 pb-0">
<button
onClick={() => setShowUpsell(false)}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<X size={18} />
</button>
</div>
<div className="px-4 pb-8">
<UpsellGate
feature="multi-profile"
requiredTier={requiredTierFor("multi-profile")}
onNavigate={() => setShowUpsell(false)}
/>
</div>
</div>
</div>
)}
</> </>
); );
} }

View file

@ -1,19 +1,44 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Lock } from "lucide-react";
import { useTranslation } from "react-i18next";
export interface HubReportNavCardProps { export interface HubReportNavCardProps {
to: string; to: string;
icon: ReactNode; icon: ReactNode;
title: string; title: string;
description: string; description: string;
/**
* Show a lock badge on the card (gated feature, license ready and not
* entitled). The card stays clickable the gated route renders the upsell.
*/
locked?: boolean;
} }
export default function HubReportNavCard({ to, icon, title, description }: HubReportNavCardProps) { export default function HubReportNavCard({
to,
icon,
title,
description,
locked,
}: HubReportNavCardProps) {
const { t } = useTranslation();
return ( return (
<Link <Link
to={to} to={to}
className="group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all" className="relative group bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 flex flex-col gap-2 hover:border-[var(--primary)] hover:shadow-sm transition-all"
> >
{locked && (
<span
className="absolute top-4 right-4 text-[var(--muted-foreground)]"
title={t("nav.locked")}
aria-label={t("nav.locked")}
role="img"
>
<Lock size={16} aria-hidden="true" />
</span>
)}
<div className="text-[var(--primary)]">{icon}</div> <div className="text-[var(--primary)]">{icon}</div>
<h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]"> <h3 className="text-base font-semibold text-[var(--foreground)] group-hover:text-[var(--primary)]">
{title} {title}

View file

@ -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() {

View file

@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { Outlet } from "react-router-dom";
import { useEntitlement } from "../../hooks/useEntitlement";
import { requiredTierFor, type FeatureKey } from "../../shared/entitlements";
import UpsellGate from "./UpsellGate";
interface RequireFeatureProps {
feature: FeatureKey;
children?: ReactNode;
}
/**
* Route guard for gated features (UI-only soft paywall).
*
* While the license is still loading or errored (`!ready`) it renders a
* NEUTRAL loader never the upsell so a paying user never sees a
* "locked" flash at boot or during a transient IPC failure (the retry loop
* lives in LicenseProvider; this component only respects `ready`).
*
* Usable two ways:
* - layout route grouping all routes of one feature (children omitted
* renders <Outlet/>):
* <Route element={<RequireFeature feature="balance" />}> sub-routes
* - explicit wrapper:
* <RequireFeature feature="budget"><BudgetPage /></RequireFeature>
*/
export default function RequireFeature({ feature, children }: RequireFeatureProps) {
const { allowed, ready } = useEntitlement(feature);
if (!ready) {
return (
<div className="flex min-h-full items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]" />
</div>
);
}
if (!allowed) {
return <UpsellGate feature={feature} requiredTier={requiredTierFor(feature)} />;
}
return children !== undefined ? <>{children}</> : <Outlet />;
}

View file

@ -0,0 +1,80 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { KeyRound, Lock, ShoppingCart } from "lucide-react";
import type { Edition } from "../../services/licenseService";
import type { FeatureKey } from "../../shared/entitlements";
interface UpsellGateProps {
feature: FeatureKey;
requiredTier: Edition;
/**
* Called right after a CTA navigates away. Lets modal hosts (e.g. the
* ProfileSwitcher upsell dialog) close themselves the Sidebar stays
* mounted across navigations, so an uncontrolled modal would linger on top
* of the destination page. Optional: route-level usage ignores it.
*/
onNavigate?: () => void;
}
/**
* Full locked screen shown in place of a gated module (soft paywall).
*
* Two CTAs:
* - "Get <tier>" VISIBLE but DISABLED with a "coming soon" note: the online
* purchase flow (#270 / Stripe) is not live yet. It will be wired by #270.
* - "I already have a key" the active flow, navigates to the license card
* at /settings/users.
*
* The tier label reuses the existing `license.editions.*` keys; the feature
* description comes from `upsell.features.<FeatureKey>` (FR/EN).
*/
export default function UpsellGate({ feature, requiredTier, onNavigate }: UpsellGateProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const tier = t(`license.editions.${requiredTier}`);
return (
<div className="flex min-h-full items-center justify-center p-4">
<div className="max-w-md w-full space-y-6 text-center">
<Lock className="mx-auto h-16 w-16 text-[var(--muted-foreground)]" />
<div className="space-y-2">
<h1 className="text-2xl font-bold text-[var(--foreground)]">
{t("upsell.title", { tier })}
</h1>
<p className="text-[var(--muted-foreground)]">
{t(`upsell.features.${feature}`)}
</p>
</div>
<div className="flex flex-col gap-3">
<div>
<button
type="button"
disabled
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] opacity-50 cursor-not-allowed"
>
<ShoppingCart className="h-4 w-4" />
{t("upsell.ctaGet", { tier })}
</button>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("upsell.ctaGetSoon")}
</p>
</div>
<button
type="button"
onClick={() => {
navigate("/settings/users");
onNavigate?.();
}}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<KeyRound className="h-4 w-4" />
{t("upsell.ctaHaveKey")}
</button>
</div>
</div>
</div>
);
}

View file

@ -16,7 +16,8 @@
"budget": "Budget", "budget": "Budget",
"reports": "Reports", "reports": "Reports",
"balance": "Balance sheet", "balance": "Balance sheet",
"settings": "Settings" "settings": "Settings",
"locked": "Locked feature"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@ -1040,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": {
@ -1124,6 +1146,19 @@
"noMachines": "No machines activated" "noMachines": "No machines activated"
} }
}, },
"upsell": {
"title": "{{tier}} feature",
"features": {
"budget": "Plan monthly budgets by category and compare them against your actual spending.",
"adjustments": "Add manual entries and split transactions across multiple categories.",
"reports-advanced": "Explore the advanced reports: Highlights, Compare, Category Analysis and Cards.",
"multi-profile": "Create multiple profiles, each with its own local database and PIN protection.",
"balance": "Track your net worth: accounts, snapshots, per-security detail and market prices."
},
"ctaGet": "Get {{tier}}",
"ctaGetSoon": "Online purchase coming soon",
"ctaHaveKey": "I already have a key"
},
"account": { "account": {
"title": "Maximus Account", "title": "Maximus Account",
"optional": "Optional", "optional": "Optional",

View file

@ -16,7 +16,8 @@
"budget": "Budget", "budget": "Budget",
"reports": "Rapports", "reports": "Rapports",
"balance": "Bilan", "balance": "Bilan",
"settings": "Paramètres" "settings": "Paramètres",
"locked": "Fonctionnalité verrouillée"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@ -1040,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": {
@ -1124,6 +1146,19 @@
"noMachines": "Aucune machine activée" "noMachines": "Aucune machine activée"
} }
}, },
"upsell": {
"title": "Fonctionnalité {{tier}}",
"features": {
"budget": "Planifiez des budgets mensuels par catégorie et comparez-les à vos dépenses réelles.",
"adjustments": "Ajoutez des écritures manuelles et fractionnez des transactions sur plusieurs catégories.",
"reports-advanced": "Explorez les rapports avancés : Faits saillants, Comparables, Analyse par catégorie et Cartes.",
"multi-profile": "Créez plusieurs profils, chacun avec sa base de données locale et sa protection par NIP.",
"balance": "Suivez votre patrimoine : comptes, instantanés, détail par titre et cours du marché."
},
"ctaGet": "Obtenir {{tier}}",
"ctaGetSoon": "Achat en ligne bientôt disponible",
"ctaHaveKey": "J'ai déjà une clé"
},
"account": { "account": {
"title": "Compte Maximus", "title": "Compte Maximus",
"optional": "Optionnel", "optional": "Optionnel",

View file

@ -2,6 +2,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Lock, Plus } from "lucide-react"; import { Lock, Plus } from "lucide-react";
import { useProfile } from "../contexts/ProfileContext"; import { useProfile } from "../contexts/ProfileContext";
import { useEntitlement } from "../hooks/useEntitlement";
import { isProfileCreationLocked } from "../shared/profileGate";
import { APP_NAME } from "../shared/constants"; import { APP_NAME } from "../shared/constants";
import PinDialog from "../components/profile/PinDialog"; import PinDialog from "../components/profile/PinDialog";
import ProfileFormModal from "../components/profile/ProfileFormModal"; import ProfileFormModal from "../components/profile/ProfileFormModal";
@ -9,6 +11,13 @@ import ProfileFormModal from "../components/profile/ProfileFormModal";
export default function ProfileSelectionPage() { export default function ProfileSelectionPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { profiles, switchProfile, updateProfile } = useProfile(); const { profiles, switchProfile, updateProfile } = useProfile();
// Multi-profile gate (Base+, #300). This page renders only when no active
// profile resolves, so profile SELECTION stays free (never lock a user out
// of all of their profiles — see decisions log); only the creation entry is
// marked. The gate itself lives at the single creation point,
// ProfileFormModal, which this page opens.
const gate = useEntitlement("multi-profile");
const creationLocked = isProfileCreationLocked(profiles.length, gate);
const [pinProfileId, setPinProfileId] = useState<string | null>(null); const [pinProfileId, setPinProfileId] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
@ -66,10 +75,19 @@ export default function ProfileSelectionPage() {
<button <button
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
className="flex flex-col items-center justify-center gap-3 p-6 rounded-xl border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer" title={creationLocked ? t("nav.locked") : undefined}
className={`flex flex-col items-center justify-center gap-3 p-6 rounded-xl border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer ${
creationLocked ? "opacity-60" : ""
}`}
> >
<div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--muted)]"> <div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--muted)]">
<Plus size={24} className="text-[var(--muted-foreground)]" /> {/* Locked-not-hidden: the entry stays visible with a lock; the
modal's single creation gate shows the upsell on click. */}
{creationLocked ? (
<Lock size={24} className="text-[var(--muted-foreground)]" />
) : (
<Plus size={24} className="text-[var(--muted-foreground)]" />
)}
</div> </div>
<span className="text-sm font-medium text-[var(--muted-foreground)]"> <span className="text-sm font-medium text-[var(--muted-foreground)]">
{t("profile.create")} {t("profile.create")}

View file

@ -4,6 +4,7 @@ import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector"; import PeriodSelector from "../components/dashboard/PeriodSelector";
import HubHighlightsPanel from "../components/reports/HubHighlightsPanel"; import HubHighlightsPanel from "../components/reports/HubHighlightsPanel";
import HubReportNavCard from "../components/reports/HubReportNavCard"; import HubReportNavCard from "../components/reports/HubReportNavCard";
import { useEntitlement } from "../hooks/useEntitlement";
import { useHighlights } from "../hooks/useHighlights"; import { useHighlights } from "../hooks/useHighlights";
import { useReportsPeriod } from "../hooks/useReportsPeriod"; import { useReportsPeriod } from "../hooks/useReportsPeriod";
@ -11,6 +12,10 @@ export default function ReportsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod(); const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const { data, isLoading, error } = useHighlights(); const { data, isLoading, error } = useHighlights();
// Advanced-reports lock badge: shown only when the license is ready AND not
// entitled (no "locked" flash at boot). Trends stays Free — never locked.
const { allowed: advancedAllowed, ready: licenseReady } = useEntitlement("reports-advanced");
const advancedLocked = licenseReady && !advancedAllowed;
const preserveSearch = typeof window !== "undefined" ? window.location.search : ""; const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const navCards = [ const navCards = [
@ -19,6 +24,7 @@ export default function ReportsPage() {
icon: <Sparkles size={24} />, icon: <Sparkles size={24} />,
title: t("reports.hub.highlights"), title: t("reports.hub.highlights"),
description: t("reports.hub.highlightsDescription"), description: t("reports.hub.highlightsDescription"),
locked: advancedLocked,
}, },
{ {
to: `/reports/trends${preserveSearch}`, to: `/reports/trends${preserveSearch}`,
@ -31,18 +37,21 @@ export default function ReportsPage() {
icon: <Scale size={24} />, icon: <Scale size={24} />,
title: t("reports.hub.compare"), title: t("reports.hub.compare"),
description: t("reports.hub.compareDescription"), description: t("reports.hub.compareDescription"),
locked: advancedLocked,
}, },
{ {
to: `/reports/category${preserveSearch}`, to: `/reports/category${preserveSearch}`,
icon: <Search size={24} />, icon: <Search size={24} />,
title: t("reports.hub.categoryZoom"), title: t("reports.hub.categoryZoom"),
description: t("reports.hub.categoryZoomDescription"), description: t("reports.hub.categoryZoomDescription"),
locked: advancedLocked,
}, },
{ {
to: `/reports/cartes${preserveSearch}`, to: `/reports/cartes${preserveSearch}`,
icon: <LayoutDashboard size={24} />, icon: <LayoutDashboard size={24} />,
title: t("reports.hub.cartes"), title: t("reports.hub.cartes"),
description: t("reports.hub.cartesDescription"), description: t("reports.hub.cartesDescription"),
locked: advancedLocked,
}, },
]; ];

View file

@ -1,8 +1,15 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
// Subject lives in src/shared/entitlements.ts; test co-located here per the // Subject lives in src/shared/entitlements.ts; test co-located here per the
// issue's file plan (services/). // issue's file plan (services/).
import { isEntitled, ENTITLEMENTS, type FeatureKey } from "../shared/entitlements"; import {
isEntitled,
requiredTierFor,
ENTITLEMENTS,
type FeatureKey,
} from "../shared/entitlements";
import type { Edition } from "./licenseService"; import type { Edition } from "./licenseService";
import fr from "../i18n/locales/fr.json";
import en from "../i18n/locales/en.json";
const EDITIONS: Edition[] = ["free", "base", "premium"]; const EDITIONS: Edition[] = ["free", "base", "premium"];
const FEATURES = Object.keys(ENTITLEMENTS) as FeatureKey[]; const FEATURES = Object.keys(ENTITLEMENTS) as FeatureKey[];
@ -70,3 +77,81 @@ describe("isEntitled — unknown feature", () => {
expect(isEntitled("web-sync" as FeatureKey, "free", ["web-sync"])).toBe(false); expect(isEntitled("web-sync" as FeatureKey, "free", ["web-sync"])).toBe(false);
}); });
}); });
describe("requiredTierFor — upsell tier derivation", () => {
it("maps every Base-tier feature to base, and balance to premium", () => {
expect(requiredTierFor("budget")).toBe("base");
expect(requiredTierFor("adjustments")).toBe("base");
expect(requiredTierFor("reports-advanced")).toBe("base");
expect(requiredTierFor("multi-profile")).toBe("base");
expect(requiredTierFor("balance")).toBe("premium");
});
it("returns the MINIMUM tier satisfying isEntitled, never free", () => {
FEATURES.forEach((f) => {
const tier = requiredTierFor(f);
expect(tier).not.toBe("free");
// The returned tier does unlock the feature…
expect(isEntitled(f, tier, [])).toBe(true);
// …and is minimal: premium only when base does not unlock it.
if (tier === "premium") {
expect(isEntitled(f, "base", [])).toBe(false);
}
});
});
});
describe("upsell i18n coverage (fr + en)", () => {
// UpsellGate renders t(`upsell.features.${feature}`) — every FeatureKey must
// resolve in BOTH locales or a locked screen shows a raw key to the user.
// Structural sub-type only: full typeof-identity between the two JSON files
// is not this test's concern.
interface UpsellMessages {
nav: { locked: string };
license: { editions: { base: string; premium: string } };
upsell: {
title: string;
features: Record<string, string>;
ctaGet: string;
ctaGetSoon: string;
ctaHaveKey: string;
};
}
const locales: Record<string, UpsellMessages> = { fr, en };
it("has a non-empty upsell description for every FeatureKey in both locales", () => {
Object.entries(locales).forEach(([lng, messages]) => {
FEATURES.forEach((f) => {
expect(
messages.upsell.features[f],
`missing upsell.features.${f} in ${lng}`,
).toBeTruthy();
});
});
});
it("has no stale upsell.features key outside the ENTITLEMENTS matrix", () => {
Object.entries(locales).forEach(([lng, messages]) => {
Object.keys(messages.upsell.features).forEach((k) => {
expect(FEATURES, `stale upsell.features.${k} in ${lng}`).toContain(k);
});
});
});
it("exposes the title, CTA keys and nav.locked in both locales", () => {
Object.values(locales).forEach((messages) => {
expect(messages.upsell.title).toBeTruthy();
expect(messages.upsell.ctaGet).toBeTruthy();
expect(messages.upsell.ctaGetSoon).toBeTruthy();
expect(messages.upsell.ctaHaveKey).toBeTruthy();
expect(messages.nav.locked).toBeTruthy();
});
});
it("keeps the license.editions.<tier> label keys required by the CTA", () => {
Object.values(locales).forEach((messages) => {
expect(messages.license.editions.base).toBeTruthy();
expect(messages.license.editions.premium).toBeTruthy();
});
});
});

View file

@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { NAV_ITEMS } from "./index";
/**
* Nav gating contract (#299). Locks in the /review-spec CRITICAL caveat:
* the `reports` nav item points to the FREE hub (/reports, not route-gated)
* and must NEVER carry a `feature` gating it would show a lock over a
* fully functional free page.
*/
describe("NAV_ITEMS feature gating", () => {
const byKey = Object.fromEntries(NAV_ITEMS.map((item) => [item.key, item]));
it("gates budget, adjustments and balance with their matching feature key", () => {
expect(byKey.budget.feature).toBe("budget");
expect(byKey.adjustments.feature).toBe("adjustments");
expect(byKey.balance.feature).toBe("balance");
});
it("never gates the reports hub nav item nor any Free module", () => {
expect(byKey.reports.feature).toBeUndefined();
expect(byKey.dashboard.feature).toBeUndefined();
expect(byKey.import.feature).toBeUndefined();
expect(byKey.transactions.feature).toBeUndefined();
expect(byKey.categories.feature).toBeUndefined();
expect(byKey.settings.feature).toBeUndefined();
});
it("gates exactly 3 of the 9 nav items", () => {
const gated = NAV_ITEMS.filter((item) => item.feature !== undefined);
expect(NAV_ITEMS).toHaveLength(9);
expect(gated.map((item) => item.key).sort()).toEqual([
"adjustments",
"balance",
"budget",
]);
});
});

View file

@ -33,12 +33,14 @@ export const NAV_ITEMS: NavItem[] = [
path: "/adjustments", path: "/adjustments",
icon: "SlidersHorizontal", icon: "SlidersHorizontal",
labelKey: "nav.adjustments", labelKey: "nav.adjustments",
feature: "adjustments",
}, },
{ {
key: "budget", key: "budget",
path: "/budget", path: "/budget",
icon: "PiggyBank", icon: "PiggyBank",
labelKey: "nav.budget", labelKey: "nav.budget",
feature: "budget",
}, },
{ {
key: "reports", key: "reports",
@ -51,6 +53,7 @@ export const NAV_ITEMS: NavItem[] = [
path: "/balance", path: "/balance",
icon: "Wallet", icon: "Wallet",
labelKey: "nav.balance", labelKey: "nav.balance",
feature: "balance",
}, },
{ {
key: "settings", key: "settings",

View file

@ -48,3 +48,12 @@ export function isEntitled(
if (!tiers) return licenseFeatures.includes(f); if (!tiers) return licenseFeatures.includes(f);
return tiers.includes(edition) || licenseFeatures.includes(f); return tiers.includes(edition) || licenseFeatures.includes(f);
} }
/**
* Minimum paid tier that unlocks a feature drives the upsell copy
* ("Obtenir Base" vs "Obtenir Premium"). Derived from matrix MEMBERSHIP, not
* array order, so reordering an ENTITLEMENTS row can never change the answer.
*/
export function requiredTierFor(f: FeatureKey): Edition {
return ENTITLEMENTS[f].includes("base") ? "base" : "premium";
}

View file

@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import {
isProfileSwitchLocked,
isProfileCreationLocked,
type EntitlementGate,
} from "./profileGate";
const LOCKED_GATE: EntitlementGate = { allowed: false, ready: true };
const ALLOWED_GATE: EntitlementGate = { allowed: true, ready: true };
const BOOTING_GATE: EntitlementGate = { allowed: false, ready: false };
describe("isProfileSwitchLocked", () => {
it("locks profiles beyond the active one when gate is active (Free, ready)", () => {
expect(isProfileSwitchLocked("p2", "p1", LOCKED_GATE)).toBe(true);
expect(isProfileSwitchLocked("p3", "p1", LOCKED_GATE)).toBe(true);
});
it("keeps the active profile accessible for a Free user", () => {
expect(isProfileSwitchLocked("p1", "p1", LOCKED_GATE)).toBe(false);
});
it("locks nothing while the license is not ready (anti-flash at boot)", () => {
expect(isProfileSwitchLocked("p2", "p1", BOOTING_GATE)).toBe(false);
expect(isProfileSwitchLocked("p1", "p1", BOOTING_GATE)).toBe(false);
});
it("locks nothing when multi-profile is allowed (Base/Premium)", () => {
expect(isProfileSwitchLocked("p2", "p1", ALLOWED_GATE)).toBe(false);
});
it("locks nothing when no active profile resolves (never lock out of ALL profiles)", () => {
expect(isProfileSwitchLocked("p1", null, LOCKED_GATE)).toBe(false);
expect(isProfileSwitchLocked("p2", null, LOCKED_GATE)).toBe(false);
});
it("stays unlocked when allowed even if not ready (allowed short-circuits)", () => {
expect(isProfileSwitchLocked("p2", "p1", { allowed: true, ready: false })).toBe(false);
});
});
describe("isProfileCreationLocked", () => {
it("locks creation for a Free user who already has at least one profile", () => {
expect(isProfileCreationLocked(1, LOCKED_GATE)).toBe(true);
expect(isProfileCreationLocked(5, LOCKED_GATE)).toBe(true);
});
it("never blocks creating the FIRST profile (empty config)", () => {
expect(isProfileCreationLocked(0, LOCKED_GATE)).toBe(false);
});
it("locks nothing while the license is not ready (anti-flash at boot)", () => {
expect(isProfileCreationLocked(3, BOOTING_GATE)).toBe(false);
});
it("locks nothing when multi-profile is allowed (Base/Premium)", () => {
expect(isProfileCreationLocked(3, ALLOWED_GATE)).toBe(false);
expect(isProfileCreationLocked(0, ALLOWED_GATE)).toBe(false);
});
});

55
src/shared/profileGate.ts Normal file
View file

@ -0,0 +1,55 @@
/**
* Pure predicates for the multi-profile gate (Base+), issue #300.
*
* NON-destructive by construction: these functions only decide whether a UI
* surface is locked nothing is ever removed from profiles.json. An upgrade
* (edition becomes base/premium) flips `allowed` and every profile reappears
* untouched, without any migration.
*
* Anti-flash rule: the gate is active only when the license is `ready`
* (`status === "ready"` in LicenseContext). While loading or errored, nothing
* is locked a paying user must never see a "locked" flash at boot (the same
* rule RequireFeature/Sidebar follow).
*/
/** Shape returned by `useEntitlement("multi-profile")`. */
export interface EntitlementGate {
allowed: boolean;
ready: boolean;
}
/**
* Is switching to `profileId` locked?
*
* Locked iff the gate is active (ready && !allowed) AND the profile is beyond
* the active one. A Free user keeps full access to their active profile.
*
* `activeProfileId === null` (degenerate state: no active profile resolves)
* locks NOTHING we never lock a user out of all of their profiles; the only
* surface without an active profile is ProfileSelectionPage, where selection
* stays free by design (see decisions log #300).
*/
export function isProfileSwitchLocked(
profileId: string,
activeProfileId: string | null,
gate: EntitlementGate,
): boolean {
if (!gate.ready || gate.allowed) return false;
if (activeProfileId === null) return false;
return profileId !== activeProfileId;
}
/**
* Is creating a NEW profile locked?
*
* Locked iff the gate is active AND at least one profile already exists a
* Free user is entitled to one profile, so creation from an empty config
* (fresh/repaired install) is never blocked.
*/
export function isProfileCreationLocked(
profileCount: number,
gate: EntitlementGate,
): boolean {
if (!gate.ready || gate.allowed) return false;
return profileCount >= 1;
}

View file

@ -1,3 +1,5 @@
import type { FeatureKey } from "../entitlements";
export interface ImportSource { export interface ImportSource {
id: number; id: number;
name: string; name: string;
@ -177,6 +179,12 @@ export interface NavItem {
path: string; path: string;
icon: string; icon: string;
labelKey: string; labelKey: string;
/**
* Gated feature backing this nav entry. When set, the Sidebar shows a lock
* icon if the license is not entitled (and ready) the item stays clickable
* (the gated route renders the upsell). Absent = never gated (Free module).
*/
feature?: FeatureKey;
} }
// --- Import Wizard Types --- // --- Import Wizard Types ---