feat: add yookassa webhook endpoint

This commit is contained in:
Kirill
2026-05-20 19:19:48 +05:00
parent 317b910710
commit dcf601d4a2
3 changed files with 195 additions and 0 deletions
+2
View File
@@ -28,6 +28,7 @@ import { registerUserCartRoutes } from './routes/user-cart.js'
import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserMessageRoutes } from './routes/user-messages.js'
import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserOrderRoutes } from './routes/user-orders.js'
import { registerUserPaymentRoutes } from './routes/user-payments.js' import { registerUserPaymentRoutes } from './routes/user-payments.js'
import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js'
const port = Number(process.env.PORT) || 3333 const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? '') const origin = (process.env.CORS_ORIGIN ?? '')
@@ -93,6 +94,7 @@ await registerUserOrderRoutes(fastify)
await registerUserPaymentRoutes(fastify) await registerUserPaymentRoutes(fastify)
await registerUserNotificationRoutes(fastify) await registerUserNotificationRoutes(fastify)
await registerOAuthSocialRoutes(fastify) await registerOAuthSocialRoutes(fastify)
await registerYookassaWebhookRoute(fastify)
await registerApiRoutes(fastify) await registerApiRoutes(fastify)
await ensureAdminUser() await ensureAdminUser()
await getOrCreateUnspecifiedCategory() await getOrCreateUnspecifiedCategory()
@@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Fastify from 'fastify'
import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js'
const { mockPrisma } = vi.hoisted(() => ({
mockPrisma: {
payment: { findFirst: vi.fn(), update: vi.fn() },
order: { findFirst: vi.fn(), updateMany: vi.fn() },
},
}))
vi.mock('../../lib/prisma.js', () => ({
prisma: mockPrisma,
}))
vi.mock('../../lib/yookassa.js', () => ({
validateWebhook: vi.fn(),
}))
import { validateWebhook } from '../../lib/yookassa.js'
import { registerYookassaWebhookRoute } from '../webhook-yookassa.js'
function buildApp(eventBusMock) {
const app = Fastify({ logger: false })
app.decorate('eventBus', eventBusMock || { emit: () => {} })
return app
}
describe('POST /api/webhooks/yookassa', () => {
let app
let eventBus
beforeEach(async () => {
eventBus = { emit: vi.fn() }
validateWebhook.mockImplementation((_ip, body) => {
if (!body || typeof body !== 'object') throw new Error('Invalid webhook body')
if (body.type !== 'notification') throw new Error('Expected notification type in webhook body')
if (!body.event || !body.object) throw new Error('Missing event or object in webhook body')
return { event: body.event, paymentObject: body.object }
})
app = buildApp(eventBus)
await registerYookassaWebhookRoute(app)
await app.ready()
})
afterEach(async () => {
await app.close()
vi.clearAllMocks()
})
it('returns 400 for invalid body', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/yookassa',
payload: { not: 'valid' },
})
expect(res.statusCode).toBe(400)
})
it('returns 404 when payment not found', async () => {
mockPrisma.payment.findFirst.mockResolvedValue(null)
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/yookassa',
payload: {
type: 'notification',
event: 'payment.succeeded',
object: { id: 'unknown-id', status: 'succeeded', paid: true },
},
})
expect(res.statusCode).toBe(404)
})
it('updates payment and order on payment.succeeded', async () => {
mockPrisma.payment.findFirst.mockResolvedValue({
id: 'payment-1',
yookassaPaymentId: 'yk-id',
status: 'pending',
orderId: 'order-1',
})
mockPrisma.payment.update.mockResolvedValue({})
mockPrisma.order.findFirst.mockResolvedValue({
id: 'order-1',
status: 'PENDING_PAYMENT',
userId: 'user-1',
})
mockPrisma.order.updateMany.mockResolvedValue({ count: 1 })
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/yookassa',
payload: {
type: 'notification',
event: 'payment.succeeded',
object: { id: 'yk-id', status: 'succeeded', paid: true },
},
})
expect(res.statusCode).toBe(200)
const updateData = mockPrisma.payment.update.mock.calls[0][0].data
expect(updateData.status).toBe('succeeded')
const orderUpdateData = mockPrisma.order.updateMany.mock.calls[0][0].data
expect(orderUpdateData.status).toBe('PAID')
expect(eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
orderId: 'order-1',
userId: 'user-1',
paymentStatus: 'paid',
})
})
it('updates payment on payment.canceled without changing order', async () => {
mockPrisma.payment.findFirst.mockResolvedValue({
id: 'payment-1',
yookassaPaymentId: 'yk-id',
status: 'pending',
orderId: 'order-1',
})
mockPrisma.payment.update.mockResolvedValue({})
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/yookassa',
payload: {
type: 'notification',
event: 'payment.canceled',
object: { id: 'yk-id', status: 'canceled', paid: false },
},
})
expect(res.statusCode).toBe(200)
expect(mockPrisma.order.findFirst).not.toHaveBeenCalled()
})
})
+60
View File
@@ -0,0 +1,60 @@
import { prisma } from '../lib/prisma.js'
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { validateWebhook } from '../lib/yookassa.js'
export async function registerYookassaWebhookRoute(fastify) {
fastify.post('/api/webhooks/yookassa', async (request, reply) => {
let body
try {
body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body
} catch {
return reply.code(400).send({ error: 'Invalid JSON body' })
}
let event, paymentObject
try {
const clientIp = request.ip
;({ event, paymentObject } = validateWebhook(clientIp, body))
} catch (err) {
return reply.code(400).send({ error: err.message })
}
const yookassaPaymentId = paymentObject.id
if (!yookassaPaymentId) {
return reply.code(400).send({ error: 'Missing payment id in webhook object' })
}
const payment = await prisma.payment.findFirst({
where: { yookassaPaymentId },
})
if (!payment) {
return reply.code(404).send({ error: 'Payment not found' })
}
await prisma.payment.update({
where: { id: payment.id },
data: { status: paymentObject.status },
})
if (event === 'payment.succeeded') {
const order = await prisma.order.findFirst({
where: { id: payment.orderId },
})
if (order && order.status === 'PENDING_PAYMENT') {
const updated = await prisma.order.updateMany({
where: { id: payment.orderId, status: 'PENDING_PAYMENT' },
data: { status: 'PAID' },
})
if (updated.count > 0) {
fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
orderId: payment.orderId,
userId: order.userId,
paymentStatus: 'paid',
})
}
}
}
return { ok: true }
})
}