UNPKG

@sahabaplus/moyasar

Version:

A comprehensive TypeScript SDK for integrating with the Moyasar payment gateway

1,548 lines (1,529 loc) 74.2 kB
import axios from 'axios'; import { TypedEmitter } from 'tiny-typed-emitter'; import { z } from 'zod'; const API_ENDPOINTS = { webhooks: "/v1/webhooks", webhookAttempts: "/v1/webhooks/attempts", availableEvents: "/v1/webhooks/available_events", invoices: "/v1/invoices", bulkInvoices: "/v1/invoices/bulk", payments: "/v1/payments", bulkPayments: "/v1/payments/bulk", }; const DEFAULT_API_CONFIG = { BASE_URL: "https://api.moyasar.com", TIMEOUT: 30000, }; class MoyasarError extends Error { constructor(message, type = "MOYASAR_ERROR", statusCode, details) { super(message); this.name = "MoyasarError"; this.type = type; this.statusCode = statusCode; this.details = details; } } class BaseAxiosApiClient { constructor(apiKey, options = {}) { // Create axios instance with base configuration this.axiosInstance = axios.create({ baseURL: options.baseUrl || DEFAULT_API_CONFIG.BASE_URL, timeout: options.timeout || DEFAULT_API_CONFIG.TIMEOUT, headers: { Authorization: `Basic ${Buffer.from(apiKey + ":").toString("base64")}`, "Content-Type": "application/json", "User-Agent": "Moyasar-SDK/1.0.0", }, }); // Setup response interceptor for error handling this.setupInterceptors(); } setupInterceptors() { // Response interceptor to handle errors this.axiosInstance.interceptors.response.use((response) => response, (error) => { throw this.createErrorFromAxiosError(error); }); } async request(config) { const response = await this.axiosInstance.request(config); return response.data; } createErrorFromAxiosError(error) { const response = error.response; let errorData = {}; let message = error.message; let statusCode = response?.status ?? 500; // Handle network errors if (!response) { return new MoyasarError(error.message || "Network error occurred", "api_connection_error", statusCode, { code: error.code, originalError: error.message, }); } statusCode = response.status; // Parse error response data if (response.data) errorData = response.data; // Determine error type and message based on Moyasar API documentation let errorType = "api_error"; if (errorData.type) errorType = errorData.type; else errorType = this.getDefaultErrorType(statusCode, errorType); // Use error message from response or generate appropriate message if (errorData.message) message = errorData.message; else message = this.getDefaultErrorMessage(statusCode, errorType); const moyasarError = new MoyasarError(message, errorType, statusCode, { url: error.config?.url, status: statusCode, statusText: response.statusText, errors: errorData.errors, // Detailed validation errors ...errorData, }); return moyasarError; } getDefaultErrorType(statusCode, errorType) { switch (statusCode) { case 400: errorType = "invalid_request_error"; break; case 401: errorType = "authentication_error"; break; case 403: errorType = "account_inactive_error"; break; case 404: errorType = "invalid_request_error"; break; case 405: errorType = "account_inactive_error"; break; case 429: errorType = "rate_limit_error"; break; case 500: case 503: errorType = "api_error"; break; default: errorType = "api_error"; } return errorType; } getDefaultErrorMessage(statusCode, errorType) { switch (statusCode) { case 400: return "The request was unacceptable, often due to missing a required parameter"; case 401: return "Invalid authorization credentials"; case 403: return "Credentials not enough to access resources"; case 404: return "The requested resource doesn't exist"; case 405: return "Entity not activated to use live account"; case 429: return "Too many requests hit the API too quickly"; case 500: return "We had a problem with our server. Try again later"; case 503: return "We are temporarily offline for maintenance. Please try again later"; default: return `HTTP ${statusCode}: Request failed`; } } // Getter for base URL (maintaining API compatibility) get baseUrl() { return this.axiosInstance.defaults.baseURL || ""; } // Setter for base URL (maintaining API compatibility) set baseUrl(url) { this.axiosInstance.defaults.baseURL = url; } } const WebhookEvent = { /** * ## Initiated * First status of a payment. It indicates that the payment has been created but the cardholder did not pay yet. * @hint If the payment is initiated then you need to complete the payment transaction by completing the payment challenge found in `Payment.source.transaction_url` depending on the payment source type. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=initiated */ PAYOUT_INITIATED: "payout_initiated", /** * ## Payment Paid * Payment reaches this status when the cardholder pays successfully. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=paid */ PAYMENT_PAID: "payment_paid", /** * ## Payment Failed * Payment reaches this status when the cardholder or merchant has a certain error that caused the payment to fail (errors are attached to the `message` attribute). * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=failed */ PAYMENT_FAILED: "payment_failed", /** * ## Payment Authorized * Payment reaches this status when the merchant authorizes it to be manually captured anytime later —the cardholder is not charged yet. * @note The status authorized is used when a scheme payment is made with `manual: true` option which will cause the system to authorize the payment only without capturing it. The merchant must capture the payment within time it will be voided automatically by the issuer. * @note Please note that when an issuer voids the payment, the status will be kept authorized and **WILL NOT BE updated by the system**. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=authorized * @see https://docs.moyasar.com/api/payments/01-create-payment#responses */ PAYMENT_AUTHORIZED: "payment_authorized", /** * ## Payment Captured * Payment reaches this status when the cardholder of an authorized payment is charged successfully. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=captured */ PAYMENT_CAPTURED: "payment_captured", /** * ## Payment Refunded * Payment reaches this status when the merchant refunds a paid or captured payment successfully. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=refunded */ PAYMENT_REFUNDED: "payment_refunded", /** * ## Payment Voided * Payment reaches this status when the merchant cancels a paid, authorized, or captured payment. It works only if the amount is not settled yet in the merchant’s bank account. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=voided */ PAYMENT_VOIDED: "payment_voided", /** * ## Payment Verified * Payment reaches this status when the cardholder verifies his card in the tokenization process. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=verified */ PAYMENT_VERIFIED: "payment_verified", PAYMENT_ABANDONED: "payment_abandoned", PAYMENT_CANCELED: "payment_canceled", PAYMENT_EXPIRED: "payment_expired", /** * ## Balance Transferred * When a settlement is created and sent to the merchant bank account, Moyasar will send a webhook notification to the merchant * @see https://docs.moyasar.com/guides/settlements/settlement-notification/#:~:text=Make%20sure%20to%20selected%20the%20balance_transferred%20event */ BALANCE_TRANSFERRED: "balance_transferred", PAYOUT_PAID: "payout_paid", PAYOUT_FAILED: "payout_failed", PAYOUT_CANCELED: "payout_canceled", PAYOUT_RETURNED: "payout_returned", }; const ALL_WEBHOOK_EVENTS = Object.values(WebhookEvent); const WebhookHttpMethod = { POST: "post", PUT: "put", PATCH: "patch", }; class WebhookValidation { /** * Validate webhook event type */ static isValidWebhookEvent(event) { return Object.values(WebhookEvent).includes(event); } /** * Validate HTTP method */ static isValidHttpMethod(method) { return Object.values(WebhookHttpMethod).includes(method); } /** * Validate URL format */ static isValidUrl(url) { try { const parsedUrl = new URL(url); return parsedUrl.protocol === "https:"; // Only allow HTTPS } catch { return false; } } /** * Validate required fields */ static validateRequired(obj, requiredFields) { const errors = []; for (const field of requiredFields) { if (obj[field] === undefined || obj[field] === null || obj[field] === "") { errors.push(`${String(field)} is required`); } } return errors; } /** * Sanitize webhook URL */ static sanitizeUrl(url) { return url.trim().toLowerCase(); } } class WebhookError extends MoyasarError { constructor(message, details) { super(message, "WEBHOOK_ERROR", 500, details ?? {}); this.name = "WebhookError"; } } class WebhookVerificationError extends WebhookError { constructor(message = "Webhook signature verification failed") { super(message, {}); this.name = "WebhookVerificationError"; } } class WebhookValidationError extends WebhookError { constructor({ message = "Webhook payload validation failed", unexpected_payload, }) { super(message, { unexpected_payload }); this.unexpected_payload = unexpected_payload; this.name = "WebhookValidationError"; } } class WebhookUtils { /** * Verify webhook payload signature */ static async verifyWebhookSignature(payload, options) { const { secret_token } = options; return secret_token === payload.secret_token; } /** * Parse webhook payload safely */ static parseWebhookPayload(rawPayload) { try { const payloadString = rawPayload instanceof Buffer ? rawPayload.toString("utf8") : rawPayload; const parsed = JSON.parse(payloadString); // Basic validation if (!parsed.id || !parsed.type || !parsed.data) throw new WebhookValidationError({ message: "Invalid webhook payload structure", unexpected_payload: parsed, }); return parsed; } catch (error) { if (error instanceof WebhookError) { throw error; } throw new WebhookValidationError({ message: `Failed to parse webhook payload: ${error}`, unexpected_payload: {}, }); } } /** * Validate webhook payload */ static validateWebhookPayload(payload) { const errors = []; if (typeof payload !== "object" || payload === null) { errors.push("Invalid webhook payload, expected an object got " + typeof payload); return errors; } // Check required fields errors.push(...WebhookValidation.validateRequired(payload, [ "id", "type", "created_at", "account_name", "data", ])); // Validate event type if (!("type" in payload) || typeof payload.type !== "string") { errors.push(`Invalid webhook event type`); } else if (!WebhookValidation.isValidWebhookEvent(payload.type)) { errors.push(`Invalid webhook event type: ${JSON.stringify(payload.type)}`); } // Validate live mode if (!("live" in payload) || typeof payload.live !== "boolean") { errors.push("live field must be a boolean"); } return errors; } /** * Extract signature from headers (common patterns) */ static extractSignatureFromHeaders(headers) { // Common signature header patterns const signatureHeaders = [ "x-moyasar-signature", "x-signature", "signature", "authorization", ]; for (const header of signatureHeaders) { const value = headers[header] || headers[header.toLowerCase()]; if (value) { const signature = Array.isArray(value) ? value[0] : value; // Remove common prefixes return signature.replace(/^(sha256=|hmac-sha256=|Bearer\s+)/i, ""); } } return null; } /** * Create a consistent payload string for signature verification */ static createSignaturePayload(payload, timestamp) { const basePayload = JSON.stringify(payload); return timestamp ? `${timestamp}.${basePayload}` : basePayload; } /** * Get webhook event categories */ static getEventCategory(event) { if (event.startsWith("payment_")) { return "payment"; } return "unknown"; } /** * Check if webhook should be retried based on response */ static shouldRetryWebhook(statusCode, retryCount, maxRetries = 5) { if (retryCount >= maxRetries) { return false; } // Don't retry client errors (4xx) except specific ones if (statusCode >= 400 && statusCode < 500) { return [408, 429].includes(statusCode); // Timeout, Rate Limited } // Retry server errors (5xx) and network errors return statusCode >= 500 || statusCode === 0; } } /** * @description Indicates the payment status. If the payment is in the initiated status, then an action must be taken (e.g. 3DS challenge) in order to complete the payment. The status authorized is used when a scheme payment is made with manual: true option which will cause the system to authorize the payment only without capturing it. The merchant must capture the payment within time it will be voided automatically by the issuer. * @note Please note that when an issuer voids the payment, the status will be kept authorized and WILL NOT BE updated by the system. * @values `initiated`, `paid`, `authorized`, `failed`, `refunded`, `captured`, `voided`, `verified` * @see https://docs.moyasar.com/api/payments/payment-status-reference */ const PaymentStatus = { /** * ## Initiated * First status of a payment. It indicates that the payment has been created but the cardholder did not pay yet. * @hint If the payment is initiated then you need to complete the payment transaction by completing the payment challenge found in `Payment.source.transaction_url` depending on the payment source type. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=initiated */ INITIATED: "initiated", /** * ## Paid * Payment reaches this status when the cardholder pays successfully. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=paid */ PAID: "paid", /** * ## Failed * Payment reaches this status when the cardholder or merchant has a certain error that caused the payment to fail (errors are attached to the `message` attribute). * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=failed */ FAILED: "failed", /** * ## Authorized * Payment reaches this status when the merchant authorizes it to be manually captured anytime later —the cardholder is not charged yet. * @note The status authorized is used when a scheme payment is made with `manual: true` option which will cause the system to authorize the payment only without capturing it. The merchant must capture the payment within time it will be voided automatically by the issuer. * @note Please note that when an issuer voids the payment, the status will be kept authorized and **WILL NOT BE updated by the system**. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=authorized * @see https://docs.moyasar.com/api/payments/01-create-payment#responses */ AUTHORIZED: "authorized", /** * ## Captured * Payment reaches this status when the cardholder of an authorized payment is charged successfully. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=captured */ CAPTURED: "captured", /** * ## Refunded * Payment reaches this status when the merchant refunds a paid or captured payment successfully. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=refunded */ REFUNDED: "refunded", /** * ## Voided * Payment reaches this status when the merchant cancels a paid, authorized, or captured payment. It works only if the amount is not settled yet in the merchant’s bank account. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=voided */ VOIDED: "voided", /** * ## Verified * Payment reaches this status when the cardholder verifies his card in the tokenization process. * @see https://docs.moyasar.com/api/payments/payment-status-reference?_highlight=verified */ VERIFIED: "verified", }; const PaymentSource = { CREDITCARD: "creditcard", APPLEPAY: "applepay", GOOGLEPAY: "googlepay", SAMSUNGPAY: "samsungpay", STCPAY: "stcpay", TOKEN: "token", }; const CardScheme = { MADA: "mada", VISA: "visa", MASTER: "master", AMEX: "amex", }; const CardType = { DEBIT: "debit", CREDIT: "credit", CHARGE_CARD: "charge_card", UNSPECIFIED: "unspecified", }; class PaymentError extends MoyasarError { constructor(message, statusCode, details) { super(message, "PAYMENT_ERROR", statusCode, details ?? {}); this.name = "PaymentError"; } } const PaymentLimits = { MIN_AMOUNT: 50, // Minimum payment amount in smallest currency unit MAX_AMOUNT: 100000000, // Maximum payment amount (100M in smallest unit) REFUND_TIMEOUT_DAYS: 30, // Days after which refunds are not allowed CAPTURE_TIMEOUT_DAYS: 7, // Days after which authorized payments are auto-voided }; const PaymentValidation = { DESCRIPTION_MAX_LENGTH: 255, STATEMENT_DESCRIPTOR_MAX_LENGTH: 255, CARD_NUMBER_MIN_LENGTH: 16, CARD_NUMBER_MAX_LENGTH: 19, CVV_LENGTH: 3, AMEX_CVV_LENGTH: 4, SAUDI_MOBILE_REGEX: /^(0|(00|\+)?966)?(5\d{8})$/, RRN_REGEX: /^\d{12}$/, AUTH_CODE_REGEX: /^\d{6}$/, CARD_LAST_DIGITS_REGEX: /^\d{4}$/, }; const amountSchema = z.number().int().positive().min(1); const Currency = Object.freeze({ USD: 'USD', EUR: 'EUR', GBP: 'GBP', AUD: 'AUD', CAD: 'CAD', JPY: 'JPY', BYR: 'BYR', PAB: 'PAB', SOS: 'SOS', SRD: 'SRD', SSP: 'SSP', STD: 'STD', STN: 'STN', SVC: 'SVC', SYP: 'SYP', SZL: 'SZL', THB: 'THB', TJS: 'TJS', TMT: 'TMT', TND: 'TND', TOP: 'TOP', TRY: 'TRY', NPR: 'NPR', TTD: 'TTD', TWD: 'TWD', NOK: 'NOK', TZS: 'TZS', UAH: 'UAH', UGX: 'UGX', NIO: 'NIO', PEN: 'PEN', OMR: 'OMR', PGK: 'PGK', PHP: 'PHP', PKR: 'PKR', PLN: 'PLN', PYG: 'PYG', QAR: 'QAR', RON: 'RON', RSD: 'RSD', RUB: 'RUB', RWF: 'RWF', SAR: 'SAR', SBD: 'SBD', SCR: 'SCR', SDG: 'SDG', SEK: 'SEK', SGD: 'SGD', SHP: 'SHP', NZD: 'NZD', SKK: 'SKK', SLE: 'SLE', SLL: 'SLL', UYU: 'UYU', BCH: 'BCH', BTC: 'BTC', JEP: 'JEP', GGP: 'GGP', IMP: 'IMP', XFU: 'XFU', GBX: 'GBX', CNH: 'CNH', USDC: 'USDC', EEK: 'EEK', GHS: 'GHS', HRK: 'HRK', LTL: 'LTL', LVL: 'LVL', MRO: 'MRO', MTL: 'MTL', TMM: 'TMM', ZWD: 'ZWD', ZWL: 'ZWL', ZWN: 'ZWN', ZWR: 'ZWR', VEF: 'VEF', UZS: 'UZS', VES: 'VES', VND: 'VND', VUV: 'VUV', WST: 'WST', XAF: 'XAF', XAG: 'XAG', XAU: 'XAU', XBA: 'XBA', XBB: 'XBB', XBC: 'XBC', XBD: 'XBD', XCD: 'XCD', XDR: 'XDR', XOF: 'XOF', XPD: 'XPD', XPF: 'XPF', XPT: 'XPT', XTS: 'XTS', YER: 'YER', ZAR: 'ZAR', ZMK: 'ZMK', ZMW: 'ZMW', AED: 'AED', CDF: 'CDF', CHF: 'CHF', CLF: 'CLF', CLP: 'CLP', CNY: 'CNY', COP: 'COP', CRC: 'CRC', CUC: 'CUC', CUP: 'CUP', CVE: 'CVE', CZK: 'CZK', DJF: 'DJF', DKK: 'DKK', DOP: 'DOP', DZD: 'DZD', EGP: 'EGP', ERN: 'ERN', ETB: 'ETB', FJD: 'FJD', FKP: 'FKP', GEL: 'GEL', GIP: 'GIP', AFN: 'AFN', ALL: 'ALL', AMD: 'AMD', ANG: 'ANG', AOA: 'AOA', ARS: 'ARS', AWG: 'AWG', AZN: 'AZN', BAM: 'BAM', BBD: 'BBD', BDT: 'BDT', BGN: 'BGN', BHD: 'BHD', BIF: 'BIF', BMD: 'BMD', BND: 'BND', BOB: 'BOB', BRL: 'BRL', BSD: 'BSD', BTN: 'BTN', BWP: 'BWP', BYN: 'BYN', BZD: 'BZD', GMD: 'GMD', KZT: 'KZT', LAK: 'LAK', LBP: 'LBP', LKR: 'LKR', LRD: 'LRD', LSL: 'LSL', LYD: 'LYD', MAD: 'MAD', MDL: 'MDL', MGA: 'MGA', MKD: 'MKD', MMK: 'MMK', MNT: 'MNT', MOP: 'MOP', MRU: 'MRU', MUR: 'MUR', MVR: 'MVR', MWK: 'MWK', MXN: 'MXN', MYR: 'MYR', MZN: 'MZN', NAD: 'NAD', NGN: 'NGN', GNF: 'GNF', GTQ: 'GTQ', GYD: 'GYD', HKD: 'HKD', HNL: 'HNL', HTG: 'HTG', HUF: 'HUF', IDR: 'IDR', INR: 'INR', IQD: 'IQD', IRR: 'IRR', ISK: 'ISK', JMD: 'JMD', JOD: 'JOD', KES: 'KES', KGS: 'KGS', KHR: 'KHR', KMF: 'KMF', KPW: 'KPW', KRW: 'KRW', KWD: 'KWD', KYD: 'KYD', }); const currencySchema = z .enum(Currency) .transform(val => val.toUpperCase()); const paginationMetaSchema = z.object({ current_page: z.number(), next_page: z.number().nullable(), prev_page: z.number().nullable(), total_pages: z.number(), total_count: z.number(), }); const BasePaymentSourceSchema = z.object({ type: z.enum(PaymentSource), company: z.enum(CardScheme).nullable(), name: z.string().nullable(), number: z.string(), gateway_id: z.string(), message: z.string().nullable(), reference_number: z.string().nullable(), token: z.string().nullable().optional(), response_code: z.string().optional(), authorization_code: z .string() .regex(PaymentValidation.AUTH_CODE_REGEX) .optional(), issuer_name: z.string().optional(), issuer_country: z.string().optional(), issuer_card_type: z.enum(CardType).optional(), issuer_card_category: z.string().optional(), }); // Zod schemas for validation and parsing const CreditCardSourceResponseSchema = z.object({ ...BasePaymentSourceSchema.shape, type: z.literal(PaymentSource.CREDITCARD), transaction_url: z.url().nullable(), }); const WalletPaymentSourceResponseSchema = z.object({ ...BasePaymentSourceSchema.shape, type: z.enum([ PaymentSource.APPLEPAY, PaymentSource.GOOGLEPAY, PaymentSource.SAMSUNGPAY, ]), dpan: z.string().optional(), }); const StcPaySourceResponseSchema = z.object({ type: z.literal(PaymentSource.STCPAY), mobile: z.string().regex(PaymentValidation.SAUDI_MOBILE_REGEX), reference_number: z.string().optional(), cashier_id: z.string().optional(), branch: z.string().optional(), transaction_url: z.url().nullable(), message: z.string(), }); const PaymentSourceResponseSchema = z.discriminatedUnion("type", [ CreditCardSourceResponseSchema, WalletPaymentSourceResponseSchema, StcPaySourceResponseSchema, ]); // z.discriminatedUnion("type", [ // CreditCardSourceResponseSchema, // WalletPaymentSourceResponseSchema, // StcPaySourceResponseSchema, // ]); const PaymentSchema = z.object({ id: z.string(), status: z.enum(PaymentStatus), amount: amountSchema, fee: z.number().int().min(0), currency: currencySchema, refunded: z.number().int().min(0), refunded_at: z.coerce.date().nullable(), captured: z.number().int().min(0), captured_at: z.coerce.date().nullable(), voided_at: z.coerce.date().nullable(), description: z.string(), amount_format: z .string() .transform(val => val), fee_format: z.string().transform(val => val), refunded_format: z .string() .transform(val => val), captured_format: z .string() .transform(val => val), invoice_id: z.string().nullable(), ip: z.ipv4().nullable(), callback_url: z.url().nullable(), created_at: z.coerce.date(), updated_at: z.coerce.date(), metadata: z.record(z.string(), z.string()).nullable(), source: PaymentSourceResponseSchema, }); const CreditCardSourceSchema = z.object({ type: z.literal(PaymentSource.CREDITCARD), name: z .string() .min(1, "Name is required") .max(PaymentValidation.DESCRIPTION_MAX_LENGTH) .refine(val => val.trim().split(" ").length >= 2, { message: "Card holder name must be at least two words", }) .transform(val => val.trim()), number: z.string().regex(/^\d{16,19}$/, "Card number must be 16-19 digits"), month: z .number() .int("Month must be an integer") .min(1, "Month must be between 1 and 12") .max(12, "Month must be between 1 and 12"), year: z .number() .int("Year must be an integer") .min(new Date().getFullYear(), "Year cannot be in the past"), cvc: z.string().regex(/^\d{3,4}$/, "CVC must be 3-4 digits"), statement_descriptor: z .string() .max(PaymentValidation.STATEMENT_DESCRIPTOR_MAX_LENGTH) .optional(), "3ds": z.boolean().optional(), manual: z.boolean().optional(), save_card: z.boolean().optional(), }); const TokenSourceSchema = z.object({ type: z.literal(PaymentSource.TOKEN), token: z.string().regex(/^token_/, "Token must start with 'token_'"), cvc: z .string() .regex(/^\d{3,4}$/, "CVC must be 3-4 digits") .optional(), statement_descriptor: z .string() .max(PaymentValidation.STATEMENT_DESCRIPTOR_MAX_LENGTH) .optional(), "3ds": z.boolean().optional(), manual: z.boolean().optional(), }); const GooglePaySourceSchema = z.object({ type: z.literal(PaymentSource.GOOGLEPAY), token: z.string().optional(), manual: z.boolean().optional(), save_card: z.boolean().optional(), statement_descriptor: z .string() .max(PaymentValidation.STATEMENT_DESCRIPTOR_MAX_LENGTH) .optional(), }); const ApplePaySourceSchema = z.object({ type: z.literal(PaymentSource.APPLEPAY), token: z.string(), manual: z.boolean().optional(), save_card: z.boolean().optional(), statement_descriptor: z .string() .max(PaymentValidation.STATEMENT_DESCRIPTOR_MAX_LENGTH) .optional(), }); const SamsungPaySourceSchema = z.object({ type: z.literal(PaymentSource.SAMSUNGPAY), token: z.string(), manual: z.boolean().optional(), save_card: z.boolean().optional(), statement_descriptor: z .string() .max(PaymentValidation.STATEMENT_DESCRIPTOR_MAX_LENGTH) .optional(), }); const StcPaySourceSchema = z.object({ type: z.literal(PaymentSource.STCPAY), mobile: z .string() .regex(PaymentValidation.SAUDI_MOBILE_REGEX, "Invalid Saudi mobile number format"), cashier_id: z.string().optional(), branch: z.string().optional(), }); const CreatePaymentSourceSchema = z.discriminatedUnion("type", [ CreditCardSourceSchema, TokenSourceSchema, GooglePaySourceSchema, ApplePaySourceSchema, SamsungPaySourceSchema, StcPaySourceSchema, ]); const CreatePaymentSchema = z.object({ given_id: z.uuid("ID must be a valid UUID").optional(), amount: amountSchema, currency: currencySchema, description: z .string() .min(1, "Description is required") .max(PaymentValidation.DESCRIPTION_MAX_LENGTH, `Description must be less than ${PaymentValidation.DESCRIPTION_MAX_LENGTH} characters`) .transform(val => val.trim()), callback_url: z.url("Callback URL must be a valid URL"), source: CreatePaymentSourceSchema, metadata: z.record(z.string(), z.string()).optional(), apply_coupon: z.boolean().optional(), }); const UpdatePaymentSchema = z.object({ description: z .string() .min(1, "Description is required") .max(PaymentValidation.DESCRIPTION_MAX_LENGTH) .transform(val => val.trim()) .optional(), metadata: z.record(z.string(), z.string()).optional(), }); const RefundPaymentSchema = z.object({ amount: amountSchema.optional(), }); const CapturePaymentSchema = z .object({ amount: amountSchema.optional(), }) .optional(); const listPaymentResponseSchema = z.object({ payments: z.array(z.unknown()), meta: paginationMetaSchema, }); class PaymentUtils { constructor(p) { this.metadataValidator = p.metadataValidator; } /** * Validate payment creation request using Zod */ validateCreatePaymentRequest(request) { const result = CreatePaymentSchema.safeParse(request); const metadata = result.data?.metadata ? this.metadataValidator.parse(result.data?.metadata) : undefined; if (result.success) { return { success: true, data: { ...result.data, metadata, }, errors: [], }; } const errors = result.error.issues.map(err => { const path = err.path.length > 0 ? `${err.path.join(".")}: ` : ""; return `${path}${err.message}`; }); return { success: false, errors, }; } /** * Validate payment update request using Zod */ validateUpdatePaymentRequest(request) { const result = UpdatePaymentSchema.safeParse(request); const metadata = result.data?.metadata ? this.metadataValidator.parse(result.data?.metadata) : undefined; if (result.success) { return { success: true, data: { ...result.data, metadata, }, errors: [], }; } const errors = result.error.issues.map(err => { const path = err.path.length > 0 ? `${err.path.join(".")}: ` : ""; return `${path}${err.message}`; }); return { success: false, errors, }; } /** * Validate refund request using Zod */ validateRefundRequest(request) { const result = RefundPaymentSchema.safeParse(request); if (result.success) { return { success: true, data: result.data, errors: [], }; } const errors = result.error.issues.map(err => { const path = err.path.length > 0 ? `${err.path.join(".")}: ` : ""; return `${path}${err.message}`; }); return { success: false, errors, }; } /** * Validate capture request using Zod */ validateCaptureRequest(request) { const result = CapturePaymentSchema.safeParse(request); if (result.success) { return { success: true, data: result.data, errors: [], }; } const errors = result.error.issues.map(err => { const path = err.path.length > 0 ? `${err.path.join(".")}: ` : ""; return `${path}${err.message}`; }); return { success: false, errors, }; } /** * Format amount for display */ formatAmount(amount, currency) { const divisors = { KWD: 1000, JPY: 1, SAR: 100, USD: 100, EUR: 100, }; const divisor = divisors[currency] ?? 100; const formattedAmount = (amount / divisor).toFixed(divisor === 1 ? 0 : 2); return `${formattedAmount} ${currency}`; } /** * Parse amount from display format to smallest unit */ parseAmount(formattedAmount, currency) { const divisors = { KWD: 1000, JPY: 1, SAR: 100, USD: 100, EUR: 100, }; const amount = parseFloat(formattedAmount.replace(/[^\d.]/g, "")); const divisor = divisors[currency.toUpperCase()] || 100; return Math.round(amount * divisor); } /** * Check if payment is in a final state */ isPaymentFinal(status) { const finalStatuses = [ PaymentStatus.PAID, PaymentStatus.FAILED, PaymentStatus.REFUNDED, PaymentStatus.CAPTURED, PaymentStatus.VOIDED, PaymentStatus.VERIFIED, ]; return finalStatuses.includes(status); } /** * Check if payment can be refunded */ canRefundPayment(payment) { const refundableStatuses = [ PaymentStatus.PAID, PaymentStatus.CAPTURED, ]; // Check if status allows refund and if there's refundable amount const statusAllowed = refundableStatuses.includes(payment.status); const hasRefundableAmount = payment.captured - payment.refunded > 0; // Check if within refund timeout (if refunded_at exists, can't refund again) const notFullyRefunded = payment.refunded < payment.captured; return statusAllowed && hasRefundableAmount && notFullyRefunded; } /** * Check if payment can be captured */ canCapturePayment(payment) { return payment.status === PaymentStatus.AUTHORIZED; } /** * Check if payment can be voided */ canVoidPayment(payment) { return payment.status === PaymentStatus.AUTHORIZED; } /** * Get maximum refund amount for a payment */ getMaxRefundAmount(payment) { return Math.max(0, payment.captured - payment.refunded); } /** * Get maximum capture amount for an authorized payment */ getMaxCaptureAmount(payment) { if (payment.status !== PaymentStatus.AUTHORIZED) { return 0; } return payment.amount; } /** * Check if card scheme matches expected CVV length */ validateCvcLength(cvc, scheme) { if (scheme === CardScheme.AMEX) { return cvc.length === PaymentValidation.AMEX_CVV_LENGTH; } return cvc.length === PaymentValidation.CVV_LENGTH; } /** * Mask card number for display (show first 6 and last 4 digits) */ maskCardNumber(cardNumber) { if (cardNumber.length < 10) { return cardNumber; } const first6 = cardNumber.substring(0, 6); const last4 = cardNumber.substring(cardNumber.length - 4); const middle = "*".repeat(cardNumber.length - 10); return `${first6}${middle}${last4}`; } /** * Get last 4 digits of card number */ getCardLast4(cardNumber) { return cardNumber.substring(cardNumber.length - 4); } /** * Build metadata query parameters for filtering */ buildMetadataQuery(metadata) { const query = {}; Object.entries(metadata).forEach(([key, value]) => { query[`metadata[${key}]`] = value; }); return query; } /** * Sanitize payment description */ sanitizeDescription(description) { return description .trim() .substring(0, PaymentValidation.DESCRIPTION_MAX_LENGTH); } /** * Generate idempotency key for payment */ generateIdempotencyKey(prefix = "pay") { const timestamp = Date.now().toString(36); const random = Math.random().toString(36).substring(2, 8); return `${prefix}_${timestamp}_${random}`; } /** * Check if payment method requires 3DS by default */ requires3DS(source) { // Credit cards typically require 3DS unless explicitly bypassed if (source.type === PaymentSource.CREDITCARD) return source["3ds"] !== false; // Undefined maps to true by default // Wallet payments usually handle their own authentication return false; } /** * Parse and validate a Payment response, ensuring all data types are correct */ parsePayment(payment) { const parsed = PaymentSchema.parse(payment); const metadata = parsed.metadata ? this.metadataValidator.parse(parsed.metadata) : undefined; return { ...parsed, metadata, }; } parseListPaymentsResponse(response) { const parsed = listPaymentResponseSchema.parse(response); const payments = parsed.payments.map(payment => this.parsePayment(payment)); return { ...parsed, payments, }; } /** * Parse and validate an array of Payment responses */ parsePayments(payments) { if (!Array.isArray(payments)) { throw new Error("Expected payments to be an array"); } return payments.map(payment => this.parsePayment(payment)); } /** * Safely parse a Payment response with error handling */ safeParsePayment(payment) { try { const parsedPayment = this.parsePayment(payment); return { success: true, data: parsedPayment, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown parsing error", }; } } } class PaymentService { constructor(p) { this.apiClient = p.apiClient; this.paymentUtils = new PaymentUtils({ metadataValidator: p.apiClient.metadataValidator, }); } /** * Create a new payment */ async create(params) { // Validate input const validation = this.paymentUtils.validateCreatePaymentRequest(params); if (!validation.success) { throw new PaymentError(`Validation failed: ${validation.errors.join(", ")}`, 400); } try { const response = await this.apiClient.request({ method: "POST", url: API_ENDPOINTS.payments, data: params, }); return this.paymentUtils.parsePayment(response); } catch (error) { const paymentError = this.handleError(error, `Failed to create payment`); throw paymentError; } } /** * List payments with optional filtering */ async list(options = {}) { try { // Convert metadata filters to proper query format const queryParams = this.parseBody(options); const response = await this.apiClient.request({ method: "GET", url: API_ENDPOINTS.payments, params: queryParams, }); const parsed = this.paymentUtils.parseListPaymentsResponse(response); return parsed; } catch (error) { const paymentError = this.handleError(error, "Failed to list payments"); throw paymentError; } } /** * Retrieve a specific payment */ async retrieve(paymentId) { if (!paymentId) throw new PaymentError("Payment ID is required", 400); try { const response = await this.apiClient.request({ method: "GET", url: `${API_ENDPOINTS.payments}/${paymentId}`, }); // Parse and validate the response const payment = this.paymentUtils.parsePayment(response); return payment; } catch (error) { const paymentError = this.handleError(error, `Failed to retrieve payment ${paymentId}`); throw paymentError; } } /** * Update a payment */ async update({ paymentId, update, }) { if (!paymentId) { throw new PaymentError("Payment ID is required", 400); } // Validate input const validation = this.paymentUtils.validateUpdatePaymentRequest(update); if (!validation.success) { throw new PaymentError(`Validation failed: ${validation.errors.join(", ")}`, 400); } try { const response = await this.apiClient.request({ method: "PUT", url: `${API_ENDPOINTS.payments}/${paymentId}`, data: update, }); return response; } catch (error) { const paymentError = this.handleError(error, `Failed to update payment ${paymentId}`); throw paymentError; } } /** * Refund a payment (full or partial) */ async refund({ paymentId, refund, }) { if (!paymentId) { throw new PaymentError("Payment ID is required", 400); } refund.amount; // Validate input const validation = this.paymentUtils.validateRefundRequest(refund); if (!validation.success) { throw new PaymentError(`Validation failed: ${validation.errors.join(", ")}`, 400); } try { const response = await this.apiClient.request({ method: "POST", url: `${API_ENDPOINTS.payments}/${paymentId}/refund`, data: refund, }); // Parse and validate the response const payment = this.paymentUtils.parsePayment(response); return payment; } catch (error) { const paymentError = this.handleError(error, `Failed to refund payment ${paymentId}`); throw paymentError; } } /** * Capture an authorized payment (full or partial) */ async capture({ paymentId, capture, }) { if (!paymentId) throw new PaymentError("Payment ID is required", 400); // Validate input const validation = this.paymentUtils.validateCaptureRequest(capture); if (!validation.success) { throw new PaymentError(`Validation failed: ${validation.errors}`, 400, { errors: validation.errors, }); } try { const response = await this.apiClient.request({ method: "POST", url: `${API_ENDPOINTS.payments}/${paymentId}/capture`, data: capture, }); // Parse and validate the response const payment = this.paymentUtils.parsePayment(response); return payment; } catch (error) { const paymentError = this.handleError(error, `Failed to capture payment ${paymentId}`); throw paymentError; } } /** * Void an authorized payment */ async void(paymentId) { if (!paymentId) throw new PaymentError("Payment ID is required", 400); try { const response = await this.apiClient.request({ method: "POST", url: `${API_ENDPOINTS.payments}/${paymentId}/void`, }); // Parse and validate the response const payment = this.paymentUtils.parsePayment(response); return payment; } catch (error) { const paymentError = this.handleError(error, `Failed to void payment ${paymentId}`); throw paymentError; } } /** * Search payments by metadata */ async searchByMetadata({ metadata, options, }) { const metadataQuery = this.paymentUtils.buildMetadataQuery(metadata); return this.list({ ...options, ...metadataQuery, }); } /** * Get payments by status */ async getByStatus(status, options = {}) { return this.list({ ...options, status, }); } /** * Get paid payments */ async getPaid(options = {}) { return this.getByStatus(PaymentStatus.PAID, options); } /** * Get failed payments */ async getFailed(options = {}) { return this.getByStatus(PaymentStatus.FAILED, options); } /** * Get authorized payments */ async getAuthorized(options = {}) { return this.getByStatus(PaymentStatus.AUTHORIZED, options); } /** * Get refunded payments */ async getRefunded(options = {}) { return this.getByStatus(PaymentStatus.REFUNDED, options); } /** * Get payments by card last 4 digits */ async getByCardLast4({ last4, options, }) { if (!/^\d{4}$/.test(last4)) { throw new PaymentError("Last 4 digits must be exactly 4 digits", 400); } return this.list({ ...options, last_4: last4, }); } /** * Get payments by RRN (Retrieval Reference Number) */ async getByRRN({ rrn, options, }) { if (!/^\d{12}$/.test(rrn)) { throw new PaymentError("RRN must be exactly 12 digits", 400); } return this.list({ ...options, rrn, }); } /** * Check payment capabilities (what actions can be performed) */ async getPaymentCapabilities(paymentId) { const payment = await this.retrieve(paymentId); return { canRefund: this.paymentUtils.canRefundPayment(payment), canCapture: this.paymentUtils.canCapturePayment(payment), canVoid: this.paymentUtils.canVoidPayment(payment), maxRefundAmount: this.paymentUtils.getMaxRefundAmount(payment), maxCaptureAmount: this.paymentUtils.getMaxCaptureAmount(payment), }; } handleError(error, message) { if (error instanceof PaymentError) return error; if (error instanceof MoyasarError) { return new PaymentError(`${message}: ${error.message}`, error.statusCode, { ...error.details }); } const errorMessage = error?.message || error?.toString() || "Unknown error"; return new PaymentError(`${message}: ${errorMessage}`, 500, { cause: errorMessage, }); } parseBody(p) { const copied = { ...p }; Object.entries(copied).forEach(([key, value]) => { if (typeof value === "object") { if (value instanceof Date) { // @ts-expect-error copied[key] = value.toISOString(); } } }); return copied; } } class WebhookService extends TypedEmitter { constructor(params) { super(); this.events = Object.values(WebhookEvent); this.apiClient = params.apiClient; this.paymentUtils = new PaymentUtils({ metadataValidator: params.apiClient.metadataValidator, }); } /** * Create a new webhook */ async create(params) { // Validate input this.validateCreateRequest(params); try { const webhook = await this.apiClient.request({ method: "POST", url: API_ENDPOINTS.webhooks, data: params, }); return webhook; } catch (error) { const webhookError = this.handleError(error, "Failed to create webhook"); throw webhookError; } } /** * List all webhooks */ async list(options = {}) { try { return await this.apiClient.request({ method: "GET", url: API_ENDPOINTS.webhooks, params: options, }); } catch (error) { const webhookError = this.handleError(error, "Failed to list webhooks");