# Spec — Monétisation Simpl'Résultat

> Date: 2026-04-08
> Projet: simpl-resultat
> Statut: En cours (Phase 1 complétée)
> Dépendances: spec-simpl-resultat-web.md (version web/SaaS), Logto IdP (Compte Maximus)

## Contexte

Simpl'Résultat est une app desktop Tauri v2 open-source (GPL-3.0) pour la gestion des finances personnelles, actuellement gratuite. Le moment de monétiser approche. L'objectif est de mettre en place l'infrastructure complète pour vendre le logiciel desktop (achat unique) et offrir une version web SaaS (abonnement mensuel), tout en préservant le principe privacy-first.

**Modèle retenu : Open Core + Hybride**
- Le code desktop reste GPL-3.0 sur Forgejo (open core)
- Le code serveur (API licence, paiement, web/SaaS) est propriétaire
- Achat unique pour l'édition Base desktop
- Abonnement mensuel pour l'édition Premium (version web + sync + features avancées)

## Objectif

Implémenter les 3 piliers de la monétisation : (1) un système de licence offline pour l'app desktop, (2) l'intégration du Compte Maximus optionnel pour les features premium, et (3) l'infrastructure de paiement (achat unique + abonnement). La version web/SaaS est couverte par le spec existant `spec-simpl-resultat-web.md`.

## Scope

### IN
- Système de clé de licence offline (Ed25519, vérification côté Rust)
- API serveur de licences (génération, validation, révocation)
- Connexion optionnelle au Compte Maximus (Logto) dans l'app desktop
- Page d'achat sur lacompagniemaximus.com (processeur de paiement)
- Gestion des abonnements (Stripe Billing ou alternative)
- UI licence et compte dans les paramètres de l'app desktop
- Webhook paiement → génération de licence automatique
- Gestion TPS/TVQ pour les ventes au Canada

### OUT (explicitement exclu)
- Version web/SaaS (couvert par `spec-simpl-resultat-web.md`)
- Sync desktop ↔ web (couvert par `spec-simpl-resultat-web.md`, Issue 5)
- DRM agressif ou protection anti-compilation (incompatible GPL)
- App mobile
- Programme de revente / affiliés
- Facturation papier / comptabilité (usage externe, ex: Wave/QuickBooks)

## Design

### Modèle de tarification

| Tier | Prix | Contenu | Licence |
|------|------|---------|---------|
| **Gratuit** | 0$ | App desktop complète, sans clé, mises à jour manuelles uniquement | Aucune |
| **Base** | ~29-49$ CAD (unique) | App desktop + mises à jour automatiques + support email | Clé offline Ed25519 |
| **Premium** | ~7-12$ CAD/mois | Base + version web + sync desktop↔web + features avancées futures | Compte Maximus (abonnement actif) |

> **Note :** Les prix exacts seront déterminés avant le lancement. Les fourchettes ci-dessus sont des recommandations basées sur le marché des apps de finances personnelles.

### Architecture de licence

#### Clé de licence offline (Édition Base)

Format : JWT signé Ed25519, encodé Base64, vérifiable sans serveur.

```
SR-BASE-<base64url(JWT)>
```

**Payload JWT :**
```json
{
  "sub": "user@email.com",
  "iss": "lacompagniemaximus.com",
  "iat": 1712534400,
  "edition": "base",
  "features": ["auto-update"],
  "machine_limit": 3
}
```

> **🔴 SECURITE** — JWT sans claim `exp` : une licence signée est valide à jamais, même après révocation serveur, car la vérification offline ne peut pas checker le statut de révocation.
> **Résolution :** Ajouter un claim `exp` obligatoire (ex: 2 ans). L'app doit demander une re-validation en ligne avant expiration pour obtenir une clé rafraîchie.
> *Ref : CWE-613 (Insufficient Session Expiration)*

**Flux de validation :**
1. L'utilisateur entre sa clé dans Paramètres > Licence
2. Le backend Rust décode le JWT et vérifie la signature Ed25519 avec la clé publique embarquée
3. Si valide : stocke la clé dans un fichier `license.key` dans le répertoire app data
4. L'app vérifie la clé au démarrage (lecture locale, aucun appel réseau)
5. Features débloquées : mises à jour automatiques (l'updater vérifie la présence d'une licence valide)

**Sécurité :**
- La clé privée Ed25519 réside UNIQUEMENT sur le serveur de licences (VPS)
- La clé publique est embarquée dans le binaire Rust (hardcoded)
- Opérations crypto dans Rust uniquement (jamais dans le WebView)
- `machine_limit` vérifié lors de l'activation en ligne (premier lancement)

> **🔴 SECURITE** — `license.key` en clair est copiable entre machines, contournant `machine_limit`. La vérification offline ne peut pas détecter la copie car il n'y a pas de binding machine dans la signature JWT.
> **Résolution :** Stocker un token d'activation séparé (signé par le serveur avec le `machine_id` inclus). Vérifier à la fois le JWT licence + le token d'activation au démarrage.

> **🔴 TECHNIQUE** — Le spec propose `ed25519-dalek` mais c'est `jsonwebtoken` (avec feature EdDSA) qui décode les JWT. `ed25519-dalek` seul ne gère pas les headers/claims JWT.
> **Résolution :** Clarifier dans l'Issue 1 que `jsonwebtoken` est la dépendance primaire pour la validation JWT. `ed25519-dalek` peut ne pas être nécessaire si `jsonwebtoken` supporte EdDSA nativement.

#### Compte Maximus (Édition Premium)

L'intégration Logto dans l'app desktop utilise le flow OAuth2 PKCE :

1. L'utilisateur clique "Se connecter" dans Paramètres
2. Tauri ouvre le navigateur système vers Logto (OAuth2 Authorization Code + PKCE)
3. Après auth, callback vers `simpl-resultat://auth/callback`
4. L'app échange le code pour un access token + refresh token
5. Les tokens sont stockés de façon sécurisée (fichier chiffré dans app data)

> **🔴 SECURITE** — Le chiffrement des tokens avec une clé dérivée du machine ID est faible. Les machine IDs sont à faible entropie, déterministes et lisibles publiquement (ex: `/etc/machine-id` sur Linux). Équivaut à chiffrer avec une clé connue.
> **Résolution :** Utiliser le stockage natif de l'OS (keyring/credential manager via le crate `keyring` ou `tauri-plugin-store` avec OS keychain). Si basé fichier, dériver la clé du machine ID + un secret aléatoire par installation stocké séparément.
> *Ref : CWE-321 (Hard Coded Cryptographic Key)*

> **🟡 TECHNIQUE** — Le flow OAuth2 PKCE via `simpl-resultat://auth/callback` nécessite une config plateforme spécifique (registre Windows, etc.) non documentée dans le spec. `tauri-plugin-deep-link` v2 requiert des entrées explicites dans `tauri.conf.json` et les manifestes plateforme.
> **Résolution :** Ajouter une sous-tâche à l'Issue 6 pour la registration deep-link par plateforme. Référencer la doc `tauri-plugin-deep-link` v2.

6. L'access token JWT contient les claims : `{ "apps": {"simpl-resultat": "premium"}, "subscription_status": "active" }`
7. Vérification périodique du statut d'abonnement (1x/jour, graceful si offline)

**Dégradation gracieuse :**
- Si le token expire et le refresh échoue → affiche un avertissement mais ne bloque pas l'usage Base
- Si l'abonnement expire → l'app repasse en mode Base (clé offline toujours valide)
- Grace period de 7 jours après expiration de l'abonnement

### Comparaison des processeurs de paiement

| Critère | Stripe | Square | Paddle | LemonSqueezy | FastSpring |
|---------|--------|--------|--------|--------------|------------|
| **Frais** | 2.9% + 0.30$ | 2.9% + 0.30$ | 5% + 0.50$ | 5% | ~8.9% |
| **Merchant of Record** | Non (tu gères les taxes) | Non | Oui | Oui | Oui |
| **Gestion TPS/TVQ** | Via Stripe Tax (add-on) | Manuel | Inclus | Inclus | Inclus |
| **Abonnements** | Stripe Billing (excellent) | Square Subscriptions | Natif | Natif | Natif |
| **Clés de licence** | Non (API custom) | Non | Non | Intégré | Intégré |
| **API/Webhooks** | Excellent | Bon | Bon | Bon | Bon |
| **Desktop software** | Bon (généraliste) | Faible (focus commerce) | Bon | Bon | Excellent (spécialisé) |
| **Canada** | Oui (HQ Montréal inaccessible mais opère au Canada) | Oui | Oui | Oui (Stripe subsidiary) | Oui |
| **Paiement CAD** | Oui | Oui | Oui | Oui | Oui |

**Recommandation : Stripe** pour les raisons suivantes :
- Frais les plus bas (2.9% + 0.30$)
- API la plus robuste et documentée
- Stripe Billing pour les abonnements récurrents
- Stripe Tax pour la collecte TPS/TVQ automatique (0.5% add-on)
- Stripe Checkout pour une page de paiement hébergée (conforme PCI sans effort)
- Webhooks fiables pour automatiser la génération de licences
- LemonSqueezy est maintenant une filiale Stripe — si tu veux le MoR plus tard, migration facile

**Alternative viable : Paddle/LemonSqueezy** si tu veux zéro gestion fiscale (MoR). Plus cher (~5%) mais ils gèrent TPS/TVQ/TVA mondialement. Recommandé si tu vends internationalement dès le départ.

**Square : Non recommandé** pour la vente de logiciel. Orienté commerce de détail/point de vente physique. API d'abonnement moins mature que Stripe. Pas de Merchant of Record. Pas d'intégration licence.

### UX / Interface

#### Paramètres > Licence (nouveau card)

```
┌─────────────────────────────────────────────┐
│ 🔑 Licence                                  │
│                                              │
│ Édition : Gratuite                           │
│                                              │
│ [Entrer une clé de licence]                  │
│                                              │
│ ─────────────────────────────────────────── │
│                                              │
│ Acheter Simpl'Résultat Base — 39$ CAD        │
│ → Ouvre le navigateur vers la page d'achat   │
│                                              │
│ Découvrir Premium — 9$/mois                  │
│ → Ouvre le navigateur vers la page Premium   │
└─────────────────────────────────────────────┘
```

#### Paramètres > Compte Maximus (nouveau card, sous Licence)

```
┌─────────────────────────────────────────────┐
│ 👤 Compte Maximus                  Optionnel │
│                                              │
│ Non connecté                                 │
│                                              │
│ [Se connecter]                               │
│                                              │
│ Le compte est requis uniquement pour les     │
│ fonctionnalités Premium (version web, sync). │
└─────────────────────────────────────────────┘
```

#### État connecté + Premium actif :

```
┌─────────────────────────────────────────────┐
│ 🔑 Licence                                  │
│                                              │
│ Édition : Premium ✓                          │
│ Abonnement actif jusqu'au 2026-05-08        │
│                                              │
│ [Gérer mon abonnement]                       │
│ → Ouvre Stripe Customer Portal               │
├─────────────────────────────────────────────┤
│ 👤 Compte Maximus                            │
│                                              │
│ Connecté : max@example.com                   │
│ Dernière vérification : il y a 2 heures     │
│                                              │
│ [Se déconnecter]                             │
└─────────────────────────────────────────────┘
```

### Données

#### Fichiers locaux (app data directory)

```
simpl-resultat/
├── profiles.json          # Existant
├── license.key            # NOUVEAU — clé de licence Base (JWT signé)
├── auth/                  # NOUVEAU
│   ├── tokens.enc         # Access + refresh tokens chiffrés (AES-256-GCM, clé dérivée du machine ID)
│   └── account.json       # Métadonnées du compte (email, edition, subscription_status)
└── profile_*.db           # Existant — bases SQLite par profil
```

> Aucune modification au schéma SQLite existant. La licence et l'auth sont stockées hors des bases de profils.

#### API Serveur de licences (propriétaire, sur VPS)

Nouveau microservice déployé sur le VPS (Coolify), base URL : `https://api.lacompagniemaximus.com/licenses`

| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/licenses/generate` | POST | Webhook Stripe → génère une clé signée Ed25519 |
| `/licenses/activate` | POST | Activation (vérifie machine_limit, enregistre machine_id) |
| `/licenses/verify` | POST | Vérification en ligne (optionnelle, pour refresh status) |
| `/licenses/deactivate` | POST | Désactive une machine (libère un slot) |
| `/licenses/revoke` | POST | Révoque une licence (admin) |
| `/subscriptions/status` | GET | Statut d'abonnement (pour l'app desktop, auth JWT) |
| `/subscriptions/webhook` | POST | Webhook Stripe Billing (subscription events) |

> **🟡 SECURITE** — Aucune authentification ou rate limiting décrit sur les endpoints `/licenses/activate` et `/licenses/verify`. Un attaquant pourrait énumérer des clés valides ou brute-forcer les activations.
> **Résolution :** Requérir le JWT licence comme bearer token pour activation/verify. Ajouter rate limiting (ex: 5 req/min par IP). Utiliser des request bodies signés HMAC pour les webhooks.
> *Ref : OWASP API4:2023 (Unrestricted Resource Consumption)*

> **🟡 SECURITE** — Le spec mentionne les idempotency keys mais ne spécifie pas la vérification de signature des webhooks Stripe (`Stripe-Signature` header). Sans cela, n'importe qui peut forger des appels webhook pour générer des licences gratuites.
> **Résolution :** Exiger explicitement la vérification de signature Stripe webhook via le webhook signing secret dans les handlers `/licenses/generate` et `/subscriptions/webhook`.
> *Ref : CWE-345 (Insufficient Verification of Data Authenticity)*

### Architecture

```
┌──────────────────────────────────────────────────────────────────┐
│                        DESKTOP (Tauri v2)                        │
│                                                                  │
│  React UI                          Rust Backend                  │
│  ┌──────────────┐                  ┌───────────────────┐        │
│  │ LicenseCard  │◄────────────────►│ license_commands   │        │
│  │ AccountCard  │                  │ - validate_key()   │        │
│  │ SettingsPage │                  │ - read_license()   │        │
│  └──────────────┘                  │ - store_license()  │        │
│                                    │ - get_edition()    │        │
│                                    └───────────────────┘        │
│                                    ┌───────────────────┐        │
│                                    │ auth_commands      │        │
│                                    │ - start_oauth()    │        │
│                                    │ - handle_callback()│        │
│                                    │ - refresh_token()  │        │
│                                    │ - get_account()    │        │
│                                    │ - logout()         │        │
│                                    └───────────────────┘        │
│                                             │                    │
└─────────────────────────────────────────────┼────────────────────┘
                                              │ HTTPS (optionnel)
                                              ▼
┌──────────────────────────────────────────────────────────────────┐
│                    SERVEUR (VPS, propriétaire)                    │
│                                                                  │
│  ┌─────────────┐  ┌──────────────┐  ┌──────────────────┐       │
│  │ Logto (IdP) │  │ License API  │  │ Stripe Webhooks  │       │
│  │ OAuth2/OIDC │  │ Ed25519 sign │  │ payment events   │       │
│  └──────┬──────┘  └──────┬───────┘  └────────┬─────────┘       │
│         │                │                     │                  │
│         └────────────────┼─────────────────────┘                 │
│                          ▼                                        │
│                   ┌──────────────┐                                │
│                   │  PostgreSQL  │                                │
│                   │  licenses    │                                │
│                   │  activations │                                │
│                   │  subscriptions│                               │
│                   └──────────────┘                                │
│                                                                  │
│  ┌───────────────────────────────────────┐                       │
│  │ lacompagniemaximus.com (page d'achat) │                       │
│  │ Stripe Checkout / Customer Portal     │                       │
│  └───────────────────────────────────────┘                       │
└──────────────────────────────────────────────────────────────────┘
```

### Schéma PostgreSQL (serveur de licences)

```sql
-- Table des licences
CREATE TABLE licenses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_email TEXT NOT NULL,
    edition TEXT NOT NULL DEFAULT 'base',  -- 'base' | 'premium'
    license_key TEXT NOT NULL UNIQUE,       -- JWT signé complet
    stripe_payment_id TEXT,                 -- Référence Stripe (payment_intent ou subscription)
    machine_limit INTEGER NOT NULL DEFAULT 3,
    is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at TIMESTAMPTZ                  -- NULL = perpétuel (Base), date pour Premium
);

-- Activations (machines liées à une licence)
CREATE TABLE license_activations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
    machine_id TEXT NOT NULL,               -- Hash unique de la machine
    machine_name TEXT,                      -- Nom lisible (ex: "Max-ThinkPad")
    activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    UNIQUE(license_id, machine_id)
);

-- Abonnements (lié au Compte Maximus)
CREATE TABLE subscriptions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,                  -- FK vers Logto user
    stripe_subscription_id TEXT NOT NULL UNIQUE,
    stripe_customer_id TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'active',  -- 'active', 'past_due', 'canceled', 'expired'
    current_period_end TIMESTAMPTZ NOT NULL,
    cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_licenses_email ON licenses(user_email);
CREATE INDEX idx_licenses_stripe ON licenses(stripe_payment_id);
CREATE INDEX idx_activations_license ON license_activations(license_id);
CREATE INDEX idx_activations_machine ON license_activations(machine_id);
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
```

## Plan de travail

### Phase 1 — Infrastructure de licence (desktop)

#### Issue 1 — Commandes Tauri pour la gestion de licence [type:feature] ✅ COMPLÉTÉE
Forgejo: #46 (fermée), PR #56 (mergée 2026-04-09)
- [x] Ajouter les dépendances `jsonwebtoken`, `serde_json` au Cargo.toml
- [x] Créer `src-tauri/src/commands/license_commands.rs` (6 commandes)
- [x] Créer `src-tauri/src/commands/entitlements.rs` (système d'entitlements par édition)
- [x] Embarquer la clé publique Ed25519 dans le code Rust (constante)
- [x] Enregistrer les commandes dans `lib.rs`
- [x] Tests unitaires (licence valide, expirée, signature invalide, clé corrompue)

#### Issue 2 — UI Licence dans les paramètres [type:feature] ✅ COMPLÉTÉE
Forgejo: #47 (fermée), PR #57 (mergée 2026-04-10)
- [x] Créer `src/components/settings/LicenseCard.tsx` — affiche l'édition, permet d'entrer une clé
- [x] Créer `src/services/licenseService.ts` — wrapper des commandes Tauri license_*
- [x] Créer `src/hooks/useLicense.ts` — hook useReducer pour l'état de la licence
- [x] Ajouter les clés i18n dans `fr.json` et `en.json` (section `license.*`)
- [x] Intégrer le LicenseCard dans `SettingsPage.tsx`
- [x] Feedback visuel : édition courante, statut de la clé, erreurs de validation

#### Issue 3 — Conditionner les mises à jour auto à la licence [type:task] ✅ COMPLÉTÉE
Forgejo: #48 (fermée), PR #58 (mergée 2026-04-10)
- [x] Modifier `useUpdater.ts` : vérifier l'entitlement `auto-update` via `check_entitlement`
- [x] Si édition "free" → afficher un message "Mises à jour automatiques disponibles avec l'édition Base"
- [x] Si édition "base" ou "premium" → flux normal de mise à jour
- [x] Mettre à jour les traductions i18n

> **🟡 TECHNIQUE** — L'auto-update est le seul feature gaté par la licence Base. Les utilisateurs peuvent télécharger manuellement les nouvelles releases depuis le repo Forgejo public, rendant le paywall trivialement contournable sans modification du code.
> **Résolution :** Soit gater des features additionnelles (rapports avancés, multi-profil, export chiffré), soit assumer un soft paywall et reframer en "édition supporter". Considérer que la valeur réelle est dans le support + web/Premium.

> **🔴 TECHNIQUE** — Le projet est GPL-3.0-only (`Cargo.toml`, `CLAUDE.md`). Gater des features derrière une clé payante dans du code GPL signifie que quiconque peut forker et retirer le check. Le spec ne traite pas cette tension fondamentale.
> **Résolution :** Accepter explicitement que le gate est un honor system (cohérent avec Open Core), ou déplacer la validation de licence vers un composant serveur. Documenter cette décision dans la section "Décisions prises".

### Phase 2 — Serveur de licences + paiement

#### Issue 4 — API serveur de licences [type:feature] 🔜 PROCHAINE
Forgejo: #49 (ouverte, status:ready)
- [ ] Créer le projet Node.js/Express (ou Hono) pour l'API de licences
- [ ] Générer la paire de clés Ed25519 (clé privée sur le serveur uniquement)
- [ ] Implémenter les endpoints : generate, activate, verify, deactivate, revoke
- [ ] Schéma PostgreSQL (tables licenses, license_activations, subscriptions)
- [ ] Middleware auth (API key pour les webhooks Stripe, JWT Logto pour les endpoints utilisateur)
- [ ] Rate limiting
- [ ] Déployer sur Coolify (api.lacompagniemaximus.com)
- [ ] Tests d'intégration

#### Issue 5 — Intégration Stripe (paiement + webhooks) [type:feature]
Forgejo: #50 (ouverte, status:ready). Dépendances : Issue 4
- [ ] Créer le compte Stripe et configurer pour le Canada (TPS/TVQ via Stripe Tax)
- [ ] Configurer Stripe Checkout pour l'achat unique (édition Base)
- [ ] Configurer Stripe Billing pour l'abonnement mensuel (édition Premium)
- [ ] Configurer Stripe Customer Portal (gestion d'abonnement par l'utilisateur)
- [ ] Implémenter les webhooks Stripe :
  - `checkout.session.completed` → générer licence Base + envoyer par email
  - `invoice.payment_succeeded` → renouveler/activer abonnement Premium
  - `customer.subscription.deleted` → marquer abonnement expiré
  - `customer.subscription.updated` → mettre à jour statut
- [ ] Page d'achat sur lacompagniemaximus.com (lien vers Stripe Checkout)
- [ ] Email de confirmation avec clé de licence (via Stripe email ou service dédié)

#### Issue 6 — Intégration Logto dans l'app desktop (Compte Maximus) [type:feature]
Forgejo: #51 (ouverte, status:ready). Dépendances : Issue 4, Logto déployé

> **🟡 ARCHITECTURE** — `get_edition()` dans `license_commands` doit aussi vérifier le statut d'abonnement via `auth_commands`, créant un couplage entre les deux modules. Ce n'est pas documenté.
> **Résolution :** Soit ajouter un `get_edition()` top-level dans `mod.rs` qui combine les deux sources, soit documenter explicitement que `license_commands::get_edition()` délègue à `auth_commands` pour la détection Premium.
- [ ] Ajouter `tauri-plugin-deep-link` pour gérer le callback OAuth2 (`simpl-resultat://auth/callback`)
- [ ] Créer `src-tauri/src/commands/auth_commands.rs` :
  - `start_oauth() -> Result<String, String>` — génère PKCE, ouvre le navigateur
  - `handle_auth_callback(code: String) -> Result<AccountInfo, String>` — échange code → tokens
  - `refresh_auth_token() -> Result<AccountInfo, String>` — refresh le token
  - `get_account_info() -> Result<Option<AccountInfo>, String>` — lit les infos stockées
  - `logout() -> Result<(), String>` — supprime les tokens
- [ ] Stocker les tokens chiffrés dans `auth/tokens.enc` (AES-256-GCM, clé = machine_id dérivé)
- [ ] Créer `src/components/settings/AccountCard.tsx` — connexion/déconnexion, infos compte
- [ ] Créer `src/services/authService.ts` — wrapper des commandes Tauri auth_*
- [ ] Ajouter les clés i18n
- [ ] Vérification périodique du statut d'abonnement (1x/jour au lancement)

### Phase 3 — Page d'achat et lancement

#### Issue 7 — Page d'achat sur le site web [type:feature]
Forgejo: #52 (ouverte, status:ready). Dépendances : Issue 5
- [ ] Créer la page `/simpl-resultat` ou `/acheter` sur lacompagniemaximus.com
- [ ] Présentation des tiers (Gratuit / Base / Premium) avec comparaison
- [ ] Boutons d'achat → Stripe Checkout (Base) ou inscription + Stripe Billing (Premium)
- [ ] FAQ (licence, remboursement, support, vie privée)
- [ ] Mentions légales (TPS/TVQ, politique de remboursement)
- [ ] i18n FR/EN

#### Issue 8 — Activation en ligne et machine limit [type:feature]
Forgejo: #53 (ouverte, status:ready). Dépendances : Issue 1 ✅, Issue 4
- [ ] Au premier lancement avec une clé, appeler `/licenses/activate` avec le machine_id
- [ ] Si machine_limit atteint → message d'erreur avec option de désactiver une autre machine
- [ ] Page de gestion des machines dans le Customer Portal ou sur le site
- [ ] Graceful degradation si le serveur est injoignable (accepter la clé offline, activer plus tard)

### Ordre d'exécution

```
Phase 1 (desktop, offline)          Phase 2 (serveur, online)
========================            ========================
Issue 1 (Commandes licence)         Issue 4 (API licences)
    ├── Issue 2 (UI Licence)            ├── Issue 5 (Stripe)
    ├── Issue 3 (Updater conditionnel)  ├── Issue 6 (Logto desktop)
    └── Issue 8 (Activation) ───────────┘
                                        └── Issue 7 (Page d'achat)
```

Les Phases 1 et 2 peuvent avancer en parallèle. La Phase 1 est autonome (tout offline).

## Fichiers concernés

### Nouveaux fichiers

| Fichier | Raison | Statut |
|---------|--------|--------|
| `src-tauri/src/commands/license_commands.rs` | Commandes Tauri : validation, stockage, lecture de licence | ✅ #46 |
| `src-tauri/src/commands/entitlements.rs` | Système d'entitlements par édition | ✅ #46 |
| `src-tauri/src/commands/auth_commands.rs` | Commandes Tauri : OAuth2 PKCE, tokens, compte | 🔜 #51 |
| `src/components/settings/LicenseCard.tsx` | UI licence dans les paramètres | ✅ #47 |
| `src/components/settings/AccountCard.tsx` | UI compte Maximus dans les paramètres | 🔜 #51 |
| `src/services/licenseService.ts` | Service TypeScript pour les opérations de licence | ✅ #47 |
| `src/services/authService.ts` | Service TypeScript pour l'auth Compte Maximus | 🔜 #51 |
| `src/hooks/useLicense.ts` | Hook useReducer pour l'état de la licence | ✅ #47 |
| `src/hooks/useAuth.ts` | Hook useReducer pour l'auth Compte Maximus | 🔜 #51 |

### Fichiers modifiés

| Fichier | Action | Raison |
|---------|--------|--------|
| `src-tauri/Cargo.toml` | Modifier | Ajouter `jsonwebtoken`, `serde_json` ✅, `tauri-plugin-deep-link` (🔜 #51) |
| `src-tauri/src/lib.rs` | Modifier | Enregistrer les nouvelles commandes et plugins ✅ |
| `src-tauri/tauri.conf.json` | Modifier | Configurer deep-link protocol `simpl-resultat://` |
| `src/pages/SettingsPage.tsx` | Modifier | Intégrer LicenseCard ✅ et AccountCard (🔜 #51) |
| `src/hooks/useUpdater.ts` | Modifier | Conditionner les mises à jour à la licence ✅ |
| `src/i18n/locales/fr.json` | Modifier | Clés `license.*` ✅ et `account.*` (🔜 #51) |
| `src/i18n/locales/en.json` | Modifier | Clés `license.*` ✅ et `account.*` (🔜 #51) |

### Nouveau projet (serveur, hors du repo simpl-resultat)

| Projet | Description |
|--------|-------------|
| `simpl-resultat-api` | API de licences + webhooks Stripe (Node.js, propriétaire) |

## Critères d'acceptation

- [ ] Un utilisateur peut entrer une clé de licence valide et voir son édition passer à "Base"
- [ ] Une clé invalide ou corrompue est rejetée avec un message d'erreur clair
- [ ] Les mises à jour automatiques ne sont proposées qu'aux éditions Base et Premium
- [ ] L'édition "Gratuite" fonctionne sans restriction fonctionnelle (sauf auto-update)
- [ ] La connexion au Compte Maximus est optionnelle et ne bloque aucune fonctionnalité Base
- [ ] Un abonnement Premium actif est détecté et affiche l'édition "Premium"
- [ ] L'expiration d'un abonnement repasse gracieusement en mode Base (7 jours de grâce)
- [ ] L'achat via Stripe Checkout génère automatiquement une clé de licence envoyée par email
- [ ] Le webhook Stripe met à jour le statut d'abonnement en temps réel
- [ ] La TPS/TVQ est correctement collectée pour les ventes au Canada
- [ ] L'app fonctionne 100% offline avec une clé Base validée (aucun appel réseau requis)
- [ ] L'activation machine fonctionne en mode dégradé si le serveur est injoignable
- [ ] Toutes les chaînes UI sont traduites FR/EN

## Edge cases et risques

| Cas | Mitigation |
|-----|------------|
| L'utilisateur change de machine | Machine limit (3 par défaut) + endpoint de désactivation. UI de gestion des machines. |
| La clé publique Ed25519 est extraite du binaire | Risque accepté (GPL). La clé publique ne permet que la vérification, pas la génération. |
| Le serveur de licences est down | La validation offline fonctionne toujours. L'activation et le refresh Premium échouent gracieusement. |
| L'utilisateur compile le code sans licence | Fonctionnel mais sans auto-update ni features Premium. Acceptable sous GPL. |
| Stripe webhook rate | Implémenter idempotency keys et retry logic. Stripe a un mécanisme de retry intégré (jusqu'à 72h). |
| Remboursement Stripe | Webhook `charge.refunded` → révoquer la licence automatiquement. |
| Changement de prix | Stripe permet de modifier les prix sans affecter les abonnements existants (grandfather). |
| Double achat (même email) | Vérifier si une licence active existe déjà avant d'en générer une nouvelle. |
| Token OAuth expiré en mode offline | Grace period : si le refresh échoue, garder le statut Premium pendant 7 jours (stocké localement). |
| Migration de machine_id (réinstall OS) | Le machine_id change → l'utilisateur doit désactiver l'ancienne machine ou contacter le support. Prévoir un flow self-service. |

> **🔴 SECURITE** — Le PIN hashing existant dans `profile_commands.rs` utilise SHA-256 salé (hash rapide), pas Argon2. Un PIN de 4-6 chiffres avec SHA-256 est brute-forceable trivialement (~1M combinaisons). Avec l'ajout de tokens OAuth, la surface d'attaque locale augmente.
> **Résolution :** Migrer `hash_pin`/`verify_pin` vers Argon2id (déjà une dépendance via le crate `argon2` utilisé dans l'export). Vulnérabilité pré-existante à corriger en Phase 1.
> *Ref : CWE-916 (Use of Password Hash With Insufficient Computational Effort)*

> **🟢 SECURITE** — Le CSP est désactivé (`"csp": null` dans `tauri.conf.json`). Avec des tokens OAuth stockés dans l'app, un XSS dans le WebView pourrait exfiltrer les tokens.
> **Résolution :** Activer un CSP restrictif avant la Phase 2 : `default-src 'self'; script-src 'self'; connect-src 'self' https://api.lacompagniemaximus.com`.
> *Ref : OWASP A03:2021 (Injection)*

> **🟢 TECHNIQUE** — Le spec ne spécifie pas la stratégie `get_machine_id()` cross-plateforme (Windows vs Linux). Différentes approches (`/etc/machine-id`, WMI, SMBIOS UUID) ont des garanties de stabilité différentes.
> **Résolution :** Spécifier la stratégie par plateforme dans l'Issue 1 (ex: crate `machine-uid`). Documenter que la réinstallation de l'OS peut changer l'ID.

## Décisions prises

| Question | Décision | Raison |
|----------|----------|--------|
| Modèle de prix | Hybride : achat unique desktop + abonnement web | Maximise les revenus sur les deux segments, faible friction pour le desktop |
| Modèle de licence | Open Core (GPL desktop + serveur propriétaire) | Cohérent avec l'existant, la valeur est dans le service |
| Processeur de paiement | Stripe (recommandé), avec Stripe Tax pour TPS/TVQ | Meilleure API, frais les plus bas, Stripe Billing pour abonnements |
| Validation de licence | Ed25519 offline (JWT signé) | Privacy-first, fonctionne sans internet, crypto solide |
| Auth | Logto (OAuth2 PKCE, comme prévu dans le spec web) | Déjà planifié, self-hosted, standard OIDC |
| Stockage licence | Fichier `license.key` dans app data (hors SQLite) | Indépendant des profils, simple, pas de migration de schéma |
| Machine limit | 3 machines par licence Base | Standard industrie, équilibre entre flexibilité et protection |
| Grace period Premium | 7 jours après expiration | Évite de bloquer l'utilisateur pour un problème de carte |
| Square | Non retenu | Orienté commerce de détail, API d'abonnement immature, pas de MoR |

## Références

| Source | Pertinence |
|--------|------------|
| [Keyforge — License Tauri App](https://keyforge.dev/blog/how-to-license-tauri-app) | Pattern Ed25519 + JWT pour validation offline dans Tauri, avec stockage sécurisé |
| [Keygen.sh for Tauri](https://keygen.sh/for-tauri-apps/) | Plugin Tauri existant pour licence (alternative SaaS à notre API custom) |
| [tauri-plugin-better-auth-license](https://crates.io/crates/tauri-plugin-better-auth-license) | Device-bound licensing avec X25519 + JWE, offline-verifiable JWTs |
| [Stripe vs Paddle vs LemonSqueezy Comparison](https://appstackbuilder.com/blog/stripe-vs-lemon-squeezy-vs-paddle) | Comparaison détaillée des frais, features, et cas d'usage 2026 |
| [LemonSqueezy 2026 Update (Stripe acquisition)](https://www.lemonsqueezy.com/blog/2026-update) | LemonSqueezy intégré à l'écosystème Stripe depuis 2024 |
| [Stripe Tax — Canada](https://docs.stripe.com/tax/supported-countries/canada) | Documentation Stripe Tax pour la collecte TPS/TVQ automatique au Canada |
| [Monetizing Open Source: Open Core Strategies](https://www.getmonetizely.com/articles/monetizing-open-source-software-pricing-strategies-for-open-core-saas) | Pricing strategies pour Open Core SaaS, validation du modèle hybride |
| [Open Core Business Model Handbook](https://handbook.opencoreventures.com/open-core-business-model/) | Guide structuré du modèle Open Core, délimitation free vs premium |
| [FastSpring — Desktop Software Sales](https://fungies.io/best-subscription-billing-tools-saas-2026/) | FastSpring comme alternative spécialisée pour la vente de logiciels desktop |

## Revision — Synthese

> Date: 2026-04-08 | Experts: Securite, Architecture, Technique

### Verdict
🔴 **CRITIQUES A CORRIGER** — Le spec a une architecture solide mais présente des failles de sécurité crypto (JWT sans expiration, chiffrement tokens faible, PIN SHA-256) et des incohérences avec les patterns du codebase existant (hooks manquants).

### Resume
| Expert | 🔴 | 🟡 | 🟢 | Points cles |
|--------|-----|-----|-----|-------------|
| Securite | 4 | 2 | 1 | JWT sans expiry irrevocable, token encryption faible (machine ID), PIN SHA-256, license.key copiable, webhook signature manquante |
| Architecture | 1 | 1 | 0 | Hooks useLicense/useAuth manquants (pattern du projet), couplage get_edition() entre modules |
| Technique | 2 | 2 | 1 | Confusion ed25519-dalek vs jsonwebtoken, tension GPL vs feature gating, auto-update seul gate fragile |

### Actions requises
1. 🔴 Ajouter claim `exp` obligatoire au JWT licence (CWE-613)
2. 🔴 Remplacer le chiffrement machine-ID des tokens par OS keychain ou secret par installation (CWE-321)
3. ✅ ~~Migrer PIN hashing de SHA-256 vers Argon2id~~ — Corrigé dans #54 (PR #55, en attente de merge)
4. 🔴 Ajouter un token d'activation signé avec machine_id pour empêcher la copie de license.key — `store_activation_token()` implémenté dans #46, activation serveur dans #53
5. ✅ ~~Clarifier que `jsonwebtoken` (pas `ed25519-dalek`) est la dépendance primaire pour JWT~~ — Implémenté avec `jsonwebtoken` dans #46
6. 🔴 Documenter explicitement que le feature gating GPL est un honor system (décision Open Core)
7. ✅ ~~Ajouter `src/hooks/useLicense.ts` et `src/hooks/useAuth.ts`~~ — `useLicense.ts` ajouté dans #47. `useAuth.ts` à créer dans #51
8. 🟡 Ajouter vérification signature Stripe webhook (CWE-345)
9. 🟡 Ajouter rate limiting + auth sur les endpoints licence (OWASP API4)
10. 🟡 Documenter la config deep-link par plateforme pour OAuth2 callback
11. 🟡 Clarifier le couplage `get_edition()` entre license_commands et auth_commands
12. 🟡 Considérer des gates additionnels ou assumer le soft paywall pour l'édition Base
