feat: rewrite payment route for yookassa redirect flow
This commit is contained in:
@@ -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' }
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user