import { createHmac } from "node:crypto"; import type { MerchantProfile } from "./config.js"; import { WfpAuthError, WfpError } from "./errors.js"; const API_URL = "https://api.wayforpay.com/api"; const REGULAR_API_URL = "https://api.wayforpay.com/regularApi"; const MERCHANT_INFO_URL = "https://api.wayforpay.com/mms/merchantInfo.php"; const MERCHANT_BALANCE_URL = "https://api.wayforpay.com/mms/merchantBalance.php"; const MAX_TRANSACTION_LIST_DAYS = 31; export interface TransactionListParams { dateFrom: string; dateTo: string; apiVersion?: 1 | 2; } export interface CurrencyRatesParams { date: string; currency?: string; } export interface LineItem { name: string; price: number; count: number; } export interface CreateInvoiceParams { orderReference: string; amount: number; currency?: string; orderDate?: number; orderTimeoutSeconds?: number; language?: "UA" | "RU" | "EN"; lineItems: LineItem[]; domainName?: string; serviceUrl?: string; paymentSystems?: string[]; clientFirstName?: string; clientLastName?: string; clientEmail?: string; clientPhone?: string; } export interface RefundParams { orderReference: string; amount: number; currency?: string; comment: string; lineItems?: LineItem[]; } export interface SettleParams { orderReference: string; amount: number; currency?: string; lineItems?: LineItem[]; } type RegularRequestType = "STATUS" | "SUSPEND" | "RESUME" | "REMOVE"; interface RequestOptions { timeoutMs: number; } function ensureIsoDate(value: string, fieldName: string): void { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { throw new Error(`${fieldName} must use YYYY-MM-DD format`); } const parsed = new Date(`${value}T00:00:00Z`); if (Number.isNaN(parsed.getTime())) { throw new Error(`${fieldName} is not a valid date`); } } function toStartOfDayTimestamp(date: string): number { ensureIsoDate(date, "date"); return Math.floor(new Date(`${date}T00:00:00Z`).getTime() / 1000); } function toEndOfDayTimestamp(date: string): number { ensureIsoDate(date, "date"); return Math.floor(new Date(`${date}T23:59:59Z`).getTime() / 1000); } function diffDaysInclusive(dateFrom: string, dateTo: string): number { const fromMs = new Date(`${dateFrom}T00:00:00Z`).getTime(); const toMs = new Date(`${dateTo}T00:00:00Z`).getTime(); return Math.floor((toMs - fromMs) / 86_400_000) + 1; } function normalizeNumber(value: string | number | undefined | null): number | null { if (value === undefined || value === null || value === "") { return null; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function omitUndefined>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, entryValue]) => entryValue !== undefined)) as T; } export class WayForPayClient { constructor( private readonly profile: MerchantProfile, private readonly options: RequestOptions, ) {} sign(fields: Array): string { const payload = fields.map((field) => String(field)).join(";"); return createHmac("md5", this.profile.secretKey).update(payload, "utf8").digest("hex"); } buildServiceAck(orderReference: string, status = "accept", time = Math.floor(Date.now() / 1000)) { return { orderReference, status, time, signature: this.sign([orderReference, status, time]), }; } verifyPaymentSignature(payload: { merchantAccount: string; orderReference: string; amount: string | number; currency: string; authCode: string; cardPan: string; transactionStatus: string; reasonCode: string | number; merchantSignature: string; }) { const expectedSignature = this.sign([ payload.merchantAccount, payload.orderReference, payload.amount, payload.currency, payload.authCode, payload.cardPan, payload.transactionStatus, payload.reasonCode, ]); return { signatureValid: expectedSignature.toLowerCase() === payload.merchantSignature.toLowerCase(), expectedSignature, providedSignature: payload.merchantSignature, }; } async getMerchantInfo() { const response = await this.postJson>(MERCHANT_INFO_URL, { merchantAccount: this.profile.merchantAccount, merchantSignature: this.sign([this.profile.merchantAccount]), }); return response; } async getBalance(toDate?: string) { if (toDate && !/^\d{2}\.\d{2}\.\d{4}$/.test(toDate)) { throw new Error('toDate must use "DD.MM.YYYY" format'); } const response = await this.postJson>( MERCHANT_BALANCE_URL, omitUndefined({ merchantAccount: this.profile.merchantAccount, merchantSignature: this.sign([this.profile.merchantAccount]), toDate, }), ); return response; } async getCurrencyRates(params: CurrencyRatesParams) { ensureIsoDate(params.date, "date"); const orderDate = toStartOfDayTimestamp(params.date); const response = await this.postJson>( API_URL, omitUndefined({ transactionType: "CURRENCY_RATES", merchantAccount: this.profile.merchantAccount, merchantSignature: this.sign([this.profile.merchantAccount, orderDate]), apiVersion: 1, orderDate, currency: params.currency, }), ); return response; } async getTransactionList(params: TransactionListParams) { ensureIsoDate(params.dateFrom, "dateFrom"); ensureIsoDate(params.dateTo, "dateTo"); const days = diffDaysInclusive(params.dateFrom, params.dateTo); if (days < 1 || days > MAX_TRANSACTION_LIST_DAYS) { throw new Error(`TRANSACTION_LIST supports a maximum ${MAX_TRANSACTION_LIST_DAYS}-day window`); } const dateBegin = toStartOfDayTimestamp(params.dateFrom); const dateEnd = toEndOfDayTimestamp(params.dateTo); const response = await this.postJson>(API_URL, { transactionType: "TRANSACTION_LIST", merchantAccount: this.profile.merchantAccount, merchantSignature: this.sign([this.profile.merchantAccount, dateBegin, dateEnd]), apiVersion: params.apiVersion ?? 2, dateBegin, dateEnd, }); return { merchantAlias: this.profile.alias, dateFrom: params.dateFrom, dateTo: params.dateTo, dateBegin, dateEnd, ...response, }; } async checkStatus(orderReference: string, apiVersion: 1 | 2 = 2) { const response = await this.postJson>(API_URL, { transactionType: "CHECK_STATUS", merchantAccount: this.profile.merchantAccount, orderReference, merchantSignature: this.sign([this.profile.merchantAccount, orderReference]), apiVersion, }); return { ...response, signatureCheck: this.extractSignatureCheck(response), }; } async createInvoice(params: CreateInvoiceParams) { const domainName = params.domainName ?? this.profile.domainName; if (!domainName) { throw new Error("domainName is required for invoice creation"); } if (params.lineItems.length === 0) { throw new Error("At least one line item is required"); } const orderDate = params.orderDate ?? Math.floor(Date.now() / 1000); const currency = params.currency ?? this.profile.defaultCurrency ?? "UAH"; const productName = params.lineItems.map((lineItem) => lineItem.name); const productCount = params.lineItems.map((lineItem) => lineItem.count); const productPrice = params.lineItems.map((lineItem) => lineItem.price); const response = await this.postJson>( API_URL, omitUndefined({ transactionType: "CREATE_INVOICE", merchantAccount: this.profile.merchantAccount, merchantAuthType: "SimpleSignature", merchantDomainName: domainName, merchantSignature: this.sign([ this.profile.merchantAccount, domainName, params.orderReference, orderDate, params.amount, currency, ...productName, ...productCount, ...productPrice, ]), apiVersion: 1, language: params.language ?? "UA", serviceUrl: params.serviceUrl ?? this.profile.serviceUrl, orderReference: params.orderReference, orderDate, amount: params.amount, currency, orderTimeout: params.orderTimeoutSeconds, productName, productPrice, productCount, paymentSystems: params.paymentSystems?.join(";"), clientFirstName: params.clientFirstName, clientLastName: params.clientLastName, clientEmail: params.clientEmail, clientPhone: params.clientPhone, }), ); return response; } async refund(params: RefundParams) { const currency = params.currency ?? this.profile.defaultCurrency ?? "UAH"; const productName = params.lineItems?.map((lineItem) => lineItem.name); const productCount = params.lineItems?.map((lineItem) => lineItem.count); const productPrice = params.lineItems?.map((lineItem) => lineItem.price); const response = await this.postJson>( API_URL, omitUndefined({ transactionType: "REFUND", merchantAccount: this.profile.merchantAccount, orderReference: params.orderReference, amount: params.amount, currency, comment: params.comment, merchantSignature: this.sign([ this.profile.merchantAccount, params.orderReference, params.amount, currency, ]), apiVersion: 1, productName, productCount, productPrice, }), ); return { ...response, signatureCheck: this.extractSignatureCheck(response), }; } async settle(params: SettleParams) { const currency = params.currency ?? this.profile.defaultCurrency ?? "UAH"; const productName = params.lineItems?.map((lineItem) => lineItem.name); const productCount = params.lineItems?.map((lineItem) => lineItem.count); const productPrice = params.lineItems?.map((lineItem) => lineItem.price); const response = await this.postJson>( API_URL, omitUndefined({ transactionType: "SETTLE", merchantAccount: this.profile.merchantAccount, orderReference: params.orderReference, amount: params.amount, currency, merchantSignature: this.sign([ this.profile.merchantAccount, params.orderReference, params.amount, currency, ]), apiVersion: 1, productName, productCount, productPrice, }), ); return { ...response, signatureCheck: this.extractSignatureCheck(response), }; } async getRegularStatus(orderReference: string) { return this.callRegularApi("STATUS", orderReference); } async suspendRecurring(orderReference: string) { return this.callRegularApi("SUSPEND", orderReference); } async resumeRecurring(orderReference: string) { return this.callRegularApi("RESUME", orderReference); } async deleteRecurring(orderReference: string) { return this.callRegularApi("REMOVE", orderReference); } private extractSignatureCheck(response: Record) { const merchantSignature = response.merchantSignature; if (typeof merchantSignature !== "string") { return null; } const merchantAccount = String(response.merchantAccount ?? this.profile.merchantAccount); const orderReference = String(response.orderReference ?? ""); const amount = String( normalizeNumber(response.amount as string | number | undefined | null) ?? response.amount ?? "", ); const currency = String(response.currency ?? ""); const authCode = String(response.authCode ?? ""); const cardPan = String(response.cardPan ?? ""); const transactionStatus = String(response.transactionStatus ?? ""); const reasonCode = String(response.reasonCode ?? ""); return this.verifyPaymentSignature({ merchantAccount, orderReference, amount, currency, authCode, cardPan, transactionStatus, reasonCode, merchantSignature, }); } private requireMerchantPassword(): string { if (!this.profile.merchantPassword) { throw new Error(`Merchant "${this.profile.alias}" does not have merchantPassword configured`); } return this.profile.merchantPassword; } private async callRegularApi(requestType: RegularRequestType, orderReference: string) { return this.postJson>(REGULAR_API_URL, { requestType, merchantAccount: this.profile.merchantAccount, merchantPassword: this.requireMerchantPassword(), orderReference, }); } private async postJson(url: string, body: Record): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs); const transactionType = String(body.transactionType ?? body.requestType ?? "wayforpay"); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(body), signal: controller.signal, }); const text = await response.text(); if (response.status === 401 || response.status === 403) { throw new WfpAuthError( `WayForPay rejected the merchant signature (HTTP ${response.status}). Verify secretKey and merchantAccount.`, { status: response.status, transactionType, apiError: safeJson(text) }, ); } if (!response.ok) { throw new WfpError(`WayForPay ${transactionType} failed (HTTP ${response.status})`, { status: response.status, transactionType, apiError: safeJson(text), }); } try { return JSON.parse(text) as T; } catch { throw new WfpError(`WayForPay ${transactionType} returned non-JSON body (HTTP ${response.status})`, { status: response.status, transactionType, apiError: { rawBody: text.slice(0, 500) }, }); } } catch (err) { if (err instanceof WfpError) throw err; // Network / abort / timeout — wrap consistently so MCP fail() sees structured detail. throw new WfpError( `WayForPay ${transactionType} transport error: ${(err as Error).message ?? String(err)}`, { status: 0, transactionType, cause: err, }, ); } finally { clearTimeout(timeout); } } } function safeJson(text: string): unknown { try { return JSON.parse(text); } catch { return { rawBody: text.slice(0, 500) }; } }