From 28f8373f5d1c8fb4261593fee971bc4d9a601d85 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sun, 26 Apr 2026 05:13:37 +0300 Subject: [PATCH] =?UTF-8?q?fix(stripe):=20cancel=5Furl=20/tarifs=20?= =?UTF-8?q?=E2=86=92=20/plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La route /tarifs n'existe pas côté frontend (route réelle: /plan). Cancellation Stripe Checkout aboutissait sur un 404. Bug détecté lors du Sprint 5c frontend (gestion des retours post-Checkout). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/CHANGELOG-backend.md | 6 +- .../__tests__/createCheckoutSession.test.ts | 157 +++++++++++------- src/lib/stripe.ts | 2 +- 3 files changed, 100 insertions(+), 65 deletions(-) diff --git a/docs/CHANGELOG-backend.md b/docs/CHANGELOG-backend.md index 7ddd6f4..d688dd4 100644 --- a/docs/CHANGELOG-backend.md +++ b/docs/CHANGELOG-backend.md @@ -23,6 +23,10 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). - `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`. +### Fixed + +- `src/lib/stripe.ts` — `cancel_url` Stripe Checkout corrigé : `${APP_URL}/tarifs?upgrade=cancelled` → `${APP_URL}/plan?upgrade=cancelled`. La route `/tarifs` n'existe pas côté frontend (route réelle : `/plan`) ; les checkouts annulés aboutissaient sur un 404. Bug détecté lors du Sprint 5c frontend (gestion des retours post-Checkout) et corrigé en cross-repo. + ### Resolved - **TD-13 🔴 → Résolu** : Webhook Stripe idempotent (table `stripe_webhook_events` + helpers + wiring route + 10 tests). @@ -30,7 +34,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/). ### Notes - Tests : 261 → 278 verts (+17). -- Aucun changement frontend dans ce sprint — Sprint 5b (frontend billing) à venir. +- Aucun changement code frontend dans ce sprint — Sprint 5b/5c/5d (frontend billing) livrés en parallèle. --- diff --git a/src/lib/__tests__/createCheckoutSession.test.ts b/src/lib/__tests__/createCheckoutSession.test.ts index 82f09b2..1c69e33 100644 --- a/src/lib/__tests__/createCheckoutSession.test.ts +++ b/src/lib/__tests__/createCheckoutSession.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach } from "vitest"; // Capture du dernier appel à sessions.create pour inspection -const sessionsCreateMock = vi.fn() +const sessionsCreateMock = vi.fn(); -vi.mock('stripe', () => ({ +vi.mock("stripe", () => ({ default: vi.fn(() => ({ checkout: { sessions: { @@ -11,88 +11,119 @@ vi.mock('stripe', () => ({ }, }, })), -})) +})); -import { createCheckoutSession } from '../stripe' +import { createCheckoutSession } from "../stripe"; -describe('createCheckoutSession', () => { +describe("createCheckoutSession", () => { beforeEach(() => { - sessionsCreateMock.mockReset() - process.env.APP_URL = 'https://expria.app' - }) + sessionsCreateMock.mockReset(); + process.env.APP_URL = "https://expria.app"; + }); - it('retourne l\'URL de la session Stripe', async () => { - sessionsCreateMock.mockResolvedValue({ url: 'https://checkout.stripe.com/pay/cs_test_123' }) + it("retourne l'URL de la session Stripe", async () => { + sessionsCreateMock.mockResolvedValue({ + url: "https://checkout.stripe.com/pay/cs_test_123", + }); const result = await createCheckoutSession({ - userId: 'user-abc', - priceId: 'price_standard', - planName: 'standard', - }) + userId: "user-abc", + priceId: "price_standard", + planName: "standard", + }); - expect(result.url).toBe('https://checkout.stripe.com/pay/cs_test_123') - }) + expect(result.url).toBe("https://checkout.stripe.com/pay/cs_test_123"); + }); - it('passe metadata { userId, planName } à Stripe', async () => { - sessionsCreateMock.mockResolvedValue({ url: 'https://checkout.stripe.com/pay/cs_test_123' }) + it("passe metadata { userId, planName } à Stripe", async () => { + sessionsCreateMock.mockResolvedValue({ + url: "https://checkout.stripe.com/pay/cs_test_123", + }); await createCheckoutSession({ - userId: 'user-xyz', - priceId: 'price_premium', - planName: 'premium', - }) + userId: "user-xyz", + priceId: "price_premium", + planName: "premium", + }); - const callArg = sessionsCreateMock.mock.calls[0][0] - expect(callArg.metadata).toEqual({ userId: 'user-xyz', planName: 'premium' }) - expect(callArg.client_reference_id).toBe('user-xyz') - expect(callArg.mode).toBe('subscription') - expect(callArg.line_items).toEqual([{ price: 'price_premium', quantity: 1 }]) - }) + const callArg = sessionsCreateMock.mock.calls[0][0]; + expect(callArg.metadata).toEqual({ + userId: "user-xyz", + planName: "premium", + }); + expect(callArg.client_reference_id).toBe("user-xyz"); + expect(callArg.mode).toBe("subscription"); + expect(callArg.line_items).toEqual([ + { price: "price_premium", quantity: 1 }, + ]); + }); - it('construit success_url et cancel_url depuis APP_URL', async () => { - process.env.APP_URL = 'https://app.example.test' - sessionsCreateMock.mockResolvedValue({ url: 'https://checkout.stripe.com/pay/cs_x' }) + it("construit success_url et cancel_url depuis APP_URL", async () => { + process.env.APP_URL = "https://app.example.test"; + sessionsCreateMock.mockResolvedValue({ + url: "https://checkout.stripe.com/pay/cs_x", + }); await createCheckoutSession({ - userId: 'u1', - priceId: 'p1', - planName: 'standard', - }) + userId: "u1", + priceId: "p1", + planName: "standard", + }); - const callArg = sessionsCreateMock.mock.calls[0][0] - expect(callArg.success_url).toBe('https://app.example.test/dashboard?upgrade=success') - expect(callArg.cancel_url).toBe('https://app.example.test/tarifs?upgrade=cancelled') - }) + const callArg = sessionsCreateMock.mock.calls[0][0]; + expect(callArg.success_url).toBe( + "https://app.example.test/dashboard?upgrade=success", + ); + expect(callArg.cancel_url).toBe( + "https://app.example.test/plan?upgrade=cancelled", + ); + }); - it('rejette si userId est vide', async () => { + it("rejette si userId est vide", async () => { await expect( - createCheckoutSession({ userId: '', priceId: 'p1', planName: 'standard' }) - ).rejects.toThrow('userId requis') - }) + createCheckoutSession({ + userId: "", + priceId: "p1", + planName: "standard", + }), + ).rejects.toThrow("userId requis"); + }); - it('rejette si priceId est vide', async () => { + it("rejette si priceId est vide", async () => { await expect( - createCheckoutSession({ userId: 'u1', priceId: '', planName: 'standard' }) - ).rejects.toThrow('priceId requis') - }) + createCheckoutSession({ + userId: "u1", + priceId: "", + planName: "standard", + }), + ).rejects.toThrow("priceId requis"); + }); - it('rejette si planName est vide', async () => { + it("rejette si planName est vide", async () => { await expect( - createCheckoutSession({ userId: 'u1', priceId: 'p1', planName: '' }) - ).rejects.toThrow('planName requis') - }) + createCheckoutSession({ userId: "u1", priceId: "p1", planName: "" }), + ).rejects.toThrow("planName requis"); + }); - it('rejette si APP_URL est absent', async () => { - delete process.env.APP_URL + it("rejette si APP_URL est absent", async () => { + delete process.env.APP_URL; await expect( - createCheckoutSession({ userId: 'u1', priceId: 'p1', planName: 'standard' }) - ).rejects.toThrow('APP_URL') - }) + createCheckoutSession({ + userId: "u1", + priceId: "p1", + planName: "standard", + }), + ).rejects.toThrow("APP_URL"); + }); - it('rejette si Stripe ne retourne pas d\'URL', async () => { - sessionsCreateMock.mockResolvedValue({ url: null }) + it("rejette si Stripe ne retourne pas d'URL", async () => { + sessionsCreateMock.mockResolvedValue({ url: null }); await expect( - createCheckoutSession({ userId: 'u1', priceId: 'p1', planName: 'standard' }) - ).rejects.toThrow() - }) -}) + createCheckoutSession({ + userId: "u1", + priceId: "p1", + planName: "standard", + }), + ).rejects.toThrow(); + }); +}); diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 7a7db29..9577d1b 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -26,7 +26,7 @@ export async function createCheckoutSession( mode: "subscription", line_items: [{ price: priceId, quantity: 1 }], success_url: `${appUrl}/dashboard?upgrade=success`, - cancel_url: `${appUrl}/tarifs?upgrade=cancelled`, + cancel_url: `${appUrl}/plan?upgrade=cancelled`, client_reference_id: userId, metadata: { userId, planName }, });