# YooKassa Payment Integration — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace manual bank transfer payment flow with YooKassa (ЮKassa) online payment gateway using redirect scenario + webhooks. **Architecture:** Dedicated `server/src/lib/yookassa.js` module isolates all YooKassa API interaction (createPayment, getPayment, webhook validation). Server routes use this module. Client replaces PaymentDialog with a redirect to YooKassa payment form. **Tech Stack:** Fastify, Prisma/SQLite, React + MUI + React Query, Node 18+ built-in fetch. --- ### Task 1: Add Payment model to Prisma schema and run migration **Files:** - Modify: `server/prisma/schema.prisma` (add Payment model after Order model) - Create: migration via `prisma migrate dev` - [ ] **Step 1: Add Payment model to schema** Add after the `Order` model (line 160): ```prisma 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]) @@index([yookassaPaymentId]) } ``` Also add `payments Payment[]` relation field to the `Order` model (insert at line 155 after `messages OrderMessage[]`): ```prisma payments Payment[] ``` - [ ] **Step 2: Run Prisma migration** ```bash cd /mnt/d/my_projects/shop/server && npx prisma migrate dev --name add_payment ``` Expected: migration created and applied, no errors. - [ ] **Step 3: Generate Prisma client** ```bash cd /mnt/d/my_projects/shop/server && npx prisma generate ``` Expected: client regenerated with new Payment model. - [ ] **Step 4: Verify schema is valid** ```bash cd /mnt/d/my_projects/shop/server && npx prisma validate ``` Expected: "The datasource is valid." - [ ] **Step 5: Commit** ```bash git add server/prisma/schema.prisma server/prisma/migrations && git commit -m "feat: add Payment model for yookassa integration" ``` --- ### Task 2: Add YooKassa environment variables **Files:** - Modify: `server/.env.example` - [ ] **Step 1: Add YooKassa env vars to .env.example** Add after line 34 (`TELEGRAM_BOT_TOKEN=`): ```bash # YooKassa payment integration YOOKASSA_SHOP_ID= YOOKASSA_SECRET_KEY= ``` - [ ] **Step 2: Commit** ```bash git add server/.env.example && git commit -m "feat: add yookassa env vars to .env.example" ``` --- ### Task 3: Create YooKassa API client library **Files:** - Create: `server/src/lib/yookassa.js` - Create: `server/src/lib/__tests__/yookassa.test.js` - [ ] **Step 1: Write failing tests for yookassa module** Create `server/src/lib/__tests__/yookassa.test.js`: ```js import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPayment, getPayment } 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.id).toBe('payment-id') expect(result.status).toBe('succeeded') expect(result.paid).toBe(true) }) }) ``` - [ ] **Step 2: Run tests — verify they fail** ```bash cd /mnt/d/my_projects/shop/server && npx vitest run src/lib/__tests__/yookassa.test.js ``` Expected: FAIL — "createPayment is not a function" / "getPayment is not a function" - [ ] **Step 3: Implement yookassa.js module** Create `server/src/lib/yookassa.js`: ```js 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 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') && !isRetryable) throw err lastError = 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 fetch(`${YOOKASSA_API_URL}/payments/${paymentId}`, { headers: { Authorization: getAuthHeader() }, }) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(`YooKassa getPayment error: ${res.status} — ${body.description || 'unknown'}`) } return res.json() } 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)) } const TEST_MODE = process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') export function validateWebhook(ip, body) { if (!TEST_MODE && !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 } ``` - [ ] **Step 4: Run tests — verify they pass** ```bash cd /mnt/d/my_projects/shop/server && npx vitest run src/lib/__tests__/yookassa.test.js ``` Expected: all tests PASS. - [ ] **Step 5: Commit** ```bash git add server/src/lib/yookassa.js server/src/lib/__tests__/yookassa.test.js && git commit -m "feat: add yookassa API client library with tests" ``` --- ### Task 4: Rewrite server payment route (POST /api/me/orders/:id/pay) **Files:** - Modify: `server/src/routes/user-payments.js` - Create: `server/src/routes/__tests__/user-payments.test.js` - [ ] **Step 1: Write failing integration tests for the payment route** Create `server/src/routes/__tests__/user-payments.test.js`: ```js import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Fastify from 'fastify' import jwt from '@fastify/jwt' import { registerUserPaymentRoutes } from '../user-payments.js' const JWT_SECRET = 'test-secret' const USER_EMAIL = 'user@example.com' function signToken(userId) { const fastify = Fastify() fastify.register(jwt, { secret: JWT_SECRET }) return fastify.jwt.sign({ sub: userId, email: USER_EMAIL }) } function buildApp(overrides = {}) { const app = Fastify({ logger: false }) 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', overrides.eventBus || { emit: () => {} }) app.decorate('prisma', overrides.prisma || {}) return app } describe('POST /api/me/orders/:id/pay', () => { let app let prisma let eventBus beforeEach(async () => { eventBus = { emit: vi.fn() } prisma = { order: { findFirst: vi.fn() }, payment: { findFirst: vi.fn(), create: vi.fn() }, $transaction: vi.fn((fn) => fn(prisma)), } app = buildApp({ prisma, eventBus }) await registerUserPaymentRoutes(app) await app.ready() }) afterEach(async () => { await app.close() }) it('returns 401 without auth', async () => { const res = await app.inject({ method: 'POST', url: '/api/me/orders/order-1/pay' }) expect(res.statusCode).toBe(401) }) it('returns 404 when order not found', async () => { prisma.order.findFirst.mockResolvedValue(null) const token = signToken('user-1') const res = await app.inject({ method: 'POST', url: '/api/me/orders/order-1/pay', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(404) }) it('returns 409 when payment method is on_pickup', async () => { prisma.order.findFirst.mockResolvedValue({ id: 'order-1', userId: 'user-1', status: 'PENDING_PAYMENT', paymentMethod: 'on_pickup', }) const token = signToken('user-1') const res = await app.inject({ method: 'POST', url: '/api/me/orders/order-1/pay', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(409) }) it('returns 409 when order not in PENDING_PAYMENT status', async () => { prisma.order.findFirst.mockResolvedValue({ id: 'order-1', userId: 'user-1', status: 'PAID', paymentMethod: 'online', }) const token = signToken('user-1') const res = await app.inject({ method: 'POST', url: '/api/me/orders/order-1/pay', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(409) }) it('returns 409 when deliveryFeeLocked is false', async () => { prisma.order.findFirst.mockResolvedValue({ id: 'order-1', userId: 'user-1', status: 'PENDING_PAYMENT', paymentMethod: 'online', deliveryFeeLocked: false, }) const token = signToken('user-1') const res = await app.inject({ method: 'POST', url: '/api/me/orders/order-1/pay', headers: { authorization: `Bearer ${token}` }, }) expect(res.statusCode).toBe(409) }) }) ``` - [ ] **Step 2: Run tests — verify they fail** ```bash cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/user-payments.test.js ``` Expected: tests fail because the route is not updated yet. - [ ] **Step 3: Rewrite user-payments.js** Replace `server/src/routes/user-payments.js`: ```js import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' import { prisma } from '../lib/prisma.js' import { createPayment, buildReceipt } 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 const { id } = request.params 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.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 (existingPayment && existingPayment.confirmationUrl) { return { confirmationUrl: existingPayment.confirmationUrl } } const idempotencyKey = `${id}-v1` const returnUrl = `${process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'}/me/orders/${id}?paid=1` const clientIp = request.ip const amount = { value: (order.totalCents / 100).toFixed(2), currency: order.currency, } const receipt = buildReceipt({ orderItems: order.items, deliveryFeeCents: order.deliveryFeeCents, userEmail: userEmail || 'noemail@example.com', }) let result try { result = await createPayment({ amount, description: `Оплата заказа №${order.id.slice(-6)}`, receipt, confirmation: { type: 'redirect', return_url: returnUrl }, metadata: { orderId: order.id }, idempotencyKey, clientIp, }) } catch (err) { request.log.error({ err, orderId: id }, 'YooKassa createPayment failed') return reply.code(502).send({ error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.', }) } await prisma.payment.create({ data: { orderId: order.id, yookassaPaymentId: result.paymentId, status: result.status, amountCents: order.totalCents, currency: order.currency, confirmationUrl: result.confirmationUrl, expiresAt: result.expiresAt ? new Date(result.expiresAt) : null, }, }) 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 { getPayment } = await import('../lib/yookassa.js') const ykPayment = await getPayment(payment.yookassaPaymentId) if (ykPayment.status !== payment.status) { await prisma.payment.update({ where: { id: payment.id }, data: { status: ykPayment.status }, }) if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') { await prisma.order.update({ where: { id: orderId }, data: { status: 'PAID' }, }) fastify.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' } } }) } ``` - [ ] **Step 4: Run tests — verify they pass** ```bash cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/user-payments.test.js ``` Expected: all tests PASS. - [ ] **Step 5: Verify server starts without errors** ```bash cd /mnt/d/my_projects/shop/server && timeout 5 node --env-file=.env src/index.js 2>&1 || true ``` Expected: server starts and logs "Server listening at http://0.0.0.0:3333" - [ ] **Step 6: Commit** ```bash git add server/src/routes/user-payments.js server/src/routes/__tests__/user-payments.test.js && git commit -m "feat: rewrite payment route for yookassa redirect flow" ``` --- ### Task 5: Add webhook endpoint for YooKassa notifications **Files:** - Modify: `server/src/index.js` (register webhook route) - Create: `server/src/routes/webhook-yookassa.js` - Create: `server/src/routes/__tests__/webhook-yookassa.test.js` - [ ] **Step 1: Write failing tests for webhook route** Create `server/src/routes/__tests__/webhook-yookassa.test.js`: ```js import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Fastify from 'fastify' import jwt from '@fastify/jwt' import { registerYookassaWebhookRoute } from '../webhook-yookassa.js' function buildApp(overrides = {}) { const app = Fastify({ logger: false }) app.decorate('eventBus', overrides.eventBus || { emit: () => {} }) app.decorate('prisma', overrides.prisma || {}) return app } describe('POST /api/webhooks/yookassa', () => { let app let prisma let eventBus beforeEach(async () => { process.env.YOOKASSA_SECRET_KEY = 'test_secret' eventBus = { emit: vi.fn() } prisma = { payment: { findFirst: vi.fn(), update: vi.fn() }, order: { findFirst: vi.fn(), update: vi.fn() }, } app = buildApp({ prisma, eventBus }) await registerYookassaWebhookRoute(app) await app.ready() }) afterEach(async () => { await app.close() delete process.env.YOOKASSA_SECRET_KEY }) 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 () => { prisma.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 () => { prisma.payment.findFirst.mockResolvedValue({ id: 'payment-1', yookassaPaymentId: 'yk-id', status: 'pending', orderId: 'order-1', }) prisma.payment.update.mockResolvedValue({}) prisma.order.findFirst.mockResolvedValue({ id: 'order-1', status: 'PENDING_PAYMENT', userId: 'user-1', }) prisma.order.update.mockResolvedValue({}) 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 = prisma.payment.update.mock.calls[0][0].data expect(updateData.status).toBe('succeeded') const orderUpdateData = prisma.order.update.mock.calls[0][0].data expect(orderUpdateData.status).toBe('PAID') expect(eventBus.emit).toHaveBeenCalled() }) it('updates payment on payment.canceled without changing order', async () => { prisma.payment.findFirst.mockResolvedValue({ id: 'payment-1', yookassaPaymentId: 'yk-id', status: 'pending', orderId: 'order-1', }) prisma.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(prisma.order.update).not.toHaveBeenCalled() }) }) ``` - [ ] **Step 2: Run tests — verify they fail** ```bash cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/webhook-yookassa.test.js ``` Expected: FAIL — module not found. - [ ] **Step 3: Create webhook route file** Create `server/src/routes/webhook-yookassa.js`: ```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) { 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') { await prisma.order.update({ where: { id: payment.orderId }, data: { status: 'PAID' }, }) fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { orderId: payment.orderId, userId: order.userId, paymentStatus: 'paid', }) } } return { ok: true } }) } ``` - [ ] **Step 4: Run tests — verify they pass** ```bash cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/webhook-yookassa.test.js ``` Expected: all tests PASS. - [ ] **Step 5: Register webhook route in server/src/index.js** Add import (after line 30): ```js import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js' ``` Add registration (after line 95 — `registerOAuthSocialRoutes`): ```js await registerYookassaWebhookRoute(fastify) ``` - [ ] **Step 6: Run all server tests** ```bash cd /mnt/d/my_projects/shop/server && npx vitest run ``` Expected: all tests PASS. - [ ] **Step 7: Commit** ```bash git add server/src/routes/webhook-yookassa.js server/src/routes/__tests__/webhook-yookassa.test.js server/src/index.js && git commit -m "feat: add yookassa webhook endpoint" ``` --- ### Task 6: Client — remove old manual payment code **Files:** - Delete: `client/src/features/order-payment/ui/PaymentDialog.tsx` - Delete: `client/src/shared/constants/payment-instructions.ts` - Modify: `client/src/features/order-payment/index.ts` - Modify: `client/src/entities/order/api/order-api.ts` (remove `submitOrderPayment`) - [ ] **Step 1: Delete PaymentDialog.tsx** ```bash rm /mnt/d/my_projects/shop/client/src/features/order-payment/ui/PaymentDialog.tsx ``` - [ ] **Step 2: Delete payment-instructions.ts** ```bash rm /mnt/d/my_projects/shop/client/src/shared/constants/payment-instructions.ts ``` - [ ] **Step 3: Update features/order-payment/index.ts** Remove the PaymentDialog export: ```ts export { OrderPaymentSection } from './ui/OrderPaymentSection' ``` - [ ] **Step 4: Remove submitOrderPayment from order-api.ts** Remove lines 76-88 from `client/src/entities/order/api/order-api.ts` (the `submitOrderPayment` function and its JSDoc comment). - [ ] **Step 5: Commit** ```bash git add client/src/features/order-payment/ui/PaymentDialog.tsx client/src/shared/constants/payment-instructions.ts client/src/features/order-payment/index.ts client/src/entities/order/api/order-api.ts && git commit -m "feat: remove old manual payment dialog and api method" ``` --- ### Task 7: Client — add new API methods and rewrite OrderPaymentSection **Files:** - Modify: `client/src/entities/order/api/order-api.ts` - Modify: `client/src/features/order-payment/ui/OrderPaymentSection.tsx` - Modify: `client/src/pages/me/ui/sections/OrderDetailPage.tsx` - [ ] **Step 1: Add new API methods to order-api.ts** Add after `fetchMyOrder` function (after line 70): ```ts /** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */ export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> { const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`) return data } /** Получить статус платежа для заказа. */ export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null, paid: boolean }> { const { data } = await apiClient.get<{ status: string | null, paid: boolean }>(`me/orders/${orderId}/payment`) return data } ``` - [ ] **Step 2: Rewrite OrderPaymentSection.tsx** Replace `client/src/features/order-payment/ui/OrderPaymentSection.tsx`: ```tsx import { useState } from 'react' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Typography from '@mui/material/Typography' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' type Props = { status: string deliveryFeeLocked: boolean paymentMethod: string | null totalCents: number isPayPending: boolean payError: unknown onPay: () => void } export function OrderPaymentSection({ status, deliveryFeeLocked, paymentMethod, isPayPending, payError, onPay, }: Props) { const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup' if (payOnPickup) { return ( Оплата Оплата при получении на точке самовывоза (наличные или карта — по договорённости). ) } return ( Оплата {status === 'PENDING_PAYMENT' && deliveryFeeLocked === false && ( Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости. )} {status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && ( <> Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус « {orderStatusLabelRu('PAID')}». )} {status === 'PAID' && ( Оплачено. Спасибо! )} {status !== 'PENDING_PAYMENT' && status !== 'PAID' && ( На этом этапе действий по оплате не требуется. )} ) } ``` - [ ] **Step 3: Update OrderDetailPage.tsx** Replace the `payMut` mutation (lines 39-48) with: ```tsx const payMut = useMutation({ mutationFn: () => createOrderPayment(id!), onSuccess: async (data: { confirmationUrl: string }) => { window.location.href = data.confirmationUrl }, }) ``` Update the import in line 15 — replace `submitOrderPayment` with `createOrderPayment`: ```tsx import { confirmOrderReceived, createOrderPayment, fetchMyOrder, postOrderMessage, fetchOrderReviewEligibility, } from '@/entities/order/api/order-api' ``` Update `OrderPaymentSection` usage (lines 208-216) — change `onPay` from `(params) => payMut.mutate(params)` to just `() => payMut.mutate()`: ```tsx payMut.mutate()} /> ``` - [ ] **Step 4: Add return URL handling to OrderDetailPage.tsx** Add `useSearchParams` import (top of file, from `react-router-dom`): ```tsx import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom' ``` Add import for `getOrderPaymentStatus`: ```tsx import { confirmOrderReceived, createOrderPayment, fetchMyOrder, getOrderPaymentStatus, postOrderMessage, fetchOrderReviewEligibility, } from '@/entities/order/api/order-api' ``` Add payment status check effect after line 31 (`const qc = useQueryClient()`): ```tsx const [searchParams] = useSearchParams() const paidParam = searchParams.get('paid') ``` Add a query for payment status when returned from YooKassa (after the `orderQuery` block, around line 38): ```tsx const paymentStatusQuery = useQuery({ queryKey: ['me', 'orders', id, 'payment-status'], queryFn: () => getOrderPaymentStatus(id!), enabled: Boolean(id && paidParam === '1'), refetchInterval: (query) => { const data = query.state.data if (data && (data.paid || data.status === 'canceled')) return false return 3000 }, }) ``` Add a status banner after the top `Stack` with order info (after line 118, before "Позиции"): ```tsx {paidParam === '1' && paymentStatusQuery.data && ( {paymentStatusQuery.data.paid ? 'Оплата прошла успешно!' : paymentStatusQuery.data.status === 'canceled' ? 'Оплата отменена. Вы можете попробовать снова.' : 'Ожидаем подтверждения оплаты…'} )} ``` - [ ] **Step 5: Verify client compiles** ```bash cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -30 ``` Expected: no TypeScript errors. - [ ] **Step 6: Run client lint** ```bash cd /mnt/d/my_projects/shop/client && npm run lint ``` Expected: no lint errors. - [ ] **Step 7: Commit** ```bash git add client/src/entities/order/api/order-api.ts client/src/features/order-payment/ui/OrderPaymentSection.tsx client/src/pages/me/ui/sections/OrderDetailPage.tsx && git commit -m "feat: implement yookassa redirect payment flow on client" ``` --- ### Task 8: Final verification and cleanup **Files:** (none, verification only) - [ ] **Step 1: Run all server tests** ```bash cd /mnt/d/my_projects/shop/server && npx vitest run ``` Expected: all tests PASS. - [ ] **Step 2: Run all client tests** ```bash cd /mnt/d/my_projects/shop/client && npx vitest run ``` Expected: all tests PASS. - [ ] **Step 3: Run server lint and format check** ```bash cd /mnt/d/my_projects/shop/server && npm run lint && npm run format:check ``` Expected: no lint errors, no format issues. - [ ] **Step 4: Run client lint, format check, and build** ```bash cd /mnt/d/my_projects/shop/client && npm run lint && npm run format:check && npm run build ``` Expected: no lint errors, no format issues, build succeeds. - [ ] **Step 5: Final commit (if any fixes were needed)** If Step 1-4 required fixes, commit them. Otherwise, confirm: "All verifications passed, no additional changes needed." --- ## Summary | Task | Files | Description | |---|---|---| | 1 | `schema.prisma` + migration | Add Payment model | | 2 | `.env.example` | Add YooKassa env vars | | 3 | `lib/yookassa.js` + tests | YooKassa API client module | | 4 | `routes/user-payments.js` + tests | Rewrite payment route | | 5 | `routes/webhook-yookassa.js` + tests + `index.js` | Webhook endpoint | | 6 | Delete `PaymentDialog.tsx`, `payment-instructions.ts` | Remove old manual payment | | 7 | `order-api.ts`, `OrderPaymentSection.tsx`, `OrderDetailPage.tsx` | Client redirect flow | | 8 | Verification | Lint, format, test, build | **Total commits:** 7 (Tasks 1-7) + optional cleanup commit from Task 8.