feat: rewrite payment route for yookassa redirect flow
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 { prisma } from '../lib/prisma.js'
|
||||||
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js'
|
||||||
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
|
||||||
|
|
||||||
export async function registerUserPaymentRoutes(fastify) {
|
export async function registerUserPaymentRoutes(fastify) {
|
||||||
fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => {
|
fastify.post(
|
||||||
const userId = request.user.sub
|
'/api/me/orders/:id/pay',
|
||||||
const { id } = request.params
|
{ preHandler: [fastify.authenticate] },
|
||||||
const order = await prisma.order.findFirst({ where: { id, userId } })
|
async (request, reply) => {
|
||||||
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
const userId = request.user.sub
|
||||||
|
const userEmail = request.user.email
|
||||||
|
const { id } = request.params
|
||||||
|
|
||||||
const paymentMethod = order.paymentMethod ?? 'online'
|
const order = await prisma.order.findFirst({
|
||||||
if (paymentMethod === 'on_pickup') {
|
where: { id, userId },
|
||||||
return reply.code(409).send({
|
include: { items: true },
|
||||||
error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.',
|
|
||||||
})
|
})
|
||||||
}
|
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
|
||||||
if (order.status !== 'PENDING_PAYMENT') {
|
if (order.paymentMethod === 'on_pickup') {
|
||||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
return reply.code(409).send({
|
||||||
}
|
error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!request.isMultipart()) {
|
if (order.status !== 'PENDING_PAYMENT') {
|
||||||
return reply.code(400).send({
|
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||||
error: 'Отправьте multipart/form-data: поле detail и/или файл receipt',
|
}
|
||||||
|
|
||||||
|
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 = ''
|
if (existingPayment && existingPayment.confirmationUrl) {
|
||||||
let receiptBuffer = null
|
return { confirmationUrl: existingPayment.confirmationUrl }
|
||||||
let receiptFilename = ''
|
}
|
||||||
try {
|
|
||||||
const otherLimit = getOtherUploadMaxFileBytes()
|
const idempotencyKey = `${id}-v1`
|
||||||
const parts = request.parts({
|
const returnUrl = `${process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'}/me/orders/${id}?paid=1`
|
||||||
limits: {
|
const clientIp = request.ip
|
||||||
fileSize: otherLimit,
|
|
||||||
files: 2,
|
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
|
return { confirmationUrl: result.confirmationUrl }
|
||||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (!hasDetail && !hasReceipt) {
|
fastify.get(
|
||||||
return reply.code(400).send({
|
'/api/me/orders/:orderId/payment',
|
||||||
error: 'Укажите текст о платеже и/или прикрепите изображение чека',
|
{ 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 (payment.status === 'succeeded' || payment.status === 'canceled') {
|
||||||
if (detail.length > maxDetail) {
|
return { status: payment.status, paid: payment.status === 'succeeded' }
|
||||||
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let attachmentUrl = null
|
|
||||||
if (hasReceipt) {
|
|
||||||
try {
|
try {
|
||||||
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
const ykPayment = await getPayment(payment.yookassaPaymentId)
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
if (ykPayment.status !== payment.status) {
|
||||||
const statusCode =
|
await prisma.payment.update({
|
||||||
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
where: { id: payment.id },
|
||||||
? Number(err.statusCode)
|
data: { status: ykPayment.status },
|
||||||
: 400
|
})
|
||||||
return reply.code(statusCode).send({ error: message })
|
|
||||||
|
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