# Audit — tokens & abonnements B2C

*01/08/2026 — branche `fix_tokens`*

Question de départ : **un utilisateur qui annule son abonnement perd-il les tokens qu'il a payés ?**
Réponse : **oui, il les perdait — sur l'un des deux chemins possibles, au hasard de celui qui passait en premier.**

---

## 1. Réponse directe au cas signalé (com@gmail.com, Pro pris ce jour)

| Moment | Ce qui se passait réellement |
|---|---|
| Clic sur « Annuler l'abonnement » | **Aucun token touché.** `cancelAtPeriodEnd()` pose seulement `cancel_at_period_end` chez Stripe et passe le statut local à `canceled`. Le solde et l'accès Pro sont intacts. |
| Jusqu'au 01/09/2026 | Accès Pro maintenu, solde intact, plus aucune recharge (normal : la période est déjà payée). |
| À partir du 01/09/2026 | **Le solde était confisqué** — remis sèchement à 25 000 — si l'utilisateur ouvrait la page Abonnement avant que le cron ne passe. Si le cron passait d'abord, le solde était **conservé**. |

Donc : la modale annonçait une perte **immédiate** qui n'existait pas, pour une perte **différée** qui, elle, existait bel et bien — une fois sur deux.

---

## 2. Cause racine

Deux chemins mènent au retour en freemium, et ils ne faisaient pas la même chose :

| Chemin | Déclencheur | Effet sur le solde |
|---|---|---|
| `StripeSubscriptionService::expireToFreemium()` — appelé au chargement de `/settings/subscription` | `status = canceled` **et** `accessUntil < now` | `setTokenUsable(25 000)` + `setTokenUsed(0)` → **remise à zéro** |
| Cron `app:b2c:renew-tokens`, Passe 2 | `status = canceled` **et** période payée terminée | `addTokens(25 000, plafond)` → **complément, jamais reprise** |

Le premier des deux à s'exécuter gagnait. Même parcours, même offre, résultat opposé selon que l'utilisateur avait ouvert sa page abonnement ou non.

Deuxième subtilité, importante pour la suite : la préservation côté cron était **accidentelle**. Elle tient au fait que le plan Freemium a `tokenStorageMonths = 0`, donc un plafond de 25 000, donc `addTokens` qui rend 0 sur un solde de 775 500. Personne ne l'avait écrit comme une intention — un simple changement de configuration du plan l'aurait cassée sans bruit.

---

## 3. Règle retenue

> **Les tokens du solde ont été payés : ils appartiennent à l'utilisateur.**
> La fin d'abonnement coupe le droit à de *nouvelles* recharges au tarif du plan, pas la propriété du stock restant.
> Le retour en freemium garantit un **plancher** (la dotation freemium), jamais un plafond.

Conséquence assumée, à valider si elle ne convient pas : un ex-abonné assis sur 775 500 tokens **ne cumule pas** 25 000 de plus chaque mois. Il consomme son stock ; la dotation freemium reprend une fois son solde repassé sous 25 000, au premier passage éligible du cron — la Passe 3 fonctionne par cycles de 30 jours, elle ne réagit donc pas le jour même où le solde franchit le seuil. Elle réécrit `lastAllocationAt` à chaque exécution, même lorsqu'elle ne verse rien : le cycle se réarme et le complément finit toujours par arriver.

C'est la lecture littérale de « il garde ses tokens » : préservation, pas préservation + accumulation.

---

## 4. Corrections apportées

### 4.1 — Le solde n'est plus confisqué *(la demande initiale)*
`StripeSubscriptionService::expireToFreemium()` ne remet plus `tokenUsable`/`tokenUsed` à plat. Il calcule le manque par rapport à la dotation freemium et ne verse que le complément. Les deux chemins produisent désormais le même résultat, quel que soit celui qui passe en premier.

> Piège évité : `tokenUsable` et `tokenUsed` doivent bouger ensemble. Supprimer le premier en laissant `setTokenUsed(0)` n'aurait pas préservé le solde — il l'aurait **gonflé** de tout ce que l'utilisateur avait consommé.

Tests : `tests/Unit/Service/Payment/StripeSubscriptionServiceTest.php` (solde payé préservé, solde faible porté au plancher, solde négatif ramené au plancher, abonnement bien clôturé).

### 4.2 — Abonnements annuels : jusqu'à 11 mois d'accès payé supprimés *(plus grave que le point 1)*
La Passe 2 sélectionnait sur `lastAllocationAt < now - 30 jours` — une approximation valable pour un mensuel seulement.

Scénario réel : un abonné **annuel** annule au 3ᵉ mois. Son statut passe à `canceled`, la Passe 1 cesse donc de le recharger, sa dernière allocation vieillit, et **30 jours plus tard** la Passe 2 le basculait en freemium : plan remplacé, `stripeSubscriptionId` mis à `null` — donc plus aucune réactivation possible — alors qu'il avait payé 9 mois d'accès restants.

Le critère est désormais la **fin de période payée** (`UserSubscription::isPaidPeriodOver()`), avec trois sources par ordre de fiabilité :
1. `accessUntil` (`current_period_end` posé par Stripe) ;
2. fin d'engagement (`startedAt + 12 mois`) si le webhook manque sur un annuel ;
3. l'ancien seuil des 30 jours si le webhook manque sur un mensuel — le seul cas qu'il couvrait correctement.

Sans aucune de ces dates : on s'abstient. Un abonnement qui reste `canceled` un cycle de trop se rattrape ; un accès payé coupé par erreur, non.

Tests : `tests/Unit/Entity/UserSubscriptionPaidPeriodTest.php` (7 cas) et `tests/Functional/Subscription/CanceledSubscriptionsExpireOnPaidPeriodEndTest.php` (le vrai finder, DQL + tri PHP). **Vérifié : les deux tests annuels échouent si l'on remet l'ancien critère.**

### 4.3 — Passe 1 : fuite de tokens B2C vers des comptes B2B
`findActiveB2CDueForRenewal()` était le seul finder de ce dépôt sans `u.company IS NULL`. Un utilisateur passé en B2B tout en conservant une `UserSubscription` active recevait donc **à la fois** sa part du pool entreprise et la recharge de son ancien plan individuel. Filtre ajouté.

### 4.4 — La modale d'annulation disait le contraire de la réalité
- « Vos tokens seront perdus » → **encart entièrement supprimé**. Il a d'abord été réécrit en « Vous gardez vos tokens », puis retiré : l'étape 1 de la modale montre déjà le plan actuel, la date de fin d'accès et l'offre Freemium qui prend le relais. Le solde n'étant pas affecté par l'annulation, il n'y a rien à annoncer à l'étape 2.
- Sous-titre « Cette action est irréversible » → faux : `app_subscription_reactivate` permet de revenir tant que la période court.
- La variable `cancelTokens` disparaît avec l'encart. Elle lisait au passage `tokenUsable` (le quota crédité) au lieu de `tokensRemaining` (le disponible réel) — les deux divergent dès que l'utilisateur a consommé quelque chose.

### 4.5 — `capUserToken()` : documentation trompeuse
Son docblock annonçait « migration B2B→B2C **ou downgrade de plan** », mais aucun chemin de downgrade B2C ne l'appelait. Décision retenue et écrite dans le code : **elle ne doit pas l'être**. En B2B le solde vient du pool de l'entreprise et n'appartient pas à l'individu qui la quitte ; en B2C il a été payé par l'utilisateur. Un downgrade Pro → Starter réduit la recharge à venir, pas le stock déjà acheté.

---

## 5. Cas de figure passés en revue et jugés corrects

| Cas | Comportement | Verdict |
|---|---|---|
| Souscription initiale | `handleCheckoutCompleted` crédite le quota ; `handlePaymentSucceeded` saute la 1ʳᵉ facture (`subscription_create`) | Correct — le garde-fou anti double-crédit est en place |
| Double événement Stripe (`invoice.paid` + `invoice.payment_succeeded`) | `lastProcessedInvoiceId` bloque le second | Correct |
| Upgrade Starter → Pro | Nouvelle Checkout Session ; rien n'est muté avant paiement confirmé ; réconciliation des subs Stripe orphelines après coup | Correct |
| Upgrade payé mais webhook Messenger en retard | `applyPendingPlanIfPaid()` au chargement de page, fenêtre de 2 h sur la facture | Correct |
| Downgrade Pro → Starter | Planifié via `pendingPlan`, appliqué au renouvellement ; solde conservé | Correct (cf. §4.5) |
| Annulation immédiate depuis le Dashboard Stripe, période encore en cours | `handleSubscriptionDeleted` maintient l'accès jusqu'à `accessUntil` | Correct |
| Échec de paiement 3DS / retry Stripe | `isTransientPaymentFailure()` — Bubul ne touche à rien | Correct |
| Échec de paiement définitif | `past_due` + email + contre-passation de commission | Correct |
| Remboursement | `charge.refunded` → contre-passation ; remboursement partiel signalé pour arbitrage manuel | Correct |
| Récompense de parrainage | `bonusTokens`, jamais repris par la recharge mensuelle | Correct |
| Renouvellement mensuel B2C | Webhook Stripe en premier, cron en filet de sécurité (`lastAllocationAt + 30 j`) | Correct |
| Commerciaux (Passe 5) | Dotation par mois civil, complément et non cumul, exclus des passes 1–4 | Correct, bien testé |
| B2B — désactivation de contrat | `capUsersToMaxStorage()` **réduit** les soldes | Correct et **volontairement différent du B2C** : le pool appartient à l'entreprise |

---

## 6. Points restants — pour décision, non corrigés

1. **Planification du cron non versionnée.** Aucun crontab ni configuration de scheduler dans le dépôt. À vérifier côté serveur : `app:b2c:renew-tokens` quotidien (06:00 suggéré dans le docblock) et `app:b2b:sync`. Si l'un des deux n'est pas planifié, tout le filet de sécurité décrit ici ne tourne pas.
2. **Durée d'exécution du cron.** Passe 1 fait `sleep(5)` par utilisateur rechargé (throttling e-mail). À 500 abonnés, c'est ~40 minutes d'exécution. Fonctionne, mais devient un problème de fenêtre de nuit avec la croissance — une file Messenger pour les e-mails règlerait la question.
3. **`tokenStorageMonths = 0` sur Freemium.** Neutralisé partout par `max(1, …)`, mais la valeur reste piégeuse : posée sur un plan payant, elle ramènerait son plafond de report à un seul mois sans que rien ne le signale.
4. **Passe 2 ne nettoie pas `accessUntil` ni `pendingPlan`** lors de la bascule, là où `expireToFreemium()` le fait. Sans conséquence aujourd'hui (le statut `inactive` empêche toute reprise), mais les deux chemins gagneraient à converger complètement.

---

## 7. Vérification

```
php bin/phpunit tests/Unit                       → 2277 tests, OK
php bin/phpunit tests/Functional/Subscription    → 4 tests, OK
php bin/phpunit tests/Functional/Commercial tests/Functional/Referral → 155 tests, OK
```

Le cron a aussi été exécuté pour de vrai sur la base de test, avec un annuel annulé à 9 mois
d'accès restant et un mensuel annulé expiré :

```
app:b2c:renew-tokens --debug   → annuel : « ⏳ accès payé jusqu'au 01/05/2027 »
                                 mensuel : « ✅ période payée terminée → transition freemium »
app:b2c:renew-tokens --dry-run → Passe 2 : 1 bascule (le mensuel), l'annuel intact
```

La suite fonctionnelle complète n'a pas pu être exécutée d'une traite en local (épuisement
mémoire dans `BadgeDisplayService`, sans rapport avec ces modifications — la CI la découpe
par répertoire).
