183 lines
4.9 KiB
JavaScript
183 lines
4.9 KiB
JavaScript
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
|
|
}
|