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 if (!shopId || !secretKey) { throw new Error('YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY are required') } 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')) throw err lastError = new Error(`YooKassa API error: network failure — ${err instanceof Error ? err.message : String(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 fetchWithRetry(`${YOOKASSA_API_URL}/payments/${paymentId}`, { headers: { Authorization: getAuthHeader() }, }) 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, } } 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)) } function isTestMode() { return process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false } export function validateWebhook(ip, body) { if (!isTestMode() && !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 }