feat: add yookassa webhook endpoint
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user