feat(gating): reserve transaction splits for the Base edition #452
19 changed files with 651 additions and 46 deletions
|
|
@ -13,6 +13,7 @@
|
||||||
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
|
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
|
||||||
- Import : l'état vide montre la structure attendue (un dossier parent, un sous-dossier par compte), et les cartes du parcours demandent de choisir le dossier parent et de vérifier qu'une dépense compte parmi les sorties (#422).
|
- Import : l'état vide montre la structure attendue (un dossier parent, un sous-dossier par compte), et les cartes du parcours demandent de choisir le dossier parent et de vérifier qu'une dépense compte parmi les sorties (#422).
|
||||||
- Parcours de démarrage : la carte Transactions explique les mots-clés et propose « Ajouter comme mot-clé » ; la carte Budget précise que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel (#430).
|
- Parcours de démarrage : la carte Transactions explique les mots-clés et propose « Ajouter comme mot-clé » ; la carte Budget précise que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel (#430).
|
||||||
|
- La répartition d'une transaction sur plusieurs catégories (bouton Répartition de la page Transactions) fait désormais partie de l'édition Base, comme l'écran de déverrouillage le promettait déjà. En édition Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran de déverrouillage au lieu de la fenêtre de répartition, pour toute ligne. Les répartitions déjà faites restent dans vos données : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste (#369).
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
|
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
|
||||||
- Import: the empty state shows the expected structure (a parent folder, one subfolder per account), and the getting-started cards ask you to choose the parent folder and to check that an expense counts among the outflows (#422).
|
- Import: the empty state shows the expected structure (a parent folder, one subfolder per account), and the getting-started cards ask you to choose the parent folder and to check that an expense counts among the outflows (#422).
|
||||||
- Getting started: the Transactions card explains keywords and suggests “Add as keyword”; the Budget card states that only last-level categories take an amount and what the Annual column does (#430).
|
- Getting started: the Transactions card explains keywords and suggests “Add as keyword”; the Budget card states that only last-level categories take an amount and what the Annual column does (#430).
|
||||||
|
- Splitting a transaction across categories (the Split button of the Transactions page) is now part of the Base edition, as the unlock screen already promised. On the Free edition the button keeps its place, shows a lock and opens the unlock screen instead of the split window, for every row. Splits made earlier stay in your data: they are still counted in the reports and the dashboard, and still flagged in the list (#369).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@
|
||||||
>
|
>
|
||||||
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage ou la prochaine saisie de la clé), et une app laissée ouverte n'est recontrôlée qu'à son redémarrage ou à la saisie d'une clé (#373). La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184.
|
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage ou la prochaine saisie de la clé), et une app laissée ouverte n'est recontrôlée qu'à son redémarrage ou à la saisie d'une clé (#373). La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184.
|
||||||
|
|
||||||
|
> **Amendement du 2026-10-16 (#369)** — la répartition d'une transaction sur plusieurs catégories (bouton « Répartition » de la page Transactions) passe dans l'édition Base, avec les ajustements, sur décision de Max du 2026-10-03 (analyse de maximus/la-compagnie-maximus#180). L'écran de déverrouillage le promettait déjà (`upsell.features.adjustments`) alors que l'app ne la verrouillait pas : le code s'aligne sur l'offre, il ne la change pas. La page Transactions reste Free ; seul son bouton est gaté, par la clé `adjustments`. En Gratuite, le bouton garde sa place et son icône, porte un cadenas et ouvre l'écran verrouillé dans une fenêtre (`UpsellDialog`) au lieu de la fenêtre de répartition, pour toute ligne, répartie ou non. Tant que la licence n'est pas `ready`, il n'affiche aucun cadenas et ne fait rien (`splitAccessFor`, plus strict que `isProfileSwitchLocked` : `allowed` n'y court-circuite pas `ready`). Conformément au §5, les répartitions existantes (`transactions.parent_transaction_id`, `is_split`) restent en base, comptées par les rapports et le tableau de bord, et signalées par l'icône orange ; seules leur ouverture et leur modification sont verrouillées. Écartés : une fenêtre de répartition en lecture seule, et l'annulation d'une répartition permise en Gratuite.
|
||||||
|
|
||||||
## Contexte
|
## 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 côté serveur seulement depuis #347, voir l'amendement en tête ; 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.
|
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 côté serveur seulement depuis #347, voir l'amendement en tête ; 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.
|
||||||
|
|
@ -34,12 +36,12 @@ Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
|
||||||
|
|
||||||
| Module | Gratuite | Base | Premium |
|
| Module | Gratuite | Base | Premium |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ |
|
| Tableau de bord, Import, Transactions (sans la Répartition, #369), Catégories | ✓ | ✓ | ✓ |
|
||||||
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
|
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
|
||||||
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
|
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
|
||||||
| Profil (un seul) | ✓ | ✓ | ✓ |
|
| Profil (un seul) | ✓ | ✓ | ✓ |
|
||||||
| Budget | — | ✓ | ✓ |
|
| Budget | — | ✓ | ✓ |
|
||||||
| Ajustements | — | ✓ | ✓ |
|
| Ajustements, Répartition des transactions (bouton de la page Transactions, #369) | — | ✓ | ✓ |
|
||||||
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
|
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
|
||||||
| Profils multiples | — | ✓ | ✓ |
|
| Profils multiples | — | ✓ | ✓ |
|
||||||
| Mises à jour automatiques | — | ✓ | ✓ |
|
| Mises à jour automatiques | — | ✓ | ✓ |
|
||||||
|
|
@ -83,11 +85,11 @@ Le gating est appliqué **uniquement dans l'interface** (routes, navigation, poi
|
||||||
|
|
||||||
### 4. Upsell verrouillé visible, jamais masqué
|
### 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 ; voir l'amendement en tête), 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**.
|
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 ; voir l'amendement en tête), 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**. Depuis #369, une action gatée peut aussi vivre dans une page Free : le bouton Répartition de la page Transactions porte le cadenas (`SplitButton`, si `ready && !allowed` pour `adjustments`) et ouvre `UpsellGate` dans une fenêtre (`UpsellDialog`).
|
||||||
|
|
||||||
### 5. Durcissement sec, non destructif
|
### 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, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : 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 ; voir l'amendement en tête). 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.
|
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : 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 ; voir l'amendement en tête). 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. Même règle pour les répartitions de transactions (#369) : en Gratuite, elles restent comptées par les rapports et signalées dans la liste ; seules leur ouverture et leur modification sont verrouillées.
|
||||||
|
|
||||||
### 6. Dev-override compilé hors des builds normaux
|
### 6. Dev-override compilé hors des builds normaux
|
||||||
|
|
||||||
|
|
@ -144,6 +146,7 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
|
||||||
- `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
|
- `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/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/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
|
||||||
|
- `src/shared/splitGate.ts` — accès du bouton Répartition (`open`, `locked`, `pending` ; #369) ; `src/components/transactions/SplitButton.tsx` ; `src/components/shared/UpsellDialog.tsx` — écran verrouillé dans une fenêtre
|
||||||
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `remove_license_in` (retrait de la clé, `license.check` gardé, #370), `dev_override_edition` (CWE-489)
|
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `remove_license_in` (retrait de la clé, `license.check` gardé, #370), `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
|
- [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 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0)
|
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0) ; #369 (Répartition réservée à l'édition Base, voir l'amendement en tête)
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,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/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
|
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
|
||||||
│ │ ├── shared/ # 12 composants réutilisables (dont RequireFeature, UpsellGate, PurchaseButton, EmptyState)
|
│ │ ├── shared/ # 14 composants réutilisables (dont RequireFeature, UpsellGate, UpsellDialog, PurchaseButton, EmptyState)
|
||||||
│ │ └── transactions/ # 5 composants
|
│ │ └── transactions/ # 6 composants (dont SplitButton, #369)
|
||||||
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
|
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
|
||||||
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
|
│ ├── hooks/ # 29 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/ # 26 services métier (20 détaillés dans la section « Services TypeScript »)
|
│ ├── services/ # 26 services métier (20 détaillés dans la section « Services TypeScript »)
|
||||||
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
||||||
│ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
|
│ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
|
||||||
│ ├── i18n/ # Config i18next + locales FR/EN
|
│ ├── i18n/ # Config i18next + locales FR/EN
|
||||||
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
|
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
|
||||||
|
|
@ -406,9 +406,11 @@ Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de l
|
||||||
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
|
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
|
||||||
| 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) |
|
| 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, qui réutilise `UpsellGate`) ; `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 |
|
| 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, qui réutilise `UpsellGate`) ; `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 |
|
||||||
|
| Gate de la Répartition | `src/shared/splitGate.ts`, `src/components/transactions/SplitButton.tsx` | `splitAccessFor(gate)` → `open` / `locked` / `pending` sur `useEntitlement("adjustments")`, lu par `TransactionsPage` et passé à `TransactionTable` en prop obligatoire (#369). `locked` : cadenas sur le bouton et `UpsellDialog` au lieu de la fenêtre de répartition, pour toute ligne ; `pending` (licence pas `ready`, chargement ou erreur) : ni cadenas ni fenêtre, bouton inactif, `allowed` ne court-circuitant pas `ready`. La fenêtre de répartition n'est rendue que hors `locked`, l'incitation que hors `open`. Non destructif : les répartitions existantes restent comptées et gardent leur icône orange |
|
||||||
|
| `UpsellDialog` | `src/components/shared/UpsellDialog.tsx` | `UpsellGate` dans une fenêtre (`role="dialog"`, `aria-modal`, nom accessible `upsell.title`), pour une action gatée dans une page Free ; `onNavigate` ferme la fenêtre après « J'ai déjà une clé ». Même balisage que la fenêtre d'incitation de `ProfileSwitcher`, pas encore migrée dessus |
|
||||||
| 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` |
|
| 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.
|
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). 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 (39)
|
## Commandes Tauri (39)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
|
||||||
- Assignation de catégorie en ligne via menu déroulant
|
- Assignation de catégorie en ligne via menu déroulant
|
||||||
- Auto-catégorisation basée sur les règles de mots-clés
|
- Auto-catégorisation basée sur les règles de mots-clés
|
||||||
- Ajout de mots-clés directement depuis une transaction
|
- Ajout de mots-clés directement depuis une transaction
|
||||||
- Répartition (split) d'une transaction sur plusieurs catégories
|
- Répartition (split) d'une transaction sur plusieurs catégories (édition Base et plus)
|
||||||
- Notes sur les transactions
|
- Notes sur les transactions
|
||||||
|
|
||||||
### Comment faire
|
### Comment faire
|
||||||
|
|
@ -251,14 +251,14 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
|
||||||
3. Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie
|
3. Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie
|
||||||
4. Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser
|
4. Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser
|
||||||
5. Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie
|
5. Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie
|
||||||
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie
|
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie (édition Base et plus ; en édition Gratuite, le bouton porte un cadenas et ouvre l'écran de déverrouillage)
|
||||||
|
|
||||||
### Astuces
|
### Astuces
|
||||||
|
|
||||||
- Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date
|
- Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date
|
||||||
- L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles
|
- L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles
|
||||||
- Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles
|
- Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles
|
||||||
- Les transactions réparties affichent un indicateur visuel et le détail de la répartition
|
- Les transactions réparties affichent un indicateur visuel, quelle que soit l'édition ; le détail de la répartition s'ouvre par le bouton Répartition (édition Base et plus)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -622,13 +622,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
|
||||||
|---|:---:|:---:|:---:|
|
|---|:---:|:---:|:---:|
|
||||||
| Tableau de bord | ✓ | ✓ | ✓ |
|
| Tableau de bord | ✓ | ✓ | ✓ |
|
||||||
| Import CSV | ✓ | ✓ | ✓ |
|
| Import CSV | ✓ | ✓ | ✓ |
|
||||||
| Transactions | ✓ | ✓ | ✓ |
|
| Transactions (sans la Répartition) | ✓ | ✓ | ✓ |
|
||||||
| Catégories | ✓ | ✓ | ✓ |
|
| Catégories | ✓ | ✓ | ✓ |
|
||||||
| Rapports — hub et Tendances | ✓ | ✓ | ✓ |
|
| Rapports — hub et Tendances | ✓ | ✓ | ✓ |
|
||||||
| Export / import chiffré | ✓ | ✓ | ✓ |
|
| Export / import chiffré | ✓ | ✓ | ✓ |
|
||||||
| Journal des modifications | ✓ | ✓ | ✓ |
|
| Journal des modifications | ✓ | ✓ | ✓ |
|
||||||
| Profils | 1 profil | ✓ multiples | ✓ multiples |
|
| Profils | 1 profil | ✓ multiples | ✓ multiples |
|
||||||
| Ajustements | — | ✓ | ✓ |
|
| Ajustements | — | ✓ | ✓ |
|
||||||
|
| Répartition des transactions | — | ✓ | ✓ |
|
||||||
| Budget | — | ✓ | ✓ |
|
| Budget | — | ✓ | ✓ |
|
||||||
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
|
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
|
||||||
| Mises à jour automatiques | — | ✓ | ✓ |
|
| Mises à jour automatiques | — | ✓ | ✓ |
|
||||||
|
|
@ -636,14 +637,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
|
||||||
|
|
||||||
### Comment ça se présente
|
### 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 :
|
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan), sur les tuiles de rapports avancés du hub Rapports et sur le bouton Répartition de la page Transactions. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
|
||||||
|
|
||||||
- **Obtenir Base** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton s'intitule « Obtenir Premium » et reste désactivé : Premium n'est pas encore offert en ligne
|
- **Obtenir Base** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton s'intitule « Obtenir Premium » et reste désactivé : Premium n'est pas encore offert en ligne
|
||||||
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
|
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
|
||||||
|
|
||||||
### Comment faire
|
### 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
|
1. Repérez le cadenas dans la barre latérale, sur les tuiles du hub Rapports ou sur le bouton Répartition de la page Transactions : il marque ce qui est au-dessus de votre édition
|
||||||
2. Cliquez sur un module verrouillé pour voir l'édition requise
|
2. Cliquez sur un module verrouillé pour voir l'édition requise
|
||||||
3. Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)
|
3. Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)
|
||||||
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
|
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
|
||||||
|
|
@ -666,5 +667,6 @@ La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machin
|
||||||
|
|
||||||
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), 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
|
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), 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
|
- 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
|
||||||
|
- En édition Gratuite, les **répartitions déjà faites restent dans vos données** : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste des transactions — seuls l'ouverture et la modification d'une répartition sont verrouillées
|
||||||
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
|
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
|
||||||
- En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite
|
- En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite
|
||||||
|
|
|
||||||
60
src/components/shared/UpsellDialog.render.test.tsx
Normal file
60
src/components/shared/UpsellDialog.render.test.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// UpsellDialog — the locked screen in a modal (#369). No jsdom: react-dom/server
|
||||||
|
// renders the markup in vitest's node environment, and calling the component as
|
||||||
|
// a function (UpsellCtas.render.test.tsx pattern) exposes the props it hands
|
||||||
|
// to UpsellGate and to its close button.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()) }));
|
||||||
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
|
// The locked screen reads one thing from the license context: a revoked key (#365).
|
||||||
|
vi.mock("../../contexts/LicenseContext", () => ({ useLicenseContext: () => ({ revoked: false }) }));
|
||||||
|
|
||||||
|
import UpsellDialog from "./UpsellDialog";
|
||||||
|
import UpsellGate from "./UpsellGate";
|
||||||
|
|
||||||
|
type Props = Record<string, unknown> & { children?: ReactNode };
|
||||||
|
|
||||||
|
function find(node: ReactNode, pred: (el: ReactElement<Props>) => boolean): ReactElement<Props>[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
const el = node as ReactElement<Props>;
|
||||||
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UpsellDialog (#369)", () => {
|
||||||
|
it("shows the locked screen of the feature in a named modal dialog", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<UpsellDialog feature="adjustments" onClose={vi.fn()} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(html).toContain('role="dialog"');
|
||||||
|
expect(html).toContain('aria-modal="true"');
|
||||||
|
expect(html).toContain('aria-label="t(upsell.title[tier=t(license.editions.base)])"');
|
||||||
|
expect(html).toContain("t(upsell.features.adjustments)");
|
||||||
|
expect(html).toContain("t(upsell.ctaGet[tier=t(license.editions.base)])");
|
||||||
|
expect(html).toContain("t(upsell.ctaHaveKey)");
|
||||||
|
expect(html).toContain('aria-label="t(common.close)"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on its X button and after 'I already have a key'", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const tree = UpsellDialog({ feature: "adjustments", onClose });
|
||||||
|
const gates = find(tree, (el) => el.type === UpsellGate);
|
||||||
|
expect(gates).toHaveLength(1);
|
||||||
|
expect(gates[0].props).toMatchObject({ feature: "adjustments", requiredTier: "base" });
|
||||||
|
expect(gates[0].props.onNavigate).toBe(onClose);
|
||||||
|
const close = find(tree, (el) => el.type === "button" && el.props["aria-label"] === "t(common.close)");
|
||||||
|
expect(close).toHaveLength(1);
|
||||||
|
(close[0].props.onClick as () => void)();
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for the tier the feature needs, not always Base", () => {
|
||||||
|
const tree = UpsellDialog({ feature: "balance", onClose: vi.fn() });
|
||||||
|
expect(find(tree, (el) => el.type === UpsellGate)[0].props.requiredTier).toBe("premium");
|
||||||
|
});
|
||||||
|
});
|
||||||
49
src/components/shared/UpsellDialog.tsx
Normal file
49
src/components/shared/UpsellDialog.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { requiredTierFor, type FeatureKey } from "../../shared/entitlements";
|
||||||
|
import UpsellGate from "./UpsellGate";
|
||||||
|
|
||||||
|
interface UpsellDialogProps {
|
||||||
|
feature: FeatureKey;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locked screen in a modal, for a gated action inside a free page (#369:
|
||||||
|
* the Répartition button of the Transactions page). Same markup as the upsell
|
||||||
|
* dialog of ProfileSwitcher, which has not moved onto this component yet.
|
||||||
|
*
|
||||||
|
* "I already have a key" navigates to the license card and closes the dialog
|
||||||
|
* through UpsellGate's `onNavigate`, so it does not linger over the destination
|
||||||
|
* page; the purchase button leaves it open (the product page opens in the
|
||||||
|
* browser).
|
||||||
|
*/
|
||||||
|
export default function UpsellDialog({ feature, onClose }: UpsellDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const requiredTier = requiredTierFor(feature);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("upsell.title", { tier: t(`license.editions.${requiredTier}`) })}
|
||||||
|
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
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-8">
|
||||||
|
<UpsellGate feature={feature} requiredTier={requiredTier} onNavigate={onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/components/transactions/SplitButton.test.tsx
Normal file
106
src/components/transactions/SplitButton.test.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// SplitButton — the Répartition button of a transaction row (#369). No jsdom:
|
||||||
|
// react-dom/server renders the markup in vitest's node environment, and calling
|
||||||
|
// the component as a function (UpsellCtas.render.test.tsx pattern) hands over
|
||||||
|
// the button's props, so a click runs without a DOM.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import fr from "../../i18n/locales/fr.json";
|
||||||
|
import en from "../../i18n/locales/en.json";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
|
|
||||||
|
import SplitButton from "./SplitButton";
|
||||||
|
import type { SplitAccess } from "../../shared/splitGate";
|
||||||
|
import type { TransactionRow } from "../../shared/types";
|
||||||
|
|
||||||
|
const row = (isSplit: boolean): TransactionRow => ({
|
||||||
|
id: isSplit ? 1 : 2,
|
||||||
|
date: "2026-09-12",
|
||||||
|
description: isSplit ? "COSTCO 123" : "IGA 456",
|
||||||
|
amount: -120,
|
||||||
|
category_id: 7,
|
||||||
|
category_name: "Épicerie",
|
||||||
|
category_color: null,
|
||||||
|
source_name: "Exemple Desjardins",
|
||||||
|
notes: null,
|
||||||
|
is_manually_categorized: true,
|
||||||
|
is_split: isSplit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ACCESSES: SplitAccess[] = ["open", "locked", "pending"];
|
||||||
|
const LOCK = "lucide-lock";
|
||||||
|
const ORANGE = "text-orange-500";
|
||||||
|
|
||||||
|
const markup = (access: SplitAccess, isSplit = false) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<SplitButton row={row(isSplit)} access={access} onOpenSplit={vi.fn()} onOpenUpsell={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** One click on the button, through its own props: which callback ran. */
|
||||||
|
function click(access: SplitAccess, isSplit: boolean) {
|
||||||
|
const onOpenSplit = vi.fn();
|
||||||
|
const onOpenUpsell = vi.fn();
|
||||||
|
const button = SplitButton({ row: row(isSplit), access, onOpenSplit, onOpenUpsell }) as ReactElement<{
|
||||||
|
onClick: () => void;
|
||||||
|
}>;
|
||||||
|
button.props.onClick();
|
||||||
|
return { split: onOpenSplit.mock.calls.length, upsell: onOpenUpsell.mock.calls.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SplitButton — what it shows (#369)", () => {
|
||||||
|
it("shows the lock in the locked state only", () => {
|
||||||
|
expect(markup("locked")).toContain(LOCK);
|
||||||
|
expect(markup("open")).not.toContain(LOCK);
|
||||||
|
expect(markup("pending")).not.toContain(LOCK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the orange of a split row in every state, and only for a split row", () => {
|
||||||
|
for (const access of ACCESSES) {
|
||||||
|
expect(markup(access, true), access).toContain(ORANGE);
|
||||||
|
expect(markup(access, false), access).not.toContain(ORANGE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled while the license is not ready, and only then", () => {
|
||||||
|
// The attribute itself: the class also names the `disabled:` variants.
|
||||||
|
expect(markup("pending")).toContain('disabled=""');
|
||||||
|
expect(markup("open")).not.toContain('disabled=""');
|
||||||
|
expect(markup("locked")).not.toContain('disabled=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the edition in the locked title, keeps the usual title otherwise", () => {
|
||||||
|
expect(markup("locked")).toContain('title="t(transactions.splitLocked[tier=t(license.editions.base)])"');
|
||||||
|
expect(markup("open")).toContain('title="t(transactions.splitAdjustment)"');
|
||||||
|
expect(markup("pending")).toContain('title="t(transactions.splitAdjustment)"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SplitButton — what a click does (#369)", () => {
|
||||||
|
// Every row: the decision never depends on the row being split already.
|
||||||
|
it("locked: opens the upsell, never the split window, on a split row and on a plain one", () => {
|
||||||
|
expect(click("locked", true)).toEqual({ split: 0, upsell: 1 });
|
||||||
|
expect(click("locked", false)).toEqual({ split: 0, upsell: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("open: opens the split window, never the upsell", () => {
|
||||||
|
expect(click("open", true)).toEqual({ split: 1, upsell: 0 });
|
||||||
|
expect(click("open", false)).toEqual({ split: 1, upsell: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pending: opens nothing", () => {
|
||||||
|
expect(click("pending", true)).toEqual({ split: 0, upsell: 0 });
|
||||||
|
expect(click("pending", false)).toEqual({ split: 0, upsell: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The i18n mock renders any key: a key missing from one locale would only show
|
||||||
|
// up in the app (the raw key in English, or English in French).
|
||||||
|
describe("transactions.splitLocked in both locales", () => {
|
||||||
|
it("exists in fr and en and interpolates the edition", () => {
|
||||||
|
for (const messages of [fr, en]) {
|
||||||
|
expect(messages.transactions.splitLocked).toContain("{{tier}}");
|
||||||
|
expect(messages.transactions.splitLocked).not.toBe(messages.transactions.splitAdjustment);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/components/transactions/SplitButton.tsx
Normal file
59
src/components/transactions/SplitButton.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Lock, Split } from "lucide-react";
|
||||||
|
import type { TransactionRow } from "../../shared/types";
|
||||||
|
import { requiredTierFor } from "../../shared/entitlements";
|
||||||
|
import type { SplitAccess } from "../../shared/splitGate";
|
||||||
|
|
||||||
|
interface SplitButtonProps {
|
||||||
|
row: TransactionRow;
|
||||||
|
access: SplitAccess;
|
||||||
|
onOpenSplit: () => void;
|
||||||
|
onOpenUpsell: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Répartition button of a transaction row (#369).
|
||||||
|
*
|
||||||
|
* Locked, never hidden: on the Free edition the button keeps its place and its
|
||||||
|
* icon, carries a lock and opens the upsell instead of the split window, for
|
||||||
|
* every row, split or not. The orange colour of a split row is kept in every
|
||||||
|
* state: existing splits stay flagged whatever the edition. While the license
|
||||||
|
* is not ready ("pending") the button shows no lock and does nothing.
|
||||||
|
*/
|
||||||
|
export default function SplitButton({ row, access, onOpenSplit, onOpenUpsell }: SplitButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const locked = access === "locked";
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (access === "open") onOpenSplit();
|
||||||
|
else if (access === "locked") onOpenUpsell();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={access === "pending"}
|
||||||
|
className={`relative p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 disabled:opacity-50 disabled:cursor-default ${
|
||||||
|
row.is_split ? "text-orange-500" : "text-[var(--muted-foreground)]"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
locked
|
||||||
|
? t("transactions.splitLocked", {
|
||||||
|
tier: t(`license.editions.${requiredTierFor("adjustments")}`),
|
||||||
|
})
|
||||||
|
: t("transactions.splitAdjustment")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Split size={14} />
|
||||||
|
{locked && (
|
||||||
|
<Lock
|
||||||
|
size={8}
|
||||||
|
strokeWidth={3}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute bottom-0 right-0 rounded-full bg-[var(--card)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nM
|
||||||
|
|
||||||
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
|
import TransactionTable, { TRANSACTION_CATEGORY_TARGET } from "./TransactionTable";
|
||||||
import type { Category, TransactionRow } from "../../shared/types";
|
import type { Category, TransactionRow } from "../../shared/types";
|
||||||
|
import type { SplitAccess } from "../../shared/splitGate";
|
||||||
|
|
||||||
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||||
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||||
|
|
@ -23,7 +24,12 @@ const GROCERIES: Category = {
|
||||||
created_at: "",
|
created_at: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const row = (id: number, description: string, category: Category | null = null): TransactionRow => ({
|
const row = (
|
||||||
|
id: number,
|
||||||
|
description: string,
|
||||||
|
category: Category | null = null,
|
||||||
|
isSplit = false,
|
||||||
|
): TransactionRow => ({
|
||||||
id,
|
id,
|
||||||
date: "2026-09-12",
|
date: "2026-09-12",
|
||||||
description,
|
description,
|
||||||
|
|
@ -34,13 +40,13 @@ const row = (id: number, description: string, category: Category | null = null):
|
||||||
source_name: "Exemple Desjardins",
|
source_name: "Exemple Desjardins",
|
||||||
notes: null,
|
notes: null,
|
||||||
is_manually_categorized: false,
|
is_manually_categorized: false,
|
||||||
is_split: false,
|
is_split: isSplit,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The guided entry: uncategorized rows only. */
|
/** The guided entry: uncategorized rows only. */
|
||||||
const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")];
|
const UNCATEGORIZED = [row(1, "IGA 123"), row(2, "METRO 456"), row(3, "SAQ 789")];
|
||||||
|
|
||||||
const markup = (rows: TransactionRow[], onboardingTarget?: boolean) =>
|
const markup = (rows: TransactionRow[], onboardingTarget?: boolean, splitAccess: SplitAccess = "open") =>
|
||||||
renderToStaticMarkup(
|
renderToStaticMarkup(
|
||||||
<TransactionTable
|
<TransactionTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|
@ -53,6 +59,7 @@ const markup = (rows: TransactionRow[], onboardingTarget?: boolean) =>
|
||||||
onLoadSplitChildren={vi.fn(async () => [])}
|
onLoadSplitChildren={vi.fn(async () => [])}
|
||||||
onSaveSplit={vi.fn(async () => undefined)}
|
onSaveSplit={vi.fn(async () => undefined)}
|
||||||
onDeleteSplit={vi.fn(async () => undefined)}
|
onDeleteSplit={vi.fn(async () => undefined)}
|
||||||
|
splitAccess={splitAccess}
|
||||||
onboardingTarget={onboardingTarget}
|
onboardingTarget={onboardingTarget}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
@ -100,3 +107,26 @@ describe("TransactionTable — getting-started target (#399)", () => {
|
||||||
expect(html).not.toContain("data-onboarding");
|
expect(html).not.toContain("data-onboarding");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TransactionTable — the Répartition button (#369)", () => {
|
||||||
|
/** A split row, then a plain one, both categorized (the button needs a category). */
|
||||||
|
const CATEGORIZED = [row(1, "COSTCO 123", GROCERIES, true), row(2, "IGA 456", GROCERIES)];
|
||||||
|
/** The rows' split buttons, in order (their title names them). */
|
||||||
|
const splitButtons = (html: string) =>
|
||||||
|
html.match(/<button type="button"[^>]*title="t\(transactions\.split(?:Adjustment|Locked\[[^"]*\])\)"[^>]*>.*?<\/button>/g) ?? [];
|
||||||
|
|
||||||
|
it("Free: every categorized row's button carries the lock, the split row keeps its orange", () => {
|
||||||
|
const buttons = splitButtons(markup(CATEGORIZED, undefined, "locked"));
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
expect(buttons.every((b) => b.includes("lucide-lock"))).toBe(true);
|
||||||
|
expect(buttons[0]).toContain("text-orange-500");
|
||||||
|
expect(buttons[1]).not.toContain("text-orange-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Base and Premium: no lock, the split row keeps its orange", () => {
|
||||||
|
const buttons = splitButtons(markup(CATEGORIZED));
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
expect(buttons.some((b) => b.includes("lucide-lock"))).toBe(false);
|
||||||
|
expect(buttons[0]).toContain("text-orange-500");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Fragment, useState, useMemo } from "react";
|
import { Fragment, useState, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split, Link2 } from "lucide-react";
|
import { ChevronUp, ChevronDown, MessageSquare, Tag, Link2 } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
TransactionRow,
|
TransactionRow,
|
||||||
TransactionSort,
|
TransactionSort,
|
||||||
|
|
@ -8,9 +8,12 @@ import type {
|
||||||
SplitChild,
|
SplitChild,
|
||||||
} from "../../shared/types";
|
} from "../../shared/types";
|
||||||
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
|
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
|
||||||
|
import type { SplitAccess } from "../../shared/splitGate";
|
||||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||||
|
import UpsellDialog from "../shared/UpsellDialog";
|
||||||
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
||||||
|
import SplitButton from "./SplitButton";
|
||||||
|
|
||||||
interface TransactionTableProps {
|
interface TransactionTableProps {
|
||||||
rows: TransactionRow[];
|
rows: TransactionRow[];
|
||||||
|
|
@ -23,6 +26,12 @@ interface TransactionTableProps {
|
||||||
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
|
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
|
||||||
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
|
||||||
onDeleteSplit: (parentId: number) => Promise<void>;
|
onDeleteSplit: (parentId: number) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* What the Répartition button does (#369): `splitAccessFor` over
|
||||||
|
* `useEntitlement("adjustments")`. Required on purpose: a default of "open"
|
||||||
|
* would let a new caller leave the split ungated without a word.
|
||||||
|
*/
|
||||||
|
splitAccess: SplitAccess;
|
||||||
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
|
onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void;
|
||||||
/**
|
/**
|
||||||
* Issue #142 — when supplied, a small Link2 icon appears next to the
|
* Issue #142 — when supplied, a small Link2 icon appears next to the
|
||||||
|
|
@ -69,6 +78,7 @@ export default function TransactionTable({
|
||||||
onLoadSplitChildren,
|
onLoadSplitChildren,
|
||||||
onSaveSplit,
|
onSaveSplit,
|
||||||
onDeleteSplit,
|
onDeleteSplit,
|
||||||
|
splitAccess,
|
||||||
onRowContextMenu,
|
onRowContextMenu,
|
||||||
linkedTransfersByTxId,
|
linkedTransfersByTxId,
|
||||||
onboardingTarget,
|
onboardingTarget,
|
||||||
|
|
@ -80,6 +90,7 @@ export default function TransactionTable({
|
||||||
const [keywordText, setKeywordText] = useState("");
|
const [keywordText, setKeywordText] = useState("");
|
||||||
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
|
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
|
||||||
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
|
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
|
||||||
|
const [showSplitUpsell, setShowSplitUpsell] = useState(false);
|
||||||
const noCategoryExtra = useMemo(
|
const noCategoryExtra = useMemo(
|
||||||
() => [{ value: "", label: t("transactions.table.noCategory") }],
|
() => [{ value: "", label: t("transactions.table.noCategory") }],
|
||||||
[t]
|
[t]
|
||||||
|
|
@ -235,17 +246,12 @@ export default function TransactionTable({
|
||||||
>
|
>
|
||||||
<Tag size={14} />
|
<Tag size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<SplitButton
|
||||||
onClick={() => setSplitRow(row)}
|
row={row}
|
||||||
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
|
access={splitAccess}
|
||||||
row.is_split
|
onOpenSplit={() => setSplitRow(row)}
|
||||||
? "text-orange-500"
|
onOpenUpsell={() => setShowSplitUpsell(true)}
|
||||||
: "text-[var(--muted-foreground)]"
|
/>
|
||||||
}`}
|
|
||||||
title={t("transactions.splitAdjustment")}
|
|
||||||
>
|
|
||||||
<Split size={14} />
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -326,7 +332,14 @@ export default function TransactionTable({
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{splitRow && (
|
{/*
|
||||||
|
The edition can change under an open window: the revocation check at
|
||||||
|
startup refreshes the license. The split window never stays open once
|
||||||
|
the edition resolves to Free, but a refresh ("pending") keeps it
|
||||||
|
mounted: `refresh()` goes back through "loading", and an `=== "open"`
|
||||||
|
guard would unmount the window and lose what was being typed.
|
||||||
|
*/}
|
||||||
|
{splitRow && splitAccess !== "locked" && (
|
||||||
<SplitAdjustmentModal
|
<SplitAdjustmentModal
|
||||||
transaction={splitRow}
|
transaction={splitRow}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
|
@ -336,6 +349,10 @@ export default function TransactionTable({
|
||||||
onClose={() => setSplitRow(null)}
|
onClose={() => setSplitRow(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Symmetric: the upsell goes away once the edition resolves to Base or Premium. */}
|
||||||
|
{showSplitUpsell && splitAccess !== "open" && (
|
||||||
|
<UpsellDialog feature="adjustments" onClose={() => setShowSplitUpsell(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
src/components/transactions/TransactionTable.wiring.test.ts
Normal file
133
src/components/transactions/TransactionTable.wiring.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// TransactionTable — what the Répartition button opens (#369).
|
||||||
|
//
|
||||||
|
// A static render never shows a window: they open on a click, through the
|
||||||
|
// table's own state. React's hooks come from the shared harness
|
||||||
|
// (src/test/hookHarness.ts, #409): the table runs as a plain function, its
|
||||||
|
// state survives from one call to the next as between two renders of a mounted
|
||||||
|
// component, and the test clicks through the props the table hands to each
|
||||||
|
// SplitButton, then renders again to read what opened.
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
|
||||||
|
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
||||||
|
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||||
|
|
||||||
|
import { hookHarness } from "../../test/hookHarness";
|
||||||
|
import TransactionTable from "./TransactionTable";
|
||||||
|
import SplitButton from "./SplitButton";
|
||||||
|
import SplitAdjustmentModal from "./SplitAdjustmentModal";
|
||||||
|
import UpsellDialog from "../shared/UpsellDialog";
|
||||||
|
import type { SplitAccess } from "../../shared/splitGate";
|
||||||
|
import type { Category, TransactionRow } from "../../shared/types";
|
||||||
|
|
||||||
|
const harness = hookHarness();
|
||||||
|
|
||||||
|
type Props = Record<string, unknown> & { children?: ReactNode };
|
||||||
|
|
||||||
|
function find(node: ReactNode, type: unknown): ReactElement<Props>[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, type));
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
const el = node as ReactElement<Props>;
|
||||||
|
return [...(el.type === type ? [el] : []), ...find(el.props.children, type)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROCERIES: Category = {
|
||||||
|
id: 7,
|
||||||
|
name: "Épicerie",
|
||||||
|
type: "expense",
|
||||||
|
is_active: true,
|
||||||
|
is_inputable: true,
|
||||||
|
sort_order: 1,
|
||||||
|
created_at: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const row = (id: number, isSplit: boolean): TransactionRow => ({
|
||||||
|
id,
|
||||||
|
date: "2026-09-12",
|
||||||
|
description: `TX ${id}`,
|
||||||
|
amount: -42.5,
|
||||||
|
category_id: GROCERIES.id,
|
||||||
|
category_name: GROCERIES.name,
|
||||||
|
category_color: null,
|
||||||
|
source_name: "Exemple Desjardins",
|
||||||
|
notes: null,
|
||||||
|
is_manually_categorized: true,
|
||||||
|
is_split: isSplit,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A split row and a plain one: the button must behave the same on both. */
|
||||||
|
const ROWS = [row(1, true), row(2, false)];
|
||||||
|
|
||||||
|
/** One render of the mounted table, under the given access. */
|
||||||
|
const render = (splitAccess: SplitAccess) =>
|
||||||
|
harness.render(() =>
|
||||||
|
TransactionTable({
|
||||||
|
rows: ROWS,
|
||||||
|
sort: { column: "date", direction: "desc" },
|
||||||
|
categories: [GROCERIES],
|
||||||
|
onSort: vi.fn(),
|
||||||
|
onCategoryChange: vi.fn(),
|
||||||
|
onNotesChange: vi.fn(),
|
||||||
|
onAddKeyword: vi.fn(async () => undefined),
|
||||||
|
onLoadSplitChildren: vi.fn(async () => []),
|
||||||
|
onSaveSplit: vi.fn(async () => undefined),
|
||||||
|
onDeleteSplit: vi.fn(async () => undefined),
|
||||||
|
splitAccess,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const splitButtons = (tree: ReactNode) => find(tree, SplitButton);
|
||||||
|
const upsells = (tree: ReactNode) => find(tree, UpsellDialog);
|
||||||
|
const splitWindows = (tree: ReactNode) => find(tree, SplitAdjustmentModal);
|
||||||
|
|
||||||
|
beforeEach(() => harness.clear());
|
||||||
|
|
||||||
|
describe("TransactionTable — the Répartition button's wiring (#369)", () => {
|
||||||
|
it("hands the table's access to every row's button, and opens nothing before a click", () => {
|
||||||
|
for (const access of ["open", "locked", "pending"] as const) {
|
||||||
|
harness.clear();
|
||||||
|
const tree = render(access);
|
||||||
|
const buttons = splitButtons(tree);
|
||||||
|
expect(buttons.map((b) => b.props.access)).toEqual([access, access]);
|
||||||
|
expect(buttons.map((b) => b.props.row)).toEqual(ROWS);
|
||||||
|
expect(upsells(tree)).toHaveLength(0);
|
||||||
|
expect(splitWindows(tree)).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Free: the click opens the upsell for 'adjustments', never the split window, on any row", () => {
|
||||||
|
for (const index of [0, 1]) {
|
||||||
|
harness.clear();
|
||||||
|
(splitButtons(render("locked"))[index].props.onOpenUpsell as () => void)();
|
||||||
|
const opened = render("locked");
|
||||||
|
const dialogs = upsells(opened);
|
||||||
|
expect(dialogs, `row ${index}`).toHaveLength(1);
|
||||||
|
expect(dialogs[0].props.feature).toBe("adjustments");
|
||||||
|
expect(splitWindows(opened)).toHaveLength(0);
|
||||||
|
// The dialog's close hands control back to the table.
|
||||||
|
(dialogs[0].props.onClose as () => void)();
|
||||||
|
expect(upsells(render("locked"))).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Base and Premium: the click opens the split window on its own row, no upsell", () => {
|
||||||
|
(splitButtons(render("open"))[1].props.onOpenSplit as () => void)();
|
||||||
|
const opened = render("open");
|
||||||
|
const windows = splitWindows(opened);
|
||||||
|
expect(windows).toHaveLength(1);
|
||||||
|
expect(windows[0].props.transaction).toBe(ROWS[1]);
|
||||||
|
expect(upsells(opened)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an open split window survives a license refresh and closes once the edition is Free", () => {
|
||||||
|
(splitButtons(render("open"))[0].props.onOpenSplit as () => void)();
|
||||||
|
expect(splitWindows(render("pending"))).toHaveLength(1);
|
||||||
|
expect(splitWindows(render("locked"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an open upsell survives a license refresh and goes away once the edition is Base", () => {
|
||||||
|
(splitButtons(render("locked"))[0].props.onOpenUpsell as () => void)();
|
||||||
|
expect(upsells(render("pending"))).toHaveLength(1);
|
||||||
|
expect(upsells(render("open"))).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -381,6 +381,7 @@
|
||||||
"keywordAdded": "Keyword added",
|
"keywordAdded": "Keyword added",
|
||||||
"keywordPlaceholder": "Keyword to match...",
|
"keywordPlaceholder": "Keyword to match...",
|
||||||
"splitAdjustment": "Split adjustment",
|
"splitAdjustment": "Split adjustment",
|
||||||
|
"splitLocked": "Split adjustment — {{tier}} feature",
|
||||||
"splitBase": "Base",
|
"splitBase": "Base",
|
||||||
"splitAdjusted": "Adjusted",
|
"splitAdjusted": "Adjusted",
|
||||||
"splitCategory": "Category",
|
"splitCategory": "Category",
|
||||||
|
|
@ -1122,7 +1123,7 @@
|
||||||
"Inline category assignment via dropdown",
|
"Inline category assignment via dropdown",
|
||||||
"Auto-categorize based on keyword rules",
|
"Auto-categorize based on keyword rules",
|
||||||
"Add keywords directly from a transaction",
|
"Add keywords directly from a transaction",
|
||||||
"Split a transaction across multiple categories",
|
"Split a transaction across multiple categories (Base edition and above)",
|
||||||
"Transaction notes"
|
"Transaction notes"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
|
@ -1131,13 +1132,13 @@
|
||||||
"To categorize a transaction, click its category dropdown and select a category",
|
"To categorize a transaction, click its category dropdown and select a category",
|
||||||
"To auto-categorize all uncategorized transactions, click the Auto-categorize button",
|
"To auto-categorize all uncategorized transactions, click the Auto-categorize button",
|
||||||
"To add a keyword rule from a transaction, right-click its row, then “Add as keyword”; on a transaction that already has a category, the tag icon also adds a keyword to that category",
|
"To add a keyword rule from a transaction, right-click its row, then “Add as keyword”; on a transaction that already has a category, the tag icon also adds a keyword to that category",
|
||||||
"To split a transaction across categories, use the Split button and add amounts per category"
|
"To split a transaction across categories, use the Split button and add amounts per category (Base edition and above; on the Free edition the button shows a lock and opens the unlock screen)"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Use the quick period buttons (This month, Last month, etc.) for fast date filtering",
|
"Use the quick period buttons (This month, Last month, etc.) for fast date filtering",
|
||||||
"Auto-categorize only affects uncategorized transactions — it won't overwrite manual assignments",
|
"Auto-categorize only affects uncategorized transactions — it won't overwrite manual assignments",
|
||||||
"Adding a keyword from a transaction pre-fills the category so you can quickly build rules",
|
"Adding a keyword from a transaction pre-fills the category so you can quickly build rules",
|
||||||
"Split transactions display a visual indicator and show the breakdown details"
|
"Split transactions display a visual indicator whatever the edition; the breakdown opens from the Split button (Base edition and above)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|
@ -1331,13 +1332,13 @@
|
||||||
"title": "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.",
|
"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": [
|
"features": [
|
||||||
"Free — Dashboard, CSV Import, Transactions, Categories, the Trends report (and the Reports hub), encrypted export/import and the changelog, with a single profile",
|
"Free — Dashboard, CSV Import, Transactions (without splits), 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",
|
"Base — everything in Free, plus Budget, Adjustments, transaction splits, 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)",
|
"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"
|
"Modules above your edition stay visible but locked: a lock badge shows in the sidebar, on the report tiles and on the Split button of the Transactions page — opening one shows the unlock screen"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Look for the lock badge in the sidebar or on the Reports hub tiles: it marks the modules above your edition",
|
"Look for the lock badge in the sidebar, on the Reports hub tiles or on the Split button of the Transactions page: it marks what is above your edition",
|
||||||
"Click a locked module to see which edition it requires",
|
"Click a locked module to see which edition it requires",
|
||||||
"To buy the Base edition, click \"Get Base\": the product page opens in your browser (Premium isn't sold online yet)",
|
"To buy the Base edition, click \"Get Base\": the product page opens in your browser (Premium isn't sold online yet)",
|
||||||
"Once you have a license key, click \"I already have a key\" (or go to Settings → Users) and enter it",
|
"Once you have a license key, click \"I already have a key\" (or go to Settings → Users) and enter it",
|
||||||
|
|
@ -1346,6 +1347,7 @@
|
||||||
"tips": [
|
"tips": [
|
||||||
"Locking is never destructive: if your edition goes down (expired, invalid, revoked or removed key), the data of locked modules — budgets, adjustments, balance snapshots, profiles — is fully kept and reappears as soon as a valid key is entered",
|
"Locking is never destructive: if your edition goes down (expired, invalid, revoked or removed key), 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",
|
"On the Free edition your active profile always stays accessible — only creating an extra profile and switching to another profile are locked",
|
||||||
|
"On the Free edition, splits made earlier stay in your data: they are still counted in the reports and the dashboard, and still flagged in the transaction list — only opening and editing a split are locked",
|
||||||
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer",
|
"The license key applies to the whole machine, not to one profile: it unlocks the modules for every profile on this computer",
|
||||||
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
|
"On the Free edition automatic updates are locked: the Updates card (Settings → System) offers to download the latest version from the product page, and a manual install stays free",
|
||||||
"The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"",
|
"The License card (Settings → Users) shows your edition and when the key expires; a key valid for more than 50 years shows \"No expiry date\"",
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,7 @@
|
||||||
"keywordAdded": "Mot-clé ajouté",
|
"keywordAdded": "Mot-clé ajouté",
|
||||||
"keywordPlaceholder": "Mot-clé à rechercher...",
|
"keywordPlaceholder": "Mot-clé à rechercher...",
|
||||||
"splitAdjustment": "Répartition",
|
"splitAdjustment": "Répartition",
|
||||||
|
"splitLocked": "Répartition — fonctionnalité {{tier}}",
|
||||||
"splitBase": "Base",
|
"splitBase": "Base",
|
||||||
"splitAdjusted": "Ajusté",
|
"splitAdjusted": "Ajusté",
|
||||||
"splitCategory": "Catégorie",
|
"splitCategory": "Catégorie",
|
||||||
|
|
@ -1122,7 +1123,7 @@
|
||||||
"Assignation de catégorie en ligne via menu déroulant",
|
"Assignation de catégorie en ligne via menu déroulant",
|
||||||
"Auto-catégorisation basée sur les règles de mots-clés",
|
"Auto-catégorisation basée sur les règles de mots-clés",
|
||||||
"Ajout de mots-clés directement depuis une transaction",
|
"Ajout de mots-clés directement depuis une transaction",
|
||||||
"Répartition (split) d'une transaction sur plusieurs catégories",
|
"Répartition (split) d'une transaction sur plusieurs catégories (édition Base et plus)",
|
||||||
"Notes sur les transactions"
|
"Notes sur les transactions"
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
|
|
@ -1131,13 +1132,13 @@
|
||||||
"Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie",
|
"Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie",
|
||||||
"Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser",
|
"Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser",
|
||||||
"Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie",
|
"Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie",
|
||||||
"Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie"
|
"Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie (édition Base et plus ; en édition Gratuite, le bouton porte un cadenas et ouvre l'écran de déverrouillage)"
|
||||||
],
|
],
|
||||||
"tips": [
|
"tips": [
|
||||||
"Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date",
|
"Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date",
|
||||||
"L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles",
|
"L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles",
|
||||||
"Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles",
|
"Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles",
|
||||||
"Les transactions réparties affichent un indicateur visuel et le détail de la répartition"
|
"Les transactions réparties affichent un indicateur visuel, quelle que soit l'édition ; le détail de la répartition s'ouvre par le bouton Répartition (édition Base et plus)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|
@ -1331,13 +1332,13 @@
|
||||||
"title": "Éditions",
|
"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.",
|
"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": [
|
"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",
|
"Gratuite — Tableau de bord, Import CSV, Transactions (sans la Répartition), 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",
|
"Base — tout de la Gratuite, plus Budget, Ajustements, la Répartition des transactions, 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é)",
|
"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"
|
"Les modules au-dessus de votre édition restent visibles mais verrouillés : cadenas dans la barre latérale, sur les tuiles de rapports et sur le bouton Répartition de la page Transactions — les ouvrir affiche l'écran de déverrouillage"
|
||||||
],
|
],
|
||||||
"steps": [
|
"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",
|
"Repérez le cadenas dans la barre latérale, sur les tuiles du hub Rapports ou sur le bouton Répartition de la page Transactions : il marque ce qui est au-dessus de votre édition",
|
||||||
"Cliquez sur un module verrouillé pour voir l'édition requise",
|
"Cliquez sur un module verrouillé pour voir l'édition requise",
|
||||||
"Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)",
|
"Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)",
|
||||||
"Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la",
|
"Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la",
|
||||||
|
|
@ -1346,6 +1347,7 @@
|
||||||
"tips": [
|
"tips": [
|
||||||
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), 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",
|
"Le verrouillage n'est jamais destructif : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), 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",
|
"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",
|
||||||
|
"En édition Gratuite, les répartitions déjà faites restent dans vos données : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste des transactions — seuls l'ouverture et la modification d'une répartition sont verrouillées",
|
||||||
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste",
|
"La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste",
|
||||||
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
|
"En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite",
|
||||||
"La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »",
|
"La carte Licence (Paramètres → Utilisateurs) affiche votre édition et l'échéance de la clé ; une clé valide pour plus de 50 ans indique « Sans échéance »",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,18 @@ const harness = vi.hoisted(() => ({
|
||||||
rows: [] as TransactionRow[],
|
rows: [] as TransactionRow[],
|
||||||
/** Filters a later dispatch would have set (the fallback, a filter picked by hand). */
|
/** Filters a later dispatch would have set (the fallback, a filter picked by hand). */
|
||||||
filters: null as Partial<TransactionFilters> | null,
|
filters: null as Partial<TransactionFilters> | null,
|
||||||
|
/** The license gate the page reads (#369), and the features it asked for. */
|
||||||
|
entitlement: { allowed: true, ready: true },
|
||||||
|
entitlementCalls: [] as string[],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
vi.mock("../contexts/OnboardingContext", () => ({ useOnboarding: () => harness.onboarding }));
|
||||||
|
vi.mock("../hooks/useEntitlement", () => ({
|
||||||
|
useEntitlement: (feature: string) => {
|
||||||
|
harness.entitlementCalls.push(feature);
|
||||||
|
return harness.entitlement;
|
||||||
|
},
|
||||||
|
}));
|
||||||
vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
vi.mock("../hooks/useTransactions", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../hooks/useTransactions")>();
|
const actual = await importOriginal<typeof import("../hooks/useTransactions")>();
|
||||||
return {
|
return {
|
||||||
|
|
@ -131,6 +140,8 @@ beforeEach(() => {
|
||||||
harness.onboarding = path(IMPORTED);
|
harness.onboarding = path(IMPORTED);
|
||||||
harness.rows = [];
|
harness.rows = [];
|
||||||
harness.filters = null;
|
harness.filters = null;
|
||||||
|
harness.entitlement = { allowed: true, ready: true };
|
||||||
|
harness.entitlementCalls = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TransactionsPage — the guided entry (#399)", () => {
|
describe("TransactionsPage — the guided entry (#399)", () => {
|
||||||
|
|
@ -235,3 +246,62 @@ describe("TransactionsPage — the step card (#399)", () => {
|
||||||
expect(html).not.toContain('href="/budget"');
|
expect(html).not.toContain('href="/budget"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TransactionsPage — the Répartition button follows the edition (#369)", () => {
|
||||||
|
/** Categorized rows, one of them split: the button shows on categorized rows only. */
|
||||||
|
const categorized = (id: number, description: string, isSplit = false): TransactionRow => ({
|
||||||
|
...row(id, description),
|
||||||
|
category_id: 7,
|
||||||
|
category_name: "Épicerie",
|
||||||
|
is_split: isSplit,
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* The split buttons only, named by their title: the pagination always renders
|
||||||
|
* a disabled button of its own, so the page's markup as a whole says nothing.
|
||||||
|
*/
|
||||||
|
const splitButtons = (html: string) =>
|
||||||
|
html.match(/<button type="button"[^>]*title="t\(transactions\.split(?:Adjustment|Locked\[[^"]*\])\)"[^>]*>.*?<\/button>/g) ?? [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
harness.rows = [categorized(1, "COSTCO 123", true), categorized(2, "IGA 456")];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the license for 'adjustments'", () => {
|
||||||
|
render();
|
||||||
|
expect(harness.entitlementCalls).toContain("adjustments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Free, license ready: every button carries the lock and names the edition", () => {
|
||||||
|
harness.entitlement = { allowed: false, ready: true };
|
||||||
|
const buttons = splitButtons(render());
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) {
|
||||||
|
expect(button).toContain("lucide-lock");
|
||||||
|
expect(button).toContain("t(transactions.splitLocked[tier=t(license.editions.base)])");
|
||||||
|
expect(button).not.toContain('disabled=""');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Base, license ready: no lock, the buttons work as before", () => {
|
||||||
|
const buttons = splitButtons(render());
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) {
|
||||||
|
expect(button).not.toContain("lucide-lock");
|
||||||
|
expect(button).toContain('title="t(transactions.splitAdjustment)"');
|
||||||
|
expect(button).not.toContain('disabled=""');
|
||||||
|
}
|
||||||
|
expect(buttons[0]).toContain("text-orange-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("license not ready yet: no lock, the buttons wait", () => {
|
||||||
|
for (const allowed of [false, true]) {
|
||||||
|
harness.entitlement = { allowed, ready: false };
|
||||||
|
const buttons = splitButtons(render());
|
||||||
|
expect(buttons).toHaveLength(2);
|
||||||
|
for (const button of buttons) {
|
||||||
|
expect(button).not.toContain("lucide-lock");
|
||||||
|
expect(button).toContain('disabled=""');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import EmptyState from "../components/shared/EmptyState";
|
import EmptyState from "../components/shared/EmptyState";
|
||||||
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
||||||
import { useOnboarding } from "../contexts/OnboardingContext";
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
||||||
|
import { useEntitlement } from "../hooks/useEntitlement";
|
||||||
|
import { splitAccessFor } from "../shared/splitGate";
|
||||||
import {
|
import {
|
||||||
readOnboardingNavState,
|
readOnboardingNavState,
|
||||||
showsStepCard,
|
showsStepCard,
|
||||||
|
|
@ -40,6 +42,9 @@ export default function TransactionsPage() {
|
||||||
readOnboardingNavState(location.state).guided ? GUIDED_ENTRY_FILTERS : undefined
|
readOnboardingNavState(location.state).guided ? GUIDED_ENTRY_FILTERS : undefined
|
||||||
);
|
);
|
||||||
const onboarding = useOnboarding();
|
const onboarding = useOnboarding();
|
||||||
|
// The Répartition button belongs to the Base edition (#369): locked with the
|
||||||
|
// upsell on Free, inert while the license is not ready. The page itself stays Free.
|
||||||
|
const splitGate = useEntitlement("adjustments");
|
||||||
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
const [resultMessage, setResultMessage] = useState<string | null>(null);
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
|
||||||
const [pending, setPending] = useState<TransactionRow | null>(null);
|
const [pending, setPending] = useState<TransactionRow | null>(null);
|
||||||
|
|
@ -156,6 +161,7 @@ export default function TransactionsPage() {
|
||||||
onLoadSplitChildren={loadSplitChildren}
|
onLoadSplitChildren={loadSplitChildren}
|
||||||
onSaveSplit={saveSplit}
|
onSaveSplit={saveSplit}
|
||||||
onDeleteSplit={deleteSplit}
|
onDeleteSplit={deleteSplit}
|
||||||
|
splitAccess={splitAccessFor(splitGate)}
|
||||||
onRowContextMenu={handleRowContextMenu}
|
onRowContextMenu={handleRowContextMenu}
|
||||||
linkedTransfersByTxId={linkedTransfersByTxId}
|
linkedTransfersByTxId={linkedTransfersByTxId}
|
||||||
onboardingTarget={target === "firstRowCategory"}
|
onboardingTarget={target === "firstRowCategory"}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ import type { Edition } from "../services/licenseService";
|
||||||
* server only).
|
* server only).
|
||||||
*
|
*
|
||||||
* Modules that stay Free (dashboard, import, transactions, categories,
|
* Modules that stay Free (dashboard, import, transactions, categories,
|
||||||
* reports/trends, export, changelog, docs) have NO key here → never gated.
|
* reports/trends, export, changelog, docs) have NO key here → never gated as
|
||||||
|
* pages. One action inside a Free page is gated: the Répartition (split) button
|
||||||
|
* of the Transactions page belongs to `adjustments` (#369, splitGate.ts).
|
||||||
*/
|
*/
|
||||||
export type FeatureKey =
|
export type FeatureKey =
|
||||||
| "budget"
|
| "budget"
|
||||||
|
|
|
||||||
30
src/shared/splitGate.test.ts
Normal file
30
src/shared/splitGate.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { splitAccessFor } from "./splitGate";
|
||||||
|
import { isEntitled } from "./entitlements";
|
||||||
|
|
||||||
|
// The gate as `useEntitlement("adjustments")` builds it, for each edition.
|
||||||
|
const gateFor = (edition: "free" | "base" | "premium", ready = true) => ({
|
||||||
|
allowed: isEntitled("adjustments", edition, []),
|
||||||
|
ready,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitAccessFor (#369)", () => {
|
||||||
|
it("locks the split on the Free edition once the license is ready", () => {
|
||||||
|
expect(splitAccessFor(gateFor("free"))).toBe("locked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the split on Base and Premium", () => {
|
||||||
|
expect(splitAccessFor(gateFor("base"))).toBe("open");
|
||||||
|
expect(splitAccessFor(gateFor("premium"))).toBe("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never locks before the license is ready (anti-flash)", () => {
|
||||||
|
expect(splitAccessFor({ allowed: false, ready: false })).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlike isProfileSwitchLocked, `allowed` does not short-circuit `ready`:
|
||||||
|
// `ready` is false while loading AND on error, and `allowed` is fail-closed.
|
||||||
|
it("stays pending while not ready even when allowed (stricter than profileGate)", () => {
|
||||||
|
expect(splitAccessFor({ allowed: true, ready: false })).toBe("pending");
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/shared/splitGate.ts
Normal file
30
src/shared/splitGate.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Pure predicate for the transaction split gate (Base+), issue #369.
|
||||||
|
*
|
||||||
|
* Splitting a transaction across categories (the Répartition button of the
|
||||||
|
* Transactions page) belongs to the `adjustments` feature, as the upsell copy
|
||||||
|
* already promised (`upsell.features.adjustments`).
|
||||||
|
*
|
||||||
|
* NON-destructive by construction: this only decides what the button does.
|
||||||
|
* Existing splits stay in `transactions` (`parent_transaction_id`,
|
||||||
|
* `is_split`), stay counted by every report, and keep their orange indicator
|
||||||
|
* whatever the edition (ADR 0017: the gate blocks access, never data).
|
||||||
|
*
|
||||||
|
* Anti-flash rule, deliberately stricter than `isProfileSwitchLocked`: while
|
||||||
|
* the license is not `ready` the access is "pending" even when `allowed` is
|
||||||
|
* true. `ready` is false both while the license loads and when it errored (the
|
||||||
|
* provider's retry loop), and `refresh()` goes back through "loading": the
|
||||||
|
* button then does nothing and shows no lock, like RequireFeature's neutral
|
||||||
|
* loader. A Base user's button is therefore disabled for the length of a
|
||||||
|
* license refresh — never locked, never open on a fail-closed `allowed`.
|
||||||
|
*/
|
||||||
|
import type { EntitlementGate } from "./profileGate";
|
||||||
|
|
||||||
|
/** What the split button does: open the split window, show the upsell, or nothing yet. */
|
||||||
|
export type SplitAccess = "open" | "locked" | "pending";
|
||||||
|
|
||||||
|
/** The split button's access, from the gate `useEntitlement("adjustments")` returns. */
|
||||||
|
export function splitAccessFor(gate: EntitlementGate): SplitAccess {
|
||||||
|
if (!gate.ready) return "pending";
|
||||||
|
return gate.allowed ? "open" : "locked";
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue