yoomoney-sdk
Version:
⭐ Typed YooMoney Wallet SDK for NodeJS. Supported API's: Auth, Wallet & Notifications
591 lines (590 loc) • 19.7 kB
JavaScript
import axios from "axios";
import { stringify, parse } from "querystring";
import { FormBuilder } from "redirect-form-builder";
import { createHash, timingSafeEqual } from "crypto";
async function fetch(url, parameters, headers = {}, agent) {
return await axios.post(url, stringify(parameters), {
headers: {
"User-Agent": "yoomoney-sdk/2.1.0 (+https://npmjs.com/package/yoomoney-sdk)",
...headers,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json"
},
httpAgent: agent,
httpsAgent: agent,
responseType: "json"
}).then((response) => response.data).catch(
(error) => axios.isAxiosError(error) && error.response ? error.response.data : Promise.reject(error)
);
}
class YMApiError extends Error {
/**
* Объект ответа
* @param {AnyRecord} response
*/
constructor(response) {
super(`API returned error code: ${response.error}`);
this.response = response;
this.code = response.error;
this.name = "YMApiError";
}
}
class YMApiVoidResponseError extends Error {
constructor() {
super("YooMoney returned empty answer: method not allowed");
this.name = "YMApiVoidResponseError";
}
}
class API {
/**
* Creates an instance of API.
* @param {string} token Токен авторизации пользователя
* @param {string=} [endpoint="https://yoomoney.ru/api"] По умолчанию `https://yoomoney.ru/api`
* @param {Agent=} [agent]
*/
constructor(token, endpoint = "https://yoomoney.ru/api", agent) {
this.token = token;
this.endpoint = endpoint;
this.agent = agent;
}
/**
* Позволяет совершить вызов произвольного метода API
*
* @template T
* @param {string} method Название метода
* @param {QueryStringifiable} parameters Параметры метода
*
* @throws {YMApiError}
* @throws {YMApiVoidResponseError}
*
* @return {Promise<T>}
*/
async call(method, parameters) {
const data = await fetch(
`${this.endpoint}/${method}`,
parameters,
{ Authorization: `Bearer ${this.token}` },
this.agent
);
if (data.error) throw new YMApiError(data);
if (typeof data === "string" && data.trim() === "") {
throw new YMApiVoidResponseError();
}
return data;
}
/**
* Получение информации о состоянии счета пользователя.
*
* Требуемые права токена: `account-info`.
*
* @throws {YMApiError}
* @throws {YMApiVoidResponseError}
*
* @return {t.AccountInfoResponse}
*/
async accountInfo() {
return await this.call("account-info", {});
}
/**
* Метод позволяет просматривать историю операций (полностью или частично) в постраничном режиме. Записи истории выдаются в обратном хронологическом порядке: от последних к более ранним.
*
* Требуемые права токена: `operation-history`.
*
* @throws {YMApiError}
* @throws {YMApiVoidResponseError}
*
* @param {t.OperationHistoryParameters=} [parameters={}] Параметры вызова
* @return {Promise<t.OperationHistoryResponse>}
*/
async operationHistory(parameters = {}) {
return await this.call("operation-history", parameters);
}
/**
* Позволяет получить детальную информацию об операции из истории.
*
* Требуемые права токена: `operation-details`.
*
* @throws {YMApiError}
* @throws {YMApiVoidResponseError}
*
* @param {t.OperationDetailsParameters} parameters Параметры вызова
* @return {Promise<t.Operation>}
*/
async operationDetails(parameters) {
return await this.call("operation-details", parameters);
}
/**
* Создание платежа, проверка параметров и возможности приема
* платежа магазином или перевода средств на счет пользователя
* ЮMoney.
*
* Требуемые права токена:
* - для платежа в магазин: `payment.to-pattern`
* («шаблон платежа») или `payment-shop`.
*
* - для перевода средств на счета других пользователей:
* `payment.to-account` («идентификатор получателя»,
* «тип идентификатора») или `payment-p2p`.
*
* @throws {YMApiError}
* @throws {YMApiVoidResponseError}
*
* @param {t.RequestPaymentParameters} parameters Параметры вызова
* @return {Promise<t.RequestPaymentResponse>}
*/
async requestPayment(parameters) {
return await this.call("request-payment", parameters);
}
/**
* Подтверждение платежа, ранее созданного методом
* [request-payment](https://yoomoney.ru/docs/wallet/process-payments/request-payment).
* Указание метода проведения платежа.
*
* @throws {YMApiError}
* @throws {YMApiVoidResponseError}
*
* @param {t.ProcessPaymentParameters} parameters Параметры вызова
* @return {Promise<t.ProcessPaymentResponse>}
*/
async processPayment(parameters) {
return await this.call("process-payment", parameters);
}
/**
* Прием входящих переводов, защищенных кодом протекции, и
* переводов до востребования.
*
* Количество попыток приема входящего перевода с кодом протекции
* ограничено. При исчерпании количества попыток, перевод
* автоматически отвергается (перевод возвращается отправителю).
*
* Требуемые права токена: `incoming-transfers`
*
* @throws {YMApiError}
* @throws {YMApiVoidResponseError}
*
* @param {t.IncomingTransferAcceptParameters} parameters Параметры вызова
* @return {Promise<t.IncomingTransferAcceptResponse>}
*/
async incomingTransferAccept(parameters) {
return await this.call("incoming-transfer-accept", parameters);
}
/**
* Отмена входящих переводов, защищенных кодом протекции, и
* переводов до востребования. При отмене перевода он возвращается
* отправителю.
*
* Требуемые права токена: `incoming-transfers`
*
* @throws {YMApiError}
* @throws {YMApiVoidResponseError}
*
* @param {t.IncomingTransferRejectParameters} parameters Параметры вызова
* @return {Promise<t.IncomingTransferRejectResponse>}
*/
async incomingTransferReject(parameters) {
return await this.call("incoming-transfer-accept", parameters);
}
}
const AuthScope = {
AccountInfo: "account-info",
OperationHistory: "operation-history",
OperationDetails: "operation-details",
IncomingTransfers: "incoming-transfers",
Payment: "payment",
PaymentShop: "payment-shop",
PaymentP2P: "payment-p2p",
MoneySource: "money-source"
};
class YMAuthError extends Error {
/**
*
* @param {string} code Код ошибки
*/
constructor(code) {
super(`API returned error: ${code}`);
this.code = code;
this.name = "YMAuthError";
}
}
class Auth {
/**
* Creates an instance of Auth.
* @param {string} clientId ID приложения
* @param {string} redirectUrl URL-перенаправления
* @param {string=} [clientSecret] Секретное Слово
* @param {string} [endpoint="https://yoomoney.ru/oauth"] По умолчанию `https://yoomoney.ru/oauth`
* @param {Agent=} [agent] HTTP Agent для использования с Proxy
*/
constructor(clientId, redirectUrl, clientSecret, endpoint = "https://yoomoney.ru/oauth", agent) {
this.clientId = clientId;
this.redirectUrl = redirectUrl;
this.clientSecret = clientSecret;
this.endpoint = endpoint;
this.agent = agent;
}
/**
* Генерирует html-форму перенаправления пользователя на авторизацию
*
* @param {AuthScope[]} scope
* @param {string=} instanceName
* @return {string}
*/
getAuthForm(scope, instanceName) {
const builder = new FormBuilder(`${this.endpoint}/authorize`, "POST", {
client_id: this.clientId,
response_type: "code",
redirect_uri: this.redirectUrl,
scope: scope.join(" ")
});
if (instanceName) {
builder.setField("instance_name", instanceName);
}
return builder.buildHtml();
}
/**
* Генерирует URL для перенаправления пользователя на авторизацию
*
* @param {AuthScope[]} scope
* @param {string=} instanceName
* @return {string}
*/
getAuthUrl(scope, instanceName) {
const url = new URL(`${this.endpoint}/authorize`);
url.searchParams.set("client_id", this.clientId);
url.searchParams.set("response_type", "code");
url.searchParams.set("redirect_uri", this.redirectUrl);
url.searchParams.set("scope", scope.join(" "));
if (instanceName) {
url.searchParams.set("instance_name", instanceName);
}
return url.toString();
}
/**
* Обменивает временный токен на постоянный токен авторизации
*
* @throws {YMAuthError}
* @param {string} code Временный токен (authorization code)
* @return {Promise<string>} Токен авторизации
*/
async exchangeCode2Token(code) {
const json = await fetch(
`${this.endpoint}/token`,
{
code,
client_id: this.clientId,
grant_type: "authorization_code",
redirect_uri: this.redirectUrl,
client_secret: this.clientSecret
},
{},
this.agent
);
if (json.error) throw new YMAuthError(json.error);
return json.access_token;
}
}
class YMNotificationError extends Error {
constructor(message) {
super(message);
this.name = "YMNotificationError";
}
}
function promise(function_) {
const wrapper = (...parameters) => {
try {
const result = function_(...parameters);
if (result instanceof Promise) return result;
return Promise.resolve(result);
} catch (error) {
return Promise.reject(error);
}
};
return wrapper;
}
const signatureFields = "notification_type&operation_id&amount¤cy&datetime&sender&codepro¬ification_secret&label".split(
"&"
);
class NotificationChecker {
/**
* Creates an instance of NotificationChecker.
* @param {string} secret Секретное слово
*/
constructor(secret) {
this.secret = secret;
}
/**
* Проверяет полученное уведомление и возвращает типизированную версию
*
* @throws {YMNotificationError} Если хеш уведомления не совпадает
* @param {Object} notification Объект уведомления
* @return {NotificationDTO}
*/
check(notification) {
const notificationWithSecret = {
...notification,
notification_secret: this.secret
};
const signature = signatureFields.map(
(key) => notificationWithSecret[key]
).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,
withdraw_amount: Number.parseFloat(notification.withdraw_amount) || 0,
currency: notification.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 = {}, actualHandler = (_request, _response, next) => next()) {
const calls = /* @__PURE__ */ new Set();
const { memo = true } = options;
return async (request, response, next) => {
let body = {};
if (!request.body) {
const text = await new Promise((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);
}
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);
};
}
}
const PaymentType = {
FromCard: "AC",
FromWallet: "PC",
/** @deprecated **Вариант игнорируется ЮMoney**. Используйте {@link PaymentType.FromCard} (`"AC"`) */
FromMobileBalance: "MC"
};
const QuickPayForm = {
Button: "button"
};
function convert(config) {
return {
"quickpay-form": config.quickPayForm ?? "button",
paymentType: config.paymentType ?? PaymentType.FromCard,
receiver: config.receiver,
sum: config.sum.toString(),
targets: config.targets,
"need-address": String(!!config.needAddress),
"need-email": String(!!config.needEmail),
"need-fio": String(!!config.needFio),
"need-phone": String(!!config.needPhone),
"short-dest": config.shortDest,
comment: config.comment,
formcomment: config.formComment,
label: config.label,
successURL: config.successURL
};
}
const FORM_ACTION_URL = "https://yoomoney.ru/quickpay/confirm.xml";
class PaymentFormBuilder {
/**
*
* Creates an instance of PaymentFormBuilder.
* @param {FormConfig} [config={
* receiver: "",
* sum: 10,
* }] Изначальные настройки формы
*/
constructor(config = { receiver: "", sum: 10 }, url = FORM_ACTION_URL) {
this.config = config;
this.url = url;
this.setQuickPayForm = this._makeSetter("quickPayForm");
this.setPaymentType = this._makeSetter("paymentType");
this.setLabel = this._makeSetter("label");
this.setTargets = this._makeSetter("targets");
this.setFormComment = this._makeSetter("formComment");
this.setShortDest = this._makeSetter("shortDest");
this.setComment = this._makeSetter("comment");
}
/**
* Генерирует стандартные сеттеры
*
* @param {string} field
* @return {Function}
* @private
*/
_makeSetter(field) {
return (value) => Object.defineProperty(this, field, { value });
}
/**
* Задаёт сумму платежа
*
* @alias {@link setSum}
* @param {string | number} amount Сумма
* @return {this}
*/
setAmount(amount) {
this.config.sum = Number.parseFloat(
Number.parseFloat(amount.toString()).toFixed(2)
);
return this;
}
/**
* Задаёт сумму платежа
*
* @alias {@link setAmount}
* @param {string | number} amount Сумма
* @return {this}
*/
setSum(amount) {
this.config.sum = Number.parseFloat(
Number.parseFloat(amount.toString()).toFixed(2)
);
return this;
}
/**
* Задаёт получателя платежа
*
* @param {string | number} receiver Получатель
* @return {this}
*/
setReceiver(receiver) {
this.config.receiver = receiver.toString();
return this;
}
/**
* Задаёт URL перенаправления после успешного платежа
*
* @param {string | URL} url URL
* @return {this}
*/
setSuccessURL(url) {
this.config.successURL = url.toString();
return this;
}
/**
* @deprecated **Поле игнорируется ЮMoney**
*
* @param {boolean} [doRequire=true]
* @return {this}
*/
requireFio(doRequire = true) {
this.config.needFio = doRequire;
return this;
}
/**
* @deprecated **Поле игнорируется ЮMoney**
*
* @param {boolean} [doRequire=true]
* @return {this}
*/
requireAddress(doRequire = true) {
this.config.needAddress = doRequire;
return this;
}
/**
* @deprecated **Поле игнорируется ЮMoney**
*
* @param {boolean} [doRequire=true]
* @return {this}
*/
requireEmail(doRequire = true) {
this.config.needEmail = doRequire;
return this;
}
/**
* @deprecated **Поле игнорируется ЮMoney**
*
* @param {boolean} [doRequire=true]
* @return {this}
*/
requirePhone(doRequire = true) {
this.config.needPhone = doRequire;
return this;
}
/**
* Генерирует HTML на основе заданных параметров
* @param {boolean} [fullPage=false]
* @return {string}
*/
buildHtml(fullPage = false) {
const fields = convert(this.config);
return new FormBuilder(this.url, "POST", fields).buildHtml(fullPage);
}
}
export {
API,
Auth,
AuthScope,
PaymentType as FormPaymentType,
NotificationChecker,
PaymentFormBuilder,
PaymentType,
QuickPayForm,
API as YMApi,
YMApiError,
YMApiVoidResponseError,
Auth as YMAuth,
YMAuthError,
PaymentType as YMFormPaymentType,
NotificationChecker as YMNotificationChecker,
YMNotificationError,
PaymentFormBuilder as YMPaymentFormBuilder
};