fix: add retry to getPayment, normalize return, env validation, webhook/builder tests

This commit is contained in:
Kirill
2026-05-20 18:11:14 +05:00
parent a3556367c6
commit abadbbd4c4
2 changed files with 122 additions and 10 deletions
+105 -2
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPayment, getPayment } from '../yookassa.js'
import { createPayment, getPayment, buildReceipt, validateWebhook } from '../yookassa.js'
describe('yookassa createPayment', () => {
beforeEach(() => {
@@ -157,8 +157,111 @@ describe('yookassa getPayment', () => {
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.paymentId).toBe('payment-id')
expect(result.status).toBe('succeeded')
expect(result.paid).toBe(true)
})
})
describe('yookassa buildReceipt', () => {
it('builds receipt with order items', () => {
const result = buildReceipt({
orderItems: [
{ titleSnapshot: 'Test Product', qty: 2, priceCentsSnapshot: 100000 },
],
deliveryFeeCents: 0,
userEmail: 'user@test.ru',
})
expect(result.customer.email).toBe('user@test.ru')
expect(result.items).toHaveLength(1)
expect(result.items[0].description).toBe('Test Product')
expect(result.items[0].quantity).toBe(2)
expect(result.items[0].amount.value).toBe('1000.00')
expect(result.items[0].vat_code).toBe(1)
expect(result.items[0].measure).toBe('piece')
expect(result.items[0].payment_subject).toBe('commodity')
expect(result.items[0].payment_mode).toBe('full_prepayment')
expect(result.tax_system_code).toBe(1)
})
it('adds delivery item when deliveryFeeCents > 0', () => {
const result = buildReceipt({
orderItems: [
{ titleSnapshot: 'Item A', qty: 1, priceCentsSnapshot: 50000 },
],
deliveryFeeCents: 35000,
userEmail: 'user@test.ru',
})
expect(result.items).toHaveLength(2)
expect(result.items[1].description).toBe('Доставка')
expect(result.items[1].amount.value).toBe('350.00')
expect(result.items[1].payment_subject).toBe('service')
})
it('passes through taxSystemCode', () => {
const result = buildReceipt({
orderItems: [
{ titleSnapshot: 'Item', qty: 1, priceCentsSnapshot: 1000 },
],
deliveryFeeCents: 0,
userEmail: 'user@test.ru',
taxSystemCode: 3,
})
expect(result.tax_system_code).toBe(3)
})
})
describe('yookassa validateWebhook', () => {
beforeEach(() => {
process.env.YOOKASSA_SECRET_KEY = 'test_secret'
})
afterEach(() => {
delete process.env.YOOKASSA_SECRET_KEY
})
it('returns event and paymentObject for valid notification', () => {
const body = {
type: 'notification',
event: 'payment.succeeded',
object: { id: 'yk-id', status: 'succeeded', paid: true },
}
const result = validateWebhook('127.0.0.1', body)
expect(result.event).toBe('payment.succeeded')
expect(result.paymentObject.id).toBe('yk-id')
})
it('throws if type is not notification', () => {
expect(() => validateWebhook('127.0.0.1', { type: 'other', event: 'x', object: {} })).toThrow(
'Expected notification type',
)
})
it('throws if missing event', () => {
expect(() => validateWebhook('127.0.0.1', { type: 'notification', object: {} })).toThrow(
'Missing event or object',
)
})
it('throws if missing object', () => {
expect(() => validateWebhook('127.0.0.1', { type: 'notification', event: 'x' })).toThrow(
'Missing event or object',
)
})
it('throws for invalid body type', () => {
expect(() => validateWebhook('127.0.0.1', 'not an object')).toThrow('Invalid webhook body')
})
it('throws for null body', () => {
expect(() => validateWebhook('127.0.0.1', null)).toThrow('Invalid webhook body')
})
it('skips IP validation in test mode (test_ key)', () => {
const body = { type: 'notification', event: 'payment.succeeded', object: {} }
expect(() => validateWebhook('1.2.3.4', body)).not.toThrow()
})
})
+17 -8
View File
@@ -3,6 +3,9 @@ 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}`
}
@@ -31,7 +34,7 @@ async function fetchWithRetry(url, opts, maxRetries = 3) {
)
} catch (err) {
if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw err
lastError = err
lastError = new Error(`YooKassa API error: network failure — ${err instanceof Error ? err.message : String(err)}`)
if (attempt === maxRetries) throw lastError
}
}
@@ -86,14 +89,18 @@ export async function createPayment({
}
export async function getPayment(paymentId) {
const res = await fetch(`${YOOKASSA_API_URL}/payments/${paymentId}`, {
const res = await fetchWithRetry(`${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'}`)
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,
}
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']
@@ -116,10 +123,12 @@ function isYookassaIp(ip) {
return YOOKASSA_IP_RANGES_V4.some((cidr) => cidrMatch(v4, cidr))
}
const TEST_MODE = process.env.YOOKASSA_SECRET_KEY?.startsWith('test_') ?? false
function isTestMode() {
return (process.env.YOOKASSA_SECRET_KEY?.startsWith('test_')) ?? false
}
export function validateWebhook(ip, body) {
if (!TEST_MODE && !isYookassaIp(ip)) {
if (!isTestMode() && !isYookassaIp(ip)) {
throw new Error('Invalid webhook source IP')
}
if (!body || typeof body !== 'object') {