yoomoney-sdk
Version:
⭐ Typed YooMoney Wallet SDK for NodeJS. Supported API's: Auth, Wallet & Notifications
316 lines (269 loc) • 9.72 kB
text/typescript
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¤cy&datetime&sender&codepro¬ification_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);
};
}
}