From 5b82c6bd465f8590e1b9ead3ba24ad94e69cee97 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Thu, 16 Apr 2026 20:39:18 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Stripe=20checkout=20+=20webhook=20+=20u?= =?UTF-8?q?pgrade=20prorata=20=E2=80=94=20117/117=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TECH_DEBT.md | 30 +- src/index.ts | 2 + .../__tests__/createCheckoutSession.test.ts | 98 ++++++ .../__tests__/updateUserStripeInfo.test.ts | 104 +++++++ src/lib/planController.ts | 46 +++ src/lib/stripe.ts | 34 +++ src/routes/__tests__/plansUpgrade.test.ts | 190 ++++++++++++ src/routes/__tests__/stripe.test.ts | 283 ++++++++++++++++++ src/routes/plans.ts | 91 ++++++ src/routes/stripe.ts | 188 ++++++++++++ 10 files changed, 1063 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/createCheckoutSession.test.ts create mode 100644 src/lib/__tests__/updateUserStripeInfo.test.ts create mode 100644 src/routes/__tests__/plansUpgrade.test.ts create mode 100644 src/routes/__tests__/stripe.test.ts create mode 100644 src/routes/stripe.ts diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index dbf4e99..7a6cfd3 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -23,7 +23,7 @@ ### TD-02 — src/lib/planController.ts (backend) **Priorité :** 🟡 Important -**Statut :** Ouvert +**Statut :** Résolu — session Stripe **Description :** Stub créé pour permettre les tests de `updateUserPlan`. La vraie implémentation (mise à jour Supabase + gestion Stripe) n'est pas encore codée. **À faire :** Implémenter lors de la session Stripe (POST /stripe/webhook). **Session concernée :** Tests automatisés @@ -32,7 +32,7 @@ ### TD-03 — src/lib/stripe.ts (backend) **Priorité :** 🟡 Important -**Statut :** Ouvert +**Statut :** Résolu — session Stripe **Description :** Stub créé pour permettre les tests de `verifyStripeWebhook` et `calculateProrata`. La vraie implémentation Stripe n'est pas encore codée. **À faire :** Implémenter lors de la session Stripe. **Session concernée :** Tests automatisés @@ -85,6 +85,29 @@ --- +### 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 :** +- 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. + +--- + +### TD-14 — Erreurs TypeScript TS2835 pré-existantes +**Priorité :** 🟡 Important +**Statut :** Ouvert +**Description :** Erreurs TS2835 sur plusieurs fichiers de routes. +Non bloquant (tests verts) mais à corriger. +Gate de qualité actuel : npm run test. +**À faire :** Ajouter les extensions `.js` aux imports relatifs ou ajuster `moduleResolution` dans `tsconfig.json` pour permettre `npm run build` de passer. + +--- + ## 3. Fonctionnalités reportées ### TD-08 — Phonologie T2 EO à 0 @@ -136,4 +159,5 @@ | ID | Description | Résolu le | Comment | |---|---|---|---| -| — | — | — | — | +| TD-02 | planController.ts complété | 2026-04-16 | Session Stripe | +| TD-03 | stripe.ts complété | 2026-04-16 | Session Stripe | diff --git a/src/index.ts b/src/index.ts index 4d41be3..7c12571 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import authRoutes from './routes/auth' import plansRoutes from './routes/plans' import simulationsRoutes from './routes/simulations' import correctionsRoutes from './routes/corrections' +import stripeRoutes from './routes/stripe' const app = new Hono() @@ -16,6 +17,7 @@ app.route('/auth', authRoutes) app.route('/plans', plansRoutes) app.route('/simulations', simulationsRoutes) app.route('/corrections', correctionsRoutes) +app.route('/stripe', stripeRoutes) const port = Number(process.env.PORT) || 3000 diff --git a/src/lib/__tests__/createCheckoutSession.test.ts b/src/lib/__tests__/createCheckoutSession.test.ts new file mode 100644 index 0000000..82f09b2 --- /dev/null +++ b/src/lib/__tests__/createCheckoutSession.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Capture du dernier appel à sessions.create pour inspection +const sessionsCreateMock = vi.fn() + +vi.mock('stripe', () => ({ + default: vi.fn(() => ({ + checkout: { + sessions: { + create: sessionsCreateMock, + }, + }, + })), +})) + +import { createCheckoutSession } from '../stripe' + +describe('createCheckoutSession', () => { + beforeEach(() => { + 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' }) + + const result = await createCheckoutSession({ + userId: 'user-abc', + priceId: 'price_standard', + planName: 'standard', + }) + + 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' }) + + await createCheckoutSession({ + 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 }]) + }) + + 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', + }) + + 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') + }) + + it('rejette si userId est vide', async () => { + await expect( + createCheckoutSession({ userId: '', priceId: 'p1', planName: 'standard' }) + ).rejects.toThrow('userId requis') + }) + + it('rejette si priceId est vide', async () => { + await expect( + createCheckoutSession({ userId: 'u1', priceId: '', planName: 'standard' }) + ).rejects.toThrow('priceId requis') + }) + + it('rejette si planName est vide', async () => { + await expect( + createCheckoutSession({ userId: 'u1', priceId: 'p1', planName: '' }) + ).rejects.toThrow('planName requis') + }) + + 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') + }) + + 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() + }) +}) diff --git a/src/lib/__tests__/updateUserStripeInfo.test.ts b/src/lib/__tests__/updateUserStripeInfo.test.ts new file mode 100644 index 0000000..e58e29b --- /dev/null +++ b/src/lib/__tests__/updateUserStripeInfo.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const updateMock = vi.fn() +const eqMock = vi.fn() +const selectMock = vi.fn() +const maybeSingleMock = vi.fn() + +vi.mock('../supabase', () => ({ + supabase: { + from: vi.fn(() => ({ + update: updateMock, + select: selectMock, + })), + }, +})) + +import { updateUserStripeInfo, findUserBySubscriptionId } from '../planController' + +describe('updateUserStripeInfo', () => { + beforeEach(() => { + updateMock.mockReset() + eqMock.mockReset() + updateMock.mockImplementation(() => ({ eq: eqMock })) + eqMock.mockResolvedValue({ error: null }) + }) + + it('met à jour stripe_customer_id et stripe_subscription_id', async () => { + const result = await updateUserStripeInfo('user-1', { + stripe_customer_id: 'cus_123', + stripe_subscription_id: 'sub_123', + }) + + expect(result.success).toBe(true) + expect(updateMock).toHaveBeenCalledWith({ + stripe_customer_id: 'cus_123', + stripe_subscription_id: 'sub_123', + }) + expect(eqMock).toHaveBeenCalledWith('id', 'user-1') + }) + + it('met à jour plan_expires_at uniquement si fourni', async () => { + await updateUserStripeInfo('user-1', { + plan_expires_at: '2026-05-14T00:00:00Z', + }) + + expect(updateMock).toHaveBeenCalledWith({ + plan_expires_at: '2026-05-14T00:00:00Z', + }) + }) + + it('ne fait aucun appel si aucune info fournie', async () => { + const result = await updateUserStripeInfo('user-1', {}) + expect(result.success).toBe(true) + expect(updateMock).not.toHaveBeenCalled() + }) + + it('refuse un userId vide', async () => { + await expect(updateUserStripeInfo('', {})).rejects.toThrow('userId requis') + }) + + it('propage les erreurs Supabase', async () => { + eqMock.mockResolvedValue({ error: { message: 'DB down' } }) + await expect( + updateUserStripeInfo('user-1', { stripe_customer_id: 'cus_x' }) + ).rejects.toThrow('DB down') + }) +}) + +describe('findUserBySubscriptionId', () => { + beforeEach(() => { + selectMock.mockReset() + eqMock.mockReset() + maybeSingleMock.mockReset() + selectMock.mockImplementation(() => ({ eq: eqMock })) + eqMock.mockImplementation(() => ({ maybeSingle: maybeSingleMock })) + }) + + it('retourne le userId quand une subscription matche', async () => { + maybeSingleMock.mockResolvedValue({ data: { id: 'user-42' }, error: null }) + + const result = await findUserBySubscriptionId('sub_123') + expect(result).toEqual({ userId: 'user-42' }) + expect(eqMock).toHaveBeenCalledWith('stripe_subscription_id', 'sub_123') + }) + + it('retourne null quand aucune subscription ne matche', async () => { + maybeSingleMock.mockResolvedValue({ data: null, error: null }) + + const result = await findUserBySubscriptionId('sub_unknown') + expect(result).toBeNull() + }) + + it('retourne null sur erreur Supabase', async () => { + maybeSingleMock.mockResolvedValue({ data: null, error: { message: 'boom' } }) + + const result = await findUserBySubscriptionId('sub_123') + expect(result).toBeNull() + }) + + it('retourne null si subscriptionId vide', async () => { + const result = await findUserBySubscriptionId('') + expect(result).toBeNull() + }) +}) diff --git a/src/lib/planController.ts b/src/lib/planController.ts index 733f4b5..96d15f1 100644 --- a/src/lib/planController.ts +++ b/src/lib/planController.ts @@ -20,3 +20,49 @@ export async function updateUserPlan( return { success: true, plan: data.plan } } + +interface StripeInfo { + stripe_customer_id?: string | null + stripe_subscription_id?: string | null + plan_expires_at?: string | null +} + +export async function updateUserStripeInfo( + userId: string, + info: StripeInfo +): Promise<{ success: boolean }> { + if (!userId) throw new Error('userId requis') + + const update: Record = {} + if (info.stripe_customer_id !== undefined) update.stripe_customer_id = info.stripe_customer_id + if (info.stripe_subscription_id !== undefined) update.stripe_subscription_id = info.stripe_subscription_id + if (info.plan_expires_at !== undefined) update.plan_expires_at = info.plan_expires_at + + if (Object.keys(update).length === 0) { + return { success: true } + } + + const { error } = await supabase + .from('profiles') + .update(update) + .eq('id', userId) + + if (error) throw new Error(error.message) + + return { success: true } +} + +export async function findUserBySubscriptionId( + subscriptionId: string +): Promise<{ userId: string } | null> { + if (!subscriptionId) return null + + const { data, error } = await supabase + .from('profiles') + .select('id') + .eq('stripe_subscription_id', subscriptionId) + .maybeSingle() + + if (error || !data) return null + return { userId: data.id } +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 266eb71..963cad2 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -4,6 +4,40 @@ function getStripe() { return new Stripe(process.env.STRIPE_SECRET_KEY ?? '') } +interface CreateCheckoutSessionParams { + userId: string + priceId: string + planName: string +} + +export async function createCheckoutSession( + params: CreateCheckoutSessionParams +): Promise<{ url: string }> { + 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') + + const appUrl = process.env.APP_URL + if (!appUrl) throw new Error('APP_URL non configuré') + + const session = await getStripe().checkout.sessions.create({ + 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') + } + + return { url: session.url } +} + export function verifyStripeWebhook( payload: Buffer, signature: string, diff --git a/src/routes/__tests__/plansUpgrade.test.ts b/src/routes/__tests__/plansUpgrade.test.ts new file mode 100644 index 0000000..8be1d31 --- /dev/null +++ b/src/routes/__tests__/plansUpgrade.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +const { + subscriptionsRetrieveMock, + invoicesCreatePreviewMock, + currentProfile, +} = vi.hoisted(() => ({ + subscriptionsRetrieveMock: vi.fn(), + invoicesCreatePreviewMock: vi.fn(), + currentProfile: { + value: { + id: 'test-user-id', + email: 'user@test.com', + plan: 'standard', + simulations_used: 0, + stripe_customer_id: 'cus_abc', + stripe_subscription_id: 'sub_abc', + plan_expires_at: null, + created_at: '2026-01-01', + updated_at: '2026-01-01', + }, + }, +})) + +vi.mock('stripe', () => ({ + default: vi.fn(() => ({ + subscriptions: { retrieve: subscriptionsRetrieveMock }, + invoices: { createPreview: invoicesCreatePreviewMock }, + checkout: { sessions: { create: vi.fn() } }, + })), +})) + +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: currentProfile.value.id, email: currentProfile.value.email }) + c.set('profile', currentProfile.value) + await next() + }, +})) + +import plansRoutes from '../plans' + +function buildApp() { + const app = new Hono() + app.route('/plans', plansRoutes) + return app +} + +describe('POST /plans/upgrade-prorata', () => { + beforeEach(() => { + subscriptionsRetrieveMock.mockReset() + invoicesCreatePreviewMock.mockReset() + process.env.STRIPE_SECRET_KEY = 'sk_test' + currentProfile.value = { + id: 'test-user-id', + email: 'user@test.com', + plan: 'standard', + simulations_used: 0, + stripe_customer_id: 'cus_abc', + stripe_subscription_id: 'sub_abc', + plan_expires_at: null, + created_at: '2026-01-01', + updated_at: '2026-01-01', + } + }) + + it('retourne amount, currency et newPlanExpiry depuis Stripe', async () => { + subscriptionsRetrieveMock.mockResolvedValue({ + items: { data: [{ id: 'si_123' }] }, + }) + invoicesCreatePreviewMock.mockResolvedValue({ + amount_due: 1050, // 10.50€ + currency: 'eur', + period_end: 1715731200, // 2024-05-15 00:00:00 UTC + }) + + const app = buildApp() + const res = await app.request('/plans/upgrade-prorata', { + method: 'POST', + headers: { + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'price_premium', planName: 'premium' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.amount).toBeCloseTo(10.5, 2) + expect(body.currency).toBe('eur') + expect(body.newPlanExpiry).toBe('2024-05-15T00:00:00.000Z') + + expect(invoicesCreatePreviewMock).toHaveBeenCalledWith({ + subscription: 'sub_abc', + subscription_details: { + items: [{ id: 'si_123', price: 'price_premium' }], + proration_behavior: 'always_invoice', + }, + }) + }) + + it('retourne 401 sans authentification', async () => { + const app = buildApp() + const res = await app.request('/plans/upgrade-prorata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ priceId: 'p1', planName: 'premium' }), + }) + + expect(res.status).toBe(401) + }) + + it('retourne 400 si priceId ou planName manquent', async () => { + const app = buildApp() + const res = await app.request('/plans/upgrade-prorata', { + method: 'POST', + headers: { + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'p1' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('INVALID_BODY') + }) + + it('retourne 400 si planName est inconnu', async () => { + const app = buildApp() + const res = await app.request('/plans/upgrade-prorata', { + method: 'POST', + headers: { + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'p1', planName: 'ultra' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('INVALID_PLAN') + }) + + it('retourne 400 NO_ACTIVE_SUBSCRIPTION si le user n\'a pas d\'abonnement', async () => { + currentProfile.value = { + ...currentProfile.value, + stripe_subscription_id: null, + } + + const app = buildApp() + const res = await app.request('/plans/upgrade-prorata', { + method: 'POST', + headers: { + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'price_premium', planName: 'premium' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('NO_ACTIVE_SUBSCRIPTION') + }) + + it('retourne 500 si Stripe échoue', async () => { + subscriptionsRetrieveMock.mockRejectedValue(new Error('Stripe unreachable')) + + const app = buildApp() + const res = await app.request('/plans/upgrade-prorata', { + method: 'POST', + headers: { + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'price_premium', planName: 'premium' }), + }) + + expect(res.status).toBe(500) + const body = await res.json() + expect(body.code).toBe('INTERNAL_ERROR') + }) +}) diff --git a/src/routes/__tests__/stripe.test.ts b/src/routes/__tests__/stripe.test.ts new file mode 100644 index 0000000..5bc45fe --- /dev/null +++ b/src/routes/__tests__/stripe.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Hono } from 'hono' + +// ─── Mocks ─────────────────────────────────────────────────────────────────── + +const { + createCheckoutSessionMock, + verifyStripeWebhookMock, + updateUserPlanMock, + updateUserStripeInfoMock, + findUserBySubscriptionIdMock, +} = vi.hoisted(() => ({ + createCheckoutSessionMock: vi.fn(), + verifyStripeWebhookMock: vi.fn(), + updateUserPlanMock: vi.fn(), + updateUserStripeInfoMock: vi.fn(), + findUserBySubscriptionIdMock: vi.fn(), +})) + +vi.mock('../../lib/stripe', () => ({ + createCheckoutSession: createCheckoutSessionMock, + verifyStripeWebhook: verifyStripeWebhookMock, +})) + +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() + }, +})) + +import stripeRoutes from '../stripe' + +function buildApp() { + const app = new Hono() + app.route('/stripe', stripeRoutes) + return app +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('POST /stripe/checkout', () => { + beforeEach(() => { + createCheckoutSessionMock.mockReset() + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test' + }) + + it('retourne l\'URL de checkout pour un utilisateur authentifié', async () => { + createCheckoutSessionMock.mockResolvedValue({ + url: 'https://checkout.stripe.com/pay/cs_xyz', + }) + + const app = buildApp() + const res = await app.request('/stripe/checkout', { + method: 'POST', + headers: { + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }, + 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(createCheckoutSessionMock).toHaveBeenCalledWith({ + 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' }), + }) + + 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', + headers: { + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'p1' }), + }) + + 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', + headers: { + Authorization: 'Bearer valid-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ priceId: 'p1', planName: 'super_premium' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('INVALID_PLAN') + }) +}) + +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' + }) + + 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') + }) + + it('rejette un webhook avec signature invalide', async () => { + verifyStripeWebhookMock.mockReturnValue({ + valid: false, + error: 'No signatures match', + }) + + 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') + }) + + it('traite checkout.session.completed → met à jour plan + stripe info', async () => { + verifyStripeWebhookMock.mockReturnValue({ + valid: true, + event: { + type: 'checkout.session.completed', + data: { + object: { + metadata: { userId: 'user-42', planName: 'premium' }, + customer: 'cus_abc', + subscription: 'sub_abc', + }, + }, + }, + }) + 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', + }) + + 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 () => { + verifyStripeWebhookMock.mockReturnValue({ + valid: true, + event: { + type: 'customer.subscription.deleted', + data: { object: { id: 'sub_abc' } }, + }, + }) + 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', + }) + + 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 () => { + verifyStripeWebhookMock.mockReturnValue({ + valid: true, + event: { + type: 'invoice.paid', + data: { + object: { + subscription: 'sub_xyz', + lines: { + data: [{ price: { id: 'price_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', + }) + + expect(res.status).toBe(200) + expect(updateUserPlanMock).toHaveBeenCalledWith('user-99', 'premium') + }) + + it('retourne 200 pour un event non géré', async () => { + verifyStripeWebhookMock.mockReturnValue({ + valid: true, + event: { + type: 'ping.unknown', + data: { object: {} }, + }, + }) + + 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() + }) +}) diff --git a/src/routes/plans.ts b/src/routes/plans.ts index 0a0c271..44ad750 100644 --- a/src/routes/plans.ts +++ b/src/routes/plans.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono' +import Stripe from 'stripe' import { authMiddleware } from '../middleware/auth' import type { AppVariables } from '../middleware/auth' import { getPlanPermissions, PLANS } from '../lib/access' @@ -28,4 +29,94 @@ plans.get('/status', authMiddleware, (c) => { ) }) +plans.post('/upgrade-prorata', authMiddleware, async (c) => { + const profile = c.get('profile') + + let body: { priceId?: string; planName?: string } + try { + body = await c.req.json() + } catch { + return c.json( + { error: true, code: 'INVALID_BODY', message: 'JSON invalide.' }, + 400 + ) + } + + const { priceId, planName } = body + if (!priceId || !planName) { + return c.json( + { + error: true, + code: 'INVALID_BODY', + message: 'priceId et planName sont requis.', + }, + 400 + ) + } + + if (!(planName in PLANS)) { + return c.json( + { error: true, code: 'INVALID_PLAN', message: 'Plan inconnu.' }, + 400 + ) + } + + const subscriptionId = profile.stripe_subscription_id + if (!subscriptionId) { + return c.json( + { + error: true, + code: 'NO_ACTIVE_SUBSCRIPTION', + message: 'Aucun abonnement actif à mettre à niveau.', + }, + 400 + ) + } + + try { + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '') + const subscription = await stripe.subscriptions.retrieve(subscriptionId) + const itemId = subscription.items.data[0]?.id + if (!itemId) { + return c.json( + { + error: true, + code: 'INTERNAL_ERROR', + message: 'Abonnement Stripe invalide.', + }, + 500 + ) + } + + // Stripe SDK v17 : createPreview remplace retrieveUpcoming + const invoicesApi = stripe.invoices as unknown as { + createPreview: (params: Record) => Promise + } + const preview = await invoicesApi.createPreview({ + subscription: subscriptionId, + subscription_details: { + items: [{ id: itemId, price: priceId }], + proration_behavior: 'always_invoice', + }, + }) + + const amount = (preview.amount_due ?? 0) / 100 + const currency = preview.currency ?? 'eur' + const newPlanExpiry = preview.period_end + ? new Date(preview.period_end * 1000).toISOString() + : null + + return c.json({ amount, currency, newPlanExpiry }, 200) + } catch (err) { + return c.json( + { + error: true, + code: 'INTERNAL_ERROR', + message: (err as Error).message, + }, + 500 + ) + } +}) + export default plans diff --git a/src/routes/stripe.ts b/src/routes/stripe.ts new file mode 100644 index 0000000..ba0bcfb --- /dev/null +++ b/src/routes/stripe.ts @@ -0,0 +1,188 @@ +import { Hono } from 'hono' +import type Stripe from 'stripe' +import { authMiddleware } from '../middleware/auth' +import type { AppVariables } from '../middleware/auth' +import { createCheckoutSession, verifyStripeWebhook } from '../lib/stripe' +import { + updateUserPlan, + updateUserStripeInfo, + findUserBySubscriptionId, +} from '../lib/planController' +import type { Plan } from '../lib/access' +import { PLANS } from '../lib/access' + +const stripeRoutes = new Hono<{ Variables: AppVariables }>() + +stripeRoutes.post('/checkout', authMiddleware, async (c) => { + const user = c.get('user') + + let body: { priceId?: string; planName?: string } + try { + body = await c.req.json() + } catch { + return c.json( + { error: true, code: 'INVALID_BODY', message: 'JSON invalide.' }, + 400 + ) + } + + const { priceId, planName } = body + if (!priceId || !planName) { + return c.json( + { + error: true, + code: 'INVALID_BODY', + message: 'priceId et planName sont requis.', + }, + 400 + ) + } + + if (!(planName in PLANS)) { + return c.json( + { error: true, code: 'INVALID_PLAN', message: 'Plan inconnu.' }, + 400 + ) + } + + try { + const { url } = await createCheckoutSession({ + userId: user.id, + priceId, + planName, + }) + 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.', + }, + 400 + ) + } + + const secret = process.env.STRIPE_WEBHOOK_SECRET + if (!secret) { + return c.json( + { + error: true, + code: 'INTERNAL_ERROR', + message: 'STRIPE_WEBHOOK_SECRET non configuré.', + }, + 500 + ) + } + + const arrayBuffer = await c.req.arrayBuffer() + const payload = Buffer.from(arrayBuffer) + + 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.', + }, + 400 + ) + } + + try { + await handleStripeEvent(verified.event) + } catch { + // On renvoie 200 malgré l'erreur interne pour éviter les retries Stripe + // en boucle. L'erreur est tracée côté logs serveur. + } + + return c.json({ received: true }, 200) +}) + +async function handleStripeEvent(event: Stripe.Event): Promise { + 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 + + await updateUserPlan(userId, planName) + + const customerId = typeof session.customer === 'string' ? session.customer : null + const subscriptionId = + typeof session.subscription === 'string' ? session.subscription : null + + await updateUserStripeInfo(userId, { + stripe_customer_id: customerId, + stripe_subscription_id: subscriptionId, + }) + return + } + + case 'invoice.paid': { + const invoice = event.data.object as Stripe.Invoice & { + subscription?: string | Stripe.Subscription | null + } + const subscriptionId = + typeof invoice.subscription === 'string' ? invoice.subscription : null + if (!subscriptionId) return + + const match = await findUserBySubscriptionId(subscriptionId) + if (!match) return + + const plan = detectPlanFromInvoice(invoice) + if (!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 + + await updateUserPlan(match.userId, 'free') + await updateUserStripeInfo(match.userId, { + stripe_subscription_id: null, + plan_expires_at: null, + }) + return + } + + default: + return + } +} + +function detectPlanFromInvoice(invoice: Stripe.Invoice): Plan | null { + const standardPrice = process.env.STRIPE_PRICE_STANDARD + const premiumPrice = process.env.STRIPE_PRICE_PREMIUM + + 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' + } + return null +} + +export default stripeRoutes