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:
parent
6671bac347
commit
28f8373f5d
3 changed files with 100 additions and 65 deletions
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue