fix: add retry to getPayment, normalize return, env validation, webhook/builder tests
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
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', () => {
|
describe('yookassa createPayment', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -157,8 +157,111 @@ describe('yookassa getPayment', () => {
|
|||||||
const result = await getPayment('payment-id')
|
const result = await getPayment('payment-id')
|
||||||
expect(fetch).toHaveBeenCalledTimes(1)
|
expect(fetch).toHaveBeenCalledTimes(1)
|
||||||
expect(fetch.mock.calls[0][0]).toBe('https://api.yookassa.ru/v3/payments/payment-id')
|
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.status).toBe('succeeded')
|
||||||
expect(result.paid).toBe(true)
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ const YOOKASSA_API_URL = 'https://api.yookassa.ru/v3'
|
|||||||
function getAuthHeader() {
|
function getAuthHeader() {
|
||||||
const shopId = process.env.YOOKASSA_SHOP_ID
|
const shopId = process.env.YOOKASSA_SHOP_ID
|
||||||
const secretKey = process.env.YOOKASSA_SECRET_KEY
|
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')
|
const token = Buffer.from(`${shopId}:${secretKey}`).toString('base64')
|
||||||
return `Basic ${token}`
|
return `Basic ${token}`
|
||||||
}
|
}
|
||||||
@@ -31,7 +34,7 @@ async function fetchWithRetry(url, opts, maxRetries = 3) {
|
|||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.message.startsWith('YooKassa API error')) throw 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
|
if (attempt === maxRetries) throw lastError
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,14 +89,18 @@ export async function createPayment({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPayment(paymentId) {
|
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() },
|
headers: { Authorization: getAuthHeader() },
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
const data = await res.json()
|
||||||
const body = await res.json().catch(() => ({}))
|
return {
|
||||||
throw new Error(`YooKassa getPayment error: ${res.status} — ${body.description || 'unknown'}`)
|
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']
|
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))
|
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) {
|
export function validateWebhook(ip, body) {
|
||||||
if (!TEST_MODE && !isYookassaIp(ip)) {
|
if (!isTestMode() && !isYookassaIp(ip)) {
|
||||||
throw new Error('Invalid webhook source IP')
|
throw new Error('Invalid webhook source IP')
|
||||||
}
|
}
|
||||||
if (!body || typeof body !== 'object') {
|
if (!body || typeof body !== 'object') {
|
||||||
|
|||||||
Reference in New Issue
Block a user