diff --git a/server/src/lib/__tests__/yookassa.test.js b/server/src/lib/__tests__/yookassa.test.js index 26823bd..f15be2a 100644 --- a/server/src/lib/__tests__/yookassa.test.js +++ b/server/src/lib/__tests__/yookassa.test.js @@ -166,9 +166,7 @@ describe('yookassa getPayment', () => { describe('yookassa buildReceipt', () => { it('builds receipt with order items', () => { const result = buildReceipt({ - orderItems: [ - { titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }, - ], + orderItems: [{ titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }], deliveryFeeCents: 0, userEmail: 'user@test.ru', }) @@ -187,9 +185,7 @@ describe('yookassa buildReceipt', () => { it('adds delivery item when deliveryFeeCents > 0', () => { const result = buildReceipt({ - orderItems: [ - { titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }, - ], + orderItems: [{ titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }], deliveryFeeCents: 35000, userEmail: 'user@test.ru', }) @@ -202,9 +198,7 @@ describe('yookassa buildReceipt', () => { it('passes through taxSystemCode', () => { const result = buildReceipt({ - orderItems: [ - { titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }, - ], + orderItems: [{ titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }], deliveryFeeCents: 0, userEmail: 'user@test.ru', taxSystemCode: 3, @@ -241,15 +235,11 @@ describe('yookassa validateWebhook', () => { }) it('throws if missing event', () => { - expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow( - 'Missing event or object', - ) + expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow('Missing event or object') }) it('throws if missing object', () => { - expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow( - 'Missing event or object', - ) + expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow('Missing event or object') }) it('throws for invalid body type', () => { diff --git a/server/src/lib/yookassa.js b/server/src/lib/yookassa.js index 32185ab..029274f 100644 --- a/server/src/lib/yookassa.js +++ b/server/src/lib/yookassa.js @@ -124,7 +124,7 @@ function isYookassaIp(ip) { } function isTestMode() { - return (process.env.YOOKASSA_SECRET_KEY?.startsWith('test_')) ?? false + return process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false } export function validateWebhook(ip, body) { diff --git a/server/src/routes/__tests__/user-payments.test.js b/server/src/routes/__tests__/user-payments.test.js index 525caec..d46af81 100644 --- a/server/src/routes/__tests__/user-payments.test.js +++ b/server/src/routes/__tests__/user-payments.test.js @@ -1,6 +1,6 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import Fastify from 'fastify' import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { prisma } from '../../lib/prisma.js' import { registerUserPaymentRoutes } from '../user-payments.js' diff --git a/server/src/routes/__tests__/webhook-yookassa.test.js b/server/src/routes/__tests__/webhook-yookassa.test.js index 35228d6..4227434 100644 --- a/server/src/routes/__tests__/webhook-yookassa.test.js +++ b/server/src/routes/__tests__/webhook-yookassa.test.js @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' const { mockPrisma } = vi.hoisted(() => ({ diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index a1cad89..c9272a8 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -3,149 +3,141 @@ import { prisma } from '../lib/prisma.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 userEmail = request.user.email + fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const userEmail = request.user.email - if (!userEmail) { - return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' }) - } + if (!userEmail) { + return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' }) + } - const { id } = request.params + const { id } = request.params - const order = await prisma.order.findFirst({ - where: { id, userId }, - include: { items: true }, + const order = await prisma.order.findFirst({ + where: { id, userId }, + include: { items: true }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + + if (order.paymentMethod === 'on_pickup') { + return reply.code(409).send({ + error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна', }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + } - if (order.paymentMethod === 'on_pickup') { - return reply.code(409).send({ - error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна', - }) - } + if (order.status !== 'PENDING_PAYMENT') { + return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) + } - 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' }, + if (!order.deliveryFeeLocked) { + return reply.code(409).send({ + error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже', }) + } - if (existingPayment && existingPayment.confirmationUrl) { - return { confirmationUrl: existingPayment.confirmationUrl } - } + const existingPayment = await prisma.payment.findFirst({ + where: { orderId: id, status: { in: ['pending', 'waiting_for_capture'] } }, + orderBy: { createdAt: 'desc' }, + }) - const idempotencyKey = `${id}-${Date.now()}` - const returnUrl = `${process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'}/me/orders/${id}?paid=1` - const clientIp = request.ip + if (existingPayment && existingPayment.confirmationUrl) { + return { confirmationUrl: existingPayment.confirmationUrl } + } - const amount = { - value: (order.totalCents / 100).toFixed(2), + const idempotencyKey = `${id}-${Date.now()}` + 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, + }, + }) - const receipt = buildReceipt({ - orderItems: order.items, - deliveryFeeCents: order.deliveryFeeCents, - userEmail: userEmail || 'noemail@example.com', - }) + return { confirmationUrl: result.confirmationUrl } + }) - 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, + 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 } + } + + if (payment.status === 'succeeded' || payment.status === 'canceled') { + return { status: payment.status, paid: payment.status === 'succeeded' } + } + + try { + const ykPayment = await getPayment(payment.yookassaPaymentId) + + if (ykPayment.status !== payment.status) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: ykPayment.status }, }) - } 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, - }, - }) - - return { confirmationUrl: result.confirmationUrl } - }, - ) - - 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 } - } - - if (payment.status === 'succeeded' || payment.status === 'canceled') { - return { status: payment.status, paid: payment.status === 'succeeded' } - } - - try { - 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') { + const updated = await prisma.order.updateMany({ + where: { id: orderId, status: 'PENDING_PAYMENT' }, + data: { status: 'PAID' }, }) - - if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') { - const updated = await prisma.order.updateMany({ - where: { id: orderId, status: 'PENDING_PAYMENT' }, - data: { status: 'PAID' }, + if (updated.count > 0) { + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId, + userId: order.userId, + paymentStatus: 'paid', }) - if (updated.count > 0) { - request.server.eventBus.emit(NOTIFICATION_EVENTS.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' } } - }, - ) + + return { status: ykPayment.status, paid: ykPayment.paid } + } catch { + return { status: payment.status, paid: payment.status === 'succeeded' } + } + }) } diff --git a/server/src/routes/webhook-yookassa.js b/server/src/routes/webhook-yookassa.js index 3ef80a0..6362de5 100644 --- a/server/src/routes/webhook-yookassa.js +++ b/server/src/routes/webhook-yookassa.js @@ -1,5 +1,5 @@ -import { prisma } from '../lib/prisma.js' import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { prisma } from '../lib/prisma.js' import { validateWebhook } from '../lib/yookassa.js' export async function registerYookassaWebhookRoute(fastify) {