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

1326 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (
<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:
```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
<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`):
```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 && (
<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**
```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.