11 KiB
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
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:
YOOKASSA_SHOP_ID=123456
YOOKASSA_SECRET_KEY=test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
For production (SERVER_PUBLIC_URL will be used for webhook URL construction):
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
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(fromtitleSnapshot, 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
- Each item:
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
secretKeystarts withtest_) - Parse body: validate
type === "notification", extracteventandobject - 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:
- Find order by
id, verify it belongs torequest.user.id - Validate:
status === PENDING_PAYMENTANDpaymentMethod === 'online'ANDdeliveryFeeLocked === true - 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
- If exists and not expired: return existing
- Generate
idempotencyKey:${orderId}-v1(same key means same payment; if status check fails, append timestamp) - Build
returnUrl:${CLIENT_PUBLIC_URL}/me/orders/${orderId}?paid=1 - Call
yookassa.createPayment(...) - Save
Paymentto DB - Return
{ confirmationUrl } - Emit event:
PAYMENT_CREATED
Error responses:
400: order not in payable state409: 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:
- Find latest
Paymentfor the order - If status is terminal (
succeeded,canceled): return cached status - Otherwise: call
yookassa.getPayment(yookassaPaymentId) - If status changed: update local
Payment+ transitionOrderif needed - Return
{ status, paid }
4c. POST /api/webhooks/yookassa — Receive YooKassa notifications
Auth: None (public endpoint, validated by IP + request signature)
Flow:
- Parse body and headers
- Call
yookassa.validateWebhook(body, headers)— validates IP on production - Find
PaymentbyyookassaPaymentId = object.id - Handle event:
payment.succeeded:- Update
Payment.status = 'succeeded' - Transition
OrderfromPENDING_PAYMENTtoPAID - Emit
PAYMENT_STATUS_CHANGEDevent → notification system
- Update
payment.canceled:- Update
Payment.status = 'canceled' - Order stays
PENDING_PAYMENT(user can retry)
- Update
- 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), getconfirmationUrl, 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:
- Call
getOrderPaymentStatus(orderId) - Show result toast/alert:
paid === true: "Оплата прошла успешно"paid === false+ payment pending: "Ожидаем подтверждения оплаты"canceled: "Оплата отменена, вы можете попробовать снова"
5d. API client additions (shared/api or entities/order)
// 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.succeededtriggers order →PAIDtransition - Fallback: user returning via
return_urltriggersGET /api/me/orders/:orderId/paymentwhich 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 moduleserver/prisma/migrations/*_add_payment.sql— migration for Payment model
Modified
server/prisma/schema.prisma— addPaymentmodelserver/src/routes/user-payments.js— rewrite to use YooKassaserver/src/index.js— register webhook routeserver/.env.example— add YooKassa env varsclient/src/features/order-payment/OrderPaymentSection.tsx— redirect to YooKassa instead of manual dialogclient/src/pages/order/OrderDetailPage.tsx— handle?paid=1return URLclient/src/shared/api/orentities/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 forcreatePayment,getPayment,validateWebhookwith mockedfetchuser-payments.test.js— integration tests forPOST /api/me/orders/:id/paywith 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.tsxif it exists
9. Migration & Rollout
- Add env vars to
.env - Run Prisma migration:
prisma migrate dev --name add_payment - Deploy server changes first (new routes + webhook)
- Deploy client changes (redirect behavior)
- Configure webhook in YooKassa dashboard:
{SERVER_PUBLIC_URL}/api/webhooks/yookassa - Test with YooKassa test credentials
- Switch to live credentials after successful testing