From fc7bc43c9f67256e2f8d627384325740b9f17996 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:33:08 +0500 Subject: [PATCH] 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