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

@ -1,283 +1,452 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const {
createCheckoutSessionMock,
createBillingPortalSessionMock,
verifyStripeWebhookMock,
updateUserPlanMock,
updateUserStripeInfoMock,
findUserBySubscriptionIdMock,
isEventProcessedMock,
markEventProcessedMock,
} = vi.hoisted(() => ({
createCheckoutSessionMock: vi.fn(),
createBillingPortalSessionMock: vi.fn(),
verifyStripeWebhookMock: vi.fn(),
updateUserPlanMock: vi.fn(),
updateUserStripeInfoMock: vi.fn(),
findUserBySubscriptionIdMock: vi.fn(),
}))
isEventProcessedMock: vi.fn(),
markEventProcessedMock: vi.fn(),
}));
vi.mock('../../lib/stripe', () => ({
vi.mock("../../lib/stripe", () => ({
createCheckoutSession: createCheckoutSessionMock,
createBillingPortalSession: createBillingPortalSessionMock,
verifyStripeWebhook: verifyStripeWebhookMock,
}))
}));
vi.mock('../../lib/planController', () => ({
vi.mock("../../lib/planController", () => ({
updateUserPlan: updateUserPlanMock,
updateUserStripeInfo: updateUserStripeInfoMock,
findUserBySubscriptionId: findUserBySubscriptionIdMock,
}))
}));
vi.mock('../../middleware/auth', () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: true, code: 'AUTH_REQUIRED' }, 401)
}
c.set('user', { id: 'test-user-id', email: 'user@test.com' })
c.set('profile', {
id: 'test-user-id',
email: 'user@test.com',
plan: 'free',
simulations_used: 0,
stripe_customer_id: null,
stripe_subscription_id: null,
plan_expires_at: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
})
await next()
vi.mock("../../lib/stripeWebhookEvents", () => ({
isEventProcessed: isEventProcessedMock,
markEventProcessed: markEventProcessedMock,
}));
// Permet aux tests d'injecter un profil custom (ex. avec stripe_customer_id).
const { profileOverrideRef } = vi.hoisted(() => ({
profileOverrideRef: {
current: null as null | Record<string, unknown>,
},
}))
}));
import stripeRoutes from '../stripe'
vi.mock("../../middleware/auth", () => ({
authMiddleware: async (c: any, next: any) => {
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return c.json({ error: true, code: "AUTH_REQUIRED" }, 401);
}
c.set("user", { id: "test-user-id", email: "user@test.com" });
c.set(
"profile",
profileOverrideRef.current ?? {
id: "test-user-id",
email: "user@test.com",
plan: "free",
simulations_used: 0,
stripe_customer_id: null,
stripe_subscription_id: null,
plan_expires_at: null,
created_at: "2026-01-01",
updated_at: "2026-01-01",
},
);
await next();
},
}));
import stripeRoutes from "../stripe";
function buildApp() {
const app = new Hono()
app.route('/stripe', stripeRoutes)
return app
const app = new Hono();
app.route("/stripe", stripeRoutes);
return app;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('POST /stripe/checkout', () => {
describe("POST /stripe/checkout", () => {
beforeEach(() => {
createCheckoutSessionMock.mockReset()
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'
})
createCheckoutSessionMock.mockReset();
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
});
it('retourne l\'URL de checkout pour un utilisateur authentifié', async () => {
it("retourne l'URL de checkout pour un utilisateur authentifié", async () => {
createCheckoutSessionMock.mockResolvedValue({
url: 'https://checkout.stripe.com/pay/cs_xyz',
})
url: "https://checkout.stripe.com/pay/cs_xyz",
});
const app = buildApp()
const res = await app.request('/stripe/checkout', {
method: 'POST',
const app = buildApp();
const res = await app.request("/stripe/checkout", {
method: "POST",
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ priceId: 'price_standard', planName: 'standard' }),
})
body: JSON.stringify({ priceId: "price_standard", planName: "standard" }),
});
expect(res.status).toBe(200)
const body = await res.json()
expect(body.url).toBe('https://checkout.stripe.com/pay/cs_xyz')
expect(res.status).toBe(200);
const body = await res.json();
expect(body.url).toBe("https://checkout.stripe.com/pay/cs_xyz");
expect(createCheckoutSessionMock).toHaveBeenCalledWith({
userId: 'test-user-id',
priceId: 'price_standard',
planName: 'standard',
})
})
userId: "test-user-id",
priceId: "price_standard",
planName: "standard",
});
});
it('retourne 401 sans authentification', async () => {
const app = buildApp()
const res = await app.request('/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId: 'p1', planName: 'standard' }),
})
it("retourne 401 sans authentification", async () => {
const app = buildApp();
const res = await app.request("/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId: "p1", planName: "standard" }),
});
expect(res.status).toBe(401)
})
expect(res.status).toBe(401);
});
it('retourne 400 si priceId ou planName manquent', async () => {
const app = buildApp()
const res = await app.request('/stripe/checkout', {
method: 'POST',
it("retourne 400 si priceId ou planName manquent", async () => {
const app = buildApp();
const res = await app.request("/stripe/checkout", {
method: "POST",
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ priceId: 'p1' }),
})
body: JSON.stringify({ priceId: "p1" }),
});
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('INVALID_BODY')
})
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("INVALID_BODY");
});
it('retourne 400 pour un planName inconnu', async () => {
const app = buildApp()
const res = await app.request('/stripe/checkout', {
method: 'POST',
it("retourne 400 pour un planName inconnu", async () => {
const app = buildApp();
const res = await app.request("/stripe/checkout", {
method: "POST",
headers: {
Authorization: 'Bearer valid-token',
'Content-Type': 'application/json',
Authorization: "Bearer valid-token",
"Content-Type": "application/json",
},
body: JSON.stringify({ priceId: 'p1', planName: 'super_premium' }),
})
body: JSON.stringify({ priceId: "p1", planName: "super_premium" }),
});
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('INVALID_PLAN')
})
})
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("INVALID_PLAN");
});
});
describe('POST /stripe/webhook', () => {
describe("POST /stripe/webhook", () => {
beforeEach(() => {
verifyStripeWebhookMock.mockReset()
updateUserPlanMock.mockReset()
updateUserStripeInfoMock.mockReset()
findUserBySubscriptionIdMock.mockReset()
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'
process.env.STRIPE_PRICE_STANDARD = 'price_standard'
process.env.STRIPE_PRICE_PREMIUM = 'price_premium'
})
verifyStripeWebhookMock.mockReset();
updateUserPlanMock.mockReset();
updateUserStripeInfoMock.mockReset();
findUserBySubscriptionIdMock.mockReset();
isEventProcessedMock.mockReset();
markEventProcessedMock.mockReset();
// Défaut : event jamais vu → traitement normal pour les tests existants.
isEventProcessedMock.mockResolvedValue(false);
markEventProcessedMock.mockResolvedValue(undefined);
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
process.env.STRIPE_PRICE_STANDARD = "price_standard";
process.env.STRIPE_PRICE_PREMIUM = "price_premium";
});
it('rejette un webhook sans signature', async () => {
const app = buildApp()
const res = await app.request('/stripe/webhook', {
method: 'POST',
body: 'payload',
})
it("rejette un webhook sans signature", async () => {
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
body: "payload",
});
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('STRIPE_WEBHOOK_INVALID')
})
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("STRIPE_WEBHOOK_INVALID");
});
it('rejette un webhook avec signature invalide', async () => {
it("rejette un webhook avec signature invalide", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: false,
error: 'No signatures match',
})
error: "No signatures match",
});
const app = buildApp()
const res = await app.request('/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'bad-sig' },
body: 'payload',
})
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "bad-sig" },
body: "payload",
});
expect(res.status).toBe(400)
const body = await res.json()
expect(body.code).toBe('STRIPE_WEBHOOK_INVALID')
})
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("STRIPE_WEBHOOK_INVALID");
});
it('traite checkout.session.completed → met à jour plan + stripe info', async () => {
it("traite checkout.session.completed → met à jour plan + stripe info", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
type: 'checkout.session.completed',
type: "checkout.session.completed",
data: {
object: {
metadata: { userId: 'user-42', planName: 'premium' },
customer: 'cus_abc',
subscription: 'sub_abc',
metadata: { userId: "user-42", planName: "premium" },
customer: "cus_abc",
subscription: "sub_abc",
},
},
},
})
updateUserPlanMock.mockResolvedValue({ success: true, plan: 'premium' })
updateUserStripeInfoMock.mockResolvedValue({ success: true })
});
updateUserPlanMock.mockResolvedValue({ success: true, plan: "premium" });
updateUserStripeInfoMock.mockResolvedValue({ success: true });
const app = buildApp()
const res = await app.request('/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'good-sig' },
body: 'payload',
})
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200)
expect(updateUserPlanMock).toHaveBeenCalledWith('user-42', 'premium')
expect(updateUserStripeInfoMock).toHaveBeenCalledWith('user-42', {
stripe_customer_id: 'cus_abc',
stripe_subscription_id: 'sub_abc',
})
})
expect(res.status).toBe(200);
expect(updateUserPlanMock).toHaveBeenCalledWith("user-42", "premium");
expect(updateUserStripeInfoMock).toHaveBeenCalledWith("user-42", {
stripe_customer_id: "cus_abc",
stripe_subscription_id: "sub_abc",
});
});
it('traite customer.subscription.deleted → remet le plan à free', async () => {
it("traite customer.subscription.deleted → remet le plan à free", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
type: 'customer.subscription.deleted',
data: { object: { id: 'sub_abc' } },
type: "customer.subscription.deleted",
data: { object: { id: "sub_abc" } },
},
})
findUserBySubscriptionIdMock.mockResolvedValue({ userId: 'user-42' })
updateUserPlanMock.mockResolvedValue({ success: true, plan: 'free' })
updateUserStripeInfoMock.mockResolvedValue({ success: true })
});
findUserBySubscriptionIdMock.mockResolvedValue({ userId: "user-42" });
updateUserPlanMock.mockResolvedValue({ success: true, plan: "free" });
updateUserStripeInfoMock.mockResolvedValue({ success: true });
const app = buildApp()
const res = await app.request('/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'good-sig' },
body: 'payload',
})
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200)
expect(findUserBySubscriptionIdMock).toHaveBeenCalledWith('sub_abc')
expect(updateUserPlanMock).toHaveBeenCalledWith('user-42', 'free')
})
expect(res.status).toBe(200);
expect(findUserBySubscriptionIdMock).toHaveBeenCalledWith("sub_abc");
expect(updateUserPlanMock).toHaveBeenCalledWith("user-42", "free");
});
it('traite invoice.paid avec price Premium → plan premium', async () => {
it("traite invoice.paid avec price Premium → plan premium", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
type: 'invoice.paid',
type: "invoice.paid",
data: {
object: {
subscription: 'sub_xyz',
subscription: "sub_xyz",
lines: {
data: [{ price: { id: 'price_premium' } }],
data: [{ price: { id: "price_premium" } }],
},
},
},
},
})
findUserBySubscriptionIdMock.mockResolvedValue({ userId: 'user-99' })
updateUserPlanMock.mockResolvedValue({ success: true, plan: 'premium' })
});
findUserBySubscriptionIdMock.mockResolvedValue({ userId: "user-99" });
updateUserPlanMock.mockResolvedValue({ success: true, plan: "premium" });
const app = buildApp()
const res = await app.request('/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'good-sig' },
body: 'payload',
})
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200)
expect(updateUserPlanMock).toHaveBeenCalledWith('user-99', 'premium')
})
expect(res.status).toBe(200);
expect(updateUserPlanMock).toHaveBeenCalledWith("user-99", "premium");
});
it('retourne 200 pour un event non géré', async () => {
it("retourne 200 pour un event non géré", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
type: 'ping.unknown',
type: "ping.unknown",
data: { object: {} },
},
})
});
const app = buildApp()
const res = await app.request('/stripe/webhook', {
method: 'POST',
headers: { 'stripe-signature': 'good-sig' },
body: 'payload',
})
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200)
expect(updateUserPlanMock).not.toHaveBeenCalled()
})
})
expect(res.status).toBe(200);
expect(updateUserPlanMock).not.toHaveBeenCalled();
});
// ─── Sprint 5a — Idempotency (TD-13) ──────────────────────────────────────
it("event déjà traité → 200 replayed sans appel handler ni mark", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
id: "evt_already",
type: "checkout.session.completed",
data: {
object: {
metadata: { userId: "user-x", planName: "standard" },
customer: "cus_x",
subscription: "sub_x",
},
},
},
});
isEventProcessedMock.mockResolvedValue(true);
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ received: true, replayed: true });
expect(updateUserPlanMock).not.toHaveBeenCalled();
expect(updateUserStripeInfoMock).not.toHaveBeenCalled();
expect(markEventProcessedMock).not.toHaveBeenCalled();
});
it("event nouveau → traitement normal puis markEventProcessed(event.id)", async () => {
verifyStripeWebhookMock.mockReturnValue({
valid: true,
event: {
id: "evt_fresh",
type: "checkout.session.completed",
data: {
object: {
metadata: { userId: "user-fresh", planName: "standard" },
customer: "cus_fresh",
subscription: "sub_fresh",
},
},
},
});
isEventProcessedMock.mockResolvedValue(false);
updateUserPlanMock.mockResolvedValue({ success: true, plan: "standard" });
updateUserStripeInfoMock.mockResolvedValue({ success: true });
const app = buildApp();
const res = await app.request("/stripe/webhook", {
method: "POST",
headers: { "stripe-signature": "good-sig" },
body: "payload",
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ received: true });
expect(updateUserPlanMock).toHaveBeenCalledWith("user-fresh", "standard");
expect(markEventProcessedMock).toHaveBeenCalledTimes(1);
expect(markEventProcessedMock).toHaveBeenCalledWith("evt_fresh");
});
});
// ─── Sprint 5a — POST /stripe/customer-portal ────────────────────────────────
describe("POST /stripe/customer-portal", () => {
beforeEach(() => {
createBillingPortalSessionMock.mockReset();
profileOverrideRef.current = null;
process.env.APP_URL = "https://expria.app";
});
it("retourne 401 sans authentification", async () => {
const app = buildApp();
const res = await app.request("/stripe/customer-portal", {
method: "POST",
});
expect(res.status).toBe(401);
expect(createBillingPortalSessionMock).not.toHaveBeenCalled();
});
it("retourne 400 NO_ACTIVE_SUBSCRIPTION quand stripe_customer_id est absent", async () => {
profileOverrideRef.current = {
id: "u1",
email: "u@test.com",
plan: "free",
simulations_used: 0,
stripe_customer_id: null,
stripe_subscription_id: null,
plan_expires_at: null,
created_at: "2026-01-01",
updated_at: "2026-01-01",
};
const app = buildApp();
const res = await app.request("/stripe/customer-portal", {
method: "POST",
headers: { Authorization: "Bearer valid-token" },
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("NO_ACTIVE_SUBSCRIPTION");
expect(createBillingPortalSessionMock).not.toHaveBeenCalled();
});
it("retourne l'URL de la billing portal session pour un user avec stripe_customer_id", async () => {
profileOverrideRef.current = {
id: "u1",
email: "u@test.com",
plan: "standard",
simulations_used: 0,
stripe_customer_id: "cus_existing",
stripe_subscription_id: "sub_existing",
plan_expires_at: null,
created_at: "2026-01-01",
updated_at: "2026-01-01",
};
createBillingPortalSessionMock.mockResolvedValue({
url: "https://billing.stripe.com/p/session/abc",
});
const app = buildApp();
const res = await app.request("/stripe/customer-portal", {
method: "POST",
headers: { Authorization: "Bearer valid-token" },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.url).toBe("https://billing.stripe.com/p/session/abc");
expect(createBillingPortalSessionMock).toHaveBeenCalledWith({
customerId: "cus_existing",
returnUrl: "https://expria.app/dashboard",
});
});
});

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;