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:
Hermann_Kitio 2026-04-26 04:15:46 +03:00
parent ec0598d122
commit 6671bac347
10 changed files with 891 additions and 324 deletions

View file

@ -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).

View file

@ -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

View file

@ -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 |

View 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");
});
});

View 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();
});
});

View file

@ -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 };
} }

View 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à é 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}`,
);
}
}
}

View file

@ -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",
});
});
});

View file

@ -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;

View 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).';