From 6671bac34719ede6c93ec0e8ae88985058534a82 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sun, 26 Apr 2026 04:15:46 +0300 Subject: [PATCH] feat(billing): TD-13 webhook idempotency + Stripe Customer Portal + doc cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/ARCHITECTURE-backend.md | 27 +- docs/CHANGELOG-backend.md | 28 + docs/TECH_DEBT-backend.md | 16 +- .../createBillingPortalSession.test.ts | 69 +++ src/lib/__tests__/stripeWebhookEvents.test.ts | 101 ++++ src/lib/stripe.ts | 107 ++-- src/lib/stripeWebhookEvents.ts | 55 ++ src/routes/__tests__/stripe.test.ts | 541 ++++++++++++------ src/routes/stripe.ts | 241 +++++--- .../007_sprint_5a_stripe_webhook_events.sql | 30 + 10 files changed, 891 insertions(+), 324 deletions(-) create mode 100644 src/lib/__tests__/createBillingPortalSession.test.ts create mode 100644 src/lib/__tests__/stripeWebhookEvents.test.ts create mode 100644 src/lib/stripeWebhookEvents.ts create mode 100644 supabase/migrations/007_sprint_5a_stripe_webhook_events.sql diff --git a/docs/ARCHITECTURE-backend.md b/docs/ARCHITECTURE-backend.md index 158bf58..2bcfdd5 100644 --- a/docs/ARCHITECTURE-backend.md +++ b/docs/ARCHITECTURE-backend.md @@ -72,6 +72,7 @@ Tier gratuit, déploiement automatique depuis GitHub. ### Pourquoi Supabase est conservé Supabase fournit trois services critiques déjà en production : + - Authentification complète (email, OAuth Google/Apple, sessions JWT) - Base de données PostgreSQL avec Row Level Security - Stockage de fichiers (enregistrements audio EO) @@ -159,8 +160,8 @@ expria-backend/ │ │ ├── auth.ts # POST /auth/verify-token │ │ ├── simulations.ts # POST /simulations, GET /simulations/:id │ │ ├── corrections.ts # POST /corrections/ee, POST /corrections/eo -│ │ ├── plans.ts # GET /plans/status, POST /plans/upgrade -│ │ ├── stripe.ts # POST /stripe/checkout, POST /stripe/webhook +│ │ ├── plans.ts # GET /plans/status, POST /plans/upgrade-prorata +│ │ ├── stripe.ts # POST /stripe/checkout, /stripe/customer-portal, /stripe/webhook │ │ └── t2live.ts # WebSocket /t2/live (T2 EO proxy Gemini) │ ├── controllers/ # Logique métier (une par domaine) │ │ ├── simulationController.ts @@ -292,11 +293,13 @@ USING (auth.uid() = user_id); ## 6. Routes API backend ### Authentification + ``` POST /auth/verify-token Vérifie le JWT Supabase, retourne le profil + plan ``` ### Simulations + ``` POST /simulations Crée une simulation, vérifie les quotas selon le plan GET /simulations/:id Récupère une simulation par ID @@ -304,25 +307,29 @@ GET /simulations Liste les simulations de l'utilisateur connec ``` ### Corrections + ``` POST /corrections/ee Soumet une production EE pour correction (DeepSeek) POST /corrections/eo Soumet une production EO pour correction (Gemini) ``` ### Plans + ``` 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) +POST /plans/upgrade-prorata Upgrade en cours d'abonnement (prorata Stripe — preview du montant) ``` ### Stripe + ``` -POST /stripe/checkout Crée une Checkout Session Stripe -POST /stripe/webhook Reçoit les events Stripe (checkout, invoice, deleted) +POST /stripe/checkout Crée une Checkout Session Stripe (nouveau abonnement) +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 + ``` 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 ### Frontend (.env) + ``` VITE_API_URL=https://api.expria.app # URL du backend Render VITE_SUPABASE_URL=https://xxx.supabase.co @@ -395,6 +403,7 @@ VITE_SUPABASE_ANON_KEY=xxx # Clé publique uniquement ``` ### Backend (.env) + ``` # Supabase SUPABASE_URL=https://xxx.supabase.co @@ -481,29 +490,35 @@ npx wrangler pages deploy dist --project-name=expria ## 10. Règles de développement ### Règle 1 — Séparation stricte + Le frontend ne contient aucune logique métier. 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. ### Règle 2 — Source de vérité unique des plans + `lib/access.ts` existe dans les deux dépôts (frontend et backend). Le fichier doit être identique dans les deux. Toute modification des plans tarifaires met à jour ce fichier en premier, 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 + Si une modification nécessite de toucher plus de 3 fichiers, elle doit être découpée en plusieurs sessions avec validation intermédiaire. ### Règle 4 — Plan avant code + Claude Code ne commence jamais à coder sans avoir d'abord produit un plan détaillé (fichiers impactés, risques, étapes). Le plan est validé par Hermann avant l'exécution. ### Règle 5 — Tests manuels après chaque session + Après chaque session Claude Code, rejouer le golden dataset (voir GOLDEN_DATASET.md) avant de passer à la session suivante. ### Règle 6 — Variables d'environnement + 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). diff --git a/docs/CHANGELOG-backend.md b/docs/CHANGELOG-backend.md index 3ed8b7c..7ddd6f4 100644 --- a/docs/CHANGELOG-backend.md +++ b/docs/CHANGELOG-backend.md @@ -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 ### Added diff --git a/docs/TECH_DEBT-backend.md b/docs/TECH_DEBT-backend.md index 1329a9b..5f9b674 100644 --- a/docs/TECH_DEBT-backend.md +++ b/docs/TECH_DEBT-backend.md @@ -97,15 +97,14 @@ ### TD-13 — Webhook Stripe non idempotent **Priorité :** 🔴 Critique -**Statut :** Ouvert — à faire avant mise en production -**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. -**À faire :** +**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 désormais chaque réception via une déduplication explicite : check `stripe_webhook_events(id)` avant traitement, INSERT après succès. +**Résolution Sprint 5a :** -- Créer une table `stripe_webhook_events(id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ)` -- Avant traitement, vérifier si `event.id` est déjà en base → si oui, retourner 200 sans rien faire -- Après traitement, insérer l'`event.id` dans la table - **Session concernée :** Stripe (POST /stripe/webhook) - **Condition de résolution :** Avant la mise en production publique. +- 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`. +- Helper `src/lib/stripeWebhookEvents.ts` — `isEventProcessed` / `markEventProcessed` (insert idempotent, conflit unique avalé silencieusement). +- `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). +- 8 tests unitaires + 2 tests d'intégration (`isEventProcessed`/`markEventProcessed` + comportement route). --- @@ -258,3 +257,4 @@ Gate de qualité actuel : npm run test. | 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-18 | RLS Storage caduque | 2026-04-25 | Sprint 4b | +| TD-13 | Webhook Stripe idempotent | 2026-04-26 | Sprint 5a | diff --git a/src/lib/__tests__/createBillingPortalSession.test.ts b/src/lib/__tests__/createBillingPortalSession.test.ts new file mode 100644 index 0000000..82d4650 --- /dev/null +++ b/src/lib/__tests__/createBillingPortalSession.test.ts @@ -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"); + }); +}); diff --git a/src/lib/__tests__/stripeWebhookEvents.test.ts b/src/lib/__tests__/stripeWebhookEvents.test.ts new file mode 100644 index 0000000..16d17bb --- /dev/null +++ b/src/lib/__tests__/stripeWebhookEvents.test.ts @@ -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(); + }); +}); diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 963cad2..7a7db29 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -1,80 +1,119 @@ -import Stripe from 'stripe' +import Stripe from "stripe"; function getStripe() { - return new Stripe(process.env.STRIPE_SECRET_KEY ?? '') + return new Stripe(process.env.STRIPE_SECRET_KEY ?? ""); } interface CreateCheckoutSessionParams { - userId: string - priceId: string - planName: string + userId: string; + priceId: string; + planName: string; } export async function createCheckoutSession( - params: CreateCheckoutSessionParams + params: CreateCheckoutSessionParams, ): Promise<{ url: string }> { - const { userId, priceId, planName } = params + const { userId, priceId, planName } = params; - if (!userId) throw new Error('userId requis') - if (!priceId) throw new Error('priceId requis') - if (!planName) throw new Error('planName requis') + if (!userId) throw new Error("userId requis"); + if (!priceId) throw new Error("priceId requis"); + if (!planName) throw new Error("planName requis"); - const appUrl = process.env.APP_URL - if (!appUrl) throw new Error('APP_URL non configuré') + const appUrl = process.env.APP_URL; + if (!appUrl) throw new Error("APP_URL non configuré"); const session = await getStripe().checkout.sessions.create({ - mode: 'subscription', + mode: "subscription", line_items: [{ price: priceId, quantity: 1 }], success_url: `${appUrl}/dashboard?upgrade=success`, cancel_url: `${appUrl}/tarifs?upgrade=cancelled`, client_reference_id: userId, metadata: { userId, planName }, - }) + }); 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( payload: Buffer, signature: string, - secret: string + secret: string, ): { valid: boolean; event?: Stripe.Event; error?: string } { if (!payload.length || !signature) { - return { valid: false, error: 'Payload ou signature manquant' } + return { valid: false, error: "Payload ou signature manquant" }; } try { - const event = getStripe().webhooks.constructEvent(payload, signature, secret) - return { valid: true, event } + const event = getStripe().webhooks.constructEvent( + payload, + signature, + secret, + ); + return { valid: true, event }; } catch (err) { - return { valid: false, error: (err as Error).message } + return { valid: false, error: (err as Error).message }; } } interface ProrataParams { - currentPlanPrice: number - newPlanPrice: number - totalDays: number - daysRemaining: number + currentPlanPrice: number; + newPlanPrice: number; + totalDays: number; + daysRemaining: 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) { - throw new Error('Les valeurs ne peuvent pas être négatives') + if ( + currentPlanPrice < 0 || + newPlanPrice < 0 || + totalDays < 0 || + daysRemaining < 0 + ) { + throw new Error("Les valeurs ne peuvent pas être négatives"); } 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 credit = currentPlanPrice * ratio - const cost = newPlanPrice * ratio - const amount = Math.max(0, cost - credit) + const ratio = daysRemaining / totalDays; + const credit = currentPlanPrice * ratio; + const cost = newPlanPrice * ratio; + const amount = Math.max(0, cost - credit); - return { amount } + return { amount }; } diff --git a/src/lib/stripeWebhookEvents.ts b/src/lib/stripeWebhookEvents.ts new file mode 100644 index 0000000..8b0e3c1 --- /dev/null +++ b/src/lib/stripeWebhookEvents.ts @@ -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 { + 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 { + 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}`, + ); + } + } +} diff --git a/src/routes/__tests__/stripe.test.ts b/src/routes/__tests__/stripe.test.ts index 5bc45fe..96a4494 100644 --- a/src/routes/__tests__/stripe.test.ts +++ b/src/routes/__tests__/stripe.test.ts @@ -1,283 +1,452 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { Hono } from 'hono' +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; // ─── Mocks ─────────────────────────────────────────────────────────────────── const { createCheckoutSessionMock, + createBillingPortalSessionMock, verifyStripeWebhookMock, updateUserPlanMock, updateUserStripeInfoMock, findUserBySubscriptionIdMock, + isEventProcessedMock, + markEventProcessedMock, } = vi.hoisted(() => ({ createCheckoutSessionMock: vi.fn(), + createBillingPortalSessionMock: vi.fn(), verifyStripeWebhookMock: vi.fn(), updateUserPlanMock: vi.fn(), updateUserStripeInfoMock: vi.fn(), findUserBySubscriptionIdMock: vi.fn(), -})) + isEventProcessedMock: vi.fn(), + markEventProcessedMock: vi.fn(), +})); -vi.mock('../../lib/stripe', () => ({ +vi.mock("../../lib/stripe", () => ({ createCheckoutSession: createCheckoutSessionMock, + createBillingPortalSession: createBillingPortalSessionMock, verifyStripeWebhook: verifyStripeWebhookMock, -})) +})); -vi.mock('../../lib/planController', () => ({ +vi.mock("../../lib/planController", () => ({ updateUserPlan: updateUserPlanMock, updateUserStripeInfo: updateUserStripeInfoMock, findUserBySubscriptionId: findUserBySubscriptionIdMock, -})) +})); -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', { - 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() +vi.mock("../../lib/stripeWebhookEvents", () => ({ + isEventProcessed: isEventProcessedMock, + markEventProcessed: markEventProcessedMock, +})); + +// Permet aux tests d'injecter un profil custom (ex. avec stripe_customer_id). +const { profileOverrideRef } = vi.hoisted(() => ({ + profileOverrideRef: { + current: null as null | Record, }, -})) +})); -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() { - const app = new Hono() - app.route('/stripe', stripeRoutes) - return app + const app = new Hono(); + app.route("/stripe", stripeRoutes); + return app; } // ─── Tests ─────────────────────────────────────────────────────────────────── -describe('POST /stripe/checkout', () => { +describe("POST /stripe/checkout", () => { beforeEach(() => { - createCheckoutSessionMock.mockReset() - process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test' - }) + createCheckoutSessionMock.mockReset(); + 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({ - url: 'https://checkout.stripe.com/pay/cs_xyz', - }) + url: "https://checkout.stripe.com/pay/cs_xyz", + }); - const app = buildApp() - const res = await app.request('/stripe/checkout', { - method: 'POST', + const app = buildApp(); + const res = await app.request("/stripe/checkout", { + method: "POST", headers: { - Authorization: 'Bearer valid-token', - 'Content-Type': 'application/json', + Authorization: "Bearer valid-token", + "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) - const body = await res.json() - expect(body.url).toBe('https://checkout.stripe.com/pay/cs_xyz') + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.url).toBe("https://checkout.stripe.com/pay/cs_xyz"); expect(createCheckoutSessionMock).toHaveBeenCalledWith({ - userId: 'test-user-id', - priceId: 'price_standard', - planName: 'standard', - }) - }) + userId: "test-user-id", + priceId: "price_standard", + planName: "standard", + }); + }); - it('retourne 401 sans authentification', async () => { - const app = buildApp() - const res = await app.request('/stripe/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ priceId: 'p1', planName: 'standard' }), - }) + it("retourne 401 sans authentification", async () => { + const app = buildApp(); + const res = await app.request("/stripe/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + 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 () => { - const app = buildApp() - const res = await app.request('/stripe/checkout', { - method: 'POST', + it("retourne 400 si priceId ou planName manquent", async () => { + const app = buildApp(); + const res = await app.request("/stripe/checkout", { + method: "POST", headers: { - Authorization: 'Bearer valid-token', - 'Content-Type': 'application/json', + Authorization: "Bearer valid-token", + "Content-Type": "application/json", }, - body: JSON.stringify({ priceId: 'p1' }), - }) + body: JSON.stringify({ priceId: "p1" }), + }); - expect(res.status).toBe(400) - const body = await res.json() - expect(body.code).toBe('INVALID_BODY') - }) + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("INVALID_BODY"); + }); - it('retourne 400 pour un planName inconnu', async () => { - const app = buildApp() - const res = await app.request('/stripe/checkout', { - method: 'POST', + it("retourne 400 pour un planName inconnu", async () => { + const app = buildApp(); + const res = await app.request("/stripe/checkout", { + method: "POST", headers: { - Authorization: 'Bearer valid-token', - 'Content-Type': 'application/json', + Authorization: "Bearer valid-token", + "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) - const body = await res.json() - expect(body.code).toBe('INVALID_PLAN') - }) -}) + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe("INVALID_PLAN"); + }); +}); -describe('POST /stripe/webhook', () => { +describe("POST /stripe/webhook", () => { beforeEach(() => { - verifyStripeWebhookMock.mockReset() - updateUserPlanMock.mockReset() - updateUserStripeInfoMock.mockReset() - findUserBySubscriptionIdMock.mockReset() - process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test' - process.env.STRIPE_PRICE_STANDARD = 'price_standard' - process.env.STRIPE_PRICE_PREMIUM = 'price_premium' - }) + verifyStripeWebhookMock.mockReset(); + updateUserPlanMock.mockReset(); + updateUserStripeInfoMock.mockReset(); + findUserBySubscriptionIdMock.mockReset(); + isEventProcessedMock.mockReset(); + markEventProcessedMock.mockReset(); + // 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 () => { - const app = buildApp() - const res = await app.request('/stripe/webhook', { - method: 'POST', - body: 'payload', - }) + it("rejette un webhook sans signature", async () => { + const app = buildApp(); + const res = await app.request("/stripe/webhook", { + method: "POST", + body: "payload", + }); - expect(res.status).toBe(400) - const body = await res.json() - expect(body.code).toBe('STRIPE_WEBHOOK_INVALID') - }) + expect(res.status).toBe(400); + const body = await res.json(); + 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({ valid: false, - error: 'No signatures match', - }) + error: "No signatures match", + }); - const app = buildApp() - const res = await app.request('/stripe/webhook', { - method: 'POST', - headers: { 'stripe-signature': 'bad-sig' }, - body: 'payload', - }) + const app = buildApp(); + const res = await app.request("/stripe/webhook", { + method: "POST", + headers: { "stripe-signature": "bad-sig" }, + body: "payload", + }); - expect(res.status).toBe(400) - const body = await res.json() - expect(body.code).toBe('STRIPE_WEBHOOK_INVALID') - }) + expect(res.status).toBe(400); + const body = await res.json(); + 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({ valid: true, event: { - type: 'checkout.session.completed', + type: "checkout.session.completed", data: { object: { - metadata: { userId: 'user-42', planName: 'premium' }, - customer: 'cus_abc', - subscription: 'sub_abc', + metadata: { userId: "user-42", planName: "premium" }, + customer: "cus_abc", + subscription: "sub_abc", }, }, }, - }) - updateUserPlanMock.mockResolvedValue({ success: true, plan: 'premium' }) - updateUserStripeInfoMock.mockResolvedValue({ success: true }) + }); + updateUserPlanMock.mockResolvedValue({ success: true, plan: "premium" }); + updateUserStripeInfoMock.mockResolvedValue({ success: true }); - const app = buildApp() - const res = await app.request('/stripe/webhook', { - method: 'POST', - headers: { 'stripe-signature': 'good-sig' }, - body: 'payload', - }) + 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(updateUserPlanMock).toHaveBeenCalledWith('user-42', 'premium') - expect(updateUserStripeInfoMock).toHaveBeenCalledWith('user-42', { - stripe_customer_id: 'cus_abc', - stripe_subscription_id: 'sub_abc', - }) - }) + expect(res.status).toBe(200); + expect(updateUserPlanMock).toHaveBeenCalledWith("user-42", "premium"); + expect(updateUserStripeInfoMock).toHaveBeenCalledWith("user-42", { + stripe_customer_id: "cus_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({ valid: true, event: { - type: 'customer.subscription.deleted', - data: { object: { id: 'sub_abc' } }, + type: "customer.subscription.deleted", + data: { object: { id: "sub_abc" } }, }, - }) - findUserBySubscriptionIdMock.mockResolvedValue({ userId: 'user-42' }) - updateUserPlanMock.mockResolvedValue({ success: true, plan: 'free' }) - updateUserStripeInfoMock.mockResolvedValue({ success: true }) + }); + findUserBySubscriptionIdMock.mockResolvedValue({ userId: "user-42" }); + updateUserPlanMock.mockResolvedValue({ success: true, plan: "free" }); + updateUserStripeInfoMock.mockResolvedValue({ success: true }); - const app = buildApp() - const res = await app.request('/stripe/webhook', { - method: 'POST', - headers: { 'stripe-signature': 'good-sig' }, - body: 'payload', - }) + 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(findUserBySubscriptionIdMock).toHaveBeenCalledWith('sub_abc') - expect(updateUserPlanMock).toHaveBeenCalledWith('user-42', 'free') - }) + expect(res.status).toBe(200); + expect(findUserBySubscriptionIdMock).toHaveBeenCalledWith("sub_abc"); + 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({ valid: true, event: { - type: 'invoice.paid', + type: "invoice.paid", data: { object: { - subscription: 'sub_xyz', + subscription: "sub_xyz", lines: { - data: [{ price: { id: 'price_premium' } }], + data: [{ price: { id: "price_premium" } }], }, }, }, }, - }) - findUserBySubscriptionIdMock.mockResolvedValue({ userId: 'user-99' }) - updateUserPlanMock.mockResolvedValue({ success: true, plan: 'premium' }) + }); + findUserBySubscriptionIdMock.mockResolvedValue({ userId: "user-99" }); + updateUserPlanMock.mockResolvedValue({ success: true, plan: "premium" }); - const app = buildApp() - const res = await app.request('/stripe/webhook', { - method: 'POST', - headers: { 'stripe-signature': 'good-sig' }, - body: 'payload', - }) + 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(updateUserPlanMock).toHaveBeenCalledWith('user-99', 'premium') - }) + expect(res.status).toBe(200); + 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({ valid: true, event: { - type: 'ping.unknown', + type: "ping.unknown", data: { object: {} }, }, - }) + }); - const app = buildApp() - const res = await app.request('/stripe/webhook', { - method: 'POST', - headers: { 'stripe-signature': 'good-sig' }, - body: 'payload', - }) + 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(updateUserPlanMock).not.toHaveBeenCalled() - }) -}) + expect(res.status).toBe(200); + 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", + }); + }); +}); diff --git a/src/routes/stripe.ts b/src/routes/stripe.ts index 127ed15..78ca3ac 100644 --- a/src/routes/stripe.ts +++ b/src/routes/stripe.ts @@ -1,48 +1,56 @@ -import { Hono } from 'hono' -import type Stripe from 'stripe' -import { authMiddleware } from '../middleware/auth.js' -import type { AppVariables } from '../middleware/auth.js' -import { createCheckoutSession, verifyStripeWebhook } from '../lib/stripe.js' +import { Hono } from "hono"; +import type Stripe from "stripe"; +import { authMiddleware } from "../middleware/auth.js"; +import type { AppVariables } from "../middleware/auth.js"; +import { + createBillingPortalSession, + createCheckoutSession, + verifyStripeWebhook, +} from "../lib/stripe.js"; import { updateUserPlan, updateUserStripeInfo, findUserBySubscriptionId, -} from '../lib/planController.js' -import type { Plan } from '../lib/access.js' -import { PLANS } from '../lib/access.js' +} from "../lib/planController.js"; +import { + 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) => { - const user = c.get('user') +stripeRoutes.post("/checkout", authMiddleware, async (c) => { + const user = c.get("user"); - let body: { priceId?: string; planName?: string } + let body: { priceId?: string; planName?: string }; try { - body = await c.req.json() + body = await c.req.json(); } catch { return c.json( - { error: true, code: 'INVALID_BODY', message: 'JSON invalide.' }, - 400 - ) + { error: true, code: "INVALID_BODY", message: "JSON invalide." }, + 400, + ); } - const { priceId, planName } = body + const { priceId, planName } = body; if (!priceId || !planName) { return c.json( { error: true, - code: 'INVALID_BODY', - message: 'priceId et planName sont requis.', + code: "INVALID_BODY", + message: "priceId et planName sont requis.", }, - 400 - ) + 400, + ); } if (!(planName in PLANS)) { return c.json( - { error: true, code: 'INVALID_PLAN', message: 'Plan inconnu.' }, - 400 - ) + { error: true, code: "INVALID_PLAN", message: "Plan inconnu." }, + 400, + ); } try { @@ -50,139 +58,192 @@ stripeRoutes.post('/checkout', authMiddleware, async (c) => { userId: user.id, priceId, planName, - }) - return c.json({ url }, 200) + }); + return c.json({ url }, 200); } catch (err) { return c.json( { error: true, - code: 'INTERNAL_ERROR', + code: "INTERNAL_ERROR", message: (err as Error).message, }, - 500 - ) + 500, + ); } -}) +}); -stripeRoutes.post('/webhook', async (c) => { - const signature = c.req.header('stripe-signature') +stripeRoutes.post("/customer-portal", authMiddleware, async (c) => { + 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) { return c.json( { error: true, - code: 'STRIPE_WEBHOOK_INVALID', - message: 'Signature manquante.', + code: "STRIPE_WEBHOOK_INVALID", + message: "Signature manquante.", }, - 400 - ) + 400, + ); } - const secret = process.env.STRIPE_WEBHOOK_SECRET + const secret = process.env.STRIPE_WEBHOOK_SECRET; if (!secret) { return c.json( { error: true, - code: 'INTERNAL_ERROR', - message: 'STRIPE_WEBHOOK_SECRET non configuré.', + code: "INTERNAL_ERROR", + message: "STRIPE_WEBHOOK_SECRET non configuré.", }, - 500 - ) + 500, + ); } - const arrayBuffer = await c.req.arrayBuffer() - const payload = Buffer.from(arrayBuffer) + const arrayBuffer = await c.req.arrayBuffer(); + const payload = Buffer.from(arrayBuffer); - const verified = verifyStripeWebhook(payload, signature, secret) + const verified = verifyStripeWebhook(payload, signature, secret); if (!verified.valid || !verified.event) { return c.json( { error: true, - code: 'STRIPE_WEBHOOK_INVALID', - message: verified.error ?? 'Signature invalide.', + code: "STRIPE_WEBHOOK_INVALID", + 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 { - await handleStripeEvent(verified.event) + await handleStripeEvent(verified.event); + await markEventProcessed(verified.event.id); } catch { // 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 { switch (event.type) { - case 'checkout.session.completed': { - const session = event.data.object as Stripe.Checkout.Session - const userId = session.metadata?.userId - const planName = session.metadata?.planName as Plan | undefined - if (!userId || !planName || !(planName in PLANS)) return + case "checkout.session.completed": { + const session = event.data.object as Stripe.Checkout.Session; + const userId = session.metadata?.userId; + const planName = session.metadata?.planName as Plan | undefined; + 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 = - typeof session.subscription === 'string' ? session.subscription : null + typeof session.subscription === "string" ? session.subscription : null; await updateUserStripeInfo(userId, { stripe_customer_id: customerId, stripe_subscription_id: subscriptionId, - }) - return + }); + return; } - case 'invoice.paid': { + case "invoice.paid": { const invoice = event.data.object as Stripe.Invoice & { - subscription?: string | Stripe.Subscription | null - } + subscription?: string | Stripe.Subscription | null; + }; const subscriptionId = - typeof invoice.subscription === 'string' ? invoice.subscription : null - if (!subscriptionId) return + typeof invoice.subscription === "string" ? invoice.subscription : null; + if (!subscriptionId) return; - const match = await findUserBySubscriptionId(subscriptionId) - if (!match) return + const match = await findUserBySubscriptionId(subscriptionId); + if (!match) return; - const plan = detectPlanFromInvoice(invoice) - if (!plan) return + const plan = detectPlanFromInvoice(invoice); + if (!plan) return; - await updateUserPlan(match.userId, plan) - return + await updateUserPlan(match.userId, plan); + return; } - case 'customer.subscription.deleted': { - const subscription = event.data.object as Stripe.Subscription - const match = await findUserBySubscriptionId(subscription.id) - if (!match) return + case "customer.subscription.deleted": { + const subscription = event.data.object as Stripe.Subscription; + const match = await findUserBySubscriptionId(subscription.id); + if (!match) return; - await updateUserPlan(match.userId, 'free') + await updateUserPlan(match.userId, "free"); await updateUserStripeInfo(match.userId, { stripe_subscription_id: null, plan_expires_at: null, - }) - return + }); + return; } default: - return + return; } } function detectPlanFromInvoice(invoice: Stripe.Invoice): Plan | null { - const standardPrice = process.env.STRIPE_PRICE_STANDARD - const premiumPrice = process.env.STRIPE_PRICE_PREMIUM + const standardPrice = process.env.STRIPE_PRICE_STANDARD; + const premiumPrice = process.env.STRIPE_PRICE_PREMIUM; - const lines = invoice.lines?.data ?? [] + const lines = invoice.lines?.data ?? []; for (const line of lines) { - const priceId = line.price?.id - if (!priceId) continue - if (premiumPrice && priceId === premiumPrice) return 'premium' - if (standardPrice && priceId === standardPrice) return 'standard' + const priceId = line.price?.id; + if (!priceId) continue; + if (premiumPrice && priceId === premiumPrice) return "premium"; + if (standardPrice && priceId === standardPrice) return "standard"; } - return null + return null; } -export default stripeRoutes +export default stripeRoutes; diff --git a/supabase/migrations/007_sprint_5a_stripe_webhook_events.sql b/supabase/migrations/007_sprint_5a_stripe_webhook_events.sql new file mode 100644 index 0000000..2d33e77 --- /dev/null +++ b/supabase/migrations/007_sprint_5a_stripe_webhook_events.sql @@ -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).';