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

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_PAYMENTPAID) 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 (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)

// 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