UNPKG

yoomoney-sdk

Version:

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

591 lines (590 loc) 19.7 kB
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&currency&datetime&sender&codepro&notification_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 };