feat(billing): TD-13 webhook idempotency + Stripe Customer Portal + doc cleanup
- Table stripe_webhook_events + helpers isEventProcessed/markEventProcessed - POST /stripe/customer-portal (auth + stripe_customer_id check) - ARCHITECTURE-backend.md: suppression POST /plans/upgrade (duplication doc) - TD-13 fermé dans TECH_DEBT-backend.md - Tests: 261 → 278 verts (+17)
This commit is contained in:
parent
ec0598d122
commit
6671bac347
10 changed files with 891 additions and 324 deletions
|
|
@ -72,6 +72,7 @@ Tier gratuit, déploiement automatique depuis GitHub.
|
|||
### Pourquoi Supabase est conservé
|
||||
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
69
src/lib/__tests__/createBillingPortalSession.test.ts
Normal file
69
src/lib/__tests__/createBillingPortalSession.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const portalCreateMock = vi.fn();
|
||||
|
||||
vi.mock("stripe", () => ({
|
||||
default: vi.fn(() => ({
|
||||
billingPortal: {
|
||||
sessions: {
|
||||
create: portalCreateMock,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { createBillingPortalSession } from "../stripe";
|
||||
|
||||
describe("createBillingPortalSession", () => {
|
||||
beforeEach(() => {
|
||||
portalCreateMock.mockReset();
|
||||
});
|
||||
|
||||
it("retourne l'URL de la billing portal session", async () => {
|
||||
portalCreateMock.mockResolvedValue({
|
||||
url: "https://billing.stripe.com/p/session/abc123",
|
||||
});
|
||||
|
||||
const result = await createBillingPortalSession({
|
||||
customerId: "cus_abc",
|
||||
returnUrl: "https://expria.app/dashboard",
|
||||
});
|
||||
|
||||
expect(result.url).toBe("https://billing.stripe.com/p/session/abc123");
|
||||
expect(portalCreateMock).toHaveBeenCalledWith({
|
||||
customer: "cus_abc",
|
||||
return_url: "https://expria.app/dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("throw si customerId vide", async () => {
|
||||
await expect(
|
||||
createBillingPortalSession({
|
||||
customerId: "",
|
||||
returnUrl: "https://expria.app/dashboard",
|
||||
}),
|
||||
).rejects.toThrow("customerId requis");
|
||||
expect(portalCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throw si returnUrl vide", async () => {
|
||||
await expect(
|
||||
createBillingPortalSession({
|
||||
customerId: "cus_abc",
|
||||
returnUrl: "",
|
||||
}),
|
||||
).rejects.toThrow("returnUrl requis");
|
||||
expect(portalCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throw si Stripe ne retourne pas d'URL", async () => {
|
||||
portalCreateMock.mockResolvedValue({ url: null });
|
||||
|
||||
await expect(
|
||||
createBillingPortalSession({
|
||||
customerId: "cus_abc",
|
||||
returnUrl: "https://expria.app/dashboard",
|
||||
}),
|
||||
).rejects.toThrow("URL de billing portal");
|
||||
});
|
||||
});
|
||||
101
src/lib/__tests__/stripeWebhookEvents.test.ts
Normal file
101
src/lib/__tests__/stripeWebhookEvents.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const { fromMock, selectMock, eqMock, maybeSingleMock, insertMock } =
|
||||
vi.hoisted(() => ({
|
||||
fromMock: vi.fn(),
|
||||
selectMock: vi.fn(),
|
||||
eqMock: vi.fn(),
|
||||
maybeSingleMock: vi.fn(),
|
||||
insertMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../supabase", () => ({
|
||||
supabase: { from: fromMock },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fromMock.mockReset();
|
||||
selectMock.mockReset();
|
||||
eqMock.mockReset();
|
||||
maybeSingleMock.mockReset();
|
||||
insertMock.mockReset();
|
||||
|
||||
fromMock.mockImplementation((table: string) => {
|
||||
if (table !== "stripe_webhook_events") return {};
|
||||
return {
|
||||
select: selectMock,
|
||||
insert: insertMock,
|
||||
};
|
||||
});
|
||||
selectMock.mockReturnValue({ eq: eqMock });
|
||||
eqMock.mockReturnValue({ maybeSingle: maybeSingleMock });
|
||||
});
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("isEventProcessed", () => {
|
||||
it("retourne true quand l'event est déjà journalisé", async () => {
|
||||
maybeSingleMock.mockResolvedValue({ data: { id: "evt_123" }, error: null });
|
||||
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||
const result = await isEventProcessed("evt_123");
|
||||
expect(result).toBe(true);
|
||||
expect(eqMock).toHaveBeenCalledWith("id", "evt_123");
|
||||
});
|
||||
|
||||
it("retourne false quand l'event est absent", async () => {
|
||||
maybeSingleMock.mockResolvedValue({ data: null, error: null });
|
||||
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||
const result = await isEventProcessed("evt_456");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("retourne false sur erreur de lecture (privilégie disponibilité)", async () => {
|
||||
maybeSingleMock.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: "DB unreachable" },
|
||||
});
|
||||
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||
const result = await isEventProcessed("evt_789");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("retourne false pour un eventId vide sans toucher Supabase", async () => {
|
||||
const { isEventProcessed } = await import("../stripeWebhookEvents");
|
||||
const result = await isEventProcessed("");
|
||||
expect(result).toBe(false);
|
||||
expect(fromMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("markEventProcessed", () => {
|
||||
it("insère l'event quand il n'existe pas", async () => {
|
||||
insertMock.mockResolvedValue({ error: null });
|
||||
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||
await markEventProcessed("evt_new");
|
||||
expect(insertMock).toHaveBeenCalledWith({ id: "evt_new" });
|
||||
});
|
||||
|
||||
it("avale silencieusement un conflit unique (livraison concurrente)", async () => {
|
||||
insertMock.mockResolvedValue({
|
||||
error: { code: "23505", message: "duplicate key" },
|
||||
});
|
||||
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||
await expect(markEventProcessed("evt_dup")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("ne throw pas sur erreur DB inattendue (webhook doit toujours répondre 200)", async () => {
|
||||
insertMock.mockResolvedValue({
|
||||
error: { code: "08006", message: "connection failure" },
|
||||
});
|
||||
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||
await expect(markEventProcessed("evt_fail")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-op pour un eventId vide", async () => {
|
||||
const { markEventProcessed } = await import("../stripeWebhookEvents");
|
||||
await markEventProcessed("");
|
||||
expect(insertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,80 +1,119 @@
|
|||
import Stripe from 'stripe'
|
||||
import Stripe from "stripe";
|
||||
|
||||
function getStripe() {
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
55
src/lib/stripeWebhookEvents.ts
Normal file
55
src/lib/stripeWebhookEvents.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Sprint 5a — Idempotency des webhooks Stripe (TD-13).
|
||||
*
|
||||
* Helper isolé pour interroger / journaliser la table `stripe_webhook_events`.
|
||||
* Utilisé par `routes/stripe.ts` autour de chaque appel à `handleStripeEvent`.
|
||||
*
|
||||
* Voir migration `007_sprint_5a_stripe_webhook_events.sql` pour le schéma.
|
||||
*/
|
||||
|
||||
import { supabase } from "./supabase.js";
|
||||
|
||||
/**
|
||||
* Indique si un `event.id` Stripe a déjà été traité (présent dans la table
|
||||
* `stripe_webhook_events`). Retourne `false` en cas d'erreur de lecture pour
|
||||
* privilégier la disponibilité du webhook (mieux vaut un double traitement
|
||||
* — opérations métier idempotentes — qu'un drop silencieux).
|
||||
*/
|
||||
export async function isEventProcessed(eventId: string): Promise<boolean> {
|
||||
if (!eventId) return false;
|
||||
const { data, error } = await supabase
|
||||
.from("stripe_webhook_events")
|
||||
.select("id")
|
||||
.eq("id", eventId)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.warn(
|
||||
`[stripeWebhookEvents.isEventProcessed] lecture en erreur pour ${eventId} : ${error.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return data !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Journalise un event.id comme traité. INSERT idempotent (`ON CONFLICT DO
|
||||
* NOTHING` via la PRIMARY KEY) — un échec d'insert ne doit JAMAIS faire
|
||||
* échouer la réponse 200 du webhook (Stripe retenterait), donc on log et
|
||||
* on retourne sans throw.
|
||||
*/
|
||||
export async function markEventProcessed(eventId: string): Promise<void> {
|
||||
if (!eventId) return;
|
||||
const { error } = await supabase
|
||||
.from("stripe_webhook_events")
|
||||
.insert({ id: eventId });
|
||||
if (error) {
|
||||
// Code Postgres `23505` = unique_violation → l'event a déjà été marqué
|
||||
// par une livraison concurrente, c'est exactement ce qu'on cherche
|
||||
// (no-op silencieux). Tout autre code est loggé.
|
||||
if (error.code !== "23505") {
|
||||
console.error(
|
||||
`[stripeWebhookEvents.markEventProcessed] insert en erreur pour ${eventId} : ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,283 +1,452 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
30
supabase/migrations/007_sprint_5a_stripe_webhook_events.sql
Normal file
30
supabase/migrations/007_sprint_5a_stripe_webhook_events.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- Sprint 5a — Idempotency des webhooks Stripe (TD-13)
|
||||
--
|
||||
-- Stripe peut livrer le même `event.id` plusieurs fois (retries réseau,
|
||||
-- rejeu manuel depuis le dashboard). Cette table sert de journal de
|
||||
-- déduplication : la route `POST /stripe/webhook` consulte la table
|
||||
-- avant traitement et y insère l'event après succès.
|
||||
--
|
||||
-- Stratégie (cf. TD-13) :
|
||||
-- 1. Avant traitement, `SELECT 1 FROM stripe_webhook_events WHERE id = $1`.
|
||||
-- Présent → retour 200 immédiat sans rien faire.
|
||||
-- 2. Après traitement, `INSERT ... ON CONFLICT DO NOTHING`.
|
||||
--
|
||||
-- Race window résiduelle (deux deliveries concurrentes passent toutes deux
|
||||
-- le SELECT initial) couverte par l'idempotence native des opérations
|
||||
-- métier (`updateUserPlan`, `updateUserStripeInfo`).
|
||||
--
|
||||
-- À exécuter manuellement : `supabase db push` (Hermann — cf. Règle F).
|
||||
-- Idempotent : sûre à rejouer en dev comme en prod.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stripe_webhook_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index pour les futures purges (rétention ~90 jours envisagée).
|
||||
CREATE INDEX IF NOT EXISTS stripe_webhook_events_processed_at_idx
|
||||
ON stripe_webhook_events (processed_at);
|
||||
|
||||
COMMENT ON TABLE stripe_webhook_events IS
|
||||
'Journal de déduplication des webhooks Stripe (Sprint 5a — TD-13).';
|
||||
Loading…
Add table
Add a link
Reference in a new issue