import { parsePhoneNumber } from "libphonenumber-js"; import { MobileMoneyCheckoutOptions, CheckoutResult, Currency, PaymentMethod, PaymentProvider, RefundOptions, RefundResult, TransactionStatus, CreditCardCheckoutOptions, MobileMoneyPayoutOptions, PayoutResult, RedirectCheckoutOptions, } from "../payment-provider.interface"; import { ApisauceInstance, create } from "apisauce"; import EventEmitter2 from "eventemitter2"; import { PaymentError, PaymentErrorType } from "../payment-error"; import { isBuffer, isObject, isString, pick } from "lodash"; import { PaymentEventType, PaymentFailedEvent, PaymentSuccessfulEvent, } from "../payment-events"; export enum TaarihTransactionStatus { PENDING = "PENDING", COMPLETED = "COMPLETED", FAILED = "FAILED", } enum TaarihCreditCardPaymentProcessor { CINETPAY = "CINETPAY", STRIPE = "STRIPE", } class TaarihPaymentProvider implements PaymentProvider { private api: ApisauceInstance; private eventEmitter?: EventEmitter2; constructor(private config: TaarihPaymentProviderConfig) { this.api = create({ baseURL: config.mode === "test" ? "https://api-dev.taarih.com/api" : "https://api.taarih.com/api", headers: { "Content-Type": "application/json", }, }); this.api.addResponseTransform((response) => { if (!response.ok) { const defaultErrorMessage = "Taarih error: " + response.problem + ". Status: " + response.status + ". Data: " + JSON.stringify(response.data); console.error(response); throw new PaymentError( response.data && isObject(response.data) ? "message" in response.data ? String(response.data.message) : "response_text" in response.data ? String(response.data.response_text) : defaultErrorMessage : defaultErrorMessage ); } }); } useEventEmitter(eventEmitter: EventEmitter2) { this.eventEmitter = eventEmitter; } private getTaarihPaymentMethod( paymentMethod: PaymentMethod, processor?: TaarihCreditCardPaymentProcessor ) { switch (paymentMethod) { case PaymentMethod.WAVE: return "WAVE"; case PaymentMethod.ORANGE_MONEY: return "OM"; case PaymentMethod.CREDIT_CARD: if ( !processor || !Object.values(TaarihCreditCardPaymentProcessor).includes(processor) ) { throw new PaymentError( "Taarih error: Invalid or missing credit card processor", PaymentErrorType.UNSUPPORTED_PAYMENT_METHOD ); } return processor; default: throw new PaymentError("Invalid payment method: " + paymentMethod); } } async login() { const signEndUserResponse = await this.api.post< SignEndUserOtpRequiredResponse | SignEndUserSuccessResponse, TaarihApiErrorResponse >("/auth/signin-end-user", { callingCode: this.config.callingCode, phoneNumber: this.config.phoneNumber, authMode: "SMS", visitorId: this.config.visitorId, password: this.config.password, }); if (signEndUserResponse.data && "otpRequired" in signEndUserResponse.data) { throw new PaymentError( "Taarih error: " + signEndUserResponse.data.message ); } if (signEndUserResponse.data && "invalidData" in signEndUserResponse.data) { throw new PaymentError( "Taarih error: " + signEndUserResponse.data.message + " " + signEndUserResponse.data.invalidData ? JSON.stringify(signEndUserResponse.data.invalidData) : "" ); } if (signEndUserResponse.data && "token" in signEndUserResponse.data) { return signEndUserResponse.data; } throw new PaymentError("Taarih error: No token in response"); } async checkout( options: MobileMoneyCheckoutOptions | CreditCardCheckoutOptions ): Promise { let operationCode: string = ""; if (!("operationCode" in options)) { operationCode = options.paymentMethod === PaymentMethod.WAVE ? "PAY_WITH_WAVE" : "PAY_WITH_OM"; } else if ("operationCode" in options && options.operationCode) { operationCode = options.operationCode; } else { throw new PaymentError( "Taarih error: operation code is required", PaymentErrorType.INVALID_OPERATION_CODE ); } if (options.currency !== Currency.XOF) { throw new PaymentError( "Taarih does not support the currency: " + options.currency, PaymentErrorType.UNSUPPORTED_PAYMENT_METHOD ); } const isMobileMoney = options.paymentMethod === PaymentMethod.WAVE || options.paymentMethod === PaymentMethod.ORANGE_MONEY; const isCreditCard = options.paymentMethod === PaymentMethod.CREDIT_CARD; const user = await this.login(); const companyId = user.legalEntityId; let taarihCheckoutResponse; taarihCheckoutResponse = await this.api.post< TaarihCheckoutSuccessResponse, TaarihApiErrorResponse >( "/transaction/pos-payment", { companyId, amount: options.amount, countryCode: this.config.callingCode, mobileNumber: options.customer.phoneNumber, paymentMethod: this.getTaarihPaymentMethod( options.paymentMethod, options.metadata?.processor as TaarihCreditCardPaymentProcessor ), operationCode, firstName: options.customer.firstName, lastName: options.customer.lastName, currency: options.currency, externalTransactionId: options.transactionId, accountNumber: options.accountNumber || "", employeeId: options.employeeId }, { headers: { ...this.api.headers, "x-access-token": user.token, }, } ); if (isMobileMoney) { const parsedCustomerPhoneNumber = parsePhoneNumber( options.customer.phoneNumber, "SN" ); if (!parsedCustomerPhoneNumber.isValid()) { throw new PaymentError( "Invalid phone number: " + options.customer.phoneNumber, PaymentErrorType.INVALID_PHONE_NUMBER ); } if (!parsedCustomerPhoneNumber.isPossible()) { throw new PaymentError( "Phone number is not possible: " + options.customer.phoneNumber, PaymentErrorType.INVALID_PHONE_NUMBER ); } } if (!taarihCheckoutResponse) { throw new PaymentError("Taarih error: no payment response data"); } if (isCreditCard) { taarihCheckoutResponse.data = ( taarihCheckoutResponse.data as unknown as TaarihCreditCardCheckoutSuccessResponse ).results?.[0]; } if ( taarihCheckoutResponse.data && "externalId" in taarihCheckoutResponse.data && "internalId" in taarihCheckoutResponse.data && "payment_link" in taarihCheckoutResponse.data ) { const result: CheckoutResult = { transactionAmount: options.amount, transactionCurrency: options.currency, transactionId: taarihCheckoutResponse.data.internalId, transactionReference: taarihCheckoutResponse.data.externalId, transactionStatus: TransactionStatus.PENDING, redirectUrl: taarihCheckoutResponse.data.payment_link, }; return result; } throw new PaymentError("Taarih error: response data is not valid"); } async checkoutMobileMoney( options: MobileMoneyCheckoutOptions ): Promise { return this.checkout(options); } async checkoutCreditCard( options: CreditCardCheckoutOptions ): Promise { return this.checkout(options); } async checkoutRedirect( options: RedirectCheckoutOptions ): Promise { throw new PaymentError( "Taarih redirect checkout not yet implemented", PaymentErrorType.UNSUPPORTED_PAYMENT_METHOD ); } async refund(options: RefundOptions): Promise { throw new PaymentError( "Taarih refund not yet implemented", PaymentErrorType.UNSUPPORTED_PAYMENT_METHOD ); } async payoutMobileMoney( options: MobileMoneyPayoutOptions ): Promise { throw new PaymentError( "Taarih payout not yet implemented", PaymentErrorType.UNSUPPORTED_PAYMENT_METHOD ); } async handleWebhook(rawBody: Buffer | string | Record) { if (isBuffer(rawBody) || isString(rawBody)) { console.error( "Paydunya webhook body must be a parsed object, not the raw body" ); return null; } const body = rawBody as TaarihPaymentWebhookBody; const taarihTransactionStatusResponse = await this.callback( body.transactionId, body.timeInterval || 5000, body.maxAttempts || 20 ); if ( taarihTransactionStatusResponse.status === TaarihTransactionStatus.COMPLETED ) { const paymentSuccessfulEvent: PaymentSuccessfulEvent = { type: PaymentEventType.PAYMENT_SUCCESSFUL, paymentMethod: "" as PaymentMethod, transactionAmount: taarihTransactionStatusResponse.amount, transactionCurrency: Currency.XOF, transactionId: body.transactionId, transactionReference: body.transactionId, paymentProvider: TaarihPaymentProvider.name, }; this.eventEmitter?.emit( PaymentEventType.PAYMENT_SUCCESSFUL, paymentSuccessfulEvent ); return paymentSuccessfulEvent; } const paymentFailedEvent: PaymentFailedEvent = { type: PaymentEventType.PAYMENT_FAILED, paymentMethod: "" as PaymentMethod, transactionAmount: taarihTransactionStatusResponse.amount, transactionCurrency: Currency.XOF, transactionId: body.transactionId, transactionReference: body.transactionId, reason: "Payment failed", paymentProvider: TaarihPaymentProvider.name, }; this.eventEmitter?.emit( PaymentEventType.PAYMENT_FAILED, paymentFailedEvent ); return paymentFailedEvent; } async callback( internalId: string, timeInterval: number = 3000, maxAttempts: number = 4 ) { const user = await this.login(); let attempts = 0; while (true) { const taarihTransactionStatusResponse = await this.api.get< TaarihTransactionStatusSuccessResponse, TaarihApiErrorResponse >( `/transaction/verify-transaction-status/${internalId}`, {}, { headers: { ...this.api.headers, "x-access-token": user.token, }, } ); if ( taarihTransactionStatusResponse.data && "invalidData" in taarihTransactionStatusResponse.data ) { throw new PaymentError( "Taarih error: " + taarihTransactionStatusResponse.data.message ); } const { data } = taarihTransactionStatusResponse; if (!data) { throw new PaymentError("Taarih error: no transaction status data"); } const status = data.status; if (status !== TaarihTransactionStatus.PENDING) { return pick(data, [ "status", "amount", "currency", "bankAccountSender", ]); } attempts++; if (attempts >= maxAttempts) { return pick(data, [ "status", "amount", "currency", "bankAccountSender", ]); } await new Promise((resolve) => setTimeout(resolve, timeInterval)); } } } export type TaarihPaymentProviderConfig = { phoneNumber: string; password: string; mode: "test" | "live"; visitorId: string; callingCode: string; }; export type SignEndUserOtpRequiredResponse = { otpRequired: boolean; message: string; token: string; }; export type SignEndUserSuccessResponse = { token: string; id: number; legalEntityId: number; legalEntityName: string; refreshToken: string; firstName: string; lastName: string; phoneNumber: string; userBankAccounts: { id: number; commercial_name: string; technical_name: string; }[]; }; export type TaarihApiErrorResponse = { statusCode: number; timestamp: string; path: string; message: string; invalidData: Record | null; }; export type TaarihCheckoutSuccessResponse = { externalId: string; payment_link: string; internalId: string; }; export type TaarihCreditCardCheckoutSuccessResponse = { results?: TaarihCheckoutSuccessResponse[]; }; export type TaarihCheckoutPreAuthSuccessResponse = { amount: number; fees: number; totalAmount: number; paymentMethod: string; }; export type TaarihPaymentWebhookBody = { transactionId: string; maxAttempts: number; timeInterval: number; }; export type TaarihTransactionStatusSuccessResponse = { status: string; amount: number; currency: string; bankAccountSender: string | null; bankAccount: { id: number; refId: string; commercial_name: string; technical_name: string; type: string; mainAccountNumber: string; subAccountNumber: ( | { subAccountNumber: string; financialSubAccount: string; } | { subAccountNumber: string; financialSubAccount: string; }[] )[]; externalId: string | null; lettrable: any | null; rules: any | null; active: boolean; createdAt: string; updatedAt: string; accountOwnerId: number | null; legalEntityOwnerId: number; technicalAccountType: string; balance: number; attributions: any[]; partnerAccountNumber: string | null; financialProductId: number; status: string; }; }; export default TaarihPaymentProvider;