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.