feat: rewrite payment route for yookassa redirect flow

This commit is contained in:
Kirill
2026-05-20 18:53:21 +05:00
parent abadbbd4c4
commit 8d45155b54
2 changed files with 263 additions and 95 deletions
@@ -0,0 +1,139 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import Fastify from 'fastify'
import jwt from '@fastify/jwt'
import { prisma } from '../../lib/prisma.js'
import { registerUserPaymentRoutes } from '../user-payments.js'
const JWT_SECRET = 'test-secret'
const TEST_USER_EMAIL = 'test-pay-user@example.com'
let testUserId
let testOrderId
async function signToken(userId) {
const fastify = Fastify()
await fastify.register(jwt, { secret: JWT_SECRET })
await fastify.ready()
return fastify.jwt.sign({ sub: userId, email: TEST_USER_EMAIL })
}
async function buildApp() {
const app = Fastify({ logger: false })
await app.register(jwt, { secret: JWT_SECRET })
app.decorate('authenticate', async function (request, reply) {
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: 'Unauthorized' })
}
})
app.decorate('eventBus', { emit: () => {} })
await registerUserPaymentRoutes(app)
await app.ready()
return app
}
describe('POST /api/me/orders/:id/pay', () => {
let app
beforeAll(async () => {
const user = await prisma.user.create({
data: { email: TEST_USER_EMAIL },
})
testUserId = user.id
const order = await prisma.order.create({
data: {
userId: testUserId,
status: 'PENDING_PAYMENT',
paymentMethod: 'online',
deliveryFeeLocked: true,
totalCents: 100000,
currency: 'RUB',
deliveryFeeCents: 0,
},
})
testOrderId = order.id
})
afterAll(async () => {
await prisma.order.deleteMany({ where: { userId: testUserId } })
await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } })
})
beforeEach(async () => {
await prisma.order.update({
where: { id: testOrderId },
data: {
status: 'PENDING_PAYMENT',
paymentMethod: 'online',
deliveryFeeLocked: true,
},
})
app = await buildApp()
})
afterEach(async () => {
await app.close()
})
it('returns 401 without auth', async () => {
const res = await app.inject({
method: 'POST',
url: `/api/me/orders/${testOrderId}/pay`,
})
expect(res.statusCode).toBe(401)
})
it('returns 404 when order not found', async () => {
const token = await signToken(testUserId)
const res = await app.inject({
method: 'POST',
url: '/api/me/orders/nonexistent-id/pay',
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(404)
})
it('returns 409 when payment method is on_pickup', async () => {
await prisma.order.update({
where: { id: testOrderId },
data: { paymentMethod: 'on_pickup' },
})
const token = await signToken(testUserId)
const res = await app.inject({
method: 'POST',
url: `/api/me/orders/${testOrderId}/pay`,
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(409)
})
it('returns 409 when order not in PENDING_PAYMENT status', async () => {
await prisma.order.update({
where: { id: testOrderId },
data: { status: 'PAID' },
})
const token = await signToken(testUserId)
const res = await app.inject({
method: 'POST',
url: `/api/me/orders/${testOrderId}/pay`,
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(409)
})
it('returns 409 when deliveryFeeLocked is false', async () => {
await prisma.order.update({
where: { id: testOrderId },
data: { deliveryFeeLocked: false },
})
const token = await signToken(testUserId)
const res = await app.inject({
method: 'POST',
url: `/api/me/orders/${testOrderId}/pay`,
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(409)
})
})
+124 -95
View File
@@ -1,114 +1,143 @@
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { escapeHtml } from '../lib/escape-html.js'
import { prisma } from '../lib/prisma.js'
import { saveImageBufferToUploads } from '../lib/upload-images.js'
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js'
export async function registerUserPaymentRoutes(fastify) {
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
fastify.post(
'/api/me/orders/:id/pay',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const userEmail = request.user.email
const { id } = request.params
const paymentMethod = order.paymentMethod ?? 'online'
if (paymentMethod === 'on_pickup') {
return reply.code(409).send({
error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.',
const order = await prisma.order.findFirst({
where: { id, userId },
include: { items: true },
})
}
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
if (order.status !== 'PENDING_PAYMENT') {
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
}
if (order.paymentMethod === 'on_pickup') {
return reply.code(409).send({
error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна',
})
}
if (!request.isMultipart()) {
return reply.code(400).send({
error: 'Отправьте multipart/form-data: поле detail и/или файл receipt',
if (order.status !== 'PENDING_PAYMENT') {
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
}
if (!order.deliveryFeeLocked) {
return reply.code(409).send({
error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже',
})
}
const existingPayment = await prisma.payment.findFirst({
where: { orderId: id, status: { in: ['pending', 'waiting_for_capture'] } },
orderBy: { createdAt: 'desc' },
})
}
let detail = ''
let receiptBuffer = null
let receiptFilename = ''
try {
const otherLimit = getOtherUploadMaxFileBytes()
const parts = request.parts({
limits: {
fileSize: otherLimit,
files: 2,
if (existingPayment && existingPayment.confirmationUrl) {
return { confirmationUrl: existingPayment.confirmationUrl }
}
const idempotencyKey = `${id}-v1`
const returnUrl = `${process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'}/me/orders/${id}?paid=1`
const clientIp = request.ip
const amount = {
value: (order.totalCents / 100).toFixed(2),
currency: order.currency,
}
const receipt = buildReceipt({
orderItems: order.items,
deliveryFeeCents: order.deliveryFeeCents,
userEmail: userEmail || 'noemail@example.com',
})
let result
try {
result = await createPayment({
amount,
description: `Оплата заказа №${order.id.slice(-6)}`,
receipt,
confirmation: { type: 'redirect', return_url: returnUrl },
metadata: { orderId: order.id },
idempotencyKey,
clientIp,
})
} catch (err) {
request.log.error({ err, orderId: id }, 'YooKassa createPayment failed')
return reply.code(502).send({
error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.',
})
}
await prisma.payment.create({
data: {
orderId: order.id,
yookassaPaymentId: result.paymentId,
status: result.status,
amountCents: order.totalCents,
currency: order.currency,
confirmationUrl: result.confirmationUrl,
expiresAt: result.expiresAt ? new Date(result.expiresAt) : null,
},
})
for await (const part of parts) {
if (part.file) {
if (part.fieldname === 'receipt') {
if (receiptBuffer !== null) {
return reply.code(400).send({ error: 'Допускается один файл receipt' })
}
receiptBuffer = await part.toBuffer()
receiptFilename = part.filename ?? 'receipt'
}
} else if (part.fieldname === 'detail') {
detail = String(part.value ?? '').trim()
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
return reply.code(400).send({ error: msg })
}
const hasDetail = detail.length > 0
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
return { confirmationUrl: result.confirmationUrl }
},
)
if (!hasDetail && !hasReceipt) {
return reply.code(400).send({
error: 'Укажите текст о платеже и/или прикрепите изображение чека',
fastify.get(
'/api/me/orders/:orderId/payment',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub
const { orderId } = request.params
const order = await prisma.order.findFirst({ where: { id: orderId, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const payment = await prisma.payment.findFirst({
where: { orderId },
orderBy: { createdAt: 'desc' },
})
}
if (!payment) {
return { status: null, paid: false }
}
const maxDetail = 2000
if (detail.length > maxDetail) {
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
}
if (payment.status === 'succeeded' || payment.status === 'canceled') {
return { status: payment.status, paid: payment.status === 'succeeded' }
}
let attachmentUrl = null
if (hasReceipt) {
try {
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
} catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
const statusCode =
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
? Number(err.statusCode)
: 400
return reply.code(statusCode).send({ error: message })
const ykPayment = await getPayment(payment.yookassaPaymentId)
if (ykPayment.status !== payment.status) {
await prisma.payment.update({
where: { id: payment.id },
data: { status: ykPayment.status },
})
if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') {
await prisma.order.update({
where: { id: orderId },
data: { status: 'PAID' },
})
request.server.eventBus.emit('PAYMENT_STATUS_CHANGED', {
orderId,
userId: order.userId,
paymentStatus: 'paid',
})
}
}
return { status: ykPayment.status, paid: ykPayment.paid }
} catch {
return { status: payment.status, paid: payment.status === 'succeeded' }
}
}
const bodyHtml = hasDetail ? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>` : ''
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
try {
await prisma.$transaction(async (tx) => {
await tx.orderMessage.create({
data: {
orderId: id,
authorType: 'user',
text: messageText,
attachmentUrl,
},
})
})
} catch {
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
}
request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
orderId: id,
userId,
paymentStatus: 'pending',
})
return { ok: true, status: 'PENDING_PAYMENT' }
})
},
)
}