feat: Stripe checkout + webhook + upgrade prorata — 117/117 tests

This commit is contained in:
Hermann_Kitio 2026-04-16 20:39:18 +03:00
parent f4f8c55ce7
commit 5b82c6bd46
10 changed files with 1063 additions and 3 deletions

View file

@ -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')
})
})

View file

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

View file

@ -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<string, unknown>) => Promise<Stripe.Invoice>
}
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

188
src/routes/stripe.ts Normal file
View file

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