From fc7bc43c9f67256e2f8d627384325740b9f17996 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:33:08 +0500 Subject: [PATCH 01/17] docs: add yookassa payment integration design spec --- ...-20-yookassa-payment-integration-design.md | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-yookassa-payment-integration-design.md diff --git a/docs/superpowers/specs/2026-05-20-yookassa-payment-integration-design.md b/docs/superpowers/specs/2026-05-20-yookassa-payment-integration-design.md new file mode 100644 index 0000000..e466ce9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-yookassa-payment-integration-design.md @@ -0,0 +1,309 @@ +# YooKassa Payment Integration — Design + +**Date:** 2026-05-20 +**Status:** approved + +## Overview + +Replace the current manual bank transfer payment flow (receipt upload + admin confirmation) with YooKassa (ЮKassa) online payment gateway integration using the redirect scenario. + +## Key Decisions + +| Decision | Choice | +|---|---| +| Integration scenario | Redirect to YooKassa payment form | +| Webhooks | Accept `payment.succeeded` and `payment.canceled` | +| Receipts (54-ФЗ) | Send receipt data with order items | +| Payment methods | Bank cards + SBP (Faster Payments System) | +| Legacy manual method | Remove entirely | +| Refunds via API | Not implemented (manual via YooKassa dashboard) | +| Architecture pattern | Dedicated `lib/yookassa.js` module (Approach 2) | + +--- + +## 1. Database Changes + +### New model: `Payment` + +```prisma +model Payment { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + yookassaPaymentId String @unique + status String // pending | waiting_for_capture | succeeded | canceled + amountCents Int + currency String @default("RUB") + confirmationUrl String? + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orderId]) + @@index([yookassaPaymentId]) +} +``` + +### Model `Order` — no changes + +Existing `paymentMethod` (`online` | `on_pickup`) and status flow (`PENDING_PAYMENT` → `PAID`) remain unchanged. + +--- + +## 2. Environment Variables + +Add to `server/.env.example` and `server/.env`: + +```bash +YOOKASSA_SHOP_ID=123456 +YOOKASSA_SECRET_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +For production (`SERVER_PUBLIC_URL` will be used for webhook URL construction): +```bash +YOOKASSA_SHOP_ID=123456 +YOOKASSA_SECRET_KEY=live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +--- + +## 3. Server: `server/src/lib/yookassa.js` + +Dedicated module isolating all YooKassa API interaction. Routes remain thin HTTP wrappers. + +### Configuration + +```js +const config = { + baseUrl: 'https://api.yookassa.ru/v3', + shopId: process.env.YOOKASSA_SHOP_ID, + secretKey: process.env.YOOKASSA_SECRET_KEY, +} +``` + +### Auth + +HTTP Basic Auth: `Authorization: Basic base64(shopId:secretKey)`, set via `Idempotence-Key` header for POST requests. + +### Exported functions + +``` +createPayment({ order, orderItems, userEmail, idempotencyKey, returnUrl, clientIp }) → PaymentResponse +``` +- `amount`: `order.totalCents` → `{ value: "1234.00", currency: "RUB" }` +- `capture`: `true` (one-stage payment) +- `confirmation`: `{ type: "redirect", return_url: returnUrl }` +- `receipt`: `{ customer: { email: userEmail }, items: [...], tax_system_code: 1 }` + - Each item: `description` (from `titleSnapshot`, max 128 chars), `quantity`, `amount` (unit price), `vat_code: 1`, `measure: "piece"`, `payment_subject: "commodity"`, `payment_mode: "full_prepayment"` + - If `order.deliveryFeeCents > 0`: add a separate receipt item for delivery +- `description`: `"Оплата заказа №{order.id}"` +- `metadata`: `{ orderId: order.id }` +- `payment_method_data`: not specified — YooKassa auto-selects from available methods (cards + SBP) +- `client_ip`: forwarded from request +- **Returns:** `{ paymentId, confirmationUrl, status, expiresAt }` + +``` +getPayment(yookassaPaymentId) → PaymentResponse +``` +- GET `/payments/{paymentId}` — fetch current payment status from YooKassa. +- **Returns:** `{ id, status, paid, ... }` + +``` +validateWebhook(body, headers) → { event, paymentObject } +``` +- **Production only:** validate source IP against YooKassa IP ranges (`185.71.76.0/27`, `185.71.77.0/27`, `77.75.153.0/25`, `77.75.154.128/25`, `2a02:5180::/32`) +- Skip IP check in test mode (when `secretKey` starts with `test_`) +- Parse body: validate `type === "notification"`, extract `event` and `object` +- **Returns:** parsed notification data +- **Throws:** on validation failure + +### Error handling + +- 5xx: retry up to 3 times with exponential backoff (500ms, 1s, 2s) +- 4xx: throw descriptive error (includes YooKassa error code and description) +- Timeout: 10 seconds per request +- Uses Node.js built-in `fetch` (Node 18+) + +### Logging + +Use `fastify.log` passed via context or a simple console-based approach. Log payment creation and webhook receipt at `info` level. + +--- + +## 4. Server Routes + +### 4a. `POST /api/me/orders/:id/pay` — Create payment (replaces existing) + +**Auth:** `{ preHandler: [fastify.authenticate] }` + +**Flow:** +1. Find order by `id`, verify it belongs to `request.user.id` +2. Validate: `status === PENDING_PAYMENT` AND `paymentMethod === 'online'` AND `deliveryFeeLocked === true` +3. Check for existing active Payment (`status IN ('pending', 'waiting_for_capture')`): + - If exists and not expired: return existing `confirmationUrl` + - If exists but expired or canceled: proceed to create new one +4. Generate `idempotencyKey`: `${orderId}-v1` (same key means same payment; if status check fails, append timestamp) +5. Build `returnUrl`: `${CLIENT_PUBLIC_URL}/me/orders/${orderId}?paid=1` +6. Call `yookassa.createPayment(...)` +7. Save `Payment` to DB +8. Return `{ confirmationUrl }` +9. Emit event: `PAYMENT_CREATED` + +**Error responses:** +- `400`: order not in payable state +- `409`: conflicting payment attempt (should be handled by idempotency) +- `502`: YooKassa unavailable + +### 4b. `GET /api/me/orders/:orderId/payment` — Check payment status + +**Auth:** `{ preHandler: [fastify.authenticate] }` + +**Flow:** +1. Find latest `Payment` for the order +2. If status is terminal (`succeeded`, `canceled`): return cached status +3. Otherwise: call `yookassa.getPayment(yookassaPaymentId)` +4. If status changed: update local `Payment` + transition `Order` if needed +5. Return `{ status, paid }` + +### 4c. `POST /api/webhooks/yookassa` — Receive YooKassa notifications + +**Auth:** None (public endpoint, validated by IP + request signature) + +**Flow:** +1. Parse body and headers +2. Call `yookassa.validateWebhook(body, headers)` — validates IP on production +3. Find `Payment` by `yookassaPaymentId = object.id` +4. Handle event: + - `payment.succeeded`: + - Update `Payment.status = 'succeeded'` + - Transition `Order` from `PENDING_PAYMENT` to `PAID` + - Emit `PAYMENT_STATUS_CHANGED` event → notification system + - `payment.canceled`: + - Update `Payment.status = 'canceled'` + - Order stays `PENDING_PAYMENT` (user can retry) +5. Return `200 OK` + +### 4d. Remove old endpoint + +The existing `POST /api/me/orders/:id/pay` (multipart receipt upload) is completely removed. + +--- + +## 5. Client Changes + +### 5a. `OrderPaymentSection` (features/order-payment) + +- **State `PENDING_PAYMENT` + `deliveryFeeLocked=true` + `paymentMethod=online`:** + - Show "Оплатить" button + - On click: call `createOrderPayment(orderId)`, get `confirmationUrl`, redirect: `window.location.href = confirmationUrl` +- **State `PENDING_PAYMENT` + `deliveryFeeLocked=false`:** unchanged (waiting for delivery fee adjustment) +- **State `PAID`:** show "Оплачено" badge, hide payment button +- **State `paymentMethod=on_pickup`:** unchanged (message about paying at pickup) + +### 5b. Remove `PaymentDialog` + +Delete `PaymentDialog.tsx` and all related code (manual payment instructions, receipt upload form). Remove `PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN` constant if present. + +### 5c. Return URL handling (OrderDetailPage) + +When order page loads with `?paid=1` query param: +1. Call `getOrderPaymentStatus(orderId)` +2. Show result toast/alert: + - `paid === true`: "Оплата прошла успешно" + - `paid === false` + payment pending: "Ожидаем подтверждения оплаты" + - `canceled`: "Оплата отменена, вы можете попробовать снова" + +### 5d. API client additions (shared/api or entities/order) + +```ts +// New API endpoints to add to the client apiClient: +createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> +getOrderPaymentStatus(orderId: string): Promise<{ status: string, paid: boolean }> +``` + +### 5e. Shared constants + +Remove `online` from `PAYMENT_METHODS` if it was only used for distinction, or keep it since YooKassa IS the online payment. The constant stays as-is. + +--- + +## 6. Edge Cases & Error Handling + +### Payment creation failures + +| Scenario | Handling | +|---|---| +| YooKassa unavailable (5xx) | Retry 3x with backoff, then show user error "Платёжный сервис временно недоступен, попробуйте позже" | +| Invalid credentials (401) | Log error, show "Ошибка конфигурации платежей" | +| Duplicate idempotency key | YooKassa returns existing payment — reuse it | + +### Status synchronization + +- **Primary:** webhook `payment.succeeded` triggers order → `PAID` transition +- **Fallback:** user returning via `return_url` triggers `GET /api/me/orders/:orderId/payment` which syncs via API +- **Stale payments:** no periodic cron needed initially; the fallback on page load is sufficient + +### Payment expiration + +YooKassa cancels payments after ~1 hour (for one-stage with `capture: true`). Webhook `payment.canceled` updates local state. + +### User closes browser after paying, before return + +Webhook handles this — order transitions to `PAID` without user action. On next visit, order shows as paid. + +### Retry after cancellation + +User can click "Оплатить" again. New `Payment` record created with new `yookassaPaymentId`. Old payment remains in DB with `canceled` status. + +### Idempotency + +- Key format: `${orderId}-v1` — if user clicks "Оплатить" twice quickly, YooKassa returns the same payment (idempotency protection) +- If payment exists and is terminal (canceled/expired), generate new key: `${orderId}-v${retryCount}` + +--- + +## 7. Files Changed / Created / Deleted + +### Created +- `server/src/lib/yookassa.js` — YooKassa API client module +- `server/prisma/migrations/*_add_payment.sql` — migration for Payment model + +### Modified +- `server/prisma/schema.prisma` — add `Payment` model +- `server/src/routes/user-payments.js` — rewrite to use YooKassa +- `server/src/index.js` — register webhook route +- `server/.env.example` — add YooKassa env vars +- `client/src/features/order-payment/OrderPaymentSection.tsx` — redirect to YooKassa instead of manual dialog +- `client/src/pages/order/OrderDetailPage.tsx` — handle `?paid=1` return URL +- `client/src/shared/api/` or `entities/order/` — add new API methods + +### Deleted +- `client/src/features/order-payment/PaymentDialog.tsx` — manual payment dialog +- Any related payment instructions constants + +--- + +## 8. Testing + +### Server tests (`server/src/__tests__/` or `server/src/lib/__tests__/`) + +- `yookassa.test.js` — unit tests for `createPayment`, `getPayment`, `validateWebhook` with mocked `fetch` +- `user-payments.test.js` — integration tests for `POST /api/me/orders/:id/pay` with mocked YooKassa module +- Webhook route test — validate IP check, event handling, order transition + +### Client tests (`client/src/features/order-payment/__tests__/`) + +- `OrderPaymentSection.test.tsx` — test button shows/hides based on order state, redirect on click +- Remove `PaymentDialog.test.tsx` if it exists + +--- + +## 9. Migration & Rollout + +1. Add env vars to `.env` +2. Run Prisma migration: `prisma migrate dev --name add_payment` +3. Deploy server changes first (new routes + webhook) +4. Deploy client changes (redirect behavior) +5. Configure webhook in YooKassa dashboard: `{SERVER_PUBLIC_URL}/api/webhooks/yookassa` +6. Test with YooKassa test credentials +7. Switch to live credentials after successful testing From 585c565b7bd84c1856b668d484f3b10b49fa8b9b Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:45:20 +0500 Subject: [PATCH 02/17] docs: add yookassa payment integration implementation plan --- ...2026-05-20-yookassa-payment-integration.md | 1325 +++++++++++++++++ 1 file changed, 1325 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md diff --git a/docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md b/docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md new file mode 100644 index 0000000..5f6eb04 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-yookassa-payment-integration.md @@ -0,0 +1,1325 @@ +# YooKassa Payment Integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace manual bank transfer payment flow with YooKassa (ЮKassa) online payment gateway using redirect scenario + webhooks. + +**Architecture:** Dedicated `server/src/lib/yookassa.js` module isolates all YooKassa API interaction (createPayment, getPayment, webhook validation). Server routes use this module. Client replaces PaymentDialog with a redirect to YooKassa payment form. + +**Tech Stack:** Fastify, Prisma/SQLite, React + MUI + React Query, Node 18+ built-in fetch. + +--- + +### Task 1: Add Payment model to Prisma schema and run migration + +**Files:** +- Modify: `server/prisma/schema.prisma` (add Payment model after Order model) +- Create: migration via `prisma migrate dev` + +- [ ] **Step 1: Add Payment model to schema** + +Add after the `Order` model (line 160): + +```prisma +model Payment { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + yookassaPaymentId String @unique + status String + amountCents Int + currency String @default("RUB") + confirmationUrl String? + expiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orderId]) + @@index([yookassaPaymentId]) +} +``` + +Also add `payments Payment[]` relation field to the `Order` model (insert at line 155 after `messages OrderMessage[]`): + +```prisma + payments Payment[] +``` + +- [ ] **Step 2: Run Prisma migration** + +```bash +cd /mnt/d/my_projects/shop/server && npx prisma migrate dev --name add_payment +``` +Expected: migration created and applied, no errors. + +- [ ] **Step 3: Generate Prisma client** + +```bash +cd /mnt/d/my_projects/shop/server && npx prisma generate +``` +Expected: client regenerated with new Payment model. + +- [ ] **Step 4: Verify schema is valid** + +```bash +cd /mnt/d/my_projects/shop/server && npx prisma validate +``` +Expected: "The datasource is valid." + +- [ ] **Step 5: Commit** + +```bash +git add server/prisma/schema.prisma server/prisma/migrations && git commit -m "feat: add Payment model for yookassa integration" +``` + +--- + +### Task 2: Add YooKassa environment variables + +**Files:** +- Modify: `server/.env.example` + +- [ ] **Step 1: Add YooKassa env vars to .env.example** + +Add after line 34 (`TELEGRAM_BOT_TOKEN=`): + +```bash +# YooKassa payment integration +YOOKASSA_SHOP_ID= +YOOKASSA_SECRET_KEY= +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/.env.example && git commit -m "feat: add yookassa env vars to .env.example" +``` + +--- + +### Task 3: Create YooKassa API client library + +**Files:** +- Create: `server/src/lib/yookassa.js` +- Create: `server/src/lib/__tests__/yookassa.test.js` + +- [ ] **Step 1: Write failing tests for yookassa module** + +Create `server/src/lib/__tests__/yookassa.test.js`: + +```js +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPayment, getPayment } from '../yookassa.js' + +describe('yookassa createPayment', () => { + beforeEach(() => { + process.env.YOOKASSA_SHOP_ID = '123456' + process.env.YOOKASSA_SECRET_KEY = 'test_secret' + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + delete process.env.YOOKASSA_SHOP_ID + delete process.env.YOOKASSA_SECRET_KEY + }) + + it('calls POST /payments with Basic auth and Idempotence-Key', async () => { + const mockPayment = { + id: '2d0c6f35-000f-5000-8000-1234567890ab', + status: 'pending', + paid: false, + amount: { value: '1000.00', currency: 'RUB' }, + confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/...' }, + created_at: '2026-05-20T12:00:00.000Z', + test: true, + refundable: false, + recipient: { account_id: '123456', gateway_id: '123456' }, + } + fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockPayment), + }) + + const result = await createPayment({ + amount: { value: '1000.00', currency: 'RUB' }, + description: 'Order #test', + receipt: { + customer: { email: 'test@example.com' }, + items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }], + tax_system_code: 1, + }, + confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test?paid=1' }, + metadata: { orderId: 'test' }, + idempotencyKey: 'test-v1', + }) + + expect(fetch).toHaveBeenCalledTimes(1) + const [url, opts] = fetch.mock.calls[0] + expect(url).toBe('https://api.yookassa.ru/v3/payments') + expect(opts.method).toBe('POST') + expect(opts.headers['Idempotence-Key']).toBe('test-v1') + expect(opts.headers['Authorization']).toBe('Basic MTIzNDU2OnRlc3Rfc2VjcmV0') + expect(result.paymentId).toBe('2d0c6f35-000f-5000-8000-1234567890ab') + expect(result.confirmationUrl).toBe('https://yoomoney.ru/checkout/...') + expect(result.status).toBe('pending') + }) + + it('retries on 5xx error', async () => { + fetch + .mockResolvedValueOnce({ ok: false, status: 500 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: 'retry-id', + status: 'pending', + paid: false, + amount: { value: '500.00', currency: 'RUB' }, + confirmation: { type: 'redirect', confirmation_url: 'https://yoomoney.ru/checkout/retry' }, + created_at: '2026-05-20T12:00:00.000Z', + test: true, + refundable: false, + recipient: { account_id: '123456', gateway_id: '123456' }, + }), + }) + + const result = await createPayment({ + amount: { value: '500.00', currency: 'RUB' }, + description: 'Retry test', + receipt: { + customer: { email: 'test@example.com' }, + items: [{ description: 'Item', quantity: 1, amount: { value: '500.00', currency: 'RUB' }, vat_code: 1 }], + tax_system_code: 1, + }, + confirmation: { type: 'redirect', return_url: 'http://localhost:5173/me/orders/test' }, + metadata: {}, + idempotencyKey: 'retry-v1', + }) + + expect(fetch).toHaveBeenCalledTimes(3) + expect(result.paymentId).toBe('retry-id') + }) + + it('throws on 4xx error', async () => { + fetch.mockResolvedValue({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + type: 'error', + id: 'err-id', + code: 'invalid_request', + description: 'Missing required field', + }), + }) + + await expect( + createPayment({ + amount: { value: '1000.00', currency: 'RUB' }, + description: 'Bad request', + receipt: { + customer: { email: 'test@example.com' }, + items: [{ description: 'Item', quantity: 1, amount: { value: '1000.00', currency: 'RUB' }, vat_code: 1 }], + tax_system_code: 1, + }, + confirmation: { type: 'redirect', return_url: 'http://localhost:5173' }, + metadata: {}, + idempotencyKey: 'bad-v1', + }), + ).rejects.toThrow('YooKassa API error') + }) +}) + +describe('yookassa getPayment', () => { + beforeEach(() => { + process.env.YOOKASSA_SHOP_ID = '123456' + process.env.YOOKASSA_SECRET_KEY = 'test_secret' + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + delete process.env.YOOKASSA_SHOP_ID + delete process.env.YOOKASSA_SECRET_KEY + }) + + it('calls GET /payments/{id} and returns payment data', async () => { + fetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: 'payment-id', + status: 'succeeded', + paid: true, + amount: { value: '1000.00', currency: 'RUB' }, + created_at: '2026-05-20T12:00:00.000Z', + test: true, + refundable: true, + recipient: { account_id: '123456', gateway_id: '123456' }, + }), + }) + + const result = await getPayment('payment-id') + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch.mock.calls[0][0]).toBe('https://api.yookassa.ru/v3/payments/payment-id') + expect(result.id).toBe('payment-id') + expect(result.status).toBe('succeeded') + expect(result.paid).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/lib/__tests__/yookassa.test.js +``` +Expected: FAIL — "createPayment is not a function" / "getPayment is not a function" + +- [ ] **Step 3: Implement yookassa.js module** + +Create `server/src/lib/yookassa.js`: + +```js +const YOOKASSA_API_URL = 'https://api.yookassa.ru/v3' + +function getAuthHeader() { + const shopId = process.env.YOOKASSA_SHOP_ID + const secretKey = process.env.YOOKASSA_SECRET_KEY + const token = Buffer.from(`${shopId}:${secretKey}`).toString('base64') + return `Basic ${token}` +} + +function isRetryable(status) { + return status >= 500 || status === 429 +} + +async function fetchWithRetry(url, opts, maxRetries = 3) { + let lastError + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + const delay = 500 * 2 ** (attempt - 1) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + try { + const res = await fetch(url, opts) + if (res.ok) return res + const body = await res.json().catch(() => ({})) + if (isRetryable(res.status)) { + lastError = new Error(`YooKassa API error: ${res.status} — ${body.description || 'unknown'}`) + continue + } + throw new Error( + `YooKassa API error: ${res.status} — ${body.description || body.code || 'unknown'} (${body.parameter || 'n/a'})`, + ) + } catch (err) { + if (err instanceof Error && err.message.startsWith('YooKassa API error') && !isRetryable) throw err + lastError = err + if (attempt === maxRetries) throw lastError + } + } + throw lastError +} + +export async function createPayment({ + amount, + description, + receipt, + confirmation, + metadata, + idempotencyKey, + clientIp, +}) { + const headers = { + Authorization: getAuthHeader(), + 'Idempotence-Key': idempotencyKey, + 'Content-Type': 'application/json', + } + + const body = { + amount, + capture: true, + description, + confirmation, + metadata, + } + + if (receipt) { + body.receipt = receipt + } + if (clientIp) { + body.client_ip = clientIp + } + + const res = await fetchWithRetry(`${YOOKASSA_API_URL}/payments`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + const data = await res.json() + return { + paymentId: data.id, + status: data.status, + confirmationUrl: data.confirmation?.confirmation_url || null, + expiresAt: data.expires_at || null, + paid: data.paid, + test: data.test, + } +} + +export async function getPayment(paymentId) { + const res = await fetch(`${YOOKASSA_API_URL}/payments/${paymentId}`, { + headers: { Authorization: getAuthHeader() }, + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(`YooKassa getPayment error: ${res.status} — ${body.description || 'unknown'}`) + } + return res.json() +} + +const YOOKASSA_IP_RANGES_V4 = ['185.71.76.0/27', '185.71.77.0/27', '77.75.153.0/25', '77.75.154.128/25'] + +function ip4ToInt(ip) { + return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0 +} + +function cidrMatch(ip, cidr) { + const [range, bits] = cidr.split('/') + const mask = ~(2 ** (32 - parseInt(bits, 10)) - 1) >>> 0 + const ipInt = ip4ToInt(ip) + const rangeInt = ip4ToInt(range) + return (ipInt & mask) === (rangeInt & mask) +} + +function isYookassaIp(ip) { + const v4 = ip.replace(/^::ffff:/, '') + if (!/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v4)) return false + return YOOKASSA_IP_RANGES_V4.some((cidr) => cidrMatch(v4, cidr)) +} + +const TEST_MODE = process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') + +export function validateWebhook(ip, body) { + if (!TEST_MODE && !isYookassaIp(ip)) { + throw new Error('Invalid webhook source IP') + } + if (!body || typeof body !== 'object') { + throw new Error('Invalid webhook body') + } + if (body.type !== 'notification') { + throw new Error('Expected notification type in webhook body') + } + if (!body.event || !body.object) { + throw new Error('Missing event or object in webhook body') + } + return { event: body.event, paymentObject: body.object } +} + +export function buildReceipt({ orderItems, deliveryFeeCents, userEmail, taxSystemCode = 1 }) { + const items = orderItems.map((item) => ({ + description: (item.titleSnapshot || 'Товар').slice(0, 128), + quantity: item.qty, + amount: { + value: (item.priceCentsSnapshot / 100).toFixed(2), + currency: 'RUB', + }, + vat_code: 1, + measure: 'piece', + payment_subject: 'commodity', + payment_mode: 'full_prepayment', + })) + + if (deliveryFeeCents > 0) { + items.push({ + description: 'Доставка', + quantity: 1, + amount: { + value: (deliveryFeeCents / 100).toFixed(2), + currency: 'RUB', + }, + vat_code: 1, + measure: 'piece', + payment_subject: 'service', + payment_mode: 'full_prepayment', + }) + } + + const receipt = { + customer: { email: userEmail }, + items, + tax_system_code: taxSystemCode, + } + + return receipt +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/lib/__tests__/yookassa.test.js +``` +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/src/lib/yookassa.js server/src/lib/__tests__/yookassa.test.js && git commit -m "feat: add yookassa API client library with tests" +``` + +--- + +### Task 4: Rewrite server payment route (POST /api/me/orders/:id/pay) + +**Files:** +- Modify: `server/src/routes/user-payments.js` +- Create: `server/src/routes/__tests__/user-payments.test.js` + +- [ ] **Step 1: Write failing integration tests for the payment route** + +Create `server/src/routes/__tests__/user-payments.test.js`: + +```js +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { registerUserPaymentRoutes } from '../user-payments.js' + +const JWT_SECRET = 'test-secret' +const USER_EMAIL = 'user@example.com' + +function signToken(userId) { + const fastify = Fastify() + fastify.register(jwt, { secret: JWT_SECRET }) + return fastify.jwt.sign({ sub: userId, email: USER_EMAIL }) +} + +function buildApp(overrides = {}) { + const app = Fastify({ logger: false }) + app.register(jwt, { secret: JWT_SECRET }) + app.decorate('authenticate', async function (request, reply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', overrides.eventBus || { emit: () => {} }) + app.decorate('prisma', overrides.prisma || {}) + return app +} + +describe('POST /api/me/orders/:id/pay', () => { + let app + let prisma + let eventBus + + beforeEach(async () => { + eventBus = { emit: vi.fn() } + prisma = { + order: { findFirst: vi.fn() }, + payment: { findFirst: vi.fn(), create: vi.fn() }, + $transaction: vi.fn((fn) => fn(prisma)), + } + app = buildApp({ prisma, eventBus }) + await registerUserPaymentRoutes(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + }) + + it('returns 401 without auth', async () => { + const res = await app.inject({ method: 'POST', url: '/api/me/orders/order-1/pay' }) + expect(res.statusCode).toBe(401) + }) + + it('returns 404 when order not found', async () => { + prisma.order.findFirst.mockResolvedValue(null) + const token = signToken('user-1') + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/order-1/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('returns 409 when payment method is on_pickup', async () => { + prisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + userId: 'user-1', + status: 'PENDING_PAYMENT', + paymentMethod: 'on_pickup', + }) + const token = signToken('user-1') + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/order-1/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 409 when order not in PENDING_PAYMENT status', async () => { + prisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + userId: 'user-1', + status: 'PAID', + paymentMethod: 'online', + }) + const token = signToken('user-1') + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/order-1/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 409 when deliveryFeeLocked is false', async () => { + prisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + userId: 'user-1', + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: false, + }) + const token = signToken('user-1') + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/order-1/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) +}) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/user-payments.test.js +``` +Expected: tests fail because the route is not updated yet. + +- [ ] **Step 3: Rewrite user-payments.js** + +Replace `server/src/routes/user-payments.js`: + +```js +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { prisma } from '../lib/prisma.js' +import { createPayment, buildReceipt } from '../lib/yookassa.js' + +export async function registerUserPaymentRoutes(fastify) { + fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const userEmail = request.user.email + const { id } = request.params + + const order = await prisma.order.findFirst({ + where: { id, userId }, + include: { items: true }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + + if (order.paymentMethod === 'on_pickup') { + return reply.code(409).send({ error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна' }) + } + + if (order.status !== 'PENDING_PAYMENT') { + return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) + } + + if (!order.deliveryFeeLocked) { + return reply.code(409).send({ error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже' }) + } + + const existingPayment = await prisma.payment.findFirst({ + where: { orderId: id, status: { in: ['pending', 'waiting_for_capture'] } }, + orderBy: { createdAt: 'desc' }, + }) + + if (existingPayment && existingPayment.confirmationUrl) { + return { confirmationUrl: existingPayment.confirmationUrl } + } + + const idempotencyKey = `${id}-v1` + const returnUrl = `${process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'}/me/orders/${id}?paid=1` + const clientIp = request.ip + + const amount = { + value: (order.totalCents / 100).toFixed(2), + currency: order.currency, + } + + const receipt = buildReceipt({ + orderItems: order.items, + deliveryFeeCents: order.deliveryFeeCents, + userEmail: userEmail || 'noemail@example.com', + }) + + let result + try { + result = await createPayment({ + amount, + description: `Оплата заказа №${order.id.slice(-6)}`, + receipt, + confirmation: { type: 'redirect', return_url: returnUrl }, + metadata: { orderId: order.id }, + idempotencyKey, + clientIp, + }) + } catch (err) { + request.log.error({ err, orderId: id }, 'YooKassa createPayment failed') + return reply.code(502).send({ + error: 'Не удалось создать платёж. Платёжный сервис временно недоступен.', + }) + } + + await prisma.payment.create({ + data: { + orderId: order.id, + yookassaPaymentId: result.paymentId, + status: result.status, + amountCents: order.totalCents, + currency: order.currency, + confirmationUrl: result.confirmationUrl, + expiresAt: result.expiresAt ? new Date(result.expiresAt) : null, + }, + }) + + return { confirmationUrl: result.confirmationUrl } + }) + + fastify.get('/api/me/orders/:orderId/payment', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const { orderId } = request.params + + const order = await prisma.order.findFirst({ where: { id: orderId, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + + const payment = await prisma.payment.findFirst({ + where: { orderId }, + orderBy: { createdAt: 'desc' }, + }) + if (!payment) { + return { status: null, paid: false } + } + + if (payment.status === 'succeeded' || payment.status === 'canceled') { + return { status: payment.status, paid: payment.status === 'succeeded' } + } + + try { + const { getPayment } = await import('../lib/yookassa.js') + const ykPayment = await getPayment(payment.yookassaPaymentId) + + if (ykPayment.status !== payment.status) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: ykPayment.status }, + }) + + if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') { + await prisma.order.update({ + where: { id: orderId }, + data: { status: 'PAID' }, + }) + fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId, + userId: order.userId, + paymentStatus: 'paid', + }) + } + } + + return { status: ykPayment.status, paid: ykPayment.paid } + } catch { + return { status: payment.status, paid: payment.status === 'succeeded' } + } + }) +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/user-payments.test.js +``` +Expected: all tests PASS. + +- [ ] **Step 5: Verify server starts without errors** + +```bash +cd /mnt/d/my_projects/shop/server && timeout 5 node --env-file=.env src/index.js 2>&1 || true +``` +Expected: server starts and logs "Server listening at http://0.0.0.0:3333" + +- [ ] **Step 6: Commit** + +```bash +git add server/src/routes/user-payments.js server/src/routes/__tests__/user-payments.test.js && git commit -m "feat: rewrite payment route for yookassa redirect flow" +``` + +--- + +### Task 5: Add webhook endpoint for YooKassa notifications + +**Files:** +- Modify: `server/src/index.js` (register webhook route) +- Create: `server/src/routes/webhook-yookassa.js` +- Create: `server/src/routes/__tests__/webhook-yookassa.test.js` + +- [ ] **Step 1: Write failing tests for webhook route** + +Create `server/src/routes/__tests__/webhook-yookassa.test.js`: + +```js +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { registerYookassaWebhookRoute } from '../webhook-yookassa.js' + +function buildApp(overrides = {}) { + const app = Fastify({ logger: false }) + app.decorate('eventBus', overrides.eventBus || { emit: () => {} }) + app.decorate('prisma', overrides.prisma || {}) + return app +} + +describe('POST /api/webhooks/yookassa', () => { + let app + let prisma + let eventBus + + beforeEach(async () => { + process.env.YOOKASSA_SECRET_KEY = 'test_secret' + eventBus = { emit: vi.fn() } + prisma = { + payment: { findFirst: vi.fn(), update: vi.fn() }, + order: { findFirst: vi.fn(), update: vi.fn() }, + } + app = buildApp({ prisma, eventBus }) + await registerYookassaWebhookRoute(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + delete process.env.YOOKASSA_SECRET_KEY + }) + + it('returns 400 for invalid body', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/yookassa', + payload: { not: 'valid' }, + }) + expect(res.statusCode).toBe(400) + }) + + it('returns 404 when payment not found', async () => { + prisma.payment.findFirst.mockResolvedValue(null) + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/yookassa', + payload: { + type: 'notification', + event: 'payment.succeeded', + object: { id: 'unknown-id', status: 'succeeded', paid: true }, + }, + }) + expect(res.statusCode).toBe(404) + }) + + it('updates payment and order on payment.succeeded', async () => { + prisma.payment.findFirst.mockResolvedValue({ + id: 'payment-1', + yookassaPaymentId: 'yk-id', + status: 'pending', + orderId: 'order-1', + }) + prisma.payment.update.mockResolvedValue({}) + prisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + status: 'PENDING_PAYMENT', + userId: 'user-1', + }) + prisma.order.update.mockResolvedValue({}) + + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/yookassa', + payload: { + type: 'notification', + event: 'payment.succeeded', + object: { id: 'yk-id', status: 'succeeded', paid: true }, + }, + }) + expect(res.statusCode).toBe(200) + + const updateData = prisma.payment.update.mock.calls[0][0].data + expect(updateData.status).toBe('succeeded') + + const orderUpdateData = prisma.order.update.mock.calls[0][0].data + expect(orderUpdateData.status).toBe('PAID') + expect(eventBus.emit).toHaveBeenCalled() + }) + + it('updates payment on payment.canceled without changing order', async () => { + prisma.payment.findFirst.mockResolvedValue({ + id: 'payment-1', + yookassaPaymentId: 'yk-id', + status: 'pending', + orderId: 'order-1', + }) + prisma.payment.update.mockResolvedValue({}) + + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/yookassa', + payload: { + type: 'notification', + event: 'payment.canceled', + object: { id: 'yk-id', status: 'canceled', paid: false }, + }, + }) + expect(res.statusCode).toBe(200) + expect(prisma.order.update).not.toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/webhook-yookassa.test.js +``` +Expected: FAIL — module not found. + +- [ ] **Step 3: Create webhook route file** + +Create `server/src/routes/webhook-yookassa.js`: + +```js +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' +import { prisma } from '../lib/prisma.js' +import { validateWebhook } from '../lib/yookassa.js' + +export async function registerYookassaWebhookRoute(fastify) { + fastify.post('/api/webhooks/yookassa', async (request, reply) => { + let body + try { + body = typeof request.body === 'string' ? JSON.parse(request.body) : request.body + } catch { + return reply.code(400).send({ error: 'Invalid JSON body' }) + } + + let event, paymentObject + try { + const clientIp = request.ip + ;({ event, paymentObject } = validateWebhook(clientIp, body)) + } catch (err) { + return reply.code(400).send({ error: err.message }) + } + + const yookassaPaymentId = paymentObject.id + if (!yookassaPaymentId) { + return reply.code(400).send({ error: 'Missing payment id in webhook object' }) + } + + const payment = await prisma.payment.findFirst({ + where: { yookassaPaymentId }, + }) + if (!payment) { + return reply.code(404).send({ error: 'Payment not found' }) + } + + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: paymentObject.status }, + }) + + if (event === 'payment.succeeded') { + const order = await prisma.order.findFirst({ + where: { id: payment.orderId }, + }) + if (order && order.status === 'PENDING_PAYMENT') { + await prisma.order.update({ + where: { id: payment.orderId }, + data: { status: 'PAID' }, + }) + + fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: payment.orderId, + userId: order.userId, + paymentStatus: 'paid', + }) + } + } + + return { ok: true } + }) +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run src/routes/__tests__/webhook-yookassa.test.js +``` +Expected: all tests PASS. + +- [ ] **Step 5: Register webhook route in server/src/index.js** + +Add import (after line 30): +```js +import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js' +``` + +Add registration (after line 95 — `registerOAuthSocialRoutes`): +```js +await registerYookassaWebhookRoute(fastify) +``` + +- [ ] **Step 6: Run all server tests** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run +``` +Expected: all tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/src/routes/webhook-yookassa.js server/src/routes/__tests__/webhook-yookassa.test.js server/src/index.js && git commit -m "feat: add yookassa webhook endpoint" +``` + +--- + +### Task 6: Client — remove old manual payment code + +**Files:** +- Delete: `client/src/features/order-payment/ui/PaymentDialog.tsx` +- Delete: `client/src/shared/constants/payment-instructions.ts` +- Modify: `client/src/features/order-payment/index.ts` +- Modify: `client/src/entities/order/api/order-api.ts` (remove `submitOrderPayment`) + +- [ ] **Step 1: Delete PaymentDialog.tsx** + +```bash +rm /mnt/d/my_projects/shop/client/src/features/order-payment/ui/PaymentDialog.tsx +``` + +- [ ] **Step 2: Delete payment-instructions.ts** + +```bash +rm /mnt/d/my_projects/shop/client/src/shared/constants/payment-instructions.ts +``` + +- [ ] **Step 3: Update features/order-payment/index.ts** + +Remove the PaymentDialog export: + +```ts +export { OrderPaymentSection } from './ui/OrderPaymentSection' +``` + +- [ ] **Step 4: Remove submitOrderPayment from order-api.ts** + +Remove lines 76-88 from `client/src/entities/order/api/order-api.ts` (the `submitOrderPayment` function and its JSDoc comment). + +- [ ] **Step 5: Commit** + +```bash +git add client/src/features/order-payment/ui/PaymentDialog.tsx client/src/shared/constants/payment-instructions.ts client/src/features/order-payment/index.ts client/src/entities/order/api/order-api.ts && git commit -m "feat: remove old manual payment dialog and api method" +``` + +--- + +### Task 7: Client — add new API methods and rewrite OrderPaymentSection + +**Files:** +- Modify: `client/src/entities/order/api/order-api.ts` +- Modify: `client/src/features/order-payment/ui/OrderPaymentSection.tsx` +- Modify: `client/src/pages/me/ui/sections/OrderDetailPage.tsx` + +- [ ] **Step 1: Add new API methods to order-api.ts** + +Add after `fetchMyOrder` function (after line 70): + +```ts +/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */ +export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> { + const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`) + return data +} + +/** Получить статус платежа для заказа. */ +export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null, paid: boolean }> { + const { data } = await apiClient.get<{ status: string | null, paid: boolean }>(`me/orders/${orderId}/payment`) + return data +} +``` + +- [ ] **Step 2: Rewrite OrderPaymentSection.tsx** + +Replace `client/src/features/order-payment/ui/OrderPaymentSection.tsx`: + +```tsx +import { useState } from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Typography from '@mui/material/Typography' +import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' + +type Props = { + status: string + deliveryFeeLocked: boolean + paymentMethod: string | null + totalCents: number + isPayPending: boolean + payError: unknown + onPay: () => void +} + +export function OrderPaymentSection({ + status, + deliveryFeeLocked, + paymentMethod, + isPayPending, + payError, + onPay, +}: Props) { + const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup' + + if (payOnPickup) { + return ( + + + Оплата + + + Оплата при получении на точке самовывоза (наличные или карта — по договорённости). + + + ) + } + + return ( + + + Оплата + + {status === 'PENDING_PAYMENT' && deliveryFeeLocked === false && ( + + Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости. + + )} + {status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && ( + <> + + Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус « + {orderStatusLabelRu('PAID')}». + + + + )} + {status === 'PAID' && ( + + Оплачено. Спасибо! + + )} + {status !== 'PENDING_PAYMENT' && status !== 'PAID' && ( + + На этом этапе действий по оплате не требуется. + + )} + + ) +} +``` + +- [ ] **Step 3: Update OrderDetailPage.tsx** + +Replace the `payMut` mutation (lines 39-48) with: + +```tsx +const payMut = useMutation({ + mutationFn: () => createOrderPayment(id!), + onSuccess: async (data: { confirmationUrl: string }) => { + window.location.href = data.confirmationUrl + }, +}) +``` + +Update the import in line 15 — replace `submitOrderPayment` with `createOrderPayment`: + +```tsx +import { + confirmOrderReceived, + createOrderPayment, + fetchMyOrder, + postOrderMessage, + fetchOrderReviewEligibility, +} from '@/entities/order/api/order-api' +``` + +Update `OrderPaymentSection` usage (lines 208-216) — change `onPay` from `(params) => payMut.mutate(params)` to just `() => payMut.mutate()`: + +```tsx + payMut.mutate()} +/> +``` + +- [ ] **Step 4: Add return URL handling to OrderDetailPage.tsx** + +Add `useSearchParams` import (top of file, from `react-router-dom`): + +```tsx +import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom' +``` + +Add import for `getOrderPaymentStatus`: + +```tsx +import { + confirmOrderReceived, + createOrderPayment, + fetchMyOrder, + getOrderPaymentStatus, + postOrderMessage, + fetchOrderReviewEligibility, +} from '@/entities/order/api/order-api' +``` + +Add payment status check effect after line 31 (`const qc = useQueryClient()`): + +```tsx +const [searchParams] = useSearchParams() +const paidParam = searchParams.get('paid') +``` + +Add a query for payment status when returned from YooKassa (after the `orderQuery` block, around line 38): + +```tsx +const paymentStatusQuery = useQuery({ + queryKey: ['me', 'orders', id, 'payment-status'], + queryFn: () => getOrderPaymentStatus(id!), + enabled: Boolean(id && paidParam === '1'), + refetchInterval: (query) => { + const data = query.state.data + if (data && (data.paid || data.status === 'canceled')) return false + return 3000 + }, +}) +``` + +Add a status banner after the top `Stack` with order info (after line 118, before "Позиции"): + +```tsx +{paidParam === '1' && paymentStatusQuery.data && ( + + {paymentStatusQuery.data.paid + ? 'Оплата прошла успешно!' + : paymentStatusQuery.data.status === 'canceled' + ? 'Оплата отменена. Вы можете попробовать снова.' + : 'Ожидаем подтверждения оплаты…'} + +)} +``` + +- [ ] **Step 5: Verify client compiles** + +```bash +cd /mnt/d/my_projects/shop/client && npx tsc --noEmit 2>&1 | head -30 +``` +Expected: no TypeScript errors. + +- [ ] **Step 6: Run client lint** + +```bash +cd /mnt/d/my_projects/shop/client && npm run lint +``` +Expected: no lint errors. + +- [ ] **Step 7: Commit** + +```bash +git add client/src/entities/order/api/order-api.ts client/src/features/order-payment/ui/OrderPaymentSection.tsx client/src/pages/me/ui/sections/OrderDetailPage.tsx && git commit -m "feat: implement yookassa redirect payment flow on client" +``` + +--- + +### Task 8: Final verification and cleanup + +**Files:** (none, verification only) + +- [ ] **Step 1: Run all server tests** + +```bash +cd /mnt/d/my_projects/shop/server && npx vitest run +``` +Expected: all tests PASS. + +- [ ] **Step 2: Run all client tests** + +```bash +cd /mnt/d/my_projects/shop/client && npx vitest run +``` +Expected: all tests PASS. + +- [ ] **Step 3: Run server lint and format check** + +```bash +cd /mnt/d/my_projects/shop/server && npm run lint && npm run format:check +``` +Expected: no lint errors, no format issues. + +- [ ] **Step 4: Run client lint, format check, and build** + +```bash +cd /mnt/d/my_projects/shop/client && npm run lint && npm run format:check && npm run build +``` +Expected: no lint errors, no format issues, build succeeds. + +- [ ] **Step 5: Final commit (if any fixes were needed)** + +If Step 1-4 required fixes, commit them. +Otherwise, confirm: "All verifications passed, no additional changes needed." + +--- + +## Summary + +| Task | Files | Description | +|---|---|---| +| 1 | `schema.prisma` + migration | Add Payment model | +| 2 | `.env.example` | Add YooKassa env vars | +| 3 | `lib/yookassa.js` + tests | YooKassa API client module | +| 4 | `routes/user-payments.js` + tests | Rewrite payment route | +| 5 | `routes/webhook-yookassa.js` + tests + `index.js` | Webhook endpoint | +| 6 | Delete `PaymentDialog.tsx`, `payment-instructions.ts` | Remove old manual payment | +| 7 | `order-api.ts`, `OrderPaymentSection.tsx`, `OrderDetailPage.tsx` | Client redirect flow | +| 8 | Verification | Lint, format, test, build | + +**Total commits:** 7 (Tasks 1-7) + optional cleanup commit from Task 8. From 7bba78b4c0591c5940893078993d0961087b7136 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:49:14 +0500 Subject: [PATCH 03/17] feat: add Payment model for yookassa integration --- .../20260520124831_add_payment/migration.sql | 23 +++++++++++++++++++ server/prisma/schema.prisma | 18 +++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 server/prisma/migrations/20260520124831_add_payment/migration.sql diff --git a/server/prisma/migrations/20260520124831_add_payment/migration.sql b/server/prisma/migrations/20260520124831_add_payment/migration.sql new file mode 100644 index 0000000..121dd2e --- /dev/null +++ b/server/prisma/migrations/20260520124831_add_payment/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "orderId" TEXT NOT NULL, + "yookassaPaymentId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "amountCents" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'RUB', + "confirmationUrl" TEXT, + "expiresAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Payment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_yookassaPaymentId_key" ON "Payment"("yookassaPaymentId"); + +-- CreateIndex +CREATE INDEX "Payment_orderId_idx" ON "Payment"("orderId"); + +-- CreateIndex +CREATE INDEX "Payment_yookassaPaymentId_idx" ON "Payment"("yookassaPaymentId"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 5403c8c..83e0e96 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -153,12 +153,30 @@ model Order { items OrderItem[] messages OrderMessage[] + payments Payment[] messageReadStates UserOrderMessageReadState[] @@index([userId, createdAt]) @@index([status, updatedAt]) } +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]) +} + model OrderItem { id String @id @default(cuid()) qty Int From dad644190a19fbf84822bfc1615c18ac2a68b46f Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:54:01 +0500 Subject: [PATCH 04/17] fix: remove redundant index on yookassaPaymentId --- .../20260520125347_drop_duplicate_payment_index/migration.sql | 2 ++ server/prisma/schema.prisma | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql diff --git a/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql b/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql new file mode 100644 index 0000000..84f0f45 --- /dev/null +++ b/server/prisma/migrations/20260520125347_drop_duplicate_payment_index/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "Payment_yookassaPaymentId_idx"; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 83e0e96..60d79a7 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -174,7 +174,6 @@ model Payment { updatedAt DateTime @updatedAt @@index([orderId]) - @@index([yookassaPaymentId]) } model OrderItem { From e2cea63af0496e1bd1739bc59d14326b575b6302 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:54:54 +0500 Subject: [PATCH 05/17] feat: add yookassa env vars to .env.example --- server/.env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/.env.example b/server/.env.example index c1c4c99..78a9ec4 100644 --- a/server/.env.example +++ b/server/.env.example @@ -32,3 +32,7 @@ YANDEX_CLIENT_SECRET= # Telegram Bot (оповещения админа) TELEGRAM_BOT_TOKEN= + +# YooKassa payment integration +YOOKASSA_SHOP_ID= +YOOKASSA_SECRET_KEY= From 3879e4b388c089d3187aa9d95fc9e3dd63ca2c59 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:59:35 +0500 Subject: [PATCH 06/17] feat: add yookassa API client library with tests --- server/src/lib/__tests__/yookassa.test.js | 164 ++++++++++++++++++++ server/src/lib/yookassa.js | 173 ++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 server/src/lib/__tests__/yookassa.test.js create mode 100644 server/src/lib/yookassa.js diff --git a/server/src/lib/__tests__/yookassa.test.js b/server/src/lib/__tests__/yookassa.test.js new file mode 100644 index 0000000..ccedd24 --- /dev/null +++ b/server/src/lib/__tests__/yookassa.test.js @@ -0,0 +1,164 @@ +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) + }) +}) diff --git a/server/src/lib/yookassa.js b/server/src/lib/yookassa.js new file mode 100644 index 0000000..41ccd93 --- /dev/null +++ b/server/src/lib/yookassa.js @@ -0,0 +1,173 @@ +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(err.message)) 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_') ?? false + +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 +} From a3556367c6914cff087674a36825dcfd435523c4 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 18:04:07 +0500 Subject: [PATCH 07/17] fix: correct retryable check in yookassa fetchWithRetry --- server/src/lib/yookassa.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/lib/yookassa.js b/server/src/lib/yookassa.js index 41ccd93..c5e2b32 100644 --- a/server/src/lib/yookassa.js +++ b/server/src/lib/yookassa.js @@ -30,7 +30,7 @@ async function fetchWithRetry(url, opts, maxRetries = 3) { `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(err.message)) throw err + if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw err lastError = err if (attempt === maxRetries) throw lastError } From abadbbd4c4d6fe56851bbd6b6fb5559002bb83fb Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 18:11:14 +0500 Subject: [PATCH 08/17] fix: add retry to getPayment, normalize return, env validation, webhook/builder tests --- server/src/lib/__tests__/yookassa.test.js | 107 +++++++++++++++++++++- server/src/lib/yookassa.js | 25 +++-- 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/server/src/lib/__tests__/yookassa.test.js b/server/src/lib/__tests__/yookassa.test.js index ccedd24..26823bd 100644 --- a/server/src/lib/__tests__/yookassa.test.js +++ b/server/src/lib/__tests__/yookassa.test.js @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createPayment, getPayment } from '../yookassa.js' +import { createPayment, getPayment, buildReceipt, validateWebhook } from '../yookassa.js' describe('yookassa createPayment', () => { beforeEach(() => { @@ -157,8 +157,111 @@ describe('yookassa getPayment', () => { 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.paymentId).toBe('payment-id') expect(result.status).toBe('succeeded') expect(result.paid).toBe(true) }) }) + +describe('yookassa buildReceipt', () => { + it('builds receipt with order items', () => { + const result = buildReceipt({ + orderItems: [ + { titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }, + ], + deliveryFeeCents: 0, + userEmail: 'user@test.ru', + }) + + expect(result.customer.email).toBe('user@test.ru') + expect(result.items).toHaveLength(1) + expect(result.items[0].description).toBe('Test Product') + expect(result.items[0].quantity).toBe(2) + expect(result.items[0].amount.value).toBe('1000.00') + expect(result.items[0].vat_code).toBe(1) + expect(result.items[0].measure).toBe('piece') + expect(result.items[0].payment_subject).toBe('commodity') + expect(result.items[0].payment_mode).toBe('full_prepayment') + expect(result.tax_system_code).toBe(1) + }) + + it('adds delivery item when deliveryFeeCents > 0', () => { + const result = buildReceipt({ + orderItems: [ + { titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }, + ], + deliveryFeeCents: 35000, + userEmail: 'user@test.ru', + }) + + expect(result.items).toHaveLength(2) + expect(result.items[1].description).toBe('Доставка') + expect(result.items[1].amount.value).toBe('350.00') + expect(result.items[1].payment_subject).toBe('service') + }) + + it('passes through taxSystemCode', () => { + const result = buildReceipt({ + orderItems: [ + { titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }, + ], + deliveryFeeCents: 0, + userEmail: 'user@test.ru', + taxSystemCode: 3, + }) + + expect(result.tax_system_code).toBe(3) + }) +}) + +describe('yookassa validateWebhook', () => { + beforeEach(() => { + process.env.YOOKASSA_SECRET_KEY = 'test_secret' + }) + + afterEach(() => { + delete process.env.YOOKASSA_SECRET_KEY + }) + + it('returns event and paymentObject for valid notification', () => { + const body = { + type: 'notification', + event: 'payment.succeeded', + object: { id: 'yk-id', status: 'succeeded', paid: true }, + } + const result = validateWebhook('127.0.0.1', body) + expect(result.event).toBe('payment.succeeded') + expect(result.paymentObject.id).toBe('yk-id') + }) + + it('throws if type is not notification', () => { + expect(() => validateWebhook('127.0.0.1', { type: 'other', event: 'x', object: {} })).toThrow( + 'Expected notification type', + ) + }) + + it('throws if missing event', () => { + expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow( + 'Missing event or object', + ) + }) + + it('throws if missing object', () => { + expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow( + 'Missing event or object', + ) + }) + + it('throws for invalid body type', () => { + expect(() => validateWebhook('127.0.0.1', 'not an object')).toThrow('Invalid webhook body') + }) + + it('throws for null body', () => { + expect(() => validateWebhook('127.0.0.1', null)).toThrow('Invalid webhook body') + }) + + it('skips IP validation in test mode (test_ key)', () => { + const body = { type: 'notification', event: 'payment.succeeded', object: {} } + expect(() => validateWebhook('1.2.3.4', body)).not.toThrow() + }) +}) diff --git a/server/src/lib/yookassa.js b/server/src/lib/yookassa.js index c5e2b32..32185ab 100644 --- a/server/src/lib/yookassa.js +++ b/server/src/lib/yookassa.js @@ -3,6 +3,9 @@ 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 + if (!shopId || !secretKey) { + throw new Error('YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY are required') + } const token = Buffer.from(`${shopId}:${secretKey}`).toString('base64') return `Basic ${token}` } @@ -31,7 +34,7 @@ async function fetchWithRetry(url, opts, maxRetries = 3) { ) } catch (err) { if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw err - lastError = err + lastError = new Error(`YooKassa API error: network failure — ${err instanceof Error ? err.message : String(err)}`) if (attempt === maxRetries) throw lastError } } @@ -86,14 +89,18 @@ export async function createPayment({ } export async function getPayment(paymentId) { - const res = await fetch(`${YOOKASSA_API_URL}/payments/${paymentId}`, { + const res = await fetchWithRetry(`${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'}`) + 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, } - 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'] @@ -116,10 +123,12 @@ function isYookassaIp(ip) { return YOOKASSA_IP_RANGES_V4.some((cidr) => cidrMatch(v4, cidr)) } -const TEST_MODE = process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false +function isTestMode() { + return (process.env.YOOKASSA_SECRET_KEY?.startsWith('test_')) ?? false +} export function validateWebhook(ip, body) { - if (!TEST_MODE && !isYookassaIp(ip)) { + if (!isTestMode() && !isYookassaIp(ip)) { throw new Error('Invalid webhook source IP') } if (!body || typeof body !== 'object') { From 8d45155b54b6d99aac4611d35265cb35e09c5a67 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 18:53:21 +0500 Subject: [PATCH 09/17] feat: rewrite payment route for yookassa redirect flow --- .../routes/__tests__/user-payments.test.js | 139 +++++++++++ server/src/routes/user-payments.js | 219 ++++++++++-------- 2 files changed, 263 insertions(+), 95 deletions(-) create mode 100644 server/src/routes/__tests__/user-payments.test.js diff --git a/server/src/routes/__tests__/user-payments.test.js b/server/src/routes/__tests__/user-payments.test.js new file mode 100644 index 0000000..7ab9b90 --- /dev/null +++ b/server/src/routes/__tests__/user-payments.test.js @@ -0,0 +1,139 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { prisma } from '../../lib/prisma.js' +import { registerUserPaymentRoutes } from '../user-payments.js' + +const JWT_SECRET = 'test-secret' +const TEST_USER_EMAIL = 'test-pay-user@example.com' + +let testUserId +let testOrderId + +async function signToken(userId) { + const fastify = Fastify() + await fastify.register(jwt, { secret: JWT_SECRET }) + await fastify.ready() + return fastify.jwt.sign({ sub: userId, email: TEST_USER_EMAIL }) +} + +async function buildApp() { + const app = Fastify({ logger: false }) + await 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', { emit: () => {} }) + await registerUserPaymentRoutes(app) + await app.ready() + return app +} + +describe('POST /api/me/orders/:id/pay', () => { + let app + + beforeAll(async () => { + const user = await prisma.user.create({ + data: { email: TEST_USER_EMAIL }, + }) + testUserId = user.id + + const order = await prisma.order.create({ + data: { + userId: testUserId, + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: true, + totalCents: 100000, + currency: 'RUB', + deliveryFeeCents: 0, + }, + }) + testOrderId = order.id + }) + + afterAll(async () => { + await prisma.order.deleteMany({ where: { userId: testUserId } }) + await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } }) + }) + + beforeEach(async () => { + await prisma.order.update({ + where: { id: testOrderId }, + data: { + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: true, + }, + }) + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + }) + + it('returns 401 without auth', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${testOrderId}/pay`, + }) + expect(res.statusCode).toBe(401) + }) + + it('returns 404 when order not found', async () => { + const token = await signToken(testUserId) + const res = await app.inject({ + method: 'POST', + url: '/api/me/orders/nonexistent-id/pay', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('returns 409 when payment method is on_pickup', async () => { + await prisma.order.update({ + where: { id: testOrderId }, + data: { paymentMethod: 'on_pickup' }, + }) + const token = await signToken(testUserId) + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${testOrderId}/pay`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 409 when order not in PENDING_PAYMENT status', async () => { + await prisma.order.update({ + where: { id: testOrderId }, + data: { status: 'PAID' }, + }) + const token = await signToken(testUserId) + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${testOrderId}/pay`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) + + it('returns 409 when deliveryFeeLocked is false', async () => { + await prisma.order.update({ + where: { id: testOrderId }, + data: { deliveryFeeLocked: false }, + }) + const token = await signToken(testUserId) + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${testOrderId}/pay`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(409) + }) +}) diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index 98ccd4d..a12d0d0 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -1,114 +1,143 @@ -import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' -import { escapeHtml } from '../lib/escape-html.js' import { prisma } from '../lib/prisma.js' -import { saveImageBufferToUploads } from '../lib/upload-images.js' -import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js' +import { createPayment, buildReceipt, getPayment } 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 { id } = request.params - const order = await prisma.order.findFirst({ where: { id, userId } }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + 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 paymentMethod = order.paymentMethod ?? 'online' - if (paymentMethod === 'on_pickup') { - return reply.code(409).send({ - error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.', + const order = await prisma.order.findFirst({ + where: { id, userId }, + include: { items: true }, }) - } + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - if (order.status !== 'PENDING_PAYMENT') { - return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' }) - } + if (order.paymentMethod === 'on_pickup') { + return reply.code(409).send({ + error: 'Для этого заказа оплата при получении — онлайн-оплата недоступна', + }) + } - if (!request.isMultipart()) { - return reply.code(400).send({ - error: 'Отправьте multipart/form-data: поле detail и/или файл receipt', + 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' }, }) - } - let detail = '' - let receiptBuffer = null - let receiptFilename = '' - try { - const otherLimit = getOtherUploadMaxFileBytes() - const parts = request.parts({ - limits: { - fileSize: otherLimit, - files: 2, + 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, }, }) - for await (const part of parts) { - if (part.file) { - if (part.fieldname === 'receipt') { - if (receiptBuffer !== null) { - return reply.code(400).send({ error: 'Допускается один файл receipt' }) - } - receiptBuffer = await part.toBuffer() - receiptFilename = part.filename ?? 'receipt' - } - } else if (part.fieldname === 'detail') { - detail = String(part.value ?? '').trim() - } - } - } catch (err) { - const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму' - return reply.code(400).send({ error: msg }) - } - const hasDetail = detail.length > 0 - const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0 + return { confirmationUrl: result.confirmationUrl } + }, + ) - if (!hasDetail && !hasReceipt) { - return reply.code(400).send({ - error: 'Укажите текст о платеже и/или прикрепите изображение чека', + 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 } + } - const maxDetail = 2000 - if (detail.length > maxDetail) { - return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` }) - } + if (payment.status === 'succeeded' || payment.status === 'canceled') { + return { status: payment.status, paid: payment.status === 'succeeded' } + } - let attachmentUrl = null - if (hasReceipt) { try { - attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer) - } catch (err) { - const message = err instanceof Error ? err.message : 'Не удалось сохранить файл' - const statusCode = - err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode) - ? Number(err.statusCode) - : 400 - return reply.code(statusCode).send({ error: message }) + 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' }, + }) + request.server.eventBus.emit('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' } } - } - - const bodyHtml = hasDetail ? `

${escapeHtml(detail).replace(/\r\n|\n|\r/g, '
')}

` : '' - const messageText = `

Подтверждение оплаты (перевод ВТБ / Сбербанк)

${bodyHtml}` - - try { - await prisma.$transaction(async (tx) => { - await tx.orderMessage.create({ - data: { - orderId: id, - authorType: 'user', - text: messageText, - attachmentUrl, - }, - }) - }) - } catch { - return reply.code(500).send({ error: 'Не удалось сохранить оплату' }) - } - - request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { - orderId: id, - userId, - paymentStatus: 'pending', - }) - - return { ok: true, status: 'PENDING_PAYMENT' } - }) + }, + ) } From 7d0854a294e9044734333c238f15d54176751ad9 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 19:00:39 +0500 Subject: [PATCH 10/17] fix: use correct notification event name in payment route --- server/src/routes/user-payments.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index a12d0d0..8941a8c 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -1,3 +1,4 @@ +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' import { prisma } from '../lib/prisma.js' import { createPayment, buildReceipt, getPayment } from '../lib/yookassa.js' @@ -126,7 +127,7 @@ export async function registerUserPaymentRoutes(fastify) { where: { id: orderId }, data: { status: 'PAID' }, }) - request.server.eventBus.emit('PAYMENT_STATUS_CHANGED', { + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { orderId, userId: order.userId, paymentStatus: 'paid', From 317b910710be978198d65fffb87573088f4da2c3 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 19:12:46 +0500 Subject: [PATCH 11/17] fix: email validation, conditional order update, improved tests for payment routes --- .../routes/__tests__/user-payments.test.js | 114 +++++++++++++++++- server/src/routes/user-payments.js | 23 ++-- 2 files changed, 125 insertions(+), 12 deletions(-) diff --git a/server/src/routes/__tests__/user-payments.test.js b/server/src/routes/__tests__/user-payments.test.js index 7ab9b90..525caec 100644 --- a/server/src/routes/__tests__/user-payments.test.js +++ b/server/src/routes/__tests__/user-payments.test.js @@ -1,20 +1,20 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import Fastify from 'fastify' import jwt from '@fastify/jwt' import { prisma } from '../../lib/prisma.js' import { registerUserPaymentRoutes } from '../user-payments.js' const JWT_SECRET = 'test-secret' -const TEST_USER_EMAIL = 'test-pay-user@example.com' +const TEST_USER_EMAIL = `test-pay-${Date.now()}@example.com` let testUserId let testOrderId -async function signToken(userId) { +async function signToken(userId, email = TEST_USER_EMAIL) { const fastify = Fastify() await fastify.register(jwt, { secret: JWT_SECRET }) await fastify.ready() - return fastify.jwt.sign({ sub: userId, email: TEST_USER_EMAIL }) + return fastify.jwt.sign({ sub: userId, email }) } async function buildApp() { @@ -37,6 +37,10 @@ describe('POST /api/me/orders/:id/pay', () => { let app beforeAll(async () => { + await prisma.payment.deleteMany() + await prisma.order.deleteMany({ where: { user: { email: TEST_USER_EMAIL } } }) + await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } }) + const user = await prisma.user.create({ data: { email: TEST_USER_EMAIL }, }) @@ -57,6 +61,7 @@ describe('POST /api/me/orders/:id/pay', () => { }) afterAll(async () => { + await prisma.payment.deleteMany({ where: { orderId: testOrderId } }) await prisma.order.deleteMany({ where: { userId: testUserId } }) await prisma.user.deleteMany({ where: { email: TEST_USER_EMAIL } }) }) @@ -75,6 +80,7 @@ describe('POST /api/me/orders/:id/pay', () => { afterEach(async () => { await app.close() + vi.restoreAllMocks() }) it('returns 401 without auth', async () => { @@ -136,4 +142,104 @@ describe('POST /api/me/orders/:id/pay', () => { }) expect(res.statusCode).toBe(409) }) + + it('returns 422 when user has no email', async () => { + const noEmailUser = await prisma.user.create({ + data: { email: `noemail-${Date.now()}@test.com` }, + }) + const noEmailOrder = await prisma.order.create({ + data: { + userId: noEmailUser.id, + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: true, + totalCents: 100000, + currency: 'RUB', + }, + }) + + const fastify = Fastify() + await fastify.register(jwt, { secret: JWT_SECRET }) + const token = fastify.jwt.sign({ sub: noEmailUser.id }) + await fastify.close() + + const res = await app.inject({ + method: 'POST', + url: `/api/me/orders/${noEmailOrder.id}/pay`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(422) + + await prisma.order.deleteMany({ where: { userId: noEmailUser.id } }) + await prisma.user.deleteMany({ where: { id: noEmailUser.id } }) + }) +}) + +describe('GET /api/me/orders/:orderId/payment', () => { + let app + let getTestUserId + let getTestOrderId + + beforeAll(async () => { + const getEmail = `get-pay-${Date.now()}@example.com` + const user = await prisma.user.create({ data: { email: getEmail } }) + getTestUserId = user.id + + const order = await prisma.order.create({ + data: { + userId: getTestUserId, + status: 'PENDING_PAYMENT', + paymentMethod: 'online', + deliveryFeeLocked: true, + totalCents: 100000, + currency: 'RUB', + }, + }) + getTestOrderId = order.id + }) + + afterAll(async () => { + await prisma.payment.deleteMany({ where: { orderId: getTestOrderId } }) + await prisma.order.deleteMany({ where: { userId: getTestUserId } }) + await prisma.user.deleteMany({ where: { id: getTestUserId } }) + }) + + beforeEach(async () => { + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + }) + + it('returns 401 without auth', async () => { + const res = await app.inject({ + method: 'GET', + url: `/api/me/orders/${getTestOrderId}/payment`, + }) + expect(res.statusCode).toBe(401) + }) + + it('returns 404 when order not found', async () => { + const token = await signToken(getTestUserId) + const res = await app.inject({ + method: 'GET', + url: '/api/me/orders/nonexistent-id/payment', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('returns status null when no payment exists', async () => { + const token = await signToken(getTestUserId) + const res = await app.inject({ + method: 'GET', + url: `/api/me/orders/${getTestOrderId}/payment`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.payload) + expect(body.status).toBeNull() + expect(body.paid).toBe(false) + }) }) diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index 8941a8c..a1cad89 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -9,6 +9,11 @@ export async function registerUserPaymentRoutes(fastify) { async (request, reply) => { const userId = request.user.sub const userEmail = request.user.email + + if (!userEmail) { + return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' }) + } + const { id } = request.params const order = await prisma.order.findFirst({ @@ -42,7 +47,7 @@ export async function registerUserPaymentRoutes(fastify) { return { confirmationUrl: existingPayment.confirmationUrl } } - const idempotencyKey = `${id}-v1` + const idempotencyKey = `${id}-${Date.now()}` const returnUrl = `${process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'}/me/orders/${id}?paid=1` const clientIp = request.ip @@ -123,15 +128,17 @@ export async function registerUserPaymentRoutes(fastify) { }) if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') { - await prisma.order.update({ - where: { id: orderId }, + const updated = await prisma.order.updateMany({ + where: { id: orderId, status: 'PENDING_PAYMENT' }, data: { status: 'PAID' }, }) - request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { - orderId, - userId: order.userId, - paymentStatus: 'paid', - }) + if (updated.count > 0) { + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId, + userId: order.userId, + paymentStatus: 'paid', + }) + } } } From dcf601d4a25b56a603d06e5c727f1bcd7f80351f Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 19:19:48 +0500 Subject: [PATCH 12/17] feat: add yookassa webhook endpoint --- server/src/index.js | 2 + .../routes/__tests__/webhook-yookassa.test.js | 133 ++++++++++++++++++ server/src/routes/webhook-yookassa.js | 60 ++++++++ 3 files changed, 195 insertions(+) create mode 100644 server/src/routes/__tests__/webhook-yookassa.test.js create mode 100644 server/src/routes/webhook-yookassa.js diff --git a/server/src/index.js b/server/src/index.js index 9b57aa3..4ed109d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -28,6 +28,7 @@ import { registerUserCartRoutes } from './routes/user-cart.js' import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserPaymentRoutes } from './routes/user-payments.js' +import { registerYookassaWebhookRoute } from './routes/webhook-yookassa.js' const port = Number(process.env.PORT) || 3333 const origin = (process.env.CORS_ORIGIN ?? '') @@ -93,6 +94,7 @@ await registerUserOrderRoutes(fastify) await registerUserPaymentRoutes(fastify) await registerUserNotificationRoutes(fastify) await registerOAuthSocialRoutes(fastify) +await registerYookassaWebhookRoute(fastify) await registerApiRoutes(fastify) await ensureAdminUser() await getOrCreateUnspecifiedCategory() diff --git a/server/src/routes/__tests__/webhook-yookassa.test.js b/server/src/routes/__tests__/webhook-yookassa.test.js new file mode 100644 index 0000000..35228d6 --- /dev/null +++ b/server/src/routes/__tests__/webhook-yookassa.test.js @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Fastify from 'fastify' +import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' + +const { mockPrisma } = vi.hoisted(() => ({ + mockPrisma: { + payment: { findFirst: vi.fn(), update: vi.fn() }, + order: { findFirst: vi.fn(), updateMany: vi.fn() }, + }, +})) + +vi.mock('../../lib/prisma.js', () => ({ + prisma: mockPrisma, +})) + +vi.mock('../../lib/yookassa.js', () => ({ + validateWebhook: vi.fn(), +})) + +import { validateWebhook } from '../../lib/yookassa.js' +import { registerYookassaWebhookRoute } from '../webhook-yookassa.js' + +function buildApp(eventBusMock) { + const app = Fastify({ logger: false }) + app.decorate('eventBus', eventBusMock || { emit: () => {} }) + return app +} + +describe('POST /api/webhooks/yookassa', () => { + let app + let eventBus + + beforeEach(async () => { + eventBus = { emit: vi.fn() } + validateWebhook.mockImplementation((_ip, body) => { + 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 } + }) + app = buildApp(eventBus) + await registerYookassaWebhookRoute(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + vi.clearAllMocks() + }) + + 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 () => { + mockPrisma.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 () => { + mockPrisma.payment.findFirst.mockResolvedValue({ + id: 'payment-1', + yookassaPaymentId: 'yk-id', + status: 'pending', + orderId: 'order-1', + }) + mockPrisma.payment.update.mockResolvedValue({}) + mockPrisma.order.findFirst.mockResolvedValue({ + id: 'order-1', + status: 'PENDING_PAYMENT', + userId: 'user-1', + }) + mockPrisma.order.updateMany.mockResolvedValue({ count: 1 }) + + 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 = mockPrisma.payment.update.mock.calls[0][0].data + expect(updateData.status).toBe('succeeded') + + const orderUpdateData = mockPrisma.order.updateMany.mock.calls[0][0].data + expect(orderUpdateData.status).toBe('PAID') + expect(eventBus.emit).toHaveBeenCalledWith(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: 'order-1', + userId: 'user-1', + paymentStatus: 'paid', + }) + }) + + it('updates payment on payment.canceled without changing order', async () => { + mockPrisma.payment.findFirst.mockResolvedValue({ + id: 'payment-1', + yookassaPaymentId: 'yk-id', + status: 'pending', + orderId: 'order-1', + }) + mockPrisma.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(mockPrisma.order.findFirst).not.toHaveBeenCalled() + }) +}) diff --git a/server/src/routes/webhook-yookassa.js b/server/src/routes/webhook-yookassa.js new file mode 100644 index 0000000..3ef80a0 --- /dev/null +++ b/server/src/routes/webhook-yookassa.js @@ -0,0 +1,60 @@ +import { prisma } from '../lib/prisma.js' +import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.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') { + const updated = await prisma.order.updateMany({ + where: { id: payment.orderId, status: 'PENDING_PAYMENT' }, + data: { status: 'PAID' }, + }) + if (updated.count > 0) { + fastify.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId: payment.orderId, + userId: order.userId, + paymentStatus: 'paid', + }) + } + } + } + + return { ok: true } + }) +} From 698293e2f1e93633c6aa6e9b91cdb89ae4b98052 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 19:22:51 +0500 Subject: [PATCH 13/17] feat: remove old manual payment dialog and api method --- client/src/entities/order/api/order-api.ts | 14 -- client/src/features/order-payment/index.ts | 1 - .../order-payment/ui/PaymentDialog.tsx | 146 ------------------ .../shared/constants/payment-instructions.ts | 12 -- 4 files changed, 173 deletions(-) delete mode 100644 client/src/features/order-payment/ui/PaymentDialog.tsx delete mode 100644 client/src/shared/constants/payment-instructions.ts diff --git a/client/src/entities/order/api/order-api.ts b/client/src/entities/order/api/order-api.ts index 6217aea..3b5f2fb 100644 --- a/client/src/entities/order/api/order-api.ts +++ b/client/src/entities/order/api/order-api.ts @@ -73,20 +73,6 @@ export async function postOrderMessage(id: string, text: string): Promise await apiClient.post(`me/orders/${id}/messages`, { text }) } -/** Подтверждение оплаты переводом: multipart detail + необязательный файл receipt (хотя бы одно нужно на сервере). */ -export async function submitOrderPayment( - orderId: string, - payload: { detail: string; receiptFile: File | null }, -): Promise<{ ok: boolean; status: string }> { - const formData = new FormData() - formData.append('detail', payload.detail) - if (payload.receiptFile) { - formData.append('receipt', payload.receiptFile) - } - const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${orderId}/pay`, formData) - return data -} - export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> { const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`) return data diff --git a/client/src/features/order-payment/index.ts b/client/src/features/order-payment/index.ts index a7a90a5..e9b76a3 100644 --- a/client/src/features/order-payment/index.ts +++ b/client/src/features/order-payment/index.ts @@ -1,2 +1 @@ export { OrderPaymentSection } from './ui/OrderPaymentSection' -export { PaymentDialog } from './ui/PaymentDialog' diff --git a/client/src/features/order-payment/ui/PaymentDialog.tsx b/client/src/features/order-payment/ui/PaymentDialog.tsx deleted file mode 100644 index 767b62a..0000000 --- a/client/src/features/order-payment/ui/PaymentDialog.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' -import Alert from '@mui/material/Alert' -import Box from '@mui/material/Box' -import Button from '@mui/material/Button' -import Dialog from '@mui/material/Dialog' -import DialogActions from '@mui/material/DialogActions' -import DialogContent from '@mui/material/DialogContent' -import DialogTitle from '@mui/material/DialogTitle' -import Stack from '@mui/material/Stack' -import TextField from '@mui/material/TextField' -import Typography from '@mui/material/Typography' -import axios from 'axios' -import { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions' - -type Props = { - open: boolean - isPending: boolean - error: unknown - onClose: () => void - onSubmit: (params: { detail: string; receiptFile: File | null }) => void -} - -function paySubmitErrorMessage(err: unknown): string { - if (axios.isAxiosError(err)) { - const raw = err.response?.data - const apiMsg = - raw && typeof raw === 'object' && 'error' in raw && typeof (raw as { error: unknown }).error === 'string' - ? (raw as { error: string }).error - : null - return apiMsg || err.message || 'Не удалось отправить данные оплаты' - } - if (err instanceof Error) return err.message - return 'Не удалось отправить данные оплаты' -} - -export function PaymentDialog({ open, isPending, error, onClose, onSubmit }: Props) { - const [detail, setDetail] = useState('') - const [receiptFile, setReceiptFile] = useState(null) - const [clientError, setClientError] = useState(null) - - const receiptPreviewUrl = useMemo(() => { - if (!receiptFile) return null - return URL.createObjectURL(receiptFile) - }, [receiptFile]) - - useEffect(() => { - if (!receiptPreviewUrl) return - return () => URL.revokeObjectURL(receiptPreviewUrl) - }, [receiptPreviewUrl]) - - const reset = () => { - setDetail('') - setReceiptFile(null) - setClientError(null) - } - - const handleClose = () => { - if (isPending) return - reset() - onClose() - } - - const handleSubmit = () => { - const hasText = detail.trim().length > 0 - const hasFile = Boolean(receiptFile) - if (!hasText && !hasFile) { - setClientError('Укажите комментарий и/или прикрепите чек.') - return - } - setClientError(null) - onSubmit({ detail: detail.trim(), receiptFile }) - } - - return ( - - Подтверждение оплаты - - - {PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN} - - { - setDetail(e.target.value) - setClientError(null) - }} - fullWidth - multiline - minRows={3} - sx={{ mb: 2 }} - /> - - - {receiptFile && ( - - )} - - - Нужен текст комментария и/или изображение чека. - - {receiptPreviewUrl && ( - - )} - {clientError && ( - - {clientError} - - )} - {error ? ( - - {paySubmitErrorMessage(error)} - - ) : null} - - - - - - - ) -} diff --git a/client/src/shared/constants/payment-instructions.ts b/client/src/shared/constants/payment-instructions.ts deleted file mode 100644 index d1694e5..0000000 --- a/client/src/shared/constants/payment-instructions.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** Текст модалки оплаты (можно переопределить через VITE_PAYMENT_INSTRUCTIONS — многострочная строка \n). */ -const fromEnv = - typeof import.meta.env.VITE_PAYMENT_INSTRUCTIONS === 'string' ? import.meta.env.VITE_PAYMENT_INSTRUCTIONS.trim() : '' - -export const PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN = - fromEnv || - [ - 'Временно оплата доступна только переводом на ВТБ / Сбербанк.', - '', - 'По номеру +79524181624', - 'Получатель: Лариса К', - ].join('\n') From faac3321389dc23976c81999e222305e72446812 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 19:28:46 +0500 Subject: [PATCH 14/17] feat: implement yookassa redirect payment flow on client --- client/src/entities/order/api/order-api.ts | 12 +++++ .../order-payment/ui/OrderPaymentSection.tsx | 33 +++++------- .../pages/me/ui/sections/OrderDetailPage.tsx | 50 +++++++++++++++---- 3 files changed, 64 insertions(+), 31 deletions(-) diff --git a/client/src/entities/order/api/order-api.ts b/client/src/entities/order/api/order-api.ts index 3b5f2fb..a57bc29 100644 --- a/client/src/entities/order/api/order-api.ts +++ b/client/src/entities/order/api/order-api.ts @@ -69,6 +69,18 @@ export async function fetchMyOrder(id: string): Promise { return data } +/** Создать платёж в Ю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 +} + export async function postOrderMessage(id: string, text: string): Promise { await apiClient.post(`me/orders/${id}/messages`, { text }) } diff --git a/client/src/features/order-payment/ui/OrderPaymentSection.tsx b/client/src/features/order-payment/ui/OrderPaymentSection.tsx index 35873a2..4f13e7b 100644 --- a/client/src/features/order-payment/ui/OrderPaymentSection.tsx +++ b/client/src/features/order-payment/ui/OrderPaymentSection.tsx @@ -1,9 +1,7 @@ -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' -import { PaymentDialog } from './PaymentDialog' type Props = { status: string @@ -12,7 +10,7 @@ type Props = { totalCents: number isPayPending: boolean payError: unknown - onPay: (params: { detail: string; receiptFile: File | null }) => void + onPay: () => void } export function OrderPaymentSection({ @@ -24,7 +22,6 @@ export function OrderPaymentSection({ onPay, }: Props) { const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup' - const [payModalOpen, setPayModalOpen] = useState(false) if (payOnPickup) { return ( @@ -52,30 +49,24 @@ export function OrderPaymentSection({ {status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && ( <> - После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус « + Вы будете перенаправлены на защищённую платёжную страницу ЮKassa. После оплаты заказ получит статус « {orderStatusLabelRu('PAID')}». - )} - {status !== 'PENDING_PAYMENT' && ( - - На этом этапе действий по оплате в этом блоке не требуется. + {status === 'PAID' && ( + + Оплачено. Спасибо! + + )} + {status !== 'PENDING_PAYMENT' && status !== 'PAID' && ( + + На этом этапе действий по оплате не требуется. )} - - setPayModalOpen(false)} - onSubmit={(params) => { - onPay(params) - setPayModalOpen(false) - }} - /> ) } diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx index d409012..353c515 100644 --- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -7,12 +7,13 @@ import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Link as RouterLink, useParams } from 'react-router-dom' +import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom' import { confirmOrderReceived, + createOrderPayment, fetchMyOrder, + getOrderPaymentStatus, postOrderMessage, - submitOrderPayment, fetchOrderReviewEligibility, } from '@/entities/order/api/order-api' import { postProductReview, uploadReviewImage } from '@/entities/review/api/reviews-api' @@ -30,6 +31,9 @@ export function OrderDetailPage() { const { id } = useParams() const qc = useQueryClient() + const [searchParams] = useSearchParams() + const paidParam = searchParams.get('paid') + const orderQuery = useQuery({ queryKey: ['me', 'orders', id], queryFn: () => fetchMyOrder(id!), @@ -37,13 +41,20 @@ export function OrderDetailPage() { }) const payMut = useMutation({ - mutationFn: (params: { detail: string; receiptFile: File | null }) => submitOrderPayment(id!, params), - onSuccess: async () => { - await Promise.all([ - qc.invalidateQueries({ queryKey: ['me', 'orders', id] }), - qc.invalidateQueries({ queryKey: ['me', 'orders'] }), - qc.invalidateQueries({ queryKey: ['me', 'conversations'] }), - ]) + mutationFn: () => createOrderPayment(id!), + onSuccess: async (data) => { + window.location.href = data.confirmationUrl + }, + }) + + 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 }, }) @@ -117,6 +128,25 @@ export function OrderDetailPage() { + {paidParam === '1' && paymentStatusQuery.data && ( + + {paymentStatusQuery.data.paid + ? 'Оплата прошла успешно!' + : paymentStatusQuery.data.status === 'canceled' + ? 'Оплата отмена. Вы можете попробовать снова.' + : 'Ожидаем подтверждения оплаты…'} + + )} + @@ -212,7 +242,7 @@ export function OrderDetailPage() { totalCents={order.totalCents} isPayPending={payMut.isPending} payError={payMut.error} - onPay={(params) => payMut.mutate(params)} + onPay={() => payMut.mutate()} /> {(order.deliveryType === 'delivery' && order.status === 'SHIPPED') || From 3177413acd49586eb0ec95b6e79f6ffef2ebae9e Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 19:33:13 +0500 Subject: [PATCH 15/17] chore: fix prettier formatting --- server/src/lib/__tests__/yookassa.test.js | 20 +- server/src/lib/yookassa.js | 2 +- .../routes/__tests__/user-payments.test.js | 4 +- .../routes/__tests__/webhook-yookassa.test.js | 2 +- server/src/routes/user-payments.js | 242 +++++++++--------- server/src/routes/webhook-yookassa.js | 2 +- 6 files changed, 127 insertions(+), 145 deletions(-) diff --git a/server/src/lib/__tests__/yookassa.test.js b/server/src/lib/__tests__/yookassa.test.js index 26823bd..f15be2a 100644 --- a/server/src/lib/__tests__/yookassa.test.js +++ b/server/src/lib/__tests__/yookassa.test.js @@ -166,9 +166,7 @@ describe('yookassa getPayment', () => { describe('yookassa buildReceipt', () => { it('builds receipt with order items', () => { const result = buildReceipt({ - orderItems: [ - { titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }, - ], + orderItems: [{ titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 }], deliveryFeeCents: 0, userEmail: 'user@test.ru', }) @@ -187,9 +185,7 @@ describe('yookassa buildReceipt', () => { it('adds delivery item when deliveryFeeCents > 0', () => { const result = buildReceipt({ - orderItems: [ - { titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }, - ], + orderItems: [{ titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 }], deliveryFeeCents: 35000, userEmail: 'user@test.ru', }) @@ -202,9 +198,7 @@ describe('yookassa buildReceipt', () => { it('passes through taxSystemCode', () => { const result = buildReceipt({ - orderItems: [ - { titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }, - ], + orderItems: [{ titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 }], deliveryFeeCents: 0, userEmail: 'user@test.ru', taxSystemCode: 3, @@ -241,15 +235,11 @@ describe('yookassa validateWebhook', () => { }) it('throws if missing event', () => { - expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow( - 'Missing event or object', - ) + expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow('Missing event or object') }) it('throws if missing object', () => { - expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow( - 'Missing event or object', - ) + expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow('Missing event or object') }) it('throws for invalid body type', () => { diff --git a/server/src/lib/yookassa.js b/server/src/lib/yookassa.js index 32185ab..029274f 100644 --- a/server/src/lib/yookassa.js +++ b/server/src/lib/yookassa.js @@ -124,7 +124,7 @@ function isYookassaIp(ip) { } function isTestMode() { - return (process.env.YOOKASSA_SECRET_KEY?.startsWith('test_')) ?? false + return process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false } export function validateWebhook(ip, body) { diff --git a/server/src/routes/__tests__/user-payments.test.js b/server/src/routes/__tests__/user-payments.test.js index 525caec..d46af81 100644 --- a/server/src/routes/__tests__/user-payments.test.js +++ b/server/src/routes/__tests__/user-payments.test.js @@ -1,6 +1,6 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import Fastify from 'fastify' import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { prisma } from '../../lib/prisma.js' import { registerUserPaymentRoutes } from '../user-payments.js' diff --git a/server/src/routes/__tests__/webhook-yookassa.test.js b/server/src/routes/__tests__/webhook-yookassa.test.js index 35228d6..4227434 100644 --- a/server/src/routes/__tests__/webhook-yookassa.test.js +++ b/server/src/routes/__tests__/webhook-yookassa.test.js @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { NOTIFICATION_EVENTS } from '../../../../shared/constants/notification-events.js' const { mockPrisma } = vi.hoisted(() => ({ diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index a1cad89..c9272a8 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -3,149 +3,141 @@ import { prisma } from '../lib/prisma.js' import { createPayment, buildReceipt, getPayment } 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 + fastify.post('/api/me/orders/:id/pay', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const userEmail = request.user.email - if (!userEmail) { - return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' }) - } + if (!userEmail) { + return reply.code(422).send({ error: 'Для онлайн-оплаты необходим email в профиле' }) + } - const { id } = request.params + const { id } = request.params - const order = await prisma.order.findFirst({ - where: { id, userId }, - include: { items: true }, + 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) 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.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 (!order.deliveryFeeLocked) { + return reply.code(409).send({ + error: 'Стоимость доставки ещё утверждается — оплата станет доступна позже', }) + } - if (existingPayment && existingPayment.confirmationUrl) { - return { confirmationUrl: existingPayment.confirmationUrl } - } + const existingPayment = await prisma.payment.findFirst({ + where: { orderId: id, status: { in: ['pending', 'waiting_for_capture'] } }, + orderBy: { createdAt: 'desc' }, + }) - const idempotencyKey = `${id}-${Date.now()}` - const returnUrl = `${process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'}/me/orders/${id}?paid=1` - const clientIp = request.ip + if (existingPayment && existingPayment.confirmationUrl) { + return { confirmationUrl: existingPayment.confirmationUrl } + } - const amount = { - value: (order.totalCents / 100).toFixed(2), + const idempotencyKey = `${id}-${Date.now()}` + 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, + }, + }) - const receipt = buildReceipt({ - orderItems: order.items, - deliveryFeeCents: order.deliveryFeeCents, - userEmail: userEmail || 'noemail@example.com', - }) + return { confirmationUrl: result.confirmationUrl } + }) - 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, + 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 ykPayment = await getPayment(payment.yookassaPaymentId) + + if (ykPayment.status !== payment.status) { + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: ykPayment.status }, }) - } 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 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') { + const updated = await prisma.order.updateMany({ + where: { id: orderId, status: 'PENDING_PAYMENT' }, + data: { status: 'PAID' }, }) - - if (ykPayment.status === 'succeeded' && order.status === 'PENDING_PAYMENT') { - const updated = await prisma.order.updateMany({ - where: { id: orderId, status: 'PENDING_PAYMENT' }, - data: { status: 'PAID' }, + if (updated.count > 0) { + request.server.eventBus.emit(NOTIFICATION_EVENTS.PAYMENT_STATUS_CHANGED, { + orderId, + userId: order.userId, + paymentStatus: 'paid', }) - if (updated.count > 0) { - request.server.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' } } - }, - ) + + return { status: ykPayment.status, paid: ykPayment.paid } + } catch { + return { status: payment.status, paid: payment.status === 'succeeded' } + } + }) } diff --git a/server/src/routes/webhook-yookassa.js b/server/src/routes/webhook-yookassa.js index 3ef80a0..6362de5 100644 --- a/server/src/routes/webhook-yookassa.js +++ b/server/src/routes/webhook-yookassa.js @@ -1,5 +1,5 @@ -import { prisma } from '../lib/prisma.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) { From ae6f86041a68ffa56215978ce24a5a3a250a04e7 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 19:40:23 +0500 Subject: [PATCH 16/17] fix: trustProxy for webhook IP validation, filter expired payments, remove dead code --- server/src/index.js | 1 + server/src/routes/user-payments.js | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/server/src/index.js b/server/src/index.js index 4ed109d..19cd72e 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -39,6 +39,7 @@ const origin = (process.env.CORS_ORIGIN ?? '') const fastify = Fastify({ logger: true, bodyLimit: getMaxUploadBodyBytes(), + trustProxy: true, }) await fastify.register(cors, { diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index c9272a8..8b8e99b 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -36,7 +36,11 @@ export async function registerUserPaymentRoutes(fastify) { } const existingPayment = await prisma.payment.findFirst({ - where: { orderId: id, status: { in: ['pending', 'waiting_for_capture'] } }, + where: { + orderId: id, + status: { in: ['pending', 'waiting_for_capture'] }, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, orderBy: { createdAt: 'desc' }, }) @@ -56,7 +60,7 @@ export async function registerUserPaymentRoutes(fastify) { const receipt = buildReceipt({ orderItems: order.items, deliveryFeeCents: order.deliveryFeeCents, - userEmail: userEmail || 'noemail@example.com', + userEmail: userEmail, }) let result From 1837b36b143c58372e27496f9c464cc6e9bcdbbb Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 12:02:29 +0500 Subject: [PATCH 17/17] test commit --- .../product-review/ui/ReviewDialog.tsx | 32 ++++++---- .../product-review/ui/ReviewSection.tsx | 14 ++++- .../pages/admin-users/ui/AdminUsersPage.tsx | 16 ++++- .../me/ui/sections/NotificationsPage.tsx | 55 +++++++++++------- .../pages/me/ui/sections/OrderDetailPage.tsx | 17 +++++- .../widgets/reviews-block/ui/ReviewsBlock.tsx | 2 +- server/prisma/prisma/dev.db | Bin 311296 -> 323584 bytes server/src/routes/api/admin-users.js | 14 +++++ server/src/routes/user-cart.js | 4 +- server/src/routes/user-orders.js | 4 +- 10 files changed, 113 insertions(+), 45 deletions(-) diff --git a/client/src/features/product-review/ui/ReviewDialog.tsx b/client/src/features/product-review/ui/ReviewDialog.tsx index 2f9a01c..959ad2c 100644 --- a/client/src/features/product-review/ui/ReviewDialog.tsx +++ b/client/src/features/product-review/ui/ReviewDialog.tsx @@ -20,8 +20,8 @@ type Props = { error: unknown uploadError: unknown onClose: () => void - onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => void - onUploadImage: (file: File) => void + onSubmit: (params: { rating: number; text: string; imageUrl: string | null }) => Promise + onUploadImage: (file: File) => Promise<{ url: string }> } function reviewSubmitErrorMessage(err: unknown): string { @@ -55,11 +55,13 @@ export function ReviewDialog({ const [rating, setRating] = useState(5) const [text, setText] = useState('') const [imageUrl, setImageUrl] = useState(null) + const [localUploadError, setLocalUploadError] = useState(null) const reset = () => { setRating(5) setText('') setImageUrl(null) + setLocalUploadError(null) } const handleClose = () => { @@ -68,9 +70,9 @@ export function ReviewDialog({ onClose() } - const handleSubmit = () => { + const handleSubmit = async () => { if (isPending) return - onSubmit({ rating, text: text.trim(), imageUrl }) + await onSubmit({ rating, text: text.trim(), imageUrl }) } return ( @@ -96,11 +98,19 @@ export function ReviewDialog({ hidden type="file" accept="image/png,image/jpeg,image/webp" - onChange={(e) => { + onChange={async (e) => { const file = e.target.files?.[0] if (!file) return - onUploadImage(file) e.currentTarget.value = '' + setLocalUploadError(null) + try { + const result = await onUploadImage(file) + setImageUrl(result.url) + } catch (err) { + setLocalUploadError( + err instanceof Error ? err.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.', + ) + } }} /> @@ -126,11 +136,13 @@ export function ReviewDialog({ }} /> )} - {uploadError ? ( + {uploadError || localUploadError ? ( - {uploadError instanceof Error - ? uploadError.message - : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'} + {localUploadError + ? localUploadError + : uploadError instanceof Error + ? uploadError.message + : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'} ) : null} {error ? ( diff --git a/client/src/features/product-review/ui/ReviewSection.tsx b/client/src/features/product-review/ui/ReviewSection.tsx index a9191e8..4cc1db4 100644 --- a/client/src/features/product-review/ui/ReviewSection.tsx +++ b/client/src/features/product-review/ui/ReviewSection.tsx @@ -17,7 +17,12 @@ type Props = { isUploadPending: boolean submitError: unknown uploadError: unknown - onSubmitReview: (params: { productId: string; rating: number; text: string; imageUrl: string | null }) => void + onSubmitReview: (params: { + productId: string + rating: number + text: string + imageUrl: string | null + }) => Promise onUploadImage: (file: File) => Promise<{ url: string }> } @@ -75,17 +80,20 @@ export function ReviewSection({ setTarget(null) setUploadedImageUrl(null) }} - onSubmit={(params) => { + onSubmit={async (params) => { if (!target) return - onSubmitReview({ + await onSubmitReview({ productId: target.productId, ...params, imageUrl: uploadedImageUrl, }) + setTarget(null) + setUploadedImageUrl(null) }} onUploadImage={async (file) => { const result = await onUploadImage(file) setUploadedImageUrl(result.url) + return result }} /> diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index 90ca7b9..6a18d72 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -9,6 +9,7 @@ import TableRow from '@mui/material/TableRow' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useUnit } from 'effector-react' import { Controller, useForm } from 'react-hook-form' import { Link as RouterLink } from 'react-router-dom' import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api' @@ -16,6 +17,7 @@ import type { AdminUser } from '@/entities/user/model/types' import { getErrorMessage } from '@/shared/lib/get-error-message' import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state' +import { $user } from '@/shared/model/auth' import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog' import { AdminTable } from '@/shared/ui/AdminTable' import { EntityRowActions } from '@/shared/ui/EntityRowActions' @@ -44,6 +46,8 @@ export function AdminUsersPage() { const [q, setQ] = useState('') const [page, setPage] = useState(0) const [rowsPerPage, setRowsPerPage] = useState(20) + const currentUser = useUnit($user) + const currentUserId = currentUser?.id const userForm = useForm({ defaultValues: emptyUserForm(), @@ -192,7 +196,7 @@ export function AdminUsersPage() { openEdit(u)} - onDelete={() => deleteMut.mutate(u.id)} + onDelete={u.id === currentUserId ? undefined : () => deleteMut.mutate(u.id)} deleteDisabled={deleteMut.isPending} confirmDeleteMessage={`Удалить пользователя ${u.email}?`} /> @@ -237,7 +241,15 @@ export function AdminUsersPage() { } + render={({ field }) => ( + + )} /> ({ + orderCreated: on, + orderStatusChanged: on, + paymentStatusChanged: on, + deliveryFeeAdjusted: on, +}) export function NotificationsPage() { const queryClient = useQueryClient() @@ -45,9 +49,11 @@ export function NotificationsPage() { const handleToggle = (field: string, value: boolean) => { setError(null) - mutation.mutate({ [field]: value } as Record) + mutation.mutate({ [field]: value }) } + const statusChangesOn = isOrderStatusChangesOn(settings) + return ( @@ -80,19 +86,26 @@ export function NotificationsPage() { - {eventFields.map(({ key, label }) => ( - handleToggle(key, e.target.checked)} - /> - } - label={label} - /> - ))} + mutation.mutate(orderStatusChangesPayload(e.target.checked))} + /> + } + label="Изменения статуса заказа" + /> + handleToggle('orderMessageReceived', e.target.checked)} + /> + } + label="Сообщения в чате заказа" + /> diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx index 353c515..ae3d180 100644 --- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -7,7 +7,7 @@ import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Link as RouterLink, useParams, useSearchParams } from 'react-router-dom' +import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { confirmOrderReceived, createOrderPayment, @@ -30,6 +30,7 @@ import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' export function OrderDetailPage() { const { id } = useParams() const qc = useQueryClient() + const navigate = useNavigate() const [searchParams] = useSearchParams() const paidParam = searchParams.get('paid') @@ -58,6 +59,14 @@ export function OrderDetailPage() { }, }) + useEffect(() => { + const data = paymentStatusQuery.data + if (data && (data.paid || data.status === 'canceled') && paidParam === '1') { + qc.invalidateQueries({ queryKey: ['me', 'orders', id] }) + navigate(`/me/orders/${id}`, { replace: true }) + } + }, [paymentStatusQuery.data, paidParam, qc, id, navigate]) + const confirmMut = useMutation({ mutationFn: () => confirmOrderReceived(id!), onSuccess: () => @@ -224,7 +233,7 @@ export function OrderDetailPage() { {PICKUP_ADDRESS_FULL} - Заберите заказ точно ко времени, которое согласуем по телефону или в чате заказа. + Заберите заказ ко времени, которое согласуем в чате заказа. )} @@ -274,7 +283,9 @@ export function OrderDetailPage() { isUploadPending={uploadReviewImageMut.isPending} submitError={reviewMut.error} uploadError={uploadReviewImageMut.error} - onSubmitReview={(params) => reviewMut.mutate(params)} + onSubmitReview={async (params) => { + await reviewMut.mutateAsync(params) + }} onUploadImage={async (file) => { const result = await uploadReviewImageMut.mutateAsync(file) return result diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index 5d204db..d5a463b 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -96,7 +96,7 @@ export function ReviewsBlock() { |t8Yh_ktg%*mSBozvUD3=oHsouUwKq{3Lk`h&Am2%MngQM?z@4m>W zj4fly4Ll|&B%y)E_BZ&OHrN8Mv}e3vY;2je{b8G7Z1-e+^UZi^JZ2l9jomXg7`ILL z*Ym$G_Ke63InSDBmR7s^Gy=2e@2uAN&<%`T@B%d_{MO(l}eqshc=lL@?%SwERrPCdGuSU8V= zD`(FpE-cQUn_YY?@nGt)I~sYa*=mmO$<|D&KR_~%GpviTP!qkHBa;R zIbJGfJwC?^)k-okzp$LTKegBvta)lS&oX?aoa-sCRC9CL$|^@=?md70Y-)C)eOls7 z>b}{Pv&)HqbVL2^QmzHPGU1*+MEBX7L+{iL_eqm{OaTciBJ)7=` zln&PTb~16_`Nh=y{R^T)6SpNh$;s)&V(PvWs-8R!k?(FM!?I6m(~9 zc4=<*OiCPCxggrnIx;z3FHMZcj~$C%nhSMrrNYbN-(;`Wi9=VVay-wn%`R^9YEmh& zN@cTH_8(xC)gC4AUMSX+cO;SpmT#KC^jd5@?wp9$r?Lf~Z_U=Kt8+!4r}iT;pNjx_qU0OFGUWC64KX= z$E6#i_2IDIW|p)ViG*f0zmE-6&b^r%>e)OB!wZMAY3bUr`0S0(B4Jy&>rPFM#qU10 zUv_~xt%&!-lOK+}_E`m=5P}==vH1M0`xRN>o9R|dl&&wwyz%k)X*i4VkW9;=x6!l? z92&@`mD&Crz@Bw z2=~=&PMCasQ@o}_=}t=bpV`xe;rWoXoh=D}ChAB|UpjpKc>Lta=;M!diyfZOUBh^@ z^4{rEqui^RGu2u%l)WA%l%{WT)1IZs&xgn8G{1Ll*Y7>Lo8AR?(8QrrXz#qCS;SRN z8_eNw40uOl3}`7?>+eW!FSHI{)fmtjvHQAU|4FM*l`C}4^-k%9njRnAX^A0)@cSXomXoz+sxH&O}at7nQ>5oxnc&h=)RZ8+AOSG%;mVBcA2-< zUF)7ID^*s;%(^>=D72NU-Q>1qd9`vGD~og$Q+b@6FEy)UrBW`7f+|&Bs-(SQtq``f zwftCFSi~$hIkIlcx~aGt zGnGKLbW_s<#sl4EObIlX`xe&%P4#S>TU@psH}Gv=HFeoEIrjo9`t;mat=m;uHDyC_ zWXG^&EnVjM;sz$Ag`il%DDLL4(h4)ax}JPhDBoLze1WP5mhC%|BHON{8?q1iWK+^K zS5ZCP(Oo?VnCd&sRux0H9OeY39>^wY3QWTc6vxz5S5^Z@w;c^KYqqRtp2i(VWwvP> zJ`bzCp(&p&JDRN+Y3BQBpXah0yu6(bIOZ_^#Tt6w7rD*7nJP0>Q?YeRRW;4g6-8G$`dPPa&k8h4;fBdv=IFX3tDfgO7UXgQ zMPm-v9p8bVj&ECT5Cjbo|GFchV##vIu~)=xP|SxSezqPz7K(Vkpr~kEfnHe>#1N`z zF`4LtOnX){}wp^BPqApu=Wf6a!uBDiRuON>F1M38X@g z89oew>7JolnqjE6<~xpNdQMv~D(plHT*)y_Px2MhHQm7Fn!#jlXs}M% z_BmH{hXuaJp>+;A5n`&E?*W=8^ixI?6xY-&PoOjcW--}S!ajRjOGG(*b?mqz_Da%X z8$|fdFz-@bxh@p(t)hQzn4Al9l>*rYN?%qaD2^gIPujC5ER%FHI-_%tkjLQeXroGaO%ceKgV&o+_+%p(&!G7_zQt=~|&u;vS3y{u_1@ zZpDb+u3{glnq~!f^<38HNwfWDd^~ z_$s#;(*oUuqM_Ga-&Jjec`}#b?>xjEo+wgd85xtwiEcwPO5YeEUn2?-6;aUGZGlA?GpY}a%o*9bJp4ph@~p_)3} zqJesBZtA)WxVmGR@Rxxm>%OPMW2vqm*q-7kFl39tkK=%1I35#?)V-$FOf@BxVKo>V zLfy6z9pNxgVc|tgg8_Z#zE}6cmq04<&Vuoi(rei_znZ_*3wU}uc zn(10FDcMpO*FCr-i$g~PLkS$)lWmMNu5ZKPSg=?Ib=G}X^IYA9euUNjLrXYVzouGE zd>FzjVui@6Rpzbc#kh_ccX-0r>L-qda)zUztjJJ!g<%vlxTMR>lx)wHB}-$>_Ryh0 zpt}x-5yJs$@XvsTk;Y+gap7>m74E5l>0!Xp1Jm_E$ixVUfq@6G2-S8&12?|ZmJ^O% zX~|h)+o5oIUR^CVGJdMAOoj4=rh{>T`L-rY7*Ns4GK1;Znj^uj1*QVc_cU}IJfo~* zNVZ{38rL~6N|%^4Q4uA)ntwV&@(k3W4L8nT41_4ognHNsmDq;Vx)f0z9S;GNb_n>A_M9F}Mmvn`L zug_JoU-GgqMhGiS*A_f=B z726G*u-c#{tBgsMrtdZL>UN2X$wruWu71O0C}L>mvZDl^5jc_xKQ3Wh)FsEpyp0DK zxna<9;Cd$d+gD&sa5>0}(a=^b%hWLkVxH;viVc^8i5|1zQ&olpX6Q_HWbQy&gV4bn zZ)k|9I*M+ns*_%?u>$t?x6$A0HSAqB^z-KWO%tJT4~f2JuC7_Cr%9S+V}Qi`Msiil zlVsU2JoJm9I$Tj?7qV-<##Oi)#}BkXMJiXo-Va>EP&C8gt^%j7sHP8fc1$Ry<#7#D z&IZ56EDgq|z>R5IgCFMOnB_P1G3;LbkLx!c3dO_qu+a(KvM@Bes$?4BB$C+}bW~Zg zT}228Ju^)c%7alxvs`ddHEzmgfO#W)p%r+RVHmJ{4rTIu)$%d7mEn6-h07j18%7{k zbeaCSHaktWAzo!QTPkG>8R0B3;8b`+x?ihbKORaqBc#)yFPNH{QlLZc;G=EH#;_w{ zKo&-BV>GtKAjjbBOjU(ZnBWNuh0GY|ITCye({Gid+cArmO~>Xa!Ei7^)|sU$N;t;o zzuYAqyja6fN~^^}sBijD)o&dOrAiB_97EF-Sym;*^KF=f2AAkES;9oym30rJv+Kc) zINY+JOr~l>)i8U+6r73a76wJvm1i1Uqo%4D zpS1u^Mt9+{?QW z-mf9qTk6vzp=6=^V{ixP3`@e~6+@rm!Tq5sNyc>6G0@A747VT#T4+%K4}+>PJiACT z874C@1~@D)a5Y`W*d$|q35OnN3f5pMrcGj!6asm)OEO*2+mcnpr8RL04QtlDGc(<( zzj8Q~H6{8Q7T~cUP$ZXIn0doGC9F?m33EPA#w=Au|2gPotO!&W8pRdN9l5MxiDj!G z%VGWjre9d-D7ugF+ma3L`*1H>z~DZy%4yh#){zySzOgtE+s@&JK-iks)Tf3*!89Ql zCeW}$tVtCp47%6i5>s?cB0TsM&TSqT=u_rc82MBuFkl{z=Q0DUEX)!xXGDi8SoS&a zJWTO*2h(!^hp&QX_beC)H^XXg?g)k{k1jT>S4D#MU+YuRjy#%+nJESY$%JEopM!bW z4%R7H4+j=zE*2&==s37CU&T^W1tm-?U=~>GLGv&OLw^EH`_ZSaj)sA}g-NhxSsDgq z--3mN)&5gU9;}$rC@hn+RdM;=JU)r{8xH?zWa_g=Ge>^m=&>Vza^yR)KRf#6qrZFf z4O8n!77zbv?ANE>KlQ-W%`rW8IQEvKKOTGHNaV;{r~c;1G)|=#5g-CYfCvx)B0vO) z01+Sp-+uzP4X;Ga0P9e3T_G?y))crrAz@=zmaqknZ5tJ91~0flWIV`4yW=9`L9U*? zs!{c`U%`rtj0d@n^h%NOAXjsaiHrxixbliH;{h&lTrV;ndDB}UP94AG_gKYmzgjFA4BkqvM zc#u7@agp&L+f-v=#slmWjf#v1*|-@I84t2+GAuG4WEW%T)bPs0LADpB;H!pWKN*Sr z9^U9h1c(3;AOb{y2oM1xKm>>Y5g-CYfCzl=2)uS^bmsW+LtegwAJo<=7xB~BP5f$k zRolEMugj~r1))+^wZ{AFqoXt0Eknrb7g?@^UlPe`hS`O!Rm&{oRAu*Pc0ZQ5d};UN zyC2#89sK*$?xzb}s;#jdRw$l|iR=GEu|J5!zJfP;5dk7V1c(3;AOb{y2oM1xKm>>Y z5g-CDUIHhEXQKOV77X1qJTtNH_JCObM`QmO!Tg^Iav%+oHscT2`-Q%WLbS{Z%4Jp}m?9 zcJzGCT5DHns0jF{nzn=x>xc+}(7cGIYFm;b0*xX@vv0UwSmnsGRXNgMB?9F_CY{R& zkLkCoMEFR}NAz^Wv{UeA`UttlbQQ78nPDK9wr1-}Smp4uRXN;Wr4oXz=}y6{thcK~ zv`WNC^bot+Q4!@2LC+<`G&Uqep+ziC1izGBKdf@-S*k1zp~{KgzLZ6k>lGETGXkYu zrLM{zBKjiMxTgUu!i!2aqCZNATq>KMj=;_;5378(8l$N3P*08Wj;PU>^RidU)Y>(2 zM2SW0NLvaN(UpkOi_n4K>X-=8j39uD&h3NL*!cbb*CVmNkA3}Fm76jU0U|&IhyW2F z0z`la5CI}U1c(3;AObID0+YkXqm6h0LsP@IOmrdui1q(i>|A*Nf9%7tr($dPLN6jf z1c(3;AOb{y2oM1xKm>>Y5g-CYV1EMb`|t<-@MCOmYR_*pM)#)n{G4KBZ)(pkABOj) z_WVepb^kv%8&dWDpg^quBiBSCvHuYJi`X-dksmN;8)qD6B%a?L|Z*EOD4ONjH z^&WAR|4y%%A3FJ(;qwz2UX@C0J1nu0M^V*QAtIZ&|36&+C#Qm`;5oN~3LpYRfCvx) zB0vO)01+SpM1TnVfD@>{dHBf8%*xHf*VpGGqocT$A2;I5$_^gZR^Cx|HZ!F_&FR|} zk)ZC_>schYBEhirva6LgaO=A4YPv5WA&?BbORjiRa8tWtF>bl0RjGL%=RWtN56g=y z_pVl}rOI8WPj45C`C@@@-&wAm_Ex#KR;*P|V@;Ung=*#WhI-m77OG|DRcDH2pO@$T z*B_*^=+pBzr=Aq=n}-lV{*%3}6u)Bzz%XzQOns{_F`a`H^RQtGnt)jBxJ&bt-Q0b>1~HNJy&nHPLb@e?|dCW7hKKt zWwYJ7=cVtSr84x|naDfD`|aNl>;H+v*F<9LN56b@Y3lc^uR8P^_DHB^p)aP?P4JxgC=ngk+ zu4;C!n8_9}B|oe9c%Pg2rTURWO+iMRg7`a32!aT1Sac}5TiGt+T3}OeDDJh z?1Ims`<98dWvr=mckuVHC@?vW!p4WSwVa_NbAX5w;bJ- zReV$}-C&C6nVQ=c$NG*C#kj?FrQWYd-CP- zo#k!j*tV)@mK`|U37Bfhfy=pJTTbB00lEjh;JC8q83t#N&(gJkIff5nfnvICBq#s- zDrC6n;g8jijP&#kx4it80~w5UBfGJ>mCu`b+38mmXc8j+$u6)vuF7Of<=o`18dw^0 zxyck)fi^+md{b8pT@x8qM9@PA>d>d42_yAie)3PSRQ~qS-^2U23P^S8a~I zQOY8cT}|=Jfl^-U7Y6|kWf0K<&+%9@HnD1!ZkUfKRlZ|s_vVdK^!AM z1VGgVLfK`8%YD}aHmvkK=W?-!8>~s?bJcrZHHQ^19!j1+o^*#qgwa>2LpGA z+V}qt+`H-A|37FerE~xPpq-7*{r`hD8rt{&e+AM1KKxSdR+60v5CI}U1c(3;AOb{y z2oM1xKm>@uOP)Y$XJO!7Bg1Ik-Qmp@gYLR$t^b38cP)h1|6|ce?5nZQ#s25mzmB~+ z7L7d`yE|5U$upw{5CI}U1c(3;AOb{y2oM1xKm>>Y5$F)OX+()f+dsE&-_H~NTz;ZA zv3-wCn0Tl=vFnbPFme1!iDU7RQ}JleJt5&qqunPd_w-gba;3!KD#fLt&i4s#+n}y6=JmUQN(2!X?{dZgyH5##3LA* z7#^L#{r`vs(2D>Y5g-CYfCvx)B0vO)01+Sp9RlxIj~<;lUOah8 ziHwg=jE>HPziH2C>s&lZAPkSU#*|{!%IyBu?x%M@`Q^92d-r|2AG`ea%kSO&&E02q zKb6@1iQP|Me*5k-m+J|nd=ektA-?W@Z1(cITqO1>wrGK(>9+HszcSTYt-A9rA6T83N&T#pikmI+)v-T_0 zL^ML#K?xs1gX=i+nae+R&$+3SAN)vt^`5ieh`s)+hJywE7tVaG@g9=ou2ld@Ly)Xq zEIQ>3%XIGlk6eqM80+)~eIf!xfCvx)B0vO)01+SpM1Tko0U|&IUP1(J8lIWhcPB-x z|Hoo;k=U1GpN;)|>{86b7kUu^B0vO)01+SpM1Tko0U|&IhyW2F0(%MEJTeoFh6`*z zU(Mmcg4^N(`$jFRSF+``(XlJ1kBnY9eR$-`=|jV>9+{cgzy6^UBQu8@>;FOGwXgqw zA@)15d@R4WarB)C5CI}U1c(3;AOb{y2oM1xKm>>Y5x7bMuNj#c6)W{X-7f8@*eTF= zl$}kcV6UL;o4C>c)g#{>L79cda(>Y7_3?B5Z$C0T+%8GqG1t~mlBRn(r&QYZws)8) z>7mz64&93C#L7QFrX22}kWGpU}oocWDzZUzW*dNCJ@G4tG*@*xVAOb{y z2oM1xKm>>Y5g-CYfCvzQ=as;XBhqMeF*SSUvGjehkz=FLGv^mlSi~nj{cW*la98w( zk=sY37g7sn<`?cyUzmODTxwytxf5_4NfU#TPEAd`CK4O^xyaF5r~dD$lSjUN?NGCjyqM@Mb|?nX@YQ zXOaI55H8Q3OZA^Hx3aj1eol+jrRCXk7nO8&klx3W}tdA@%0P2=&E+oSadvjv}T z?dhL%P2};@?b6a&e{Y`S%^sd#IFov`uZIVgkW8Fk=*yYBE!n6oc}F7It}r=Wzxl=k zNZ5uuK*CoYNWz{Ho?XIr_T=>IZy1X|bbB-s60EG}vQ?gDwQ4c^p5BX8*>m7JvM1pI zUMBXm*N-)9ZSMTS((>Z${K9gd+P4fOSmWEt#C_)%Q}g#P2(OoD+ru>c%zY`C`CMu# zkz9dE!fy(fIW6qyOzLb34t8#KX>RsRN*r0aApAw^$mDc=>ezVv!IROt6>8*#a?!7O z)%iTj@N}v9#ZOm?<*G2A?&RxxEvGwaz?luB>COb3ZdV}8TGWx8wq7wN#M#GQy3bE5 z-QOlI9lm}%e)44W@yEiEr#r3t^LX!=)17ox15T~n9B$ffx9K^C=v{$E^MO>X)t;NRU&WUJ!D%7gkT6J}<=<_t+ zDrGSdm<^Swb@ZBEMQR=H&D_wRRvL7tT~u;<=jeER7M0z)r?NcD=F+8Fxm2uhp}noM zWBXOO_k{jRMGm2?%~F%ohk4|Y>0{BihE)smOY^W4SgiPVv{}{4!u&%kDX2v_4)*42 z)DoI5nibYCefr2)eD1`4X3%O#T4{Znx?~<6kDoXZePSk5|JL!=-y^+h-#UC%D&G*T zcPiG>W_0cYtg<>#AKS17p4#ch{+Ud(^jf8NrqinQIn9B>$xHXFt=nu`e7*+`I1-7z z_U>c*^>nByig?HDIvR~1I~Ki^4trP}6aOZAdsZBJzIwI9DwWM*+3)o1^Fsa#v(HJG zR-rv#7k1W}nNNG;c z&+4mmm(-QDSy;K4%b`M-d28LZ?x_;X=Q7rJ-8n>2tz7LUx91gJtz5=3G+oW+IZn=( zn$@vVDVIe-l`1b)(q6G9ma2*N{A6We2~*0{nf?Oh$!QV)|Jum!M`Axbm7jXuVR`bu zPGlzT9Q(7e|7Gm{(T|UwANlQ(`0)FOk41j}=qsmwBw~bH8ugo`@%SUhqxF^0Oc(h^ zmT#hy8}mvb2&>j0Ny~YQfw__CwN1rn4w45tvth-JtbG$oQPVpYtnv8FO!Qs1x4q{1 zvd_!supNOp?jmRY67q53HwPyCP_Me~o3BS}dsJiKc15Kss=o62{gmQZvLq|`KOxI^ zh5sjR5C5EMc5i1b+^-w0ENI#M;H5_r&VAp+?(uNnR=?l_WTO!Wlcm|3^q|>otEIhj z+px#cb62xq8ah25pM!oIgY~->;uZr^Lo21G{`AfO`;@zL+Lctgkt;d<);k+Aoa&Zg zf6Kakpk)opzo+s>7BS&%+bcF*UR)Ml*(=^F?--9igca>v2q)~~rnRSA8q#i!@IKu+ zrh7?sCk;Ni0jxV8+)%TcJ%VL#ACFr%N9z+Ex#p`p-!8W$ia2=N06F$%?y6cOb=5mv zpS*26em~|)uMg{*!(bAg-PE#_407&Q;q>i4G!~yZzTae~X>Cg5@2&4#IyoLcemweAG#nEe zM;q_SrU=a~>E^+!*@+gjC9%u9cL$*9jjMbM1F#q$ng^OD);o!759_B&yx^m}mcMT+ z?!RQ;H;+{s#2dJn>hxEyBv%|nus1I@idI^CA^py}0kCWMALN@mb@zJb3k@B*8vnen z1WnB!RDH{-KF6Ij>or%}x$5>1X{GzyEtjspwXt)R4b`zbt^0GLS2eqnu4?YVuo+V< zFK?Ijx#H2$(DtOTsRYkS`HF+58i^#UR++aduAy8(kGfmic9?@YvsF#6CWWV8jUG)- zE6K5V>gN4CN?2g%QX73KKHmJ!iCf0wH{Tq6l65*#B(yWXdO#P73|G~E>s9Op@8?~z zRa|r{uu^5U*mN^{I}z(TkI#X2rIT-;(poGBdW4SA4odExu(g~2z38}p2hgd$E%Lw_ zVmlRE=iPhi`+K29Tvc0~pIaWZuYUT~SKdcx4#?O?XneZyQsG3nkMQK_P>Y5g-CDNdk!fH_?dyHz=M!d;R}~mt^Be zXd*xahyW2F0z`la5CI}U1c(3;AOb(|1X}U`xH)L$k4S=FIP*2}`~S!_kx1;rKk&E+ z01+SpM1Tko0U|&IhyW2F0z`la5P|PEfk&caGZUjTGhsCU{7w;%8B|O~EoaL`ebe>T z9R&J&;JrT-CI{QAH3a*2MDm6!yQ}P?g9L6~@%evW`F=N%>L&t3fCvx)B0vO)01+Sp zM1Tko0U|&Io&$lKhG!a20UWe%FnRP_k+Gw%iX45_(Z`Q2#NHRX?dT_;LtS(#5g-CY zfCvx)B0vO)01+SpM1Tmq#0VI}>l3GIrCgEumD8Hz2C}EPl4AR&q`OR$T*vYxEpT*2 z^KkuHH}Bl!Zpj>8kG9V;)Id{I)0TXTGf7uHTXHqqk`&dlUCnoV!}Y?mhI>xaRoT-~ zyrg*=PBS$}vUP4quFGW8)48UqJUnf*=QOUlxFl}aQlN<1l)#p3M^`1s^qFp2wxV-8 zJZ&tNju>qr4^&&Jkm;$*kxk7v4Moxe1#hO$CB{&f$C+VR2GeX^3F{h({ZUt42LiIU zE}Pr7r+JEGIjSw`hRP+!bqq$K}YU52{a_y2z-_6Kwiz)Q?X zNn9d81c(3;AOb{y2oM1xKm>>Y5qP-~I59jE6+iA*w@W)Je%!C`C_9@>*{Eg9zB$}V z)d&6bf2e)^f5+rIIG5R#O2sQzjn%bHwSE8pFC6`G@!!i09JPlC5CI}U1c(3;AOb{y z2oM1xKm=Z#1l}|(JGr;{^r&O%I51vc4LiQl(UXp>?*sR!4ny{zrE(`+42?2jy%&Y z+uX1->$pQ-E~~XQZ*^m}5)`%WvYQ#?ZINL+x4pSjDysXI{ibLa6FrmJ*@oO%aeMz3 zuKwrTywO#5(8`bz8ERX3XJgab-nZ;F&1@ZXD5D@ZCLKm>>Y5g-CYfCvx)B0vO) z01+SpFB1Z-Yxo2AJVwyS$HW?b;I73mQtu8|_=ENxI@kXP?HshO|G!KqQZtAE5g-CY zfCvx)B0vO)01+SpM1TkofdK?s*Z&73(IFy01c(3;AOb{y2oM1xKm>>Y5g-CY;AKLf zb^SjkAN`q^Nt37gelAZ>acz`2TgMOK)JMQn`p5_M=bF-?AKt_e-buz5QJj zIxwbQ`vC!EzLDjdmD6~9fv+&7el7};SVts%bt|(4krZ*eeX**`nyF|)q|5K#{pjvT zc0beoH_`YK{=wTGanF3~Hy~~*djA#09T?=VPh6K7IE$oMS&@jYq)qi0%tZ; zG}YBj@6J8(|6gkALGls-B0vO)01+SpM1Tko0U|&IhyW4zfg;d~9WZcvq;voOpq+}= z@Bd?8`GF!vjU@s^fCvx)B0vO)01+SpM1Tko0V42nBhY^S-=L=hw%7lkf4McA+Cv10 z01+SpM1Tko0U|&IhyW2F0z}|tNWdJKnVAr`GYq^xVBb3m+V}te?9q3M|6Yb>Y5g-CYfCvx)BJd(1@RpIW8F7n$cr*Tvqu>sIIVbM$cQ=aVg2^(tEB@9e z{$2R~dts*S(vFHuy2!+ojas(sn~k&W^(+!xkzm+*+0{xLkw`SWU0v+xp{e$2^9si?js z+JU2IRy_zN=S8f6TDhDrRkwunXa3zA!}kXTgZ>Y5g-CYfCvx)B0vO)z<~)!qcbN)qv0?7 z{d`q~N7xpj2@v-{uVl+>tW+u&5sv^d0B%_hhVBr#MJPaSK1=R1-};UA`u}fZe|}(j zC?gRd0z`la5CI}U1c(3;AOb{y2oM1x@ZunF{Rp-U_UsH?hhr06$FTmN8hvl%@bGIR zqjwK`!>^4!a`Zh%Uo{m>OdtB{p@q@Uw*PzI(C4QfpIV$++|#(nSLep#@p$yzr>o4( z@$+S$m&3Qa<`z@4%c;cj?7e4GiDY;znYe8-fmbr?Clkx5N0$=|=kagl?AgSH#rboy zi;pE9Og(l-BTuEus-&C!1ExkA%mu`&KhqDErZ(S%C{hC)zS8}yXdW~;4zmGMwa%Exu zp_LT6?o8^@L~?KDWa9in`)KmEq$nggZQV5%fAC~95|&?C&p{W`tX3_C-_z%3Yt_|R z&nwmn)wI(6O}Vt0<$o~J?hYe-AY{<2)$u? z%=4fl%f&U`s~lw>KzUaC6TI(>hJ?cQXhIjVbS-RPIzZRY`{eLEPHUQ-otpee#E8#G4~@lVZ``lombNO*5BENP=)!pX#v7wg zWkUUG9&Y|Q-K$m2qx}li<$&A9-Bg)swwl}PR+~woW%Ro8R?2FzTs^}pUO8K;X7_j* zf1l&)HC8}v=&)yXs(FwsXIXCl5#r5nX*s=8IqU7`dYj^A^XQ_La<1FSBuh0nm#wUF z_@;Z$pFf+LU1+0$dNc;203z^aAs%GP6wBNF&a7cqFR+<(n?kdn(;blpT0$pzk2TCS zn=-&67UAC)=jWCOnMD2i^W*VzO0;f-UJ<3RTrsnh6aFZCPgkH8p;@G}{#JkbolSLZ zdglHNy#+Kpb$>45v|AM=r{^9Vi$9|5XHWfSr(6&TS}H zPcG4S(~v1?L8SNqkudy-`A1JFc2kbtSx8S7agO*mSO$@RTBE-L5c|wW zdP`G!zFIhSJ$~v`^zj=*UG7Qj`C|5Ja8KG*DR0=6o+*D*XRk20xJ7^OoVIyVh`rAW z+CI58@$Pih{{FjKuC?9w3pI=V%wu{?I9@p-0`hg(|mZiqPcN=f)|?>GJEx<99>gYx@PRHROYJb(g328drBx zYc7T}2FlSWFgaa+;C16-MpfSqD;1&)oBb+D!Y&Av6Ly=3>z*OI-BdEcBJnWx*=|34r5i9Kt3`bq?d z01+SpM1Tko0U|&IhyW2F0z`layif$Jk(rtOf9j84^#|&prymZPIB)+S{fqMkJ{VA} z|Hoo~6^Z?q*gwYpF80mX|AQ~|A_7E!2oM1xKm>>Y5g-CYfCvx)B0vOQN(4rSqtVeZ z@jfyt-iJrT`_S;n@C4q64^2#=HPP75MDYKe_&D}bY8=T*1c(3;AOb{y2oM1xKm>>Y z5g-CY;AKI8MUT$J>Y5g-CYfCvx)B0vO)01+Sp zFA4(nsp!Pa@tfZo86BNz{V0DM3v^de%h_y(S^CBN);1F{%&=sabw#($Z8@FZ%-oxr zH|MLR&3o^^;I5p@o>|S6&OCUZeQxWGb1M%Q7al#E&01@ZJi4MvkF36FXYtWBJPu&) zJ(>Y5g-CYfCwCjz=`27 z?BC$%ej_O2vGDi&+_eg_tQg|={MCy^r<`G#VWi%@Z|YF@`hWlH|DTWDBK|v&tdxle z5CI}U1c(3;AOb{y2oM1xKm>@u%Z)%Q9{=Elx%geZx?`_r#ZUNml$}k(*2}I|+7K)D z4>#iY4_dN|)w<;3IDU(c4sTE2MY^UMu9vySt&%W7?HJGZ^LQ!1+M z`2T~${0lktOlC)1|Idiu|JSzi&c>#<-MRk%a_cy14-p^&M1Tko0U|&IhyW2F0z`la z5P=^k0tdVP|2IESOsKI$fCvx)B0vO)01+SpM1Tko0U|&IUbY0TA31*W#GcrKBL4sJ z$i0aFfAot-@1Od>RP@N35C6g8QxwPQTva_s))Cjx zR9uajN+4Uhsp$dZfo?OV1e(izi)(?VdbZ6iF58Y9__nW_x@?-9dw~^wdhV;%?W(Mr zvY|M#W7x8mF7teGgQv5FpjcuV?&gXf0w5yetLw>Eh4Q^c$QP)3VA;MSDYETKx*_|J zPc|h@LjXokcXU?|0;c*7vsJ~=Er&UQsRy!&ngY`>1H~~l)s@x2(QQYA%$hALnx}Ec zQJHPphR?%lZ)nPA%Z_F%Mw8NHfqp zMKPIVnzkALA?VYUC<7SdE@T}=nst#qMS%?4SIRkOuHy5gc1|Is(w3)5TP({tQkbo`*Lf;H5iv^6yjw<_53G|}| z0W5=Y&(iEbQ5g@r+WbsYL`zXMM^i!(vlVD$CA-t?-(RRFUeST4%7L#Kih-_m6$uSC zC8#ls1X7{L3?GKTbk9&N%`jA3^BqSsJtwdn?&zLrXtFCifz7z1d9nw=9hj0IsJ>?S zIO5m=YpB(SnvxlcsY9Rpl~7noQ^dE|6W4c%$P_owHAS*~6?UQpuH=}eC;5u$nr`57 z&0sP&G+3u>`(t`&E^}Rm+H!Op@?r4{cFSIT$rmA$Tm>=vLZoo6v=_w!(u%*fNeOI z!2!@hQ>t7oGhpeZH{ z2&(I#F{W*Y?$NxZC9_alLrxdV5UAsTJXS7en-X4Ezu}rt!gE4G!*pE7WxAv&9t_(x z9mzEUO|k>k^jxT>4!3Ba9-EuGE(5OaSSI{spvk)L>F`*p>j$={cnS>JV({ZQpcszF zL?d;tX*E+#31wIf#)eQgGc1=A{xO?}3m5o|f3Ba5g~EA4IEx$1VV*9TzNw+heOF?J zXGo@FLGhW!EX%c+X&IX7S}-ZuQW)1gxFm~1M*~9%9NUv^j5Mxq!{J!4SO#_0eOL2b z-GzRH)&4_EI9R`?T1|X;Ua3H^G^gLYPwd=g(rNie&T2-XE+MViVTHU7)C*Z zOS;TV$@W}XvNXnQ4;>l=y6bQlF&v-<{|smtX&eR@7Y-L(;hq|p9tIpeFkK&nOpJgS z7uI0TrDrGnkI8 zITGAjU@FjjPeaGSGs-%KWE<9`ab5GcWoj5iT+^0eL#71}kshQSR-;lAt}ifV9*F#vj| z=3@-EOiK$47Yfd0&1;M}#!rSa>za(tz~m%dtH5$vU0(+MbdlAnt7+zW#af|S3HyJo zo;V!J8V=yP2Q`BtN(MZH8%ATmZEBdxc&_5ZUR~KT zT}Oe*S+;BH9(3C@EZaaAF}PT+*lys2)dnqDWlW+peXp5Uw@X}1Hp0Af^&2Kb5kosi z6zIS+0!LEe$0dx5y5!iHxA6cYHw;=1T+c**`wFZHE(dur8rq6wnL6e`%rhNdvEgzs z(PK7zs>+bS44tWt%pE9e5IT6{4Gj@hN6`&cb<*oKR;XsH!eg%2vgO9;W87T7X(ANv zA<@^&)iq1?G)c2;43L=LNUmymk}MmBhkh|shbxNgLUzs9xC&R}_<7^DAkmvr!A4MQod77L-i=|5G!bu5%BEu?Y`O;co9l@!mn zVG=9FNCZ<~$6kU%iuCHS}cXbDj2Q5%k zp@|I31>eR%5HfpTTPg^O*;uJu^lKQG+j^JBkX_EQT!US$9v=;*4CjoFW?){$FyB@b zSViFYP(01YYC)F+Oy!vd*Qlu~#%C>nlhIvxY78^J3s0}W?*y)oNseKmzhzCw1WD+F zk9h_rnaB!5Z%A2cN(l=x1iO}$;mFsJ>@D@_kx;VG{V})$bcQ8i@`|BP@!lo-|M}}Jv11+>DfQLcV7@l1unGBN|7y}%Z7r2_PV{DQ!zl1{%GzDug71Jg$NecNr z+9esH&$lJ3tQN~vpI5we|IAEx>aQFQWlf2`h6Q*m2o%ZX7G~bCP6_K1S;CyplQBzG z(SHtl87l(Sg+_4&b4M<#SYp{K$a0u}faw<&I*RUN{I+C+`##)@7BIL^ta2Lmp><@1 zr*A9{LU&zYjVYu4n)=jGD3~S$!vq?3h&8DKg+cdPTw;ojNrVTV!nw@@1AWRI3nQQE z1P08*@mywLm4#UX=8Wi21>Y5g-CYfCvx)BJg|>*cmx8#?iz|h3^da6|%kVW2;G)*_N5Nc1rRF zw~ALTq%2B9VdXr_@zrXzRJrT)>8{F7vkg{d+ursLGYiFVD_p|PDy}DED^9^RhkJy$ zqLrxUq1TNz-j^1mM`o}A*;`_cqeN^H=Nvu1WowyLJ;SRKE&+6vsHkc;y0Exg8}FU_ z|6_j;`!L1-Z{nptM1Tko0U|&IhyW2F0z`la5CI}U1YQ^dt@!@~w?IbPse|@5hTExw zHW)hb|98y(&4Tv&|IcDi#GZIznntA(0U|&IhyW2F0z`la5CI}U1c(3;AOeEGZNn>3 zO~FmEn(pH+Cpo~aVzMmZHaZ#io@lt^1~)RhrX`*Z@DT14!~JTW+n3R_bQ?FonamD` zP7SY2geBs(#&$;Bh8C8HyMJ(h0Pa4J&8eg3B4bDXHge=|V-HQGj{f}A&rJOT^$8Im z0z`la5CI}U1c(3;AOb|-B~Ret=;+M)g!rX7H{C6(jKvV{Mz>dUrSSgh-04~=S7d(W zH16<51R=$h6dS>rM63h}p@ck1L)b4wjB=6PymOPgrRdYCKY!!bA0F;0S3G150nF^x zfYsK`&Ai?&*HCfCy=vMLVw~YNZ^XtyI1}5F6cOnIL6UsK^*ZJLzYmXgmun-YfV?Wo zE#@7g$a%h9uCB@+BFZ4vm8YRxgtw4vL?4h4IYTx*9f5^Z-YNGVA0F!|m!aHjDDQ^I zyQ;R!<%mUs2nDtjC_-Mug24^yx{6p(2n2*%%@v*7opQf>^t+LVi-%f#pR2s30)A`{ zybq7`NUK8HjF7f!xsJc7uePN{s0z(Tq&D0?ui(w}aeF_5v>s=Mfxumwtt%aA|33E1 z-O^gmvDezt4s}awiMttq-(FSLt5vU{Xl-c`R0E+LJcQVCR0JbHY&Hp@mkbHZeS{1_ zj1JlLJJNm=#`j2v?Q_-Lmb70N*Z;?2=OVGMG=BddTf-N65dk7V1c(3;AOb{y2oM1x zKm>>Y5g-El6KMVpUu-VfeVZy{-Kl+BIiuaFeOnx@-~Wf(R(%^4t>6ELseKy`t>6C- z-tw9nzZ97m`&MLpdF)$9{`|Xqx4Y?d3cSB5+{(iILn{MGcJj7l zL}3S%@%8Dk_(QixBcX(q^<1`!P}#L=G5ntHmPqutvgg2k?|jt{kH>GnJ^Ey(%G?~^ zlf36^s@am|*?Z5X`nx!B+hhW-WY$k6mQ#-|Cl=1*-^$sui3^MK=VlilOFWo*?2bmB zCgLz(^AO)aZ0}_RPu_EOGuc~Zg#ynFPHgH{Z?&y*%jIIZaa!{T;&g}|=fOoG&N$DP zs+A;kemQl2YO&LdGpYM#SI#ac4t?Zk0=AC!{cjNJyndrMlLv7nPu`tK}MR_Fa|dc&5zq&wf&XkjHz^npQAT%8dQCV^G%OQyS#9pBz-6Y0hAxO8K*J{&rx3(fe@m0T^8h7LEs zj}3H{dowpYWAmsOSWrlET6$e?zpX@08doa+Z(nOuc>R9T+wB@XV1l;K-s^iykB!HTlhOM1p|#xK zctXd#ctD4E_=T{T?qfF%G@I@mt+N`Y)14xwJLOz*+In;>{@}^|%%}T|w9@_U?t1=_ z@%YJ;(Mu;nL+VcJ{(M)j(R3$WovA?Bfx~>ZverE$jE_@`+ z9<}Y1SS;kS1>PzT6Zm{(speLTRhFB>l=vVs(UwqX@j*|ys`9;BS**mcDCwQpxA`xw z+}mnE^D!Aq1y;i23#xCd6bn625MWO{D5v+>3p}8C@1JFdYWY2I?;`$xH1>I5CI}U1c(3;AOb{y2oM1xKm>>Y5qK5?b>oJ^EA^?-dQ?1U&x}4saT2?C^{Z3iH8;AVTZQq7~HWO@vvY13Z{Ed|Kn<&1X~N2LpH(w&~>Kxb$nHv znafmtgapWpw1PqO{i@zZ*nvG~mc^OtJh$c=8GE>OX5TOFZG?l^b7o1LX_(jpFjn26 z=9&Eg5-bD{nCP@qlw1hUtOUMREv&Xq?7Kn0LS&0WofE4#F(*#U^KuRw1ML(0?nB0vO)01+SpM1Tko0U|&Ih`>vkK>Hs4L3||G$~qv)9nM z|G$~qv!Bqp|G$~qvuDt`{~xJ+8w29{f8?4-B=)akHynD;q4&I$@ssRCfCvx)B0vO) zz)OQb{m95QGt$KI$noJr^$0#6YkVHQekOuTH@M&ts5|z078f+!9c5?Ju=TR5l{U_t zUr3d*-de5HxMwx`u#Eq4@8?^StvgV|duQ)THgY9@B>MFHt4F?z_r#~a9TvM?+7Z`L zbWto*Hfq_jZ{o+0e>Zdcp%W*M4-XGt|D@KGfm{COmbmKVts5e+!1kK2XUi8-3uopR z?oVHsee4|KsK1jaSX~{c8NR+#h;WeI&w$5c) z2_r{aYfLFttqd~cn2WeGcO`d!YxmQ;pTvPO_p+rd?qOZP&pwgx<+s0k_kFt`yZrXc z@7?{)-Dh?`mDv4>-A`YB`|dNB>j|WM5+B|nzV3c(_oMjyWOo@CR*MBu$nA(oZOWSF zIEIDv=3TUBac3|RFJIdI1WtTn_alkjpA|Kso{xp4edLbB?guWv`*QvAdr)@ow~y_9 z>T-Se88J`NvcxLxAyC2#81Wx$W<#&suPw##lvR~T$O_cS)u=G!M&O;J% z|E@?1oA6O&|HSTZw=-ORC*=68@T~m`H5J6wc2L5HK&6f|pSk>FXkzO|;JXU7TuxxF zChs!rI~7^A6xp;*+i-Lp#|s%8x9+qR)j^zbU9n_SQK4C>Z$C2Jcn^DzyH)|cXNW6s z)r&=^oMD+rWaNK>a-X<)k7k)0c4i%#wOv+gYumZ)&7D$F724g>i~HsuPVLd9zy4Q4 z_xw2N(#uqrzCQT@=+a-Wef|o%H1wd*r4xH~DZ@5AC|>xG0Cly<*NgccT{{1!^YBX# zzlq#Z!%1DqEv*k-GjsdIiOBIohla1mkNO*fk-lTDm0+%h?&X|7_sslGF0;=-(4_pt zhhVB0cOH7(WaC|oKBAm8l%t7qN~LXYdxx3p(S54SiSf@CS*yXC?#rrr&?--bRi0{A znZuwWo1z>&zh!HgRb5>F57+-9{{K_4r(PPf29lBp5CI}U1c(3;AOb{y2oM1xKm>@u zOO!zKw*!NByGPKd$HaEfzdo@lpBdn z1c(3;AOb{y2oM1xKm>>Y5g-CYfCvZzHx18Bgg+(m^VJ-72e!q}lJIjky^<}jwSWKr zd4fd*hyW2F0z`la5CI}U1c(3;AOb{y2)w8XIMKtV5j{LJF%h4@YgB|o6qoxm8kYNk zU8z)ZZmw#|N=;EU@#KK{hwk>Y5g-CYfCvx)B0vO)01+Sp zS0>QB{y%s_U=-&L#l9m-I3N3N>^q15*Tc@_+b2FZ5j*tA_`AnGGy4CH-ZFA-czft= z(YHmuZ%+oSFI|6RCUPo%W8@t-9}2fOIO3+PI*4_liMT0S+oiQ^pjySU&&zjtWzMSH z=XsXRm3YC=7BX*5hDr1706T-Ct{@Oht-{NlOoQUMAXBwiWw|*7-Kiw+QZ&nyWrXgj z@_c2kSSuiMjB>}`7(}gDE+Pt1>g$ug+IX-3$aQFyb^@(pU9D2kDpj;f*4M1{3g=5lIUt(*vAuIQ6vx3jJ?kv}ykM{pk*#2Lt?_4X| zFM74B{h2MaUlHxE?kI*{HOd3p|AO`H3*Y#k3mgA)^@pxO;v1$xz+W- za=$RWa0Q{iC4~Owjeqr}Z`I91kb6e(KG)*CS~h$PrCZ|Xo<*fx%&-z`=uT@oav$fj z{>54alcg8T8u}+oLh1i<{WrvW$7x{XP_oEKw@1AL8 z#MJ9fL?ndY5a%Jyu}xEx@jY9a6|=wOUDa}pha6w|CLz&He_P)fg*cj+PPFGz8MbLd zoG===@7g-IHk<~><_x)aF4-7@!wj7f=(!A2FZ5jM3)$Py_%D>a`sRpeyqI@%HGTu} z)s*llXq)Bo2HTQ12Q>bLQJ)vS<=2XqR|{XrURz%pM&nP3o^P*>&}$_$J}(qeF4tUb zb=~M`{P)n@uWD`dh5x_3YY&dAyzW}-<$c_Hk!4vn!qQ3@+rl>9_wF?iRY)td#*$?{ zBA^h>uJ*mFeLu7d4BP@^TEftoCLL1Zal%Y(oYs#(GLVvzPD4skW;)`JKspl$Zil*d zCbSuwW|Gb{J?HMdd+(>cYb0dZ?)2{1tMB{HckeyFbH3L--?!&PIw~rK1UJ48fuUCA zz)-6(1ZFPfEe=Q4L;zbGebx>KD@4VsEBh9z%b)>N_y4^AzY|Q18D;h|ZS*Djr}S&|*XT#+hv@;j zmu{p!rrxGbQqNNJ)FH}DwHrP)yl?oP;YGt^hFcA@hCxHK{=EJ<{ZI6NrBCV)>#h0@ z-EVaNtb0RuRQI^fs~ghYrfbz+(7vNRrF|YAsD4xls1i^mph`fMfGPo10{>qmAo_W- zMND>Fqg%k%A4z8BlJZ(%1kqFx43P`m$5_YFER&YwxPzFu0aGWy}3C4=Wxx#$d zQKg3RCNx-Nkeapl*=Q+fwoBBo$qv*QQsbGtBNtCNZA+<9q{dj-Sc#QOR*?#4NB!^% zQgawXCM5mQq(8jCTI5tPyqQBtHJ4d7Qd&rxt5i!Ofm9Z$ev{8@vMpFlaw>W*cOf+$ zNe2>H*Zf{N)d^H5Qhio$BI9r+?vSW~JQ$YDR-~5AiMTJB4)2ju@s+`XR9C8y3C0)f zyXDjnP|Zm7N7-Q1n`C!Ysb)5hl3YM)%Ccbd+AZwuaw=SFf@cC(wrDJ7pAYuSsTgg$ zfYfp>;fy3>shuJfVp?PXuPKVuoHH_SN+irXWYje3mzQM5oGG#aUwpfant?qcQ-r^> zH)~CVS@Uf&Y8rM>OuUYYu}C_V%?Gy0sd(ogUTZEc#PhC5@m7I~-o;9|0TII}7cQ5T zb%nBpJ~`EbcSdT?no6ZY4s)-Zig=?)g)jz%xj9GPD5t_cnJI$Q5}WgTU6w$PoQjA- zNHyEyv4Y(d?XFVi5>brK?m%iR8kx(6v#zZo)ltsQqqQiB<~gg&SA-Dj`CH^vXzk_% zQj0OCwUCI+cS+QAVjigTNcFpHv3NG-xLHmG?VICB1?5@id|`G=m0Bvfuw6xv8h|U4 zg+!ch?RYI~ON3avHMB`iMY|1eC@m#z5c(ry>5!$pNx=Bv0+2&!YRHY3QT28Im zf1o6)f;rzHr`8zA^;N2p$yq0-));oRRjQKtbiJHfW20=9s0xNaDgK|5T~3Ps2ez9U z!<7{OPsy?)`u__!glh@&i0J<>;7~F%Naz2V6U>PQ8m3`A{KsMlhNFH|38)fKC7?<` zm4GS%RRXF6R0*gOP$i&BK$Ui_S%OqA)lhKSThsS;2nph`fMfGPo10;&X538)fKC7?<`m4GUN8%F}3 zM*RS|)swvHSxTug9L_K~@Bq!iLT-_jT8_vjzeXX$Uy&(V+3QTjo8KkcNq(HrR|>etlk z)Q8kN)XUUYsi!HH+CueH6O@nA8s0Pfo#EdMMZ;M`$nXurpBik2VZ%24Cx(p%3}~o+ zR0*gOP$i&BK$UGQnGQW8T znb&TkG71QKitRh)6?=-C`$55S=`5|%-Q2icehK7ng@3Xl_vM)#ePmwOJEQ9*i`tDd z?MAYQ?wO%`$Rb_$jILW=bSeFXt@8Xu&o^%&^GCZpN4tokfmd%P^JlgUoY^8Tx^U*F zo5}oQ=b6P$dC~b9(CpgX>SEPAO;_fng@2pm@3 zMCK1L@dJ#!2plHSWd0cS!ZC^{I-)a>`4;_=7QMU({L1La{3Y$VOImpmctTlE=1;e# zPqz|9n;vYD=fUqqGnsE|inKM6MIDWijz+SmZC#{o9SuW(wlV~`fA;LZ=;&PyE^2op z^C@$Id6)SBya0TUImLXF`6~0r%wtTN2`~?UM}R@b&h#@|nKq^wtO38G&w+P|QYq1`u0Wma&Ryw0u<4BWL$W=~KJKG@5Cu7o+t>B%skkOryaaXOrr@xN-uVbX|y{?={+6QG#DGC^r?0! zeSCwIeyL4NqfJ3dA7I2Z8WN=RF{+vd>w%PR(Tiy`8A$0%S}~1w0x5mEwVDRwfS5+Z zfRt`)lF}WGQo3!O4jQr+{4B!zKczj=a8tv#8sIIxPv5Hh@%oR~4>iYik7(a-(rZsQ zen<1?%)1&2DqQ!EcfPgL+NI$%5%feqUU3{eE4VA2jrhf){X-M(!6~=VGdAqL-`J;6 zvCnvDtV-zHSq%-)&$Yx`JNLsgoWwIq$z;r%&3c7fdHl1nKuO53!qcY5Jcp-i9;$fd z(#O|S>g#{1E85!G)unlc%6Wb9fbcBgk6w7>)Zje_-4YJtPMr}xec;mAIOV>7$~bli z{!bq~XdIvLj1EpbV7$-$!0t*NL264X3dCA|$omP^DlVIqZ{eNCNC=e>38086_kQ<8 zl^@~yxqK!QNX(U%+`%~P9vPfIIAz>1F@4Vtv2HREjAZ7)-F-4Korza25om#hR3sC~ z4(9rd!=U#m&!}6xsuBl6PN_0ceaz7G#DsfnY8J1XoEjV*7cS1H{1`++@#0FAp+jSn zQxk&#ud0Ms8tymO=Zk zxFGKTqc4S8J3BfwPxtVR4vF~RNj5kBnw7M*%H0*4kUx@5#l0my8UbHHkUi)+o2w~} zdld>p5C8+^hb>;X+FS7Eykt=-oJ<6WN-5~j=nwc_Z#GO21lP!ldS6we(e*z)+$Lr#dY)Apkc1}CPmD)G|6ajx#P!Uzc24ioaQE>8lAMo& z$&koERT4N=FR61tXx+k;y@aZGhspr9q$Z4I0;b)@zUl+}`new8l{l=-WV6)^6gc!; znM0*J)Z?&Hw~yqIc@-R8A7V8eOUQ68r8d0z?Z^#8HRoE(^CKor#b!9iJMK0Y^Qc?eX zftJo$wjP)|PcbW(AG~|ErStHv<=hP0Bykrg7

*ms&e_?a~|x)eOx*mOm4+?)rl< zP(Sx5ER3Xq0_@LEl~TfdRULH&@*KpLNd$x?&{Cq%wiE~TKn8-k5K|qDhw-Gh&K%&) zK}4$*gt)3%zg1^JlgQGwTW2lNgxKg-JGxD}g{88>YU}XBEuDdZdM%5O#~|lwGLXZd zOxam;UDEJVcRtkGIWVAkrkQVTbt~2-43RCcZoL(?MSh{SqDfzjBJkN41-}Q&u0VvA1mTN>_3(1m9Aa%@F|fQD87`XOLuxIrI=bN>f+!rr`BWU&YS1g|vL**v zolK;+l4@tIcA(Gm?xDj2B;EDL%{{S}&cUvF%~g1XoHYvxTIT=nN8tZY{iqU9C7?<` zm4GS%RRXF6R0*gOP$i&BK$U$5FCX#UUU-loyU(z-`Ak2ajVUIO4I+qv=fnrM&( zSGXqF1-}s`hD+y7T8eQ^l{_UvphRK&U|%1cbE!F!!e&C=5L8-aG|2P1Q*id;{HFW4 z@&^VQS91-e~H)p-<;RI#2vl~!1rU=)-7WEV5JZ% z8QxeaHfYgyT4~c6*gH;y`u5qOO$HWnpt8GCUr5I8+MD_UXeyOS`tx(SsYou4L4?2P zi)4aA6X*BV_w_CQ#p3JlKC$@v;v0)^7$NiK;`bNNLcyDhuRz7hlfgnO^oE4Vj-i7x zsG3-Qz8nrlQ+)JnIQj&z&L#hW8)Fa?DpZcCCNK<%Nv6vi2G9WnlzIMK`~Y{57CULP zs3)axHkD3V*?%5*+K$3;mNn}Q zwTmS_ZhiXO!21W;J2`7BGL8b5GhP#0t1jbfBlLP)pGK~W-~AwcnlrQj<76D~utM0c zs}aYWbvYbX6O1EE<9NG2*z_QBbjz=}ax<_)t|gV?j8)`FtfkgW^=rMHg5%Br{^zMV zuGoa&iG`$67&Qg(Ytwwz9yuRSK9|6N@{^4Keg`fjd&I7<6eW%Tzh*kG?KseC;Ur0R z2u}p}?KAK06GtEZjLW`imLG_J9_gs46cXI{Is}GVl>@`zctn6Z0@&Kg-UMn{nqja_1VTgIb!+e7#%~k* zr0^nyT-QczC178nPp%HLy!Eae63s)}3qDY|(QRUfl%cAPHcOzY*CDSbB>3$MYN@+f z+xnG;EdvAm7$b+2F>#}}Dq+QjOT^B-;js!W5}GDg?!&%_6NOipC=>(!5|BRI+ywVn zD1cbzFg$O-UyAu{k<60rSl5)c))OVT%7Jn2@ z$u}}`e674Z`>o3}I&RX4jaj}#K=3u;nhEe%7>-tlTs|C`t;-o=#T5g;M7TWDUQsC| zxbYsbmx@aS1XdGingF&o`dl?C7-vCUZ(W|j7`)=3Bu8a7apT=eaKT_qhR*)K0JqXQ zKg8B)*b1nK6_HaZFYc(sah~T%Spnyy1EO+o0V22+js}?e)7~>ToFW+#n z8ms)u4Sw+RSb%fU^A_jnLO3|^$>G!lMOT1Rx1%_&HI}1@<=|gm^c>|pHw)U4jAUhn zDDDbyt~8)sHAKs>lU*Em72ynC;zqUzsv)q{#GF^avi7Q37LYRtWcW812hMQz&47h8 z_?1d7d|8X&hCv_}&nC-3R?%>_W}2yMJA+??1M3&Q^Fz+kiF`x%kPLpncMwhU$hRwP zv9kGML6PsZ(ZUs&UPPu}d;7wfMb5Da_$Cd0g+v^;6kNzRYfFS!yERm4JK_X75(o_X zqxmfSuyxI3PE3S0oZivk2S2mN@B9CWvvwfkDDbMm4;TlZ`er9GUP{_>&QQium+`d` zdOfc9AlDE5{`|}yZgL|qPAsBe)Ci*oe;YEMu;sBI>;E6$G}y zPHATvHZ*+N#kA04)MK#!|6Bb=-M!k>`o~*Ow7lQa+H7mOyYZQI-`AXP_;h7&|2JxF z4t^b%bb%`QeM-?PVT+jTDNv;aV=g2!b4h!^Ze7_v<-5-=zOs0B@dxt1#!80&17B;h zGaIyp7b=_PJnuQ1$?7m$ajlF#U-4TT31yowziUvG63_manKck-=_YB=PJh57Jx0rlDQCwkyTATKkJ;4F5)BA+|9{2f(;d3qaye zqqGU*gVkpD$6-VaV?djvKbrK17g%ycBxg6p!y8$D5dSTE;G0i*V=lXiw8@euQG!-$ z(T1GyhPZOtREj#wIeusW&rG1A-Xjcmz>IkCtw)Y03|H}XXLFflBc+8jiAScR8v+c* z4+1c~Pdd116ZSN;;E3b5)sjdc9v1cEH~G9K+k&uaTJB5f>TIy97{b9*7C&SIWB$~i@4H}bRdy+35zNMmrPlFM-xxYTt`qCq{*23Gw@5U{|HSuHMpE6ZsHCl zz)n!469N0I-bBXXN~~ngf~|!#&g4G4f!$@x_^heI(jikZKGo_bVsKw30=99UJwgMm z&c#+sAfE(WR%EMePQ-o5w9;Hmjv{NM%VkW*#*woKXY^riWVfIr0t>#&S`dpXRmcS6 z3-;@xqh$es1EvVXIC6@%iw%*$5;AckyOcnN0Hhf~`lDP@mYF36K2$djD?cEBPx zJ_N01Hjj&^1;moFEZDqu%ZiN;s~bw{uAjhLAF$q2dYQBIBi}OP1J5_;zyuuz1_N6( z7PHR>SGMR|)o^eHrZ-oZh8PAF19c7GUK3~8sRRf&8VU%goJ%+($yn;f00jF8s0Hwa z$Q5^BXTa%IT*%^nElfkEDDs_iM&?b4gt^igmV0_;bo=5UlUq*%rCZz2}2_&v(Ax?{~i68U0W; zI$AbXc@TRLLMzOZ-~khF{Fu4Yc-Z}dbdcT(*3UyKI5!`|*56VyWz3&;qwh$Vfe@k|CA1VXnEP0=l= zzD{jboE!I7at>U@t=#?7>FM98k9~{`$_TF(PW>4E{(f+EvvT9FS$%Ymdfym99`{ ztS%B*PGjMpJ%2%+1*qei)J9@-!ebfCQqkEkxCLUAbT|Rhs`ISk8A=Rrcq*c5!YtpQ zaWz92fqtu_0SMH`!ghyspg!hnsH=_N%aG>NP0IE*e%NHS-SLH0J+U4VYgwW#9I^HI z+k2NKK1B}~E@IMf8fU5|37>7GrEm$>G)#98-GSD8qS{U?cSM4AH6pr78-c1q9k)n{ zQ*oa;Uke6xu^I+C1DuDl&ht|V9CnNF@J*OCkHy`Fp-_J;f*QQ3a4 z3%%>c00bax>;d)l1v@~t+#eW@M(iD3LvmeR4k%|0R^-8>Y-EZEeU0d62rv?oYmL?y zR!@#`PEi+B2q)il@e&kb@((dn@6hE1j<;xh8bbtMfdgHzxaWB0K!XI$-b8d0PW2US zu;OB;D6=3SQ51!|xNgv_Mu&5IasEGknbE8}#@3V)_9Tx(UFd=G>2LG|hpZ zELGxdRKhKy(rD?aVx#Ge@I`PNLZ+MIJHA+)=N1!}Et8zK_?X`{2%YRY_eDb5{8L^i V!q?&>&-iBCxIBYX*{ipo$#4F-Yc>D? diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js index f6b3f6d..cc26341 100644 --- a/server/src/routes/api/admin-users.js +++ b/server/src/routes/api/admin-users.js @@ -92,6 +92,7 @@ export async function registerAdminUserRoutes(fastify) { fastify.patch('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { const { id } = request.params const body = request.body ?? {} + const adminUserId = request.user.sub const existing = await prisma.user.findUnique({ where: { id } }) if (!existing) { @@ -99,9 +100,15 @@ export async function registerAdminUserRoutes(fastify) { return } + const isSelf = id === adminUserId + const data = {} if (body.email !== undefined) { + if (isSelf) { + reply.code(403).send({ error: 'Нельзя изменить свою почту через панель администратора' }) + return + } const email = normalizeEmail(body.email) if (!email || !email.includes('@')) { reply.code(400).send({ error: 'Некорректная почта' }) @@ -139,6 +146,13 @@ export async function registerAdminUserRoutes(fastify) { fastify.delete('/api/admin/users/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { const { id } = request.params + const adminUserId = request.user.sub + + if (id === adminUserId) { + reply.code(403).send({ error: 'Нельзя удалить свою учётную запись' }) + return + } + try { await prisma.user.delete({ where: { id } }) reply.code(204).send() diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js index b432d1c..136453b 100644 --- a/server/src/routes/user-cart.js +++ b/server/src/routes/user-cart.js @@ -29,7 +29,7 @@ export async function registerUserCartRoutes(fastify) { const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) if (!product) return reply.code(404).send({ error: 'Товар не найден' }) - const available = product.inStock ? product.quantity : 1 + const available = product.quantity const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) const nextQty = (existing?.qty ?? 0) + Math.floor(qty) if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) @@ -57,7 +57,7 @@ export async function registerUserCartRoutes(fastify) { return reply.code(204).send() } - const available = existing.product.inStock ? existing.product.quantity : 1 + const available = existing.product.quantity const nextQty = Math.floor(qty) if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 0e20eb1..8ced32d 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -65,7 +65,7 @@ export async function registerUserOrderRoutes(fastify) { if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' }) for (const ci of cartItems) { - const available = ci.product.inStock ? ci.product.quantity : 1 + const available = ci.product.quantity if (ci.qty > available) { return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`, @@ -112,8 +112,6 @@ export async function registerUserOrderRoutes(fastify) { try { created = await prisma.$transaction(async (tx) => { for (const ci of cartItems) { - if (!ci.product.inStock) continue - const res = await tx.product.updateMany({ where: { id: ci.productId, quantity: { gte: ci.qty } }, data: { quantity: { decrement: ci.qty } },