UNPKG

yoomoney-sdk

Version:

⭐ Typed YooMoney Wallet SDK for NodeJS. Supported API's: Auth, Wallet & Notifications

316 lines (269 loc) 9.72 kB
import { createHash, timingSafeEqual } from "crypto"; import { parse } from "querystring"; import type { RequestHandler } from "express"; export type NotificationDTO = { /** * Для переводов из кошелька — `p2p-incoming`. * * Для переводов с произвольной карты — `card-incoming`. */ notification_type: "p2p-incoming" | "card-incoming"; /** Идентификатор операции в истории счета получателя. */ operation_id: string; /** Сумма, которая зачислена на счет получателя. */ amount: number; /** Сумма, которая списана со счета отправителя. */ withdraw_amount: number; /** Код валюты — всегда `643` (рубль РФ согласно ISO 4217). */ currency: "643"; /** Дата и время совершения перевода. */ datetime: string; /** * Для переводов из кошелька — номер кошелька отправителя. * * Для переводов с произвольной карты — параметр содержит пустую * строку. */ sender: string; /** * Признак того, что перевод защищен кодом протекции. В ЮMoney больше нельзя делать переводы с кодом протекции, поэтому параметр всегда имеет значение `false`. */ codepro: boolean; /** * Метка платежа. Если ее нет, параметр содержит пустую строку. */ label: string; /** SHA-1 hash параметров уведомления. */ sha1_hash: string; test_notification: boolean; /** * Перевод еще не зачислен. Получателю нужно освободить место * в кошельке или использовать код протекции (если `codepro=true`). */ unaccepted: boolean; /** * Фамилия. * * @deprecated **Больше не предоставляется ЮMoney** */ lastname?: string; /** * Имя. * * @deprecated **Больше не предоставляется ЮMoney** * */ firstname?: string; /** * Отчество. * * @deprecated **Больше не предоставляется ЮMoney** * */ fathersname?: string; /** * Адрес электронной почты отправителя перевода. Если почта не * запрашивалась, параметр содержит пустую строку. * * @deprecated **Больше не предоставляется ЮMoney** */ email?: string; /** * Телефон отправителя перевода. Если телефон не запрашивался, * параметр содержит пустую строку. * * @deprecated **Больше не предоставляется ЮMoney** */ phone?: string; /** * Город. * * @deprecated **Больше не предоставляется ЮMoney** **/ city?: string; /** * Улица. * * @deprecated **Больше не предоставляется ЮMoney** * */ street?: string; /** * Дом. * * @deprecated **Больше не предоставляется ЮMoney** * */ building?: string; /** * Корпус. * * @deprecated **Больше не предоставляется ЮMoney** * */ suite?: string; /** * Квартира. * * @deprecated **Больше не предоставляется ЮMoney** * */ flat?: string; /** * Индекс. * * @deprecated **Больше не предоставляется ЮMoney** * */ zip?: string; }; /** * Ошибка проверки уведомления от YooMoney */ export class YMNotificationError extends Error { constructor(message: string) { super(message); this.name = "YMNotificationError"; } } /** * @template {CallableFunction} T * * @param {T} function_ * @return {T} */ function promise<T extends (...parameters: any) => any>(function_: T) { const wrapper = (...parameters: any[]): any => { try { const result = function_(...parameters); if (result instanceof Promise) return result; return Promise.resolve(result); } catch (error) { return Promise.reject(error); } }; return wrapper as ( ...arguments_: Parameters<T> ) => ReturnType<T> extends Promise<any> ? ReturnType<T> : Promise<ReturnType<T>>; } const signatureFields = "notification_type&operation_id&amount&currency&datetime&sender&codepro&notification_secret&label".split( "&" ); /** * Класс, который реализует [механизм проверки уведомлений от YooMoney](https://yoomoney.ru/docs/wallet/using-api/notification-p2p-incoming#security) * * @see {@link https://yoomoney.ru/docs/wallet/using-api/notification-p2p-incoming#security|Описание механизма} */ export class NotificationChecker { /** * Creates an instance of NotificationChecker. * @param {string} secret Секретное слово */ constructor(private readonly secret: string) {} /** * Проверяет полученное уведомление и возвращает типизированную версию * * @throws {YMNotificationError} Если хеш уведомления не совпадает * @param {Object} notification Объект уведомления * @return {NotificationDTO} */ check(notification: Record<keyof NotificationDTO, string>): NotificationDTO { const notificationWithSecret = { ...notification, notification_secret: this.secret }; const signature = signatureFields .map( (key) => notificationWithSecret[key as keyof typeof notificationWithSecret] ) .join("&"); const hash = createHash("sha1").update(signature).digest(); const original = Buffer.from(notification.sha1_hash, "hex"); if (!timingSafeEqual(hash, original)) { throw new YMNotificationError(`Notification hash mismatch`); } return { ...notification, amount: Number.parseFloat(notification.amount), notification_type: notification.notification_type as NotificationDTO["notification_type"], withdraw_amount: Number.parseFloat(notification.withdraw_amount) || 0, currency: notification.currency as NotificationDTO["currency"], codepro: notification.codepro === "true", test_notification: notification.test_notification === "true", unaccepted: notification.unaccepted === "true" }; } /** * * Упрощает интеграцию с `express` * @deprecated **Экспресс морально устарел - вызывайте {@link check} самостоятельно** * * - Это middleware кидает ошибки, позаботьтесь об их обработке * * @param {Object} [options={}] Параметры обработки запроса * @param {boolean} [options.memo=true] Флаг для включения/отключения пропуска повторяющихся запросов, если один из них был успешно обработан * @param {RequestHandler<Record<string, string>, any, NotificationDTO>=} actualHandler * @return {RequestHandler} * * ##### Пример: * **В начале файла** * ```js * const nc = new YMNotificationChecker(process.env.YM_SECRET); * * ``` * *`Вариант 1 - Классический`* * * ```js * app.post('/webhook/yoomoney', nc.middleware(), (req, res) => { * req.body // Это `NotificationDTO` * }) * ``` * * *`Вариант 2 - Если нужны подсказки типов`* * * ```js * app.post('/webhook/yoomoney', nc.middleware({}, (req, res) => { * req.body // Это `NotificationDTO` * })) * ``` * * **Обработка ошибок** * ```js * app.use((error, request, response, next) => { * console.log(error); // [YMNotificationError: Notification hash mismatch] * }) * ``` */ middleware( options: { memo?: boolean } = {}, actualHandler: RequestHandler<Record<string, string>, any, NotificationDTO> = ( _request, _response, next ) => next() ): RequestHandler { const calls = new Set<string>(); const { memo = true } = options; return async (request, response, next) => { let body: Record<keyof NotificationDTO, string> = {} as any; if (!request.body) { const text = await new Promise<string>((resolve, reject) => { let accumulated = ""; request.on("error", (error) => reject(error)); request.on("data", (data) => (accumulated += String(data))); request.on("end", () => resolve(accumulated)); }); body = parse(text) as any; } if (typeof request.body === "object") { body = request.body; } const key = body.sha1_hash; if (memo && calls.has(key)) return next(); try { const notification = this.check(body); request.body = notification; } catch (error) { return next(error); } if (!memo) return actualHandler(request, response, next); await promise(actualHandler)(request, response, next); calls.add(key); }; } }