Merge branch 'payd'
This commit is contained in:
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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} шт.` })
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user