Files
shop-server/docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md
T

39 KiB

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):

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[]):

  payments Payment[]
  • Step 2: Run Prisma migration
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
cd /mnt/d/my_projects/shop/server && npx prisma generate

Expected: client regenerated with new Payment model.

  • Step 4: Verify schema is valid
cd /mnt/d/my_projects/shop/server && npx prisma validate

Expected: "The datasource is valid."

  • Step 5: Commit
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=):

# YooKassa payment integration
YOOKASSA_SHOP_ID=
YOOKASSA_SECRET_KEY=
  • Step 2: Commit
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:

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
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:

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
cd /mnt/d/my_projects/shop/server && npx vitest run src/lib/__tests__/yookassa.test.js

Expected: all tests PASS.

  • Step 5: Commit
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:

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
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:

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
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
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
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:

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
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:

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
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):

import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js'

Add registration (after line 95 — registerOAuthSocialRoutes):

await registerYookassaWebhookRoute(fastify)
  • Step 6: Run all server tests
cd /mnt/d/my_projects/shop/server && npx vitest run

Expected: all tests PASS.

  • Step 7: Commit
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

rm /mnt/d/my_projects/shop/client/src/features/order-payment/ui/PaymentDialog.tsx
  • Step 2: Delete payment-instructions.ts
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:

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
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):

/** Создать платёж в Ю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:

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 (
      <Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
        <Typography variant="h6" gutterBottom>
          Оплата
        </Typography>
        <Typography color="text.secondary" variant="body2">
          Оплата при получении на точке самовывоза (наличные или карта  по договорённости).
        </Typography>
      </Box>
    )
  }

  return (
    <Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
      <Typography variant="h6" gutterBottom>
        Оплата
      </Typography>
      {status === 'PENDING_PAYMENT' && deliveryFeeLocked === false && (
        <Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
          Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости.
        </Typography>
      )}
      {status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
        <>
          <Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
            Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус «
            {orderStatusLabelRu('PAID')}».
          </Typography>
          <Button variant="contained" onClick={onPay} disabled={isPayPending}>
            {isPayPending ? 'Создание платежа…' : 'Оплатить'}
          </Button>
        </>
      )}
      {status === 'PAID' && (
        <Typography color="success.main" variant="body1">
          Оплачено. Спасибо!
        </Typography>
      )}
      {status !== 'PENDING_PAYMENT' && status !== 'PAID' && (
        <Typography color="text.secondary" variant="body2">
          На этом этапе действий по оплате не требуется.
        </Typography>
      )}
    </Box>
  )
}
  • Step 3: Update OrderDetailPage.tsx

Replace the payMut mutation (lines 39-48) with:

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:

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():

<OrderPaymentSection
  status={order.status}
  deliveryFeeLocked={order.deliveryFeeLocked}
  paymentMethod={order.paymentMethod ?? null}
  totalCents={order.totalCents}
  isPayPending={payMut.isPending}
  payError={payMut.error}
  onPay={() => payMut.mutate()}
/>
  • Step 4: Add return URL handling to OrderDetailPage.tsx

Add useSearchParams import (top of file, from react-router-dom):

import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom'

Add import for getOrderPaymentStatus:

import {
  confirmOrderReceived,
  createOrderPayment,
  fetchMyOrder,
  getOrderPaymentStatus,
  postOrderMessage,
  fetchOrderReviewEligibility,
} from '@/entities/order/api/order-api'

Add payment status check effect after line 31 (const qc = useQueryClient()):

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):

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 "Позиции"):

{paidParam === '1' && paymentStatusQuery.data && (
  <Alert severity={paymentStatusQuery.data.paid ? 'success' : paymentStatusQuery.data.status === 'canceled' ? 'warning' : 'info'} sx={{ mb: 2 }}>
    {paymentStatusQuery.data.paid
      ? 'Оплата прошла успешно!'
      : paymentStatusQuery.data.status === 'canceled'
        ? 'Оплата отменена. Вы можете попробовать снова.'
        : 'Ожидаем подтверждения оплаты…'}
  </Alert>
)}
  • Step 5: Verify client compiles
cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -30

Expected: no TypeScript errors.

  • Step 6: Run client lint
cd /mnt/d/my_projects/shop/client && npm run lint

Expected: no lint errors.

  • Step 7: Commit
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
cd /mnt/d/my_projects/shop/server && npx vitest run

Expected: all tests PASS.

  • Step 2: Run all client tests
cd /mnt/d/my_projects/shop/client && npx vitest run

Expected: all tests PASS.

  • Step 3: Run server lint and format check
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
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.