From 585c565b7bd84c1856b668d484f3b10b49fa8b9b Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:45:20 +0500 Subject: [PATCH] docs: add yookassa payment integration implementation plan --- ...2026-05-20-yookassa-payment-integration.md | 1325 +++++++++++++++++ 1 file changed, 1325 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md diff --git a/docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md b/docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md new file mode 100644 index 0000000..5f6eb04 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md @@ -0,0 +1,1325 @@ +# 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.