# Backend — Risques & vigilance : Stripe

> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/risques/README.md` pour l'index complet.

---

<a id="risque-stripe-current-period-end"></a>
## Stripe (v17+) : confusion `billing_cycle_anchor` vs `current_period_end`

### Risques

- Stocker une date de fin de période incorrecte en DB (bug silencieux)
- État d'abonnement incohérent (UI, relances, accès premium)

### Symptômes

- `currentPeriodEnd` correspond à une date "bizarre" (souvent proche de la création), ou à un jour du mois
- Des accès premium expirent trop tôt / trop tard

### Bonnes pratiques / mitigations

- Ne jamais interpréter `billing_cycle_anchor` comme une date de fin de période
- Utiliser `subscription.current_period_end` (timestamp) pour la fin de période courante
- Ajouter un test sur un événement webhook/Subscription qui vérifie la date persistée

### Nuance SDK v20 (API 2025-03-31.basil+) : `current_period_end` est par ITEM, plus à la racine

Depuis le SDK Stripe v20, `Subscription.current_period_end` n'existe **plus** au niveau racine : lire `subscription.items.data[i].current_period_end` (prendre le **max** des items pour borner au plus tard). Symptôme : `currentPeriodEnd` revient systématiquement `null` → un abo « ACTIVE » sans borne de période reste ouvert indéfiniment (abo « zombie ») si un event de renouvellement est manqué. Garde-fou complémentaire : `isActive = status === 'ACTIVE' && currentPeriodEnd != null && currentPeriodEnd > now`. Valider sur un event Stripe réel (l'API effective dépend de la clé/compte).

---

<a id="risque-stripe-list-has-more"></a>
## Stripe `list()` sans gestion de `has_more`

### Risques

- Pagination tronquée silencieusement
- Réconciliation incomplète d'abonnements, achats ou moyens de paiement
- Décisions métier prises sur un jeu de données partiel

### Symptômes

- Comportement correct sur petits comptes mais faux sur comptes plus chargés
- Premiers éléments traités, les suivants ignorés
- Absence de boucle de pagination ou d'auto-pagination

### Bonnes pratiques / mitigations

- Traiter explicitement `has_more`
- Utiliser l'auto-pagination Stripe si adaptée
- Tester au moins un cas avec plusieurs pages de résultats
- Contexte technique : Stripe API — 10-03-2026

---

<a id="risque-trial-payant-concurrence"></a>
## Concurrence entre activation locale et webhook sur transition trial → payant

### Risques

- Double création ou double attachement d'une ressource unique
- Conflit `P2002`
- État local différent de l'état Stripe pendant la transition

### Symptômes

- La transition fonctionne parfois, puis échoue aléatoirement
- Un webhook Stripe et une action applicative écrivent la même mutation métier
- Erreurs d'unicité lors de l'activation payante

### Bonnes pratiques / mitigations

- Définir une seule source autorisée pour chaque transition d'état
- Rendre les écritures idempotentes
- Sérialiser ou réconcilier explicitement les transitions pilotées à la fois par action utilisateur et webhook
- Contexte technique : Stripe / Prisma / trial subscription — 10-03-2026

---

<a id="risque-non-idempotence"></a>
## Non-idempotence sur opérations sensibles

### Risques

- Doubles paiements / doubles créations
- Webhooks rejoués qui cassent l'état

### Symptômes

- Doublons de lignes en DB
- Actions exécutées 2 fois après timeout/retry
- Incidents difficiles à reproduire

### Bonnes pratiques / mitigations

- Idempotency key sur endpoints critiques
- Protection anti-doublon côté DB (contraintes uniques)
- Comportement défini en cas de retry

---

<a id="risque-webhook-200-processing"></a>
## Webhooks entrants — répondre 200 pendant `processing` (event perdu)

### Risques

- Le provider (Stripe, etc.) arrête ses retries après un 2xx, même si le premier worker a échoué
- Event non appliqué mais marqué "traité" → état incohérent silencieux

### Symptômes

- Webhook reçu, 200 retourné, mais l'état en base n'est pas mis à jour
- Aucun retry du provider → impossible à détecter sans monitoring actif

### Bonnes pratiques / mitigations

- Lock DB (`WebhookEvent`) avec machine d'état : `pending` → `processing` → `processed` / `failed`
- Si `processing` détecté (concurrent) : attendre brièvement la transition `processed`, sinon répondre **non-2xx** (force retry provider)
- Ne jamais passer à `processed` sans preuve d'un traitement effectif
- Contexte technique : Stripe / NestJS — 09-03-2026

---

<a id="risque-refund-lie-user-produit"></a>
## Remboursement lié à (user, produit) au lieu de la TRANSACTION (PaymentIntent)

### Risques

- Un garde-fou « ne pas ré-accorder l'accès si déjà remboursé » identifié par `(userId, productId)` casse le cas « rembourser puis racheter » : l'ancien refund contamine le nouvel achat
- Un RACHAT légitime (nouveau paiement, nouveau PaymentIntent) du même produit par le même user est bloqué → client qui re-paie sans accès (perte de revenu + incident)

### Symptômes

- Garde-fou d'idempotence/révocation indexé sur `(userId, packId)` plutôt que sur le `paymentIntentId`
- Un `RefundRecord` orphelin (refund arrivé avant `completed`) qui bloque tout achat futur du même produit

### Bonnes pratiques / mitigations

- Un remboursement concerne UNE transaction précise, pas une relation `(user, produit)` durable. La clé d'un refund et de son garde-fou d'idempotence/ordre = le `paymentIntentId` (ou l'id de charge), **jamais** `(user, produit)`.
- Propager le `paymentIntentId` depuis `checkout.session.completed` (`session.payment_intent`) jusqu'au garde-fou.
- Corollaire (arrivée désordonnée refund-avant-completed via un `RefundRecord`) : matcher ce record par `paymentIntentId` à la création du `UserPack`, sinon il bloque tout achat futur du même produit.
- **Test obligatoire** : achat → refund → rachat (nouveau PI) → accès accordé.

- Contexte technique : Stripe / refund / webhooks — app-alexandrie 02-06-2026

---

<a id="risque-refund-consommation-visionnage-reel"></a>
## Éligibilité « refund si peu consommé » mesurée sur la validation, pas le visionnage réel

### Risques

- Une politique de remboursement bornée par la consommation (ex : « < 20 % consommé ») qui mesure un drapeau de VALIDATION EXPLICITE (clic « terminer » → state `COMPLETED`) est contournable
- L'utilisateur regarde 100 % du contenu sans cliquer « valider » → `completionPct = 0` → remboursable malgré tout le contenu consommé (open-bar)

### Symptômes

- AC qui cite `maxWatchedPct` mais implémentation qui compte `state === 'COMPLETED'`
- Divergence d'oracle entre le badge UI « remboursable » et ce que le serveur accepte réellement

### Bonnes pratiques / mitigations

- Mesurer la consommation EFFECTIVE (progression vidéo `maxWatchedPct >= seuil`, lecture réelle), JAMAIS un drapeau de validation cliqué par l'utilisateur (`COMPLETED`).
- Règle de seuil : leçon vidéo consommée dès `maxWatchedPct >= 90` (seuil de complétion vidéo), leçon texte dès `COMPLETED`.
- Garder UNE seule source de mesure partagée par la décision serveur ET le badge UI « remboursable » (sinon divergence d'oracle).

- Contexte technique : Stripe / refund / consommation contenu — app-alexandrie 04-06-2026
