docs: add yookassa payment integration design spec
This commit is contained in:
@@ -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
|
||||||
Reference in New Issue
Block a user