1326 lines
39 KiB
Markdown
1326 lines
39 KiB
Markdown
# 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.
|