diff --git a/server/src/routes/__tests__/user-payments.test.js b/server/src/routes/__tests__/user-payments.test.js new file mode 100644 index 0000000..7ab9b90 --- /dev/null +++ b/server/src/routes/__tests__/user-payments.test.js @@ -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) + }) +}) diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index 98ccd4d..a12d0d0 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -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 ? `
${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}
Подтверждение оплаты (перевод ВТБ / Сбербанк)
${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' } - }) + }, + ) }