From 3879e4b388c089d3187aa9d95fc9e3dd63ca2c59 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 17:59:35 +0500 Subject: [PATCH] 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 +}