feat: add yookassa API client library with tests
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user