Merge branch 'payd'

This commit is contained in:
Kirill
2026-05-21 12:03:23 +05:00
27 changed files with 2858 additions and 337 deletions
+4
View File
@@ -32,3 +32,7 @@ YANDEX_CLIENT_SECRET=
# Telegram Bot (оповещения админа)
TELEGRAM_BOT_TOKEN=
# YooKassa payment integration
YOOKASSA_SHOP_ID=
YOOKASSA_SECRET_KEY=
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL PRIMARY KEY,
"orderId" TEXT NOT NULL,
"yookassaPaymentId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"amountCents" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'RUB',
"confirmationUrl" TEXT,
"expiresAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Payment_yookassaPaymentId_key" ON "Payment"("yookassaPaymentId");
-- CreateIndex
CREATE INDEX "Payment_orderId_idx" ON "Payment"("orderId");
-- CreateIndex
CREATE INDEX "Payment_yookassaPaymentId_idx" ON "Payment"("yookassaPaymentId");
@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "Payment_yookassaPaymentId_idx";
+17
View File
@@ -153,12 +153,29 @@ model Order {
items OrderItem[]
messages OrderMessage[]
payments Payment[]
messageReadStates UserOrderMessageReadState[]
@@index([userId, createdAt])
@@index([status, updatedAt])
}
model Payment {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
yookassaPaymentId String @unique
status String
amountCents Int
currency String @default("RUB")
confirmationUrl String?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orderId])
}
model OrderItem {
id String @id @default(cuid())
qty Int
+3
View File
@@ -28,6 +28,7 @@ import { registerUserCartRoutes } from './routes/user-cart.js'
import { registerUserMessageRoutes } from './routes/user-messages.js'
import { registerUserOrderRoutes } from './routes/user-orders.js'
import { registerUserPaymentRoutes } from './routes/user-payments.js'
import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js'
const port = Number(process.env.PORT) || 3333
const origin = (process.env.CORS_ORIGIN ?? '')
@@ -38,6 +39,7 @@ const origin = (process.env.CORS_ORIGIN ?? '')
const fastify = Fastify({
logger: true,
bodyLimit: getMaxUploadBodyBytes(),
trustProxy: true,
})
await fastify.register(cors, {
@@ -93,6 +95,7 @@ await registerUserOrderRoutes(fastify)
await registerUserPaymentRoutes(fastify)
await registerUserNotificationRoutes(fastify)
await registerOAuthSocialRoutes(fastify)
await registerYookassaWebhookRoute(fastify)
await registerApiRoutes(fastify)
await ensureAdminUser()
await getOrCreateUnspecifiedCategory()
+257
View File
@@ -0,0 +1,257 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPayment, getPayment, buildReceipt, validateWebhook } from '../yookassa.js'
describe('yookassa createPayment', () => {
beforeEach(() => {
process.env.YOOKASSA_SHOP_ID = '123456'
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
delete process.env.YOOKASSA_SHOP_ID
delete process.env.YOOKASSA_SECRET_KEY
})
it('calls POST /payments with Basic auth and Idempotence-Key', async () => {
const mockPayment = {
id: '2d0c6f35-000f-5000-8000-1234567890ab',
status: 'pending',
paid: false,
amount: { value: '1000.00', currency: 'RUB' },
confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/...' },
created_at: '2026-05-20T12:00:00.000Z',
test: true,
refundable: false,
recipient: { account_id: '123456', gateway_id: '123456' },
}
fetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockPayment),
})
const result = await createPayment({
amount: { value: '1000.00', currency: 'RUB' },
description: 'Order #test',
receipt: {
customer: { email: 'test@example.com' },
items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }],
tax_system_code: 1,
},
confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test?paid=1' },
metadata: { orderId: 'test' },
idempotencyKey: 'test-v1',
})
expect(fetch).toHaveBeenCalledTimes(1)
const [url, opts] = fetch.mock.calls[0]
expect(url).toBe('https://api.yookassa.ru/v3/payments')
expect(opts.method).toBe('POST')
expect(opts.headers['Idempotence-Key']).toBe('test-v1')
expect(opts.headers['Authorization']).toBe('Basic MTIzNDU2OnRlc3Rfc2VjcmV0')
expect(result.paymentId).toBe('2d0c6f35-000f-5000-8000-1234567890ab')
expect(result.confirmationUrl).toBe('https://yoomoney.ru/checkout/...')
expect(result.status).toBe('pending')
})
it('retries on 5xx error', async () => {
fetch
.mockResolvedValueOnce({ ok: false, status: 500 })
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: 'retry-id',
status: 'pending',
paid: false,
amount: { value: '500.00', currency: 'RUB' },
confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/retry' },
created_at: '2026-05-20T12:00:00.000Z',
test: true,
refundable: false,
recipient: { account_id: '123456', gateway_id: '123456' },
}),
})
const result = await createPayment({
amount: { value: '500.00', currency: 'RUB' },
description: 'Retry test',
receipt: {
customer: { email: 'test@example.com' },
items: [{ description: 'Item', quantity: 1, amount: { value: '500.00', currency: 'RUB' }, vat_code: 1 }],
tax_system_code: 1,
},
confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test' },
metadata: {},
idempotencyKey: 'retry-v1',
})
expect(fetch).toHaveBeenCalledTimes(3)
expect(result.paymentId).toBe('retry-id')
})
it('throws on 4xx error', async () => {
fetch.mockResolvedValue({
ok: false,
status: 400,
json: () =>
Promise.resolve({
type: 'error',
id: 'err-id',
code: 'invalid_request',
description: 'Missing required field',
}),
})
await expect(
createPayment({
amount: { value: '1000.00', currency: 'RUB' },
description: 'Bad request',
receipt: {
customer: { email: 'test@example.com' },
items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }],
tax_system_code: 1,
},
confirmation: { type: 'redirect', return_url: 'http://localhost:5173' },
metadata: {},
idempotencyKey: 'bad-v1',
}),
).rejects.toThrow('YooKassa API error')
})
})
describe('yookassa getPayment', () => {
beforeEach(() => {
process.env.YOOKASSA_SHOP_ID = '123456'
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
delete process.env.YOOKASSA_SHOP_ID
delete process.env.YOOKASSA_SECRET_KEY
})
it('calls GET /payments/{id} and returns payment data', async () => {
fetch.mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: 'payment-id',
status: 'succeeded',
paid: true,
amount: { value: '1000.00', currency: 'RUB' },
created_at: '2026-05-20T12:00:00.000Z',
test: true,
refundable: true,
recipient: { account_id: '123456', gateway_id: '123456' },
}),
})
const result = await getPayment('payment-id')
expect(fetch).toHaveBeenCalledTimes(1)
expect(fetch.mock.calls[0][0]).toBe('https://api.yookassa.ru/v3/payments/payment-id')
expect(result.paymentId).toBe('payment-id')
expect(result.status).toBe('succeeded')
expect(result.paid).toBe(true)
})
})
describe('yookassa buildReceipt', () => {
it('builds receipt with order items', () => {
const result = buildReceipt({
orderItems: [{ titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }],
deliveryFeeCents: 0,
userEmail: 'user@test.ru',
})
expect(result.customer.email).toBe('user@test.ru')
expect(result.items).toHaveLength(1)
expect(result.items[0].description).toBe('Test Product')
expect(result.items[0].quantity).toBe(2)
expect(result.items[0].amount.value).toBe('1000.00')
expect(result.items[0].vat_code).toBe(1)
expect(result.items[0].measure).toBe('piece')
expect(result.items[0].payment_subject).toBe('commodity')
expect(result.items[0].payment_mode).toBe('full_prepayment')
expect(result.tax_system_code).toBe(1)
})
it('adds delivery item when deliveryFeeCents > 0', () => {
const result = buildReceipt({
orderItems: [{ titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }],
deliveryFeeCents: 35000,
userEmail: 'user@test.ru',
})
expect(result.items).toHaveLength(2)
expect(result.items[1].description).toBe('Доставка')
expect(result.items[1].amount.value).toBe('350.00')
expect(result.items[1].payment_subject).toBe('service')
})
it('passes through taxSystemCode', () => {
const result = buildReceipt({
orderItems: [{ titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }],
deliveryFeeCents: 0,
userEmail: 'user@test.ru',
taxSystemCode: 3,
})
expect(result.tax_system_code).toBe(3)
})
})
describe('yookassa validateWebhook', () => {
beforeEach(() => {
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
})
afterEach(() => {
delete process.env.YOOKASSA_SECRET_KEY
})
it('returns event and paymentObject for valid notification', () => {
const body = {
type: 'notification',
event: 'payment.succeeded',
object: { id: 'yk-id', status: 'succeeded', paid: true },
}
const result = validateWebhook('127.0.0.1', body)
expect(result.event).toBe('payment.succeeded')
expect(result.paymentObject.id).toBe('yk-id')
})
it('throws if type is not notification', () => {
expect(() => validateWebhook('127.0.0.1', { type: 'other', event: 'x', object: {} })).toThrow(
'Expected notification type',
)
})
it('throws if missing event', () => {
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')
})
it('throws for invalid body type', () => {
expect(() => validateWebhook('127.0.0.1', 'not an object')).toThrow('Invalid webhook body')
})
it('throws for null body', () => {
expect(() => validateWebhook('127.0.0.1', null)).toThrow('Invalid webhook body')
})
it('skips IP validation in test mode (test_ key)', () => {
const body = { type: 'notification', event: 'payment.succeeded', object: {} }
expect(() => validateWebhook('1.2.3.4', body)).not.toThrow()
})
})
+182
View File
@@ -0,0 +1,182 @@
const YOOKASSA_API_URL = 'https://api.yookassa.ru/v3'
function getAuthHeader() {
const shopId = process.env.YOOKASSA_SHOP_ID
const secretKey = process.env.YOOKASSA_SECRET_KEY
if (!shopId || !secretKey) {
throw new Error('YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY are required')
}
const token = Buffer.from(`${shopId}:${secretKey}`).toString('base64')
return `Basic ${token}`
}
function isRetryable(status) {
return status >= 500 || status === 429
}
async function fetchWithRetry(url, opts, maxRetries = 3) {
let lastError
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
const delay = 500 * 2 ** (attempt - 1)
await new Promise((resolve) => setTimeout(resolve, delay))
}
try {
const res = await fetch(url, opts)
if (res.ok) return res
const body = await res.json().catch(() => ({}))
if (isRetryable(res.status)) {
lastError = new Error(`YooKassa API error: ${res.status}${body.description || 'unknown'}`)
continue
}
throw new Error(
`YooKassa API error: ${res.status}${body.description || body.code || 'unknown'} (${body.parameter || 'n/a'})`,
)
} catch (err) {
if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw err
lastError = new Error(`YooKassa API error: network failure — ${err instanceof Error ? err.message : String(err)}`)
if (attempt === maxRetries) throw lastError
}
}
throw lastError
}
export async function createPayment({
amount,
description,
receipt,
confirmation,
metadata,
idempotencyKey,
clientIp,
}) {
const headers = {
Authorization: getAuthHeader(),
'Idempotence-Key': idempotencyKey,
'Content-Type': 'application/json',
}
const body = {
amount,
capture: true,
description,
confirmation,
metadata,
}
if (receipt) {
body.receipt = receipt
}
if (clientIp) {
body.client_ip = clientIp
}
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
const data = await res.json()
return {
paymentId: data.id,
status: data.status,
confirmationUrl: data.confirmation?.confirmation_url || null,
expiresAt: data.expires_at || null,
paid: data.paid,
test: data.test,
}
}
export async function getPayment(paymentId) {
const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments/${paymentId}`, {
headers: { Authorization: getAuthHeader() },
})
const data = await res.json()
return {
paymentId: data.id,
status: data.status,
confirmationUrl: data.confirmation?.confirmation_url || null,
expiresAt: data.expires_at || null,
paid: data.paid,
test: data.test,
}
}
const YOOKASSA_IP_RANGES_V4 = ['185.71.76.0/27', '185.71.77.0/27', '77.75.153.0/25', '77.75.154.128/25']
function ip4ToInt(ip) {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0
}
function cidrMatch(ip, cidr) {
const [range, bits] = cidr.split('/')
const mask = ~(2 ** (32 - parseInt(bits, 10)) - 1) >>> 0
const ipInt = ip4ToInt(ip)
const rangeInt = ip4ToInt(range)
return (ipInt & mask) === (rangeInt & mask)
}
function isYookassaIp(ip) {
const v4 = ip.replace(/^::ffff:/, '')
if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v4)) return false
return YOOKASSA_IP_RANGES_V4.some((cidr) => cidrMatch(v4, cidr))
}
function isTestMode() {
return process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false
}
export function validateWebhook(ip, body) {
if (!isTestMode() && !isYookassaIp(ip)) {
throw new Error('Invalid webhook source IP')
}
if (!body || typeof body !== 'object') {
throw new Error('Invalid webhook body')
}
if (body.type !== 'notification') {
throw new Error('Expected notification type in webhook body')
}
if (!body.event || !body.object) {
throw new Error('Missing event or object in webhook body')
}
return { event: body.event, paymentObject: body.object }
}
export function buildReceipt({ orderItems, deliveryFeeCents, userEmail, taxSystemCode = 1 }) {
const items = orderItems.map((item) => ({
description: (item.titleSnapshot || 'Товар').slice(0, 128),
quantity: item.qty,
amount: {
value: (item.priceCentsSnapshot / 100).toFixed(2),
currency: 'RUB',
},
vat_code: 1,
measure: 'piece',
payment_subject: 'commodity',
payment_mode: 'full_prepayment',
}))
if (deliveryFeeCents > 0) {
items.push({
description: 'Доставка',
quantity: 1,
amount: {
value: (deliveryFeeCents / 100).toFixed(2),
currency: 'RUB',
},
vat_code: 1,
measure: 'piece',
payment_subject: 'service',
payment_mode: 'full_prepayment',
})
}
const receipt = {
customer: { email: userEmail },
items,
tax_system_code: taxSystemCode,
}
return receipt
}
@@ -0,0 +1,245 @@
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'
const JWT_SECRET = 'test-secret'
const TEST_USER_EMAIL = `test-pay-${Date.now()}@example.com`
let testUserId
let testOrderId
async function signToken(userId, email = TEST_USER_EMAIL) {
const fastify = Fastify()
await fastify.register(jwt, { secret: JWT_SECRET })
await fastify.ready()
return fastify.jwt.sign({ sub: userId, 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 () => {
await prisma.payment.deleteMany()
await prisma.order.deleteMany({ where: { user: { email: TEST_USER_EMAIL } } })
await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } })
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.payment.deleteMany({ where: { orderId: testOrderId } })
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()
vi.restoreAllMocks()
})
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)
})
it('returns 422 when user has no email', async () => {
const noEmailUser = await prisma.user.create({
data: { email: `noemail-${Date.now()}@test.com` },
})
const noEmailOrder = await prisma.order.create({
data: {
userId: noEmailUser.id,
status: 'PENDING_PAYMENT',
paymentMethod: 'online',
deliveryFeeLocked: true,
totalCents: 100000,
currency: 'RUB',
},
})
const fastify = Fastify()
await fastify.register(jwt, { secret: JWT_SECRET })
const token = fastify.jwt.sign({ sub: noEmailUser.id })
await fastify.close()
const res = await app.inject({
method: 'POST',
url: `/api/me/orders/${noEmailOrder.id}/pay`,
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(422)
await prisma.order.deleteMany({ where: { userId: noEmailUser.id } })
await prisma.user.deleteMany({ where: { id: noEmailUser.id } })
})
})
describe('GET /api/me/orders/:orderId/payment', () => {
let app
let getTestUserId
let getTestOrderId
beforeAll(async () => {
const getEmail = `get-pay-${Date.now()}@example.com`
const user = await prisma.user.create({ data: { email: getEmail } })
getTestUserId = user.id
const order = await prisma.order.create({
data: {
userId: getTestUserId,
status: 'PENDING_PAYMENT',
paymentMethod: 'online',
deliveryFeeLocked: true,
totalCents: 100000,
currency: 'RUB',
},
})
getTestOrderId = order.id
})
afterAll(async () => {
await prisma.payment.deleteMany({ where: { orderId: getTestOrderId } })
await prisma.order.deleteMany({ where: { userId: getTestUserId } })
await prisma.user.deleteMany({ where: { id: getTestUserId } })
})
beforeEach(async () => {
app = await buildApp()
})
afterEach(async () => {
await app.close()
})
it('returns 401 without auth', async () => {
const res = await app.inject({
method: 'GET',
url: `/api/me/orders/${getTestOrderId}/payment`,
})
expect(res.statusCode).toBe(401)
})
it('returns 404 when order not found', async () => {
const token = await signToken(getTestUserId)
const res = await app.inject({
method: 'GET',
url: '/api/me/orders/nonexistent-id/payment',
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(404)
})
it('returns status null when no payment exists', async () => {
const token = await signToken(getTestUserId)
const res = await app.inject({
method: 'GET',
url: `/api/me/orders/${getTestOrderId}/payment`,
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(200)
const body = JSON.parse(res.payload)
expect(body.status).toBeNull()
expect(body.paid).toBe(false)
})
})
@@ -0,0 +1,133 @@
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(() => ({
mockPrisma: {
payment: { findFirst: vi.fn(), update: vi.fn() },
order: { findFirst: vi.fn(), updateMany: vi.fn() },
},
}))
vi.mock('../../lib/prisma.js', () => ({
prisma: mockPrisma,
}))
vi.mock('../../lib/yookassa.js', () => ({
validateWebhook: vi.fn(),
}))
import { validateWebhook } from '../../lib/yookassa.js'
import { registerYookassaWebhookRoute } from '../webhook-yookassa.js'
function buildApp(eventBusMock) {
const app = Fastify({ logger: false })
app.decorate('eventBus', eventBusMock || { emit: () => {} })
return app
}
describe('POST /api/webhooks/yookassa', () => {
let app
let eventBus
beforeEach(async () => {
eventBus = { emit: vi.fn() }
validateWebhook.mockImplementation((_ip, body) => {
if (!body || typeof body !== 'object') throw new Error('Invalid webhook body')
if (body.type !== 'notification') throw new Error('Expected notification type in webhook body')
if (!body.event || !body.object) throw new Error('Missing event or object in webhook body')
return { event: body.event, paymentObject: body.object }
})
app = buildApp(eventBus)
await registerYookassaWebhookRoute(app)
await app.ready()
})
afterEach(async () => {
await app.close()
vi.clearAllMocks()
})
it('returns 400 for invalid body', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/yookassa',
payload: { not: 'valid' },
})
expect(res.statusCode).toBe(400)
})
it('returns 404 when payment not found', async () => {
mockPrisma.payment.findFirst.mockResolvedValue(null)
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/yookassa',
payload: {
type: 'notification',
event: 'payment.succeeded',
object: { id: 'unknown-id', status: 'succeeded', paid: true },
},
})
expect(res.statusCode).toBe(404)
})
it('updates payment and order on payment.succeeded', async () => {
mockPrisma.payment.findFirst.mockResolvedValue({
id: 'payment-1',
yookassaPaymentId: 'yk-id',
status: 'pending',
orderId: 'order-1',
})
mockPrisma.payment.update.mockResolvedValue({})
mockPrisma.order.findFirst.mockResolvedValue({
id: 'order-1',
status: 'PENDING_PAYMENT',
userId: 'user-1',
})
mockPrisma.order.updateMany.mockResolvedValue({ count: 1 })
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/yookassa',
payload: {
type: 'notification',
event: 'payment.succeeded',
object: { id: 'yk-id', status: 'succeeded', paid: true },
},
})
expect(res.statusCode).toBe(200)
const updateData = mockPrisma.payment.update.mock.calls[0][0].data
expect(updateData.status).toBe('succeeded')
const orderUpdateData = mockPrisma.order.updateMany.mock.calls[0][0].data
expect(orderUpdateData.status).toBe('PAID')
expect(eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
orderId: 'order-1',
userId: 'user-1',
paymentStatus: 'paid',
})
})
it('updates payment on payment.canceled without changing order', async () => {
mockPrisma.payment.findFirst.mockResolvedValue({
id: 'payment-1',
yookassaPaymentId: 'yk-id',
status: 'pending',
orderId: 'order-1',
})
mockPrisma.payment.update.mockResolvedValue({})
const res = await app.inject({
method: 'POST',
url: '/api/webhooks/yookassa',
payload: {
type: 'notification',
event: 'payment.canceled',
object: { id: 'yk-id', status: 'canceled', paid: false },
},
})
expect(res.statusCode).toBe(200)
expect(mockPrisma.order.findFirst).not.toHaveBeenCalled()
})
})
+14
View File
@@ -92,6 +92,7 @@ export async function registerAdminUserRoutes(fastify) {
fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const adminUserId = request.user.sub
const existing = await prisma.user.findUnique({ where: { id } })
if (!existing) {
@@ -99,9 +100,15 @@ export async function registerAdminUserRoutes(fastify) {
return
}
const isSelf = id === adminUserId
const data = {}
if (body.email !== undefined) {
if (isSelf) {
reply.code(403).send({ error: 'Нельзя изменить свою почту через панель администратора' })
return
}
const email = normalizeEmail(body.email)
if (!email || !email.includes('@')) {
reply.code(400).send({ error: 'Некорректная почта' })
@@ -139,6 +146,13 @@ export async function registerAdminUserRoutes(fastify) {
fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { id } = request.params
const adminUserId = request.user.sub
if (id === adminUserId) {
reply.code(403).send({ error: 'Нельзя удалить свою учётную запись' })
return
}
try {
await prisma.user.delete({ where: { id } })
reply.code(204).send()
+2 -2
View File
@@ -29,7 +29,7 @@ export async function registerUserCartRoutes(fastify) {
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
const available = product.inStock ? product.quantity : 1
const available = product.quantity
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
@@ -57,7 +57,7 @@ export async function registerUserCartRoutes(fastify) {
return reply.code(204).send()
}
const available = existing.product.inStock ? existing.product.quantity : 1
const available = existing.product.quantity
const nextQty = Math.floor(qty)
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
+1 -3
View File
@@ -65,7 +65,7 @@ export async function registerUserOrderRoutes(fastify) {
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
for (const ci of cartItems) {
const available = ci.product.inStock ? ci.product.quantity : 1
const available = ci.product.quantity
if (ci.qty > available) {
return reply.code(409).send({
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
@@ -112,8 +112,6 @@ export async function registerUserOrderRoutes(fastify) {
try {
created = await prisma.$transaction(async (tx) => {
for (const ci of cartItems) {
if (!ci.product.inStock) continue
const res = await tx.product.updateMany({
where: { id: ci.productId, quantity: { gte: ci.qty } },
data: { quantity: { decrement: ci.qty } },
+123 -90
View File
@@ -1,20 +1,27 @@
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 userEmail = request.user.email
if (!userEmail) {
return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' })
}
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
const order = await prisma.order.findFirst({
where: { id, userId },
include: { items: true },
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const paymentMethod = order.paymentMethod ?? 'online'
if (paymentMethod === 'on_pickup') {
if (order.paymentMethod === 'on_pickup') {
return reply.code(409).send({
error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.',
error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна',
})
}
@@ -22,93 +29,119 @@ export async function registerUserPaymentRoutes(fastify) {
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
}
if (!request.isMultipart()) {
return reply.code(400).send({
error: 'Отправьте multipart/form-data: поле detail и/или файл receipt',
if (!order.deliveryFeeLocked) {
return reply.code(409).send({
error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже',
})
}
let detail = ''
let receiptBuffer = null
let receiptFilename = ''
try {
const otherLimit = getOtherUploadMaxFileBytes()
const parts = request.parts({
limits: {
fileSize: otherLimit,
files: 2,
},
})
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
if (!hasDetail && !hasReceipt) {
return reply.code(400).send({
error: 'Укажите текст о платеже и/или прикрепите изображение чека',
})
}
const maxDetail = 2000
if (detail.length > maxDetail) {
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
}
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 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',
const existingPayment = await prisma.payment.findFirst({
where: {
orderId: id,
status: { in: ['pending', 'waiting_for_capture'] },
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
orderBy: { createdAt: 'desc' },
})
return { ok: true, status: 'PENDING_PAYMENT' }
if (existingPayment && existingPayment.confirmationUrl) {
return { confirmationUrl: existingPayment.confirmationUrl }
}
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,
})
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,
},
})
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 (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' }
}
})
}
+60
View File
@@ -0,0 +1,60 @@
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) {
fastify.post('/api/webhooks/yookassa', async (request, reply) => {
let body
try {
body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body
} catch {
return reply.code(400).send({ error: 'Invalid JSON body' })
}
let event, paymentObject
try {
const clientIp = request.ip
;({ event, paymentObject } = validateWebhook(clientIp, body))
} catch (err) {
return reply.code(400).send({ error: err.message })
}
const yookassaPaymentId = paymentObject.id
if (!yookassaPaymentId) {
return reply.code(400).send({ error: 'Missing payment id in webhook object' })
}
const payment = await prisma.payment.findFirst({
where: { yookassaPaymentId },
})
if (!payment) {
return reply.code(404).send({ error: 'Payment not found' })
}
await prisma.payment.update({
where: { id: payment.id },
data: { status: paymentObject.status },
})
if (event === 'payment.succeeded') {
const order = await prisma.order.findFirst({
where: { id: payment.orderId },
})
if (order && order.status === 'PENDING_PAYMENT') {
const updated = await prisma.order.updateMany({
where: { id: payment.orderId, status: 'PENDING_PAYMENT' },
data: { status: 'PAID' },
})
if (updated.count > 0) {
fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, {
orderId: payment.orderId,
userId: order.userId,
paymentStatus: 'paid',
})
}
}
}
return { ok: true }
})
}