feat: Stripe checkout + webhook + upgrade prorata — 117/117 tests
This commit is contained in:
parent
f4f8c55ce7
commit
5b82c6bd46
10 changed files with 1063 additions and 3 deletions
98
src/lib/__tests__/createCheckoutSession.test.ts
Normal file
98
src/lib/__tests__/createCheckoutSession.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
104
src/lib/__tests__/updateUserStripeInfo.test.ts
Normal file
104
src/lib/__tests__/updateUserStripeInfo.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, string | null> = {}
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue