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

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
### Added

View file

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

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

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 { 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', () => ({
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<string, unknown>,
},
}));
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)
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',
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()
created_at: "2026-01-01",
updated_at: "2026-01-01",
},
}))
);
await next();
},
}));
import stripeRoutes from '../stripe'
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",
});
});
});

View file

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

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