fix(stripe): cancel_url /tarifs → /plan

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) <noreply@anthropic.com>
This commit is contained in:
Hermann_Kitio 2026-04-26 05:13:37 +03:00
parent 6671bac347
commit 28f8373f5d
3 changed files with 100 additions and 65 deletions

View file

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

View file

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

View file

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