diff --git a/server/src/index.js b/server/src/index.js index 9b57aa3..4ed109d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -28,6 +28,7 @@ import { registerUserCartRoutes } from './routes/user-cart.js' import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserPaymentRoutes } from './routes/user-payments.js' +import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js' const port = Number(process.env.PORT) || 3333 const origin = (process.env.CORS_ORIGIN ?? '') @@ -93,6 +94,7 @@ await registerUserOrderRoutes(fastify) await registerUserPaymentRoutes(fastify) await registerUserNotificationRoutes(fastify) await registerOAuthSocialRoutes(fastify) +await registerYookassaWebhookRoute(fastify) await registerApiRoutes(fastify) await ensureAdminUser() await getOrCreateUnspecifiedCategory() diff --git a/server/src/routes/__tests__/webhook-yookassa.test.js b/server/src/routes/__tests__/webhook-yookassa.test.js new file mode 100644 index 0000000..35228d6 --- /dev/null +++ b/server/src/routes/__tests__/webhook-yookassa.test.js @@ -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() + }) +}) diff --git a/server/src/routes/webhook-yookassa.js b/server/src/routes/webhook-yookassa.js new file mode 100644 index 0000000..3ef80a0 --- /dev/null +++ b/server/src/routes/webhook-yookassa.js @@ -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 } + }) +}