/** * EGN Pay — однофайловый клиент для TypeScript/JavaScript. * Источник: https://pay.egnetwork.ru/sdk/egn-pay.ts · Документация: https://pay.egnetwork.ru/docs * * Без зависимостей. Работает в Node.js 18+, Bun, Deno и edge-рантаймах (fetch + WebCrypto). * * import { EgnPay } from "./egn-pay"; * const pay = new EgnPay(process.env.EGN_PAY_KEY!); * const payment = await pay.payments.create({ amount: 49900, order_id: "order-1", description: "Подписка" }); * redirect(payment.payment_url); * * // вебхук * const event = await EgnPay.webhooks.constructEvent(rawBody, req.headers.get("x-egn-signature"), secret); */ export type PaymentStatus = | "pending" | "succeeded" | "failed" | "expired" | "canceled" | "refunded" | "partially_refunded"; export type PaymentMethod = "sbp" | "card"; export type Tax = "none" | "vat0" | "vat5" | "vat7" | "vat10" | "vat20" | "vat22" | "vat105" | "vat107" | "vat110" | "vat120" | "vat122"; export interface ReceiptItem { name: string; /** копейки за единицу */ price: number; quantity: number; /** копейки; по умолчанию price × quantity */ amount?: number; tax: Tax; payment_method?: string; payment_object?: string; } export interface CreatePaymentParams { /** копейки, от 100 */ amount: number; /** ваш ID заказа — ключ идемпотентности */ order_id: string; description: string; return_url?: string; /** только https */ webhook_url?: string; methods?: PaymentMethod[]; /** секунды, 300…604800, по умолчанию 3600 */ expires_in?: number; /** обязательно хотя бы одно поле — на него банк отправит чек */ customer: { email: string; phone?: string } | { email?: string; phone: string }; receipt?: { taxation: "osn" | "usn_income" | "usn_income_outcome" | "envd" | "esn" | "patent"; email?: string; phone?: string; items: ReceiptItem[]; }; metadata?: Record; } export interface Payment { id: string; object: "payment"; livemode: boolean; status: PaymentStatus; amount: number; refunded_amount: number; currency: "RUB"; description: string; order_id: string; methods: PaymentMethod[]; customer: { email: string | null; phone: string | null }; metadata: Record; payment_url: string; return_url: string | null; expires_at: string; paid_at: string | null; created_at: string; updated_at: string; } export interface List { object: "list"; data: T[]; has_more: boolean; } export type WebhookEventType = | "payment.succeeded" | "payment.failed" | "payment.expired" | "payment.canceled" | "payment.partially_refunded" | "payment.refunded"; export interface WebhookEvent { id: string; type: WebhookEventType; created_at: string; livemode: boolean; data: { payment: Payment }; } export class EgnPayError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly details?: Record, ) { super(message); this.name = "EgnPayError"; } } export interface EgnPayOptions { baseUrl?: string; /** мс, по умолчанию 20000 */ timeout?: number; /** повторы при сетевых ошибках и 5xx, по умолчанию 2 */ maxRetries?: number; } export class EgnPay { private readonly baseUrl: string; private readonly timeout: number; private readonly maxRetries: number; constructor( private readonly apiKey: string, opts: EgnPayOptions = {}, ) { if (!/^egn_(live|test)_/.test(apiKey)) throw new Error("EgnPay: ключ должен начинаться с egn_live_ или egn_test_"); this.baseUrl = (opts.baseUrl ?? "https://pay.egnetwork.ru").replace(/\/$/, "") + "/api/v1"; this.timeout = opts.timeout ?? 20_000; this.maxRetries = opts.maxRetries ?? 2; } private async request(method: "GET" | "POST", path: string, body?: unknown): Promise { for (let attempt = 0; ; attempt++) { let res: Response; try { res = await fetch(this.baseUrl + path, { method, headers: { Authorization: `Bearer ${this.apiKey}`, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(this.timeout), }); } catch (err) { // POST /payments идемпотентен по order_id, остальные POST безопасно повторять только при сетевой ошибке до ответа if (attempt < this.maxRetries) { await sleep(300 * 2 ** attempt); continue; } throw new EgnPayError(0, "network_error", err instanceof Error ? err.message : String(err)); } if (res.status >= 500 && attempt < this.maxRetries && (method === "GET" || path === "/payments")) { await sleep(300 * 2 ** attempt); continue; } const json = (await res.json().catch(() => null)) as | (T & { error?: undefined }) | { error: { code: string; message: string; details?: Record } } | null; if (!res.ok || !json || "error" in (json as object)) { const e = (json as { error?: { code: string; message: string; details?: Record } } | null)?.error; throw new EgnPayError(res.status, e?.code ?? `http_${res.status}`, e?.message ?? res.statusText, e?.details); } return json as T; } } readonly payments = { create: (params: CreatePaymentParams) => this.request("POST", "/payments", params), retrieve: (id: string) => this.request("GET", `/payments/${encodeURIComponent(id)}`), list: (params: { order_id?: string; status?: PaymentStatus; limit?: number; starting_after?: string } = {}) => { const qs = new URLSearchParams( Object.entries(params) .filter(([, v]) => v !== undefined) .map(([k, v]) => [k, String(v)]), ).toString(); return this.request>("GET", `/payments${qs ? `?${qs}` : ""}`); }, /** Найти платёж по вашему order_id */ findByOrderId: async (orderId: string) => (await this.payments.list({ order_id: orderId, limit: 1 })).data[0] ?? null, cancel: (id: string) => this.request("POST", `/payments/${encodeURIComponent(id)}/cancel`, {}), /** amount в копейках; без него — весь остаток */ refund: (id: string, amount?: number) => this.request("POST", `/payments/${encodeURIComponent(id)}/refund`, amount === undefined ? {} : { amount }), }; me() { return this.request<{ object: "merchant"; id: string; name: string; slug: string; livemode: boolean; provider: "tbank" | "emulator"; webhook_url: string | null; allowed_return_hosts: string[]; }>("GET", "/me"); } static readonly webhooks = { /** Проверяет X-EGN-Signature. rawBody — тело запроса до JSON.parse. */ async verify(rawBody: string, header: string | null | undefined, secret: string, toleranceSec = 300): Promise { if (!header) return false; const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2) as [string, string])); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec || !parts.v1) return false; const expected = await hmacHex(secret, `${t}.${rawBody}`); return constantTimeEqual(expected, parts.v1); }, /** Проверяет подпись и возвращает событие; бросает EgnPayError при неверной подписи. */ async constructEvent(rawBody: string, header: string | null | undefined, secret: string): Promise { if (!(await EgnPay.webhooks.verify(rawBody, header, secret))) { throw new EgnPayError(400, "invalid_signature", "Неверная подпись вебхука"); } return JSON.parse(rawBody) as WebhookEvent; }, }; } async function hmacHex(secret: string, message: string): Promise { const enc = new TextEncoder(); const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(message))); return Array.from(sig, (b) => b.toString(16).padStart(2, "0")).join(""); } function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export default EgnPay;