feat(billing): TD-13 webhook idempotency + Stripe Customer Portal + doc cleanup
- Table stripe_webhook_events + helpers isEventProcessed/markEventProcessed - POST /stripe/customer-portal (auth + stripe_customer_id check) - ARCHITECTURE-backend.md: suppression POST /plans/upgrade (duplication doc) - TD-13 fermé dans TECH_DEBT-backend.md - Tests: 261 → 278 verts (+17)
This commit is contained in:
parent
ec0598d122
commit
6671bac347
10 changed files with 891 additions and 324 deletions
|
|
@ -72,6 +72,7 @@ Tier gratuit, déploiement automatique depuis GitHub.
|
||||||
### Pourquoi Supabase est conservé
|
### Pourquoi Supabase est conservé
|
||||||
|
|
||||||
Supabase fournit trois services critiques déjà en production :
|
Supabase fournit trois services critiques déjà en production :
|
||||||
|
|
||||||
- Authentification complète (email, OAuth Google/Apple, sessions JWT)
|
- Authentification complète (email, OAuth Google/Apple, sessions JWT)
|
||||||
- Base de données PostgreSQL avec Row Level Security
|
- Base de données PostgreSQL avec Row Level Security
|
||||||
- Stockage de fichiers (enregistrements audio EO)
|
- Stockage de fichiers (enregistrements audio EO)
|
||||||
|
|
@ -159,8 +160,8 @@ expria-backend/
|
||||||
│ │ ├── auth.ts # POST /auth/verify-token
|
│ │ ├── auth.ts # POST /auth/verify-token
|
||||||
│ │ ├── simulations.ts # POST /simulations, GET /simulations/:id
|
│ │ ├── simulations.ts # POST /simulations, GET /simulations/:id
|
||||||
│ │ ├── corrections.ts # POST /corrections/ee, POST /corrections/eo
|
│ │ ├── corrections.ts # POST /corrections/ee, POST /corrections/eo
|
||||||
│ │ ├── plans.ts # GET /plans/status, POST /plans/upgrade
|
│ │ ├── plans.ts # GET /plans/status, POST /plans/upgrade-prorata
|
||||||
│ │ ├── stripe.ts # POST /stripe/checkout, POST /stripe/webhook
|
│ │ ├── stripe.ts # POST /stripe/checkout, /stripe/customer-portal, /stripe/webhook
|
||||||
│ │ └── t2live.ts # WebSocket /t2/live (T2 EO proxy Gemini)
|
│ │ └── t2live.ts # WebSocket /t2/live (T2 EO proxy Gemini)
|
||||||
│ ├── controllers/ # Logique métier (une par domaine)
|
│ ├── controllers/ # Logique métier (une par domaine)
|
||||||
│ │ ├── simulationController.ts
|
│ │ ├── simulationController.ts
|
||||||
|
|
@ -292,11 +293,13 @@ USING (auth.uid() = user_id);
|
||||||
## 6. Routes API backend
|
## 6. Routes API backend
|
||||||
|
|
||||||
### Authentification
|
### Authentification
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /auth/verify-token Vérifie le JWT Supabase, retourne le profil + plan
|
POST /auth/verify-token Vérifie le JWT Supabase, retourne le profil + plan
|
||||||
```
|
```
|
||||||
|
|
||||||
### Simulations
|
### Simulations
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /simulations Crée une simulation, vérifie les quotas selon le plan
|
POST /simulations Crée une simulation, vérifie les quotas selon le plan
|
||||||
GET /simulations/:id Récupère une simulation par ID
|
GET /simulations/:id Récupère une simulation par ID
|
||||||
|
|
@ -304,25 +307,29 @@ GET /simulations Liste les simulations de l'utilisateur connec
|
||||||
```
|
```
|
||||||
|
|
||||||
### Corrections
|
### Corrections
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /corrections/ee Soumet une production EE pour correction (DeepSeek)
|
POST /corrections/ee Soumet une production EE pour correction (DeepSeek)
|
||||||
POST /corrections/eo Soumet une production EO pour correction (Gemini)
|
POST /corrections/eo Soumet une production EO pour correction (Gemini)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Plans
|
### Plans
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /plans/status Retourne le plan actuel + permissions de l'utilisateur
|
GET /plans/status Retourne le plan actuel + permissions de l'utilisateur
|
||||||
POST /plans/upgrade Crée une session Stripe Checkout (nouveau abonnement)
|
POST /plans/upgrade-prorata Upgrade en cours d'abonnement (prorata Stripe — preview du montant)
|
||||||
POST /plans/upgrade-prorata Upgrade en cours d'abonnement (prorata Stripe)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stripe
|
### Stripe
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /stripe/checkout Crée une Checkout Session Stripe
|
POST /stripe/checkout Crée une Checkout Session Stripe (nouveau abonnement)
|
||||||
POST /stripe/webhook Reçoit les events Stripe (checkout, invoice, deleted)
|
POST /stripe/customer-portal Crée une Billing Portal Session (gestion abonnement self-service)
|
||||||
|
POST /stripe/webhook Reçoit les events Stripe (checkout, invoice, deleted) — idempotent (TD-13 résolu Sprint 5a)
|
||||||
```
|
```
|
||||||
|
|
||||||
### T2 EO Live
|
### T2 EO Live
|
||||||
|
|
||||||
```
|
```
|
||||||
WS /t2/live WebSocket — proxy Gemini Live API (Premium uniquement)
|
WS /t2/live WebSocket — proxy Gemini Live API (Premium uniquement)
|
||||||
```
|
```
|
||||||
|
|
@ -388,6 +395,7 @@ WS /t2/live WebSocket — proxy Gemini Live API (Premium
|
||||||
## 8. Variables d'environnement
|
## 8. Variables d'environnement
|
||||||
|
|
||||||
### Frontend (.env)
|
### Frontend (.env)
|
||||||
|
|
||||||
```
|
```
|
||||||
VITE_API_URL=https://api.expria.app # URL du backend Render
|
VITE_API_URL=https://api.expria.app # URL du backend Render
|
||||||
VITE_SUPABASE_URL=https://xxx.supabase.co
|
VITE_SUPABASE_URL=https://xxx.supabase.co
|
||||||
|
|
@ -395,6 +403,7 @@ VITE_SUPABASE_ANON_KEY=xxx # Clé publique uniquement
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (.env)
|
### Backend (.env)
|
||||||
|
|
||||||
```
|
```
|
||||||
# Supabase
|
# Supabase
|
||||||
SUPABASE_URL=https://xxx.supabase.co
|
SUPABASE_URL=https://xxx.supabase.co
|
||||||
|
|
@ -481,29 +490,35 @@ npx wrangler pages deploy dist --project-name=expria
|
||||||
## 10. Règles de développement
|
## 10. Règles de développement
|
||||||
|
|
||||||
### Règle 1 — Séparation stricte
|
### Règle 1 — Séparation stricte
|
||||||
|
|
||||||
Le frontend ne contient aucune logique métier.
|
Le frontend ne contient aucune logique métier.
|
||||||
Il appelle le backend et affiche ce qu'il reçoit.
|
Il appelle le backend et affiche ce qu'il reçoit.
|
||||||
Toute vérification de plan, de quota, de droit d'accès se fait côté backend.
|
Toute vérification de plan, de quota, de droit d'accès se fait côté backend.
|
||||||
|
|
||||||
### Règle 2 — Source de vérité unique des plans
|
### Règle 2 — Source de vérité unique des plans
|
||||||
|
|
||||||
`lib/access.ts` existe dans les deux dépôts (frontend et backend).
|
`lib/access.ts` existe dans les deux dépôts (frontend et backend).
|
||||||
Le fichier doit être identique dans les deux.
|
Le fichier doit être identique dans les deux.
|
||||||
Toute modification des plans tarifaires met à jour ce fichier en premier,
|
Toute modification des plans tarifaires met à jour ce fichier en premier,
|
||||||
dans les deux dépôts, avant tout autre changement de code.
|
dans les deux dépôts, avant tout autre changement de code.
|
||||||
|
|
||||||
### Règle 3 — Jamais plus de 3 fichiers touchés par session Claude
|
### Règle 3 — Jamais plus de 3 fichiers touchés par session Claude
|
||||||
|
|
||||||
Si une modification nécessite de toucher plus de 3 fichiers,
|
Si une modification nécessite de toucher plus de 3 fichiers,
|
||||||
elle doit être découpée en plusieurs sessions avec validation intermédiaire.
|
elle doit être découpée en plusieurs sessions avec validation intermédiaire.
|
||||||
|
|
||||||
### Règle 4 — Plan avant code
|
### Règle 4 — Plan avant code
|
||||||
|
|
||||||
Claude Code ne commence jamais à coder sans avoir d'abord produit
|
Claude Code ne commence jamais à coder sans avoir d'abord produit
|
||||||
un plan détaillé (fichiers impactés, risques, étapes).
|
un plan détaillé (fichiers impactés, risques, étapes).
|
||||||
Le plan est validé par Hermann avant l'exécution.
|
Le plan est validé par Hermann avant l'exécution.
|
||||||
|
|
||||||
### Règle 5 — Tests manuels après chaque session
|
### Règle 5 — Tests manuels après chaque session
|
||||||
|
|
||||||
Après chaque session Claude Code, rejouer le golden dataset
|
Après chaque session Claude Code, rejouer le golden dataset
|
||||||
(voir GOLDEN_DATASET.md) avant de passer à la session suivante.
|
(voir GOLDEN_DATASET.md) avant de passer à la session suivante.
|
||||||
|
|
||||||
### Règle 6 — Variables d'environnement
|
### Règle 6 — Variables d'environnement
|
||||||
|
|
||||||
Aucune valeur de variable d'environnement n'est jamais écrite en dur dans le code.
|
Aucune valeur de variable d'environnement n'est jamais écrite en dur dans le code.
|
||||||
Toujours lire depuis `process.env` (backend) ou `import.meta.env` (frontend).
|
Toujours lire depuis `process.env` (backend) ou `import.meta.env` (frontend).
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,34 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [Unreleased] — 2026-04-26 — Sprint 5a — Backend billing cleanup
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `supabase/migrations/007_sprint_5a_stripe_webhook_events.sql` — table `stripe_webhook_events(id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` + index sur `processed_at`. Idempotente (`CREATE TABLE IF NOT EXISTS`).
|
||||||
|
- `src/lib/stripeWebhookEvents.ts` — helpers `isEventProcessed` / `markEventProcessed` (insert idempotent, conflit unique `23505` avalé silencieusement).
|
||||||
|
- `src/lib/__tests__/stripeWebhookEvents.test.ts` — 8 tests (lecture, écriture, edge cases vide/erreur DB).
|
||||||
|
- `src/lib/__tests__/createBillingPortalSession.test.ts` — 4 tests (succès, customerId vide, returnUrl vide, URL Stripe vide).
|
||||||
|
- `POST /stripe/customer-portal` — endpoint authentifié qui crée une Stripe Billing Portal Session (gestion abonnement self-service) et redirige l'utilisateur. 400 `NO_ACTIVE_SUBSCRIPTION` si pas de `stripe_customer_id` ; return_url = `${APP_URL}/dashboard`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `POST /stripe/webhook` — déduplication explicite des events Stripe (TD-13 résolu) : check `isEventProcessed(event.id)` avant traitement → early return `200 { received: true, replayed: true }` ; `markEventProcessed` après succès uniquement (pas si exception, pour permettre rejeu Stripe).
|
||||||
|
- `src/lib/stripe.ts` — nouvelle fonction `createBillingPortalSession({ customerId, returnUrl })` (mirror de `createCheckoutSession`).
|
||||||
|
- `src/routes/__tests__/stripe.test.ts` — 5 nouveaux tests (2 idempotency webhook + 3 customer-portal route).
|
||||||
|
- `docs/ARCHITECTURE-backend.md` — §3 commentaire `plans.ts` corrigé (`POST /plans/upgrade-prorata` au lieu de `POST /plans/upgrade` qui n'existait pas) ; §6 retrait de la ligne dupliquée `POST /plans/upgrade` (la création d'abonnement passe par `POST /stripe/checkout`) ; §6 ajout `POST /stripe/customer-portal`.
|
||||||
|
|
||||||
|
### Resolved
|
||||||
|
|
||||||
|
- **TD-13 🔴 → Résolu** : Webhook Stripe idempotent (table `stripe_webhook_events` + helpers + wiring route + 10 tests).
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Tests : 261 → 278 verts (+17).
|
||||||
|
- Aucun changement frontend dans ce sprint — Sprint 5b (frontend billing) à venir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [Unreleased] — 2026-04-26 — Sprint 4.8 — Phonologie EO
|
## [Unreleased] — 2026-04-26 — Sprint 4.8 — Phonologie EO
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -97,15 +97,14 @@
|
||||||
### TD-13 — Webhook Stripe non idempotent
|
### TD-13 — Webhook Stripe non idempotent
|
||||||
|
|
||||||
**Priorité :** 🔴 Critique
|
**Priorité :** 🔴 Critique
|
||||||
**Statut :** Ouvert — à faire avant mise en production
|
**Statut :** Résolu — Sprint 5a (2026-04-26)
|
||||||
**Description :** Stripe peut livrer un même event webhook deux fois (retries réseau, rejeu manuel depuis le dashboard). La route `POST /stripe/webhook` traite chaque réception sans dédoublonnage. En pratique, les opérations `updateUserPlan` et `updateUserStripeInfo` sont idempotentes par nature (même résultat en cas de double appel), mais si de la logique non idempotente est ajoutée plus tard (ex: compteur, envoi d'email, crédit utilisateur), un double traitement causerait un bug.
|
**Description :** Stripe peut livrer un même event webhook deux fois (retries réseau, rejeu manuel depuis le dashboard). La route `POST /stripe/webhook` traite désormais chaque réception via une déduplication explicite : check `stripe_webhook_events(id)` avant traitement, INSERT après succès.
|
||||||
**À faire :**
|
**Résolution Sprint 5a :**
|
||||||
|
|
||||||
- Créer une table `stripe_webhook_events(id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ)`
|
- Migration `supabase/migrations/007_sprint_5a_stripe_webhook_events.sql` — table `stripe_webhook_events(id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` + index sur `processed_at`.
|
||||||
- Avant traitement, vérifier si `event.id` est déjà en base → si oui, retourner 200 sans rien faire
|
- Helper `src/lib/stripeWebhookEvents.ts` — `isEventProcessed` / `markEventProcessed` (insert idempotent, conflit unique avalé silencieusement).
|
||||||
- Après traitement, insérer l'`event.id` dans la table
|
- `src/routes/stripe.ts` — early return `200 { received: true, replayed: true }` si l'event est déjà journalisé ; `markEventProcessed(event.id)` après traitement réussi (pas si exception, pour permettre rejeu Stripe).
|
||||||
**Session concernée :** Stripe (POST /stripe/webhook)
|
- 8 tests unitaires + 2 tests d'intégration (`isEventProcessed`/`markEventProcessed` + comportement route).
|
||||||
**Condition de résolution :** Avant la mise en production publique.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -258,3 +257,4 @@ Gate de qualité actuel : npm run test.
|
||||||
| TD-16 | Bucket Storage abandonné | 2026-04-25 | Sprint 4b — Deepgram direct |
|
| TD-16 | Bucket Storage abandonné | 2026-04-25 | Sprint 4b — Deepgram direct |
|
||||||
| TD-17 | Limite audio in-memory caduque | 2026-04-25 | Sprint 4b |
|
| TD-17 | Limite audio in-memory caduque | 2026-04-25 | Sprint 4b |
|
||||||
| TD-18 | RLS Storage caduque | 2026-04-25 | Sprint 4b |
|
| TD-18 | RLS Storage caduque | 2026-04-25 | Sprint 4b |
|
||||||
|
| TD-13 | Webhook Stripe idempotent | 2026-04-26 | Sprint 5a |
|
||||||
|
|
|
||||||
69
src/lib/__tests__/createBillingPortalSession.test.ts
Normal file
69
src/lib/__tests__/createBillingPortalSession.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const portalCreateMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("stripe", () => ({
|
||||||
|
default: vi.fn(() => ({
|
||||||
|
billingPortal: {
|
||||||
|
sessions: {
|
||||||
|
create: portalCreateMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createBillingPortalSession } from "../stripe";
|
||||||
|
|
||||||
|
describe("createBillingPortalSession", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
portalCreateMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne l'URL de la billing portal session", async () => {
|
||||||
|
portalCreateMock.mockResolvedValue({
|
||||||
|
url: "https://billing.stripe.com/p/session/abc123",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await createBillingPortalSession({
|
||||||
|
customerId: "cus_abc",
|
||||||
|
returnUrl: "https://expria.app/dashboard",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.url).toBe("https://billing.stripe.com/p/session/abc123");
|
||||||
|
expect(portalCreateMock).toHaveBeenCalledWith({
|
||||||
|
customer: "cus_abc",
|
||||||
|
return_url: "https://expria.app/dashboard",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throw si customerId vide", async () => {
|
||||||
|
await expect(
|
||||||
|
createBillingPortalSession({
|
||||||
|
customerId: "",
|
||||||
|
returnUrl: "https://expria.app/dashboard",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("customerId requis");
|
||||||
|
expect(portalCreateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throw si returnUrl vide", async () => {
|
||||||
|
await expect(
|
||||||
|
createBillingPortalSession({
|
||||||
|
customerId: "cus_abc",
|
||||||
|
returnUrl: "",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("returnUrl requis");
|
||||||
|
expect(portalCreateMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throw si Stripe ne retourne pas d'URL", async () => {
|
||||||
|
portalCreateMock.mockResolvedValue({ url: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createBillingPortalSession({
|
||||||
|
customerId: "cus_abc",
|
||||||
|
returnUrl: "https://expria.app/dashboard",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("URL de billing portal");
|
||||||
|
});
|
||||||
|
});
|
||||||
101
src/lib/__tests__/stripeWebhookEvents.test.ts
Normal file
101
src/lib/__tests__/stripeWebhookEvents.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { fromMock, selectMock, eqMock, maybeSingleMock, insertMock } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
fromMock: vi.fn(),
|
||||||
|
selectMock: vi.fn(),
|
||||||
|
eqMock: vi.fn(),
|
||||||
|
maybeSingleMock: vi.fn(),
|
||||||
|
insertMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../supabase", () => ({
|
||||||
|
supabase: { from: fromMock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fromMock.mockReset();
|
||||||
|
selectMock.mockReset();
|
||||||
|
eqMock.mockReset();
|
||||||
|
maybeSingleMock.mockReset();
|
||||||
|
insertMock.mockReset();
|
||||||
|
|
||||||
|
fromMock.mockImplementation((table: string) => {
|
||||||
|
if (table !== "stripe_webhook_events") return {};
|
||||||
|
return {
|
||||||
|
select: selectMock,
|
||||||
|
insert: insertMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
selectMock.mockReturnValue({ eq: eqMock });
|
||||||
|
eqMock.mockReturnValue({ maybeSingle: maybeSingleMock });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("isEventProcessed", () => {
|
||||||
|
it("retourne true quand l'event est déjà journalisé", async () => {
|
||||||
|
maybeSingleMock.mockResolvedValue({ data: { id: "evt_123" }, error: null });
|
||||||
|
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||||
|
const result = await isEventProcessed("evt_123");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(eqMock).toHaveBeenCalledWith("id", "evt_123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne false quand l'event est absent", async () => {
|
||||||
|
maybeSingleMock.mockResolvedValue({ data: null, error: null });
|
||||||
|
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||||
|
const result = await isEventProcessed("evt_456");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne false sur erreur de lecture (privilégie disponibilité)", async () => {
|
||||||
|
maybeSingleMock.mockResolvedValue({
|
||||||
|
data: null,
|
||||||
|
error: { message: "DB unreachable" },
|
||||||
|
});
|
||||||
|
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||||
|
const result = await isEventProcessed("evt_789");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne false pour un eventId vide sans toucher Supabase", async () => {
|
||||||
|
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||||
|
const result = await isEventProcessed("");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(fromMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("markEventProcessed", () => {
|
||||||
|
it("insère l'event quand il n'existe pas", async () => {
|
||||||
|
insertMock.mockResolvedValue({ error: null });
|
||||||
|
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||||
|
await markEventProcessed("evt_new");
|
||||||
|
expect(insertMock).toHaveBeenCalledWith({ id: "evt_new" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avale silencieusement un conflit unique (livraison concurrente)", async () => {
|
||||||
|
insertMock.mockResolvedValue({
|
||||||
|
error: { code: "23505", message: "duplicate key" },
|
||||||
|
});
|
||||||
|
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||||
|
await expect(markEventProcessed("evt_dup")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne throw pas sur erreur DB inattendue (webhook doit toujours répondre 200)", async () => {
|
||||||
|
insertMock.mockResolvedValue({
|
||||||
|
error: { code: "08006", message: "connection failure" },
|
||||||
|
});
|
||||||
|
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||||
|
await expect(markEventProcessed("evt_fail")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-op pour un eventId vide", async () => {
|
||||||
|
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||||
|
await markEventProcessed("");
|
||||||
|
expect(insertMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,80 +1,119 @@
|
||||||
import Stripe from 'stripe'
|
import Stripe from "stripe";
|
||||||
|
|
||||||
function getStripe() {
|
function getStripe() {
|
||||||
return new Stripe(process.env.STRIPE_SECRET_KEY ?? '')
|
return new Stripe(process.env.STRIPE_SECRET_KEY ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateCheckoutSessionParams {
|
interface CreateCheckoutSessionParams {
|
||||||
userId: string
|
userId: string;
|
||||||
priceId: string
|
priceId: string;
|
||||||
planName: string
|
planName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCheckoutSession(
|
export async function createCheckoutSession(
|
||||||
params: CreateCheckoutSessionParams
|
params: CreateCheckoutSessionParams,
|
||||||
): Promise<{ url: string }> {
|
): Promise<{ url: string }> {
|
||||||
const { userId, priceId, planName } = params
|
const { userId, priceId, planName } = params;
|
||||||
|
|
||||||
if (!userId) throw new Error('userId requis')
|
if (!userId) throw new Error("userId requis");
|
||||||
if (!priceId) throw new Error('priceId requis')
|
if (!priceId) throw new Error("priceId requis");
|
||||||
if (!planName) throw new Error('planName requis')
|
if (!planName) throw new Error("planName requis");
|
||||||
|
|
||||||
const appUrl = process.env.APP_URL
|
const appUrl = process.env.APP_URL;
|
||||||
if (!appUrl) throw new Error('APP_URL non configuré')
|
if (!appUrl) throw new Error("APP_URL non configuré");
|
||||||
|
|
||||||
const session = await getStripe().checkout.sessions.create({
|
const session = await getStripe().checkout.sessions.create({
|
||||||
mode: 'subscription',
|
mode: "subscription",
|
||||||
line_items: [{ price: priceId, quantity: 1 }],
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
success_url: `${appUrl}/dashboard?upgrade=success`,
|
success_url: `${appUrl}/dashboard?upgrade=success`,
|
||||||
cancel_url: `${appUrl}/tarifs?upgrade=cancelled`,
|
cancel_url: `${appUrl}/tarifs?upgrade=cancelled`,
|
||||||
client_reference_id: userId,
|
client_reference_id: userId,
|
||||||
metadata: { userId, planName },
|
metadata: { userId, planName },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!session.url) {
|
if (!session.url) {
|
||||||
throw new Error('Stripe n\'a pas retourné d\'URL de checkout')
|
throw new Error("Stripe n'a pas retourné d'URL de checkout");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { url: session.url }
|
return { url: session.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateBillingPortalSessionParams {
|
||||||
|
customerId: string;
|
||||||
|
returnUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprint 5a — Crée une session Stripe Billing Portal pour permettre à
|
||||||
|
* l'utilisateur de gérer son abonnement (mise à jour moyen de paiement,
|
||||||
|
* factures, résiliation) via l'interface hébergée Stripe.
|
||||||
|
*/
|
||||||
|
export async function createBillingPortalSession(
|
||||||
|
params: CreateBillingPortalSessionParams,
|
||||||
|
): Promise<{ url: string }> {
|
||||||
|
const { customerId, returnUrl } = params;
|
||||||
|
|
||||||
|
if (!customerId) throw new Error("customerId requis");
|
||||||
|
if (!returnUrl) throw new Error("returnUrl requis");
|
||||||
|
|
||||||
|
const session = await getStripe().billingPortal.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
return_url: returnUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session.url) {
|
||||||
|
throw new Error("Stripe n'a pas retourné d'URL de billing portal");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: session.url };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyStripeWebhook(
|
export function verifyStripeWebhook(
|
||||||
payload: Buffer,
|
payload: Buffer,
|
||||||
signature: string,
|
signature: string,
|
||||||
secret: string
|
secret: string,
|
||||||
): { valid: boolean; event?: Stripe.Event; error?: string } {
|
): { valid: boolean; event?: Stripe.Event; error?: string } {
|
||||||
if (!payload.length || !signature) {
|
if (!payload.length || !signature) {
|
||||||
return { valid: false, error: 'Payload ou signature manquant' }
|
return { valid: false, error: "Payload ou signature manquant" };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const event = getStripe().webhooks.constructEvent(payload, signature, secret)
|
const event = getStripe().webhooks.constructEvent(
|
||||||
return { valid: true, event }
|
payload,
|
||||||
|
signature,
|
||||||
|
secret,
|
||||||
|
);
|
||||||
|
return { valid: true, event };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { valid: false, error: (err as Error).message }
|
return { valid: false, error: (err as Error).message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProrataParams {
|
interface ProrataParams {
|
||||||
currentPlanPrice: number
|
currentPlanPrice: number;
|
||||||
newPlanPrice: number
|
newPlanPrice: number;
|
||||||
totalDays: number
|
totalDays: number;
|
||||||
daysRemaining: number
|
daysRemaining: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateProrata(params: ProrataParams): { amount: number } {
|
export function calculateProrata(params: ProrataParams): { amount: number } {
|
||||||
const { currentPlanPrice, newPlanPrice, totalDays, daysRemaining } = params
|
const { currentPlanPrice, newPlanPrice, totalDays, daysRemaining } = params;
|
||||||
|
|
||||||
if (currentPlanPrice < 0 || newPlanPrice < 0 || totalDays < 0 || daysRemaining < 0) {
|
if (
|
||||||
throw new Error('Les valeurs ne peuvent pas être négatives')
|
currentPlanPrice < 0 ||
|
||||||
|
newPlanPrice < 0 ||
|
||||||
|
totalDays < 0 ||
|
||||||
|
daysRemaining < 0
|
||||||
|
) {
|
||||||
|
throw new Error("Les valeurs ne peuvent pas être négatives");
|
||||||
}
|
}
|
||||||
if (daysRemaining > totalDays) {
|
if (daysRemaining > totalDays) {
|
||||||
throw new Error('daysRemaining ne peut pas dépasser totalDays')
|
throw new Error("daysRemaining ne peut pas dépasser totalDays");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ratio = daysRemaining / totalDays
|
const ratio = daysRemaining / totalDays;
|
||||||
const credit = currentPlanPrice * ratio
|
const credit = currentPlanPrice * ratio;
|
||||||
const cost = newPlanPrice * ratio
|
const cost = newPlanPrice * ratio;
|
||||||
const amount = Math.max(0, cost - credit)
|
const amount = Math.max(0, cost - credit);
|
||||||
|
|
||||||
return { amount }
|
return { amount };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
src/lib/stripeWebhookEvents.ts
Normal file
55
src/lib/stripeWebhookEvents.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Sprint 5a — Idempotency des webhooks Stripe (TD-13).
|
||||||
|
*
|
||||||
|
* Helper isolé pour interroger / journaliser la table `stripe_webhook_events`.
|
||||||
|
* Utilisé par `routes/stripe.ts` autour de chaque appel à `handleStripeEvent`.
|
||||||
|
*
|
||||||
|
* Voir migration `007_sprint_5a_stripe_webhook_events.sql` pour le schéma.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { supabase } from "./supabase.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si un `event.id` Stripe a déjà été traité (présent dans la table
|
||||||
|
* `stripe_webhook_events`). Retourne `false` en cas d'erreur de lecture pour
|
||||||
|
* privilégier la disponibilité du webhook (mieux vaut un double traitement
|
||||||
|
* — opérations métier idempotentes — qu'un drop silencieux).
|
||||||
|
*/
|
||||||
|
export async function isEventProcessed(eventId: string): Promise<boolean> {
|
||||||
|
if (!eventId) return false;
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("stripe_webhook_events")
|
||||||
|
.select("id")
|
||||||
|
.eq("id", eventId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) {
|
||||||
|
console.warn(
|
||||||
|
`[stripeWebhookEvents.isEventProcessed] lecture en erreur pour ${eventId} : ${error.message}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return data !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Journalise un event.id comme traité. INSERT idempotent (`ON CONFLICT DO
|
||||||
|
* NOTHING` via la PRIMARY KEY) — un échec d'insert ne doit JAMAIS faire
|
||||||
|
* échouer la réponse 200 du webhook (Stripe retenterait), donc on log et
|
||||||
|
* on retourne sans throw.
|
||||||
|
*/
|
||||||
|
export async function markEventProcessed(eventId: string): Promise<void> {
|
||||||
|
if (!eventId) return;
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("stripe_webhook_events")
|
||||||
|
.insert({ id: eventId });
|
||||||
|
if (error) {
|
||||||
|
// Code Postgres `23505` = unique_violation → l'event a déjà été marqué
|
||||||
|
// par une livraison concurrente, c'est exactement ce qu'on cherche
|
||||||
|
// (no-op silencieux). Tout autre code est loggé.
|
||||||
|
if (error.code !== "23505") {
|
||||||
|
console.error(
|
||||||
|
`[stripeWebhookEvents.markEventProcessed] insert en erreur pour ${eventId} : ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,283 +1,452 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { Hono } from 'hono'
|
import { Hono } from "hono";
|
||||||
|
|
||||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const {
|
const {
|
||||||
createCheckoutSessionMock,
|
createCheckoutSessionMock,
|
||||||
|
createBillingPortalSessionMock,
|
||||||
verifyStripeWebhookMock,
|
verifyStripeWebhookMock,
|
||||||
updateUserPlanMock,
|
updateUserPlanMock,
|
||||||
updateUserStripeInfoMock,
|
updateUserStripeInfoMock,
|
||||||
findUserBySubscriptionIdMock,
|
findUserBySubscriptionIdMock,
|
||||||
|
isEventProcessedMock,
|
||||||
|
markEventProcessedMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
createCheckoutSessionMock: vi.fn(),
|
createCheckoutSessionMock: vi.fn(),
|
||||||
|
createBillingPortalSessionMock: vi.fn(),
|
||||||
verifyStripeWebhookMock: vi.fn(),
|
verifyStripeWebhookMock: vi.fn(),
|
||||||
updateUserPlanMock: vi.fn(),
|
updateUserPlanMock: vi.fn(),
|
||||||
updateUserStripeInfoMock: vi.fn(),
|
updateUserStripeInfoMock: vi.fn(),
|
||||||
findUserBySubscriptionIdMock: vi.fn(),
|
findUserBySubscriptionIdMock: vi.fn(),
|
||||||
}))
|
isEventProcessedMock: vi.fn(),
|
||||||
|
markEventProcessedMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/stripe', () => ({
|
vi.mock("../../lib/stripe", () => ({
|
||||||
createCheckoutSession: createCheckoutSessionMock,
|
createCheckoutSession: createCheckoutSessionMock,
|
||||||
|
createBillingPortalSession: createBillingPortalSessionMock,
|
||||||
verifyStripeWebhook: verifyStripeWebhookMock,
|
verifyStripeWebhook: verifyStripeWebhookMock,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/planController', () => ({
|
vi.mock("../../lib/planController", () => ({
|
||||||
updateUserPlan: updateUserPlanMock,
|
updateUserPlan: updateUserPlanMock,
|
||||||
updateUserStripeInfo: updateUserStripeInfoMock,
|
updateUserStripeInfo: updateUserStripeInfoMock,
|
||||||
findUserBySubscriptionId: findUserBySubscriptionIdMock,
|
findUserBySubscriptionId: findUserBySubscriptionIdMock,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
vi.mock('../../middleware/auth', () => ({
|
vi.mock("../../lib/stripeWebhookEvents", () => ({
|
||||||
authMiddleware: async (c: any, next: any) => {
|
isEventProcessed: isEventProcessedMock,
|
||||||
const authHeader = c.req.header('Authorization')
|
markEventProcessed: markEventProcessedMock,
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
}));
|
||||||
return c.json({ error: true, code: 'AUTH_REQUIRED' }, 401)
|
|
||||||
}
|
// Permet aux tests d'injecter un profil custom (ex. avec stripe_customer_id).
|
||||||
c.set('user', { id: 'test-user-id', email: 'user@test.com' })
|
const { profileOverrideRef } = vi.hoisted(() => ({
|
||||||
c.set('profile', {
|
profileOverrideRef: {
|
||||||
id: 'test-user-id',
|
current: null as null | Record<string, unknown>,
|
||||||
email: 'user@test.com',
|
|
||||||
plan: 'free',
|
|
||||||
simulations_used: 0,
|
|
||||||
stripe_customer_id: null,
|
|
||||||
stripe_subscription_id: null,
|
|
||||||
plan_expires_at: null,
|
|
||||||
created_at: '2026-01-01',
|
|
||||||
updated_at: '2026-01-01',
|
|
||||||
})
|
|
||||||
await next()
|
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
|
|
||||||
import stripeRoutes from '../stripe'
|
vi.mock("../../middleware/auth", () => ({
|
||||||
|
authMiddleware: async (c: any, next: any) => {
|
||||||
|
const authHeader = c.req.header("Authorization");
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
|
||||||
|
}
|
||||||
|
c.set("user", { id: "test-user-id", email: "user@test.com" });
|
||||||
|
c.set(
|
||||||
|
"profile",
|
||||||
|
profileOverrideRef.current ?? {
|
||||||
|
id: "test-user-id",
|
||||||
|
email: "user@test.com",
|
||||||
|
plan: "free",
|
||||||
|
simulations_used: 0,
|
||||||
|
stripe_customer_id: null,
|
||||||
|
stripe_subscription_id: null,
|
||||||
|
plan_expires_at: null,
|
||||||
|
created_at: "2026-01-01",
|
||||||
|
updated_at: "2026-01-01",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import stripeRoutes from "../stripe";
|
||||||
|
|
||||||
function buildApp() {
|
function buildApp() {
|
||||||
const app = new Hono()
|
const app = new Hono();
|
||||||
app.route('/stripe', stripeRoutes)
|
app.route("/stripe", stripeRoutes);
|
||||||
return app
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('POST /stripe/checkout', () => {
|
describe("POST /stripe/checkout", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createCheckoutSessionMock.mockReset()
|
createCheckoutSessionMock.mockReset();
|
||||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'
|
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
|
||||||
})
|
});
|
||||||
|
|
||||||
it('retourne l\'URL de checkout pour un utilisateur authentifié', async () => {
|
it("retourne l'URL de checkout pour un utilisateur authentifié", async () => {
|
||||||
createCheckoutSessionMock.mockResolvedValue({
|
createCheckoutSessionMock.mockResolvedValue({
|
||||||
url: 'https://checkout.stripe.com/pay/cs_xyz',
|
url: "https://checkout.stripe.com/pay/cs_xyz",
|
||||||
})
|
});
|
||||||
|
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/checkout', {
|
const res = await app.request("/stripe/checkout", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'Bearer valid-token',
|
Authorization: "Bearer valid-token",
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ priceId: 'price_standard', planName: 'standard' }),
|
body: JSON.stringify({ priceId: "price_standard", planName: "standard" }),
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json()
|
const body = await res.json();
|
||||||
expect(body.url).toBe('https://checkout.stripe.com/pay/cs_xyz')
|
expect(body.url).toBe("https://checkout.stripe.com/pay/cs_xyz");
|
||||||
expect(createCheckoutSessionMock).toHaveBeenCalledWith({
|
expect(createCheckoutSessionMock).toHaveBeenCalledWith({
|
||||||
userId: 'test-user-id',
|
userId: "test-user-id",
|
||||||
priceId: 'price_standard',
|
priceId: "price_standard",
|
||||||
planName: 'standard',
|
planName: "standard",
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
it('retourne 401 sans authentification', async () => {
|
it("retourne 401 sans authentification", async () => {
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/checkout', {
|
const res = await app.request("/stripe/checkout", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ priceId: 'p1', planName: 'standard' }),
|
body: JSON.stringify({ priceId: "p1", planName: "standard" }),
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(401);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('retourne 400 si priceId ou planName manquent', async () => {
|
it("retourne 400 si priceId ou planName manquent", async () => {
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/checkout', {
|
const res = await app.request("/stripe/checkout", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'Bearer valid-token',
|
Authorization: "Bearer valid-token",
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ priceId: 'p1' }),
|
body: JSON.stringify({ priceId: "p1" }),
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json()
|
const body = await res.json();
|
||||||
expect(body.code).toBe('INVALID_BODY')
|
expect(body.code).toBe("INVALID_BODY");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('retourne 400 pour un planName inconnu', async () => {
|
it("retourne 400 pour un planName inconnu", async () => {
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/checkout', {
|
const res = await app.request("/stripe/checkout", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'Bearer valid-token',
|
Authorization: "Bearer valid-token",
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ priceId: 'p1', planName: 'super_premium' }),
|
body: JSON.stringify({ priceId: "p1", planName: "super_premium" }),
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json()
|
const body = await res.json();
|
||||||
expect(body.code).toBe('INVALID_PLAN')
|
expect(body.code).toBe("INVALID_PLAN");
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('POST /stripe/webhook', () => {
|
describe("POST /stripe/webhook", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
verifyStripeWebhookMock.mockReset()
|
verifyStripeWebhookMock.mockReset();
|
||||||
updateUserPlanMock.mockReset()
|
updateUserPlanMock.mockReset();
|
||||||
updateUserStripeInfoMock.mockReset()
|
updateUserStripeInfoMock.mockReset();
|
||||||
findUserBySubscriptionIdMock.mockReset()
|
findUserBySubscriptionIdMock.mockReset();
|
||||||
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'
|
isEventProcessedMock.mockReset();
|
||||||
process.env.STRIPE_PRICE_STANDARD = 'price_standard'
|
markEventProcessedMock.mockReset();
|
||||||
process.env.STRIPE_PRICE_PREMIUM = 'price_premium'
|
// Défaut : event jamais vu → traitement normal pour les tests existants.
|
||||||
})
|
isEventProcessedMock.mockResolvedValue(false);
|
||||||
|
markEventProcessedMock.mockResolvedValue(undefined);
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
|
||||||
|
process.env.STRIPE_PRICE_STANDARD = "price_standard";
|
||||||
|
process.env.STRIPE_PRICE_PREMIUM = "price_premium";
|
||||||
|
});
|
||||||
|
|
||||||
it('rejette un webhook sans signature', async () => {
|
it("rejette un webhook sans signature", async () => {
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/webhook', {
|
const res = await app.request("/stripe/webhook", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: 'payload',
|
body: "payload",
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json()
|
const body = await res.json();
|
||||||
expect(body.code).toBe('STRIPE_WEBHOOK_INVALID')
|
expect(body.code).toBe("STRIPE_WEBHOOK_INVALID");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('rejette un webhook avec signature invalide', async () => {
|
it("rejette un webhook avec signature invalide", async () => {
|
||||||
verifyStripeWebhookMock.mockReturnValue({
|
verifyStripeWebhookMock.mockReturnValue({
|
||||||
valid: false,
|
valid: false,
|
||||||
error: 'No signatures match',
|
error: "No signatures match",
|
||||||
})
|
});
|
||||||
|
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/webhook', {
|
const res = await app.request("/stripe/webhook", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'stripe-signature': 'bad-sig' },
|
headers: { "stripe-signature": "bad-sig" },
|
||||||
body: 'payload',
|
body: "payload",
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json()
|
const body = await res.json();
|
||||||
expect(body.code).toBe('STRIPE_WEBHOOK_INVALID')
|
expect(body.code).toBe("STRIPE_WEBHOOK_INVALID");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('traite checkout.session.completed → met à jour plan + stripe info', async () => {
|
it("traite checkout.session.completed → met à jour plan + stripe info", async () => {
|
||||||
verifyStripeWebhookMock.mockReturnValue({
|
verifyStripeWebhookMock.mockReturnValue({
|
||||||
valid: true,
|
valid: true,
|
||||||
event: {
|
event: {
|
||||||
type: 'checkout.session.completed',
|
type: "checkout.session.completed",
|
||||||
data: {
|
data: {
|
||||||
object: {
|
object: {
|
||||||
metadata: { userId: 'user-42', planName: 'premium' },
|
metadata: { userId: "user-42", planName: "premium" },
|
||||||
customer: 'cus_abc',
|
customer: "cus_abc",
|
||||||
subscription: 'sub_abc',
|
subscription: "sub_abc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
updateUserPlanMock.mockResolvedValue({ success: true, plan: 'premium' })
|
updateUserPlanMock.mockResolvedValue({ success: true, plan: "premium" });
|
||||||
updateUserStripeInfoMock.mockResolvedValue({ success: true })
|
updateUserStripeInfoMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/webhook', {
|
const res = await app.request("/stripe/webhook", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'stripe-signature': 'good-sig' },
|
headers: { "stripe-signature": "good-sig" },
|
||||||
body: 'payload',
|
body: "payload",
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200);
|
||||||
expect(updateUserPlanMock).toHaveBeenCalledWith('user-42', 'premium')
|
expect(updateUserPlanMock).toHaveBeenCalledWith("user-42", "premium");
|
||||||
expect(updateUserStripeInfoMock).toHaveBeenCalledWith('user-42', {
|
expect(updateUserStripeInfoMock).toHaveBeenCalledWith("user-42", {
|
||||||
stripe_customer_id: 'cus_abc',
|
stripe_customer_id: "cus_abc",
|
||||||
stripe_subscription_id: 'sub_abc',
|
stripe_subscription_id: "sub_abc",
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
it('traite customer.subscription.deleted → remet le plan à free', async () => {
|
it("traite customer.subscription.deleted → remet le plan à free", async () => {
|
||||||
verifyStripeWebhookMock.mockReturnValue({
|
verifyStripeWebhookMock.mockReturnValue({
|
||||||
valid: true,
|
valid: true,
|
||||||
event: {
|
event: {
|
||||||
type: 'customer.subscription.deleted',
|
type: "customer.subscription.deleted",
|
||||||
data: { object: { id: 'sub_abc' } },
|
data: { object: { id: "sub_abc" } },
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
findUserBySubscriptionIdMock.mockResolvedValue({ userId: 'user-42' })
|
findUserBySubscriptionIdMock.mockResolvedValue({ userId: "user-42" });
|
||||||
updateUserPlanMock.mockResolvedValue({ success: true, plan: 'free' })
|
updateUserPlanMock.mockResolvedValue({ success: true, plan: "free" });
|
||||||
updateUserStripeInfoMock.mockResolvedValue({ success: true })
|
updateUserStripeInfoMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/webhook', {
|
const res = await app.request("/stripe/webhook", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'stripe-signature': 'good-sig' },
|
headers: { "stripe-signature": "good-sig" },
|
||||||
body: 'payload',
|
body: "payload",
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200);
|
||||||
expect(findUserBySubscriptionIdMock).toHaveBeenCalledWith('sub_abc')
|
expect(findUserBySubscriptionIdMock).toHaveBeenCalledWith("sub_abc");
|
||||||
expect(updateUserPlanMock).toHaveBeenCalledWith('user-42', 'free')
|
expect(updateUserPlanMock).toHaveBeenCalledWith("user-42", "free");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('traite invoice.paid avec price Premium → plan premium', async () => {
|
it("traite invoice.paid avec price Premium → plan premium", async () => {
|
||||||
verifyStripeWebhookMock.mockReturnValue({
|
verifyStripeWebhookMock.mockReturnValue({
|
||||||
valid: true,
|
valid: true,
|
||||||
event: {
|
event: {
|
||||||
type: 'invoice.paid',
|
type: "invoice.paid",
|
||||||
data: {
|
data: {
|
||||||
object: {
|
object: {
|
||||||
subscription: 'sub_xyz',
|
subscription: "sub_xyz",
|
||||||
lines: {
|
lines: {
|
||||||
data: [{ price: { id: 'price_premium' } }],
|
data: [{ price: { id: "price_premium" } }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
findUserBySubscriptionIdMock.mockResolvedValue({ userId: 'user-99' })
|
findUserBySubscriptionIdMock.mockResolvedValue({ userId: "user-99" });
|
||||||
updateUserPlanMock.mockResolvedValue({ success: true, plan: 'premium' })
|
updateUserPlanMock.mockResolvedValue({ success: true, plan: "premium" });
|
||||||
|
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/webhook', {
|
const res = await app.request("/stripe/webhook", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'stripe-signature': 'good-sig' },
|
headers: { "stripe-signature": "good-sig" },
|
||||||
body: 'payload',
|
body: "payload",
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200);
|
||||||
expect(updateUserPlanMock).toHaveBeenCalledWith('user-99', 'premium')
|
expect(updateUserPlanMock).toHaveBeenCalledWith("user-99", "premium");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('retourne 200 pour un event non géré', async () => {
|
it("retourne 200 pour un event non géré", async () => {
|
||||||
verifyStripeWebhookMock.mockReturnValue({
|
verifyStripeWebhookMock.mockReturnValue({
|
||||||
valid: true,
|
valid: true,
|
||||||
event: {
|
event: {
|
||||||
type: 'ping.unknown',
|
type: "ping.unknown",
|
||||||
data: { object: {} },
|
data: { object: {} },
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const app = buildApp()
|
const app = buildApp();
|
||||||
const res = await app.request('/stripe/webhook', {
|
const res = await app.request("/stripe/webhook", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'stripe-signature': 'good-sig' },
|
headers: { "stripe-signature": "good-sig" },
|
||||||
body: 'payload',
|
body: "payload",
|
||||||
})
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200);
|
||||||
expect(updateUserPlanMock).not.toHaveBeenCalled()
|
expect(updateUserPlanMock).not.toHaveBeenCalled();
|
||||||
})
|
});
|
||||||
})
|
|
||||||
|
// ─── Sprint 5a — Idempotency (TD-13) ──────────────────────────────────────
|
||||||
|
|
||||||
|
it("event déjà traité → 200 replayed sans appel handler ni mark", async () => {
|
||||||
|
verifyStripeWebhookMock.mockReturnValue({
|
||||||
|
valid: true,
|
||||||
|
event: {
|
||||||
|
id: "evt_already",
|
||||||
|
type: "checkout.session.completed",
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
metadata: { userId: "user-x", planName: "standard" },
|
||||||
|
customer: "cus_x",
|
||||||
|
subscription: "sub_x",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
isEventProcessedMock.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const app = buildApp();
|
||||||
|
const res = await app.request("/stripe/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "stripe-signature": "good-sig" },
|
||||||
|
body: "payload",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ received: true, replayed: true });
|
||||||
|
expect(updateUserPlanMock).not.toHaveBeenCalled();
|
||||||
|
expect(updateUserStripeInfoMock).not.toHaveBeenCalled();
|
||||||
|
expect(markEventProcessedMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event nouveau → traitement normal puis markEventProcessed(event.id)", async () => {
|
||||||
|
verifyStripeWebhookMock.mockReturnValue({
|
||||||
|
valid: true,
|
||||||
|
event: {
|
||||||
|
id: "evt_fresh",
|
||||||
|
type: "checkout.session.completed",
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
metadata: { userId: "user-fresh", planName: "standard" },
|
||||||
|
customer: "cus_fresh",
|
||||||
|
subscription: "sub_fresh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
isEventProcessedMock.mockResolvedValue(false);
|
||||||
|
updateUserPlanMock.mockResolvedValue({ success: true, plan: "standard" });
|
||||||
|
updateUserStripeInfoMock.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
const app = buildApp();
|
||||||
|
const res = await app.request("/stripe/webhook", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "stripe-signature": "good-sig" },
|
||||||
|
body: "payload",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ received: true });
|
||||||
|
expect(updateUserPlanMock).toHaveBeenCalledWith("user-fresh", "standard");
|
||||||
|
expect(markEventProcessedMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(markEventProcessedMock).toHaveBeenCalledWith("evt_fresh");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Sprint 5a — POST /stripe/customer-portal ────────────────────────────────
|
||||||
|
|
||||||
|
describe("POST /stripe/customer-portal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createBillingPortalSessionMock.mockReset();
|
||||||
|
profileOverrideRef.current = null;
|
||||||
|
process.env.APP_URL = "https://expria.app";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 401 sans authentification", async () => {
|
||||||
|
const app = buildApp();
|
||||||
|
const res = await app.request("/stripe/customer-portal", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(createBillingPortalSessionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne 400 NO_ACTIVE_SUBSCRIPTION quand stripe_customer_id est absent", async () => {
|
||||||
|
profileOverrideRef.current = {
|
||||||
|
id: "u1",
|
||||||
|
email: "u@test.com",
|
||||||
|
plan: "free",
|
||||||
|
simulations_used: 0,
|
||||||
|
stripe_customer_id: null,
|
||||||
|
stripe_subscription_id: null,
|
||||||
|
plan_expires_at: null,
|
||||||
|
created_at: "2026-01-01",
|
||||||
|
updated_at: "2026-01-01",
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = buildApp();
|
||||||
|
const res = await app.request("/stripe/customer-portal", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: "Bearer valid-token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.code).toBe("NO_ACTIVE_SUBSCRIPTION");
|
||||||
|
expect(createBillingPortalSessionMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retourne l'URL de la billing portal session pour un user avec stripe_customer_id", async () => {
|
||||||
|
profileOverrideRef.current = {
|
||||||
|
id: "u1",
|
||||||
|
email: "u@test.com",
|
||||||
|
plan: "standard",
|
||||||
|
simulations_used: 0,
|
||||||
|
stripe_customer_id: "cus_existing",
|
||||||
|
stripe_subscription_id: "sub_existing",
|
||||||
|
plan_expires_at: null,
|
||||||
|
created_at: "2026-01-01",
|
||||||
|
updated_at: "2026-01-01",
|
||||||
|
};
|
||||||
|
createBillingPortalSessionMock.mockResolvedValue({
|
||||||
|
url: "https://billing.stripe.com/p/session/abc",
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = buildApp();
|
||||||
|
const res = await app.request("/stripe/customer-portal", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: "Bearer valid-token" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.url).toBe("https://billing.stripe.com/p/session/abc");
|
||||||
|
expect(createBillingPortalSessionMock).toHaveBeenCalledWith({
|
||||||
|
customerId: "cus_existing",
|
||||||
|
returnUrl: "https://expria.app/dashboard",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,56 @@
|
||||||
import { Hono } from 'hono'
|
import { Hono } from "hono";
|
||||||
import type Stripe from 'stripe'
|
import type Stripe from "stripe";
|
||||||
import { authMiddleware } from '../middleware/auth.js'
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
import type { AppVariables } from '../middleware/auth.js'
|
import type { AppVariables } from "../middleware/auth.js";
|
||||||
import { createCheckoutSession, verifyStripeWebhook } from '../lib/stripe.js'
|
import {
|
||||||
|
createBillingPortalSession,
|
||||||
|
createCheckoutSession,
|
||||||
|
verifyStripeWebhook,
|
||||||
|
} from "../lib/stripe.js";
|
||||||
import {
|
import {
|
||||||
updateUserPlan,
|
updateUserPlan,
|
||||||
updateUserStripeInfo,
|
updateUserStripeInfo,
|
||||||
findUserBySubscriptionId,
|
findUserBySubscriptionId,
|
||||||
} from '../lib/planController.js'
|
} from "../lib/planController.js";
|
||||||
import type { Plan } from '../lib/access.js'
|
import {
|
||||||
import { PLANS } from '../lib/access.js'
|
isEventProcessed,
|
||||||
|
markEventProcessed,
|
||||||
|
} from "../lib/stripeWebhookEvents.js";
|
||||||
|
import type { Plan } from "../lib/access.js";
|
||||||
|
import { PLANS } from "../lib/access.js";
|
||||||
|
|
||||||
const stripeRoutes = new Hono<{ Variables: AppVariables }>()
|
const stripeRoutes = new Hono<{ Variables: AppVariables }>();
|
||||||
|
|
||||||
stripeRoutes.post('/checkout', authMiddleware, async (c) => {
|
stripeRoutes.post("/checkout", authMiddleware, async (c) => {
|
||||||
const user = c.get('user')
|
const user = c.get("user");
|
||||||
|
|
||||||
let body: { priceId?: string; planName?: string }
|
let body: { priceId?: string; planName?: string };
|
||||||
try {
|
try {
|
||||||
body = await c.req.json()
|
body = await c.req.json();
|
||||||
} catch {
|
} catch {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: true, code: 'INVALID_BODY', message: 'JSON invalide.' },
|
{ error: true, code: "INVALID_BODY", message: "JSON invalide." },
|
||||||
400
|
400,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { priceId, planName } = body
|
const { priceId, planName } = body;
|
||||||
if (!priceId || !planName) {
|
if (!priceId || !planName) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
code: 'INVALID_BODY',
|
code: "INVALID_BODY",
|
||||||
message: 'priceId et planName sont requis.',
|
message: "priceId et planName sont requis.",
|
||||||
},
|
},
|
||||||
400
|
400,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(planName in PLANS)) {
|
if (!(planName in PLANS)) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: true, code: 'INVALID_PLAN', message: 'Plan inconnu.' },
|
{ error: true, code: "INVALID_PLAN", message: "Plan inconnu." },
|
||||||
400
|
400,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -50,139 +58,192 @@ stripeRoutes.post('/checkout', authMiddleware, async (c) => {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
priceId,
|
priceId,
|
||||||
planName,
|
planName,
|
||||||
})
|
});
|
||||||
return c.json({ url }, 200)
|
return c.json({ url }, 200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
code: 'INTERNAL_ERROR',
|
code: "INTERNAL_ERROR",
|
||||||
message: (err as Error).message,
|
message: (err as Error).message,
|
||||||
},
|
},
|
||||||
500
|
500,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
stripeRoutes.post('/webhook', async (c) => {
|
stripeRoutes.post("/customer-portal", authMiddleware, async (c) => {
|
||||||
const signature = c.req.header('stripe-signature')
|
const profile = c.get("profile");
|
||||||
|
const customerId = profile.stripe_customer_id;
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: "NO_ACTIVE_SUBSCRIPTION",
|
||||||
|
message: "Aucun abonnement actif trouvé. Souscrivez d'abord à un plan.",
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = process.env.APP_URL;
|
||||||
|
if (!appUrl) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: "INTERNAL_ERROR",
|
||||||
|
message: "APP_URL non configuré.",
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url } = await createBillingPortalSession({
|
||||||
|
customerId,
|
||||||
|
returnUrl: `${appUrl}/dashboard`,
|
||||||
|
});
|
||||||
|
return c.json({ url }, 200);
|
||||||
|
} catch (err) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error: true,
|
||||||
|
code: "INTERNAL_ERROR",
|
||||||
|
message: (err as Error).message,
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stripeRoutes.post("/webhook", async (c) => {
|
||||||
|
const signature = c.req.header("stripe-signature");
|
||||||
if (!signature) {
|
if (!signature) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
code: 'STRIPE_WEBHOOK_INVALID',
|
code: "STRIPE_WEBHOOK_INVALID",
|
||||||
message: 'Signature manquante.',
|
message: "Signature manquante.",
|
||||||
},
|
},
|
||||||
400
|
400,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = process.env.STRIPE_WEBHOOK_SECRET
|
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
code: 'INTERNAL_ERROR',
|
code: "INTERNAL_ERROR",
|
||||||
message: 'STRIPE_WEBHOOK_SECRET non configuré.',
|
message: "STRIPE_WEBHOOK_SECRET non configuré.",
|
||||||
},
|
},
|
||||||
500
|
500,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrayBuffer = await c.req.arrayBuffer()
|
const arrayBuffer = await c.req.arrayBuffer();
|
||||||
const payload = Buffer.from(arrayBuffer)
|
const payload = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
const verified = verifyStripeWebhook(payload, signature, secret)
|
const verified = verifyStripeWebhook(payload, signature, secret);
|
||||||
if (!verified.valid || !verified.event) {
|
if (!verified.valid || !verified.event) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: true,
|
error: true,
|
||||||
code: 'STRIPE_WEBHOOK_INVALID',
|
code: "STRIPE_WEBHOOK_INVALID",
|
||||||
message: verified.error ?? 'Signature invalide.',
|
message: verified.error ?? "Signature invalide.",
|
||||||
},
|
},
|
||||||
400
|
400,
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sprint 5a — TD-13 : déduplication des deliveries Stripe.
|
||||||
|
if (await isEventProcessed(verified.event.id)) {
|
||||||
|
return c.json({ received: true, replayed: true }, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleStripeEvent(verified.event)
|
await handleStripeEvent(verified.event);
|
||||||
|
await markEventProcessed(verified.event.id);
|
||||||
} catch {
|
} catch {
|
||||||
// On renvoie 200 malgré l'erreur interne pour éviter les retries Stripe
|
// On renvoie 200 malgré l'erreur interne pour éviter les retries Stripe
|
||||||
// en boucle. L'erreur est tracée côté logs serveur.
|
// en boucle. L'erreur est tracée côté logs serveur. L'event N'EST PAS
|
||||||
|
// marqué comme traité — Stripe pourra le rejouer après correction du bug.
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ received: true }, 200)
|
return c.json({ received: true }, 200);
|
||||||
})
|
});
|
||||||
|
|
||||||
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
|
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'checkout.session.completed': {
|
case "checkout.session.completed": {
|
||||||
const session = event.data.object as Stripe.Checkout.Session
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
const userId = session.metadata?.userId
|
const userId = session.metadata?.userId;
|
||||||
const planName = session.metadata?.planName as Plan | undefined
|
const planName = session.metadata?.planName as Plan | undefined;
|
||||||
if (!userId || !planName || !(planName in PLANS)) return
|
if (!userId || !planName || !(planName in PLANS)) return;
|
||||||
|
|
||||||
await updateUserPlan(userId, planName)
|
await updateUserPlan(userId, planName);
|
||||||
|
|
||||||
const customerId = typeof session.customer === 'string' ? session.customer : null
|
const customerId =
|
||||||
|
typeof session.customer === "string" ? session.customer : null;
|
||||||
const subscriptionId =
|
const subscriptionId =
|
||||||
typeof session.subscription === 'string' ? session.subscription : null
|
typeof session.subscription === "string" ? session.subscription : null;
|
||||||
|
|
||||||
await updateUserStripeInfo(userId, {
|
await updateUserStripeInfo(userId, {
|
||||||
stripe_customer_id: customerId,
|
stripe_customer_id: customerId,
|
||||||
stripe_subscription_id: subscriptionId,
|
stripe_subscription_id: subscriptionId,
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'invoice.paid': {
|
case "invoice.paid": {
|
||||||
const invoice = event.data.object as Stripe.Invoice & {
|
const invoice = event.data.object as Stripe.Invoice & {
|
||||||
subscription?: string | Stripe.Subscription | null
|
subscription?: string | Stripe.Subscription | null;
|
||||||
}
|
};
|
||||||
const subscriptionId =
|
const subscriptionId =
|
||||||
typeof invoice.subscription === 'string' ? invoice.subscription : null
|
typeof invoice.subscription === "string" ? invoice.subscription : null;
|
||||||
if (!subscriptionId) return
|
if (!subscriptionId) return;
|
||||||
|
|
||||||
const match = await findUserBySubscriptionId(subscriptionId)
|
const match = await findUserBySubscriptionId(subscriptionId);
|
||||||
if (!match) return
|
if (!match) return;
|
||||||
|
|
||||||
const plan = detectPlanFromInvoice(invoice)
|
const plan = detectPlanFromInvoice(invoice);
|
||||||
if (!plan) return
|
if (!plan) return;
|
||||||
|
|
||||||
await updateUserPlan(match.userId, plan)
|
await updateUserPlan(match.userId, plan);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'customer.subscription.deleted': {
|
case "customer.subscription.deleted": {
|
||||||
const subscription = event.data.object as Stripe.Subscription
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
const match = await findUserBySubscriptionId(subscription.id)
|
const match = await findUserBySubscriptionId(subscription.id);
|
||||||
if (!match) return
|
if (!match) return;
|
||||||
|
|
||||||
await updateUserPlan(match.userId, 'free')
|
await updateUserPlan(match.userId, "free");
|
||||||
await updateUserStripeInfo(match.userId, {
|
await updateUserStripeInfo(match.userId, {
|
||||||
stripe_subscription_id: null,
|
stripe_subscription_id: null,
|
||||||
plan_expires_at: null,
|
plan_expires_at: null,
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectPlanFromInvoice(invoice: Stripe.Invoice): Plan | null {
|
function detectPlanFromInvoice(invoice: Stripe.Invoice): Plan | null {
|
||||||
const standardPrice = process.env.STRIPE_PRICE_STANDARD
|
const standardPrice = process.env.STRIPE_PRICE_STANDARD;
|
||||||
const premiumPrice = process.env.STRIPE_PRICE_PREMIUM
|
const premiumPrice = process.env.STRIPE_PRICE_PREMIUM;
|
||||||
|
|
||||||
const lines = invoice.lines?.data ?? []
|
const lines = invoice.lines?.data ?? [];
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const priceId = line.price?.id
|
const priceId = line.price?.id;
|
||||||
if (!priceId) continue
|
if (!priceId) continue;
|
||||||
if (premiumPrice && priceId === premiumPrice) return 'premium'
|
if (premiumPrice && priceId === premiumPrice) return "premium";
|
||||||
if (standardPrice && priceId === standardPrice) return 'standard'
|
if (standardPrice && priceId === standardPrice) return "standard";
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default stripeRoutes
|
export default stripeRoutes;
|
||||||
|
|
|
||||||
30
supabase/migrations/007_sprint_5a_stripe_webhook_events.sql
Normal file
30
supabase/migrations/007_sprint_5a_stripe_webhook_events.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- Sprint 5a — Idempotency des webhooks Stripe (TD-13)
|
||||||
|
--
|
||||||
|
-- Stripe peut livrer le même `event.id` plusieurs fois (retries réseau,
|
||||||
|
-- rejeu manuel depuis le dashboard). Cette table sert de journal de
|
||||||
|
-- déduplication : la route `POST /stripe/webhook` consulte la table
|
||||||
|
-- avant traitement et y insère l'event après succès.
|
||||||
|
--
|
||||||
|
-- Stratégie (cf. TD-13) :
|
||||||
|
-- 1. Avant traitement, `SELECT 1 FROM stripe_webhook_events WHERE id = $1`.
|
||||||
|
-- Présent → retour 200 immédiat sans rien faire.
|
||||||
|
-- 2. Après traitement, `INSERT ... ON CONFLICT DO NOTHING`.
|
||||||
|
--
|
||||||
|
-- Race window résiduelle (deux deliveries concurrentes passent toutes deux
|
||||||
|
-- le SELECT initial) couverte par l'idempotence native des opérations
|
||||||
|
-- métier (`updateUserPlan`, `updateUserStripeInfo`).
|
||||||
|
--
|
||||||
|
-- À exécuter manuellement : `supabase db push` (Hermann — cf. Règle F).
|
||||||
|
-- Idempotent : sûre à rejouer en dev comme en prod.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stripe_webhook_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index pour les futures purges (rétention ~90 jours envisagée).
|
||||||
|
CREATE INDEX IF NOT EXISTS stripe_webhook_events_processed_at_idx
|
||||||
|
ON stripe_webhook_events (processed_at);
|
||||||
|
|
||||||
|
COMMENT ON TABLE stripe_webhook_events IS
|
||||||
|
'Journal de déduplication des webhooks Stripe (Sprint 5a — TD-13).';
|
||||||
Loading…
Add table
Add a link
Reference in a new issue