@tecafrik/africa-payment-sdk
Version:
A single SDK to integrate all african payment providers seamlessly
753 lines (671 loc) • 22.5 kB
text/typescript
import { parsePhoneNumber, PhoneNumber } from "libphonenumber-js";
import {
MobileMoneyCheckoutOptions,
CheckoutResult,
Currency,
PaymentMethod,
PaymentProvider,
RefundOptions,
RefundResult,
TransactionStatus,
CreditCardCheckoutOptions,
MobileMoneyPayoutOptions,
PayoutResult,
RedirectCheckoutOptions,
OrangeMoneyCheckoutOptions,
} from "../payment-provider.interface";
import { ApisauceInstance, create } from "apisauce";
import EventEmitter2 from "eventemitter2";
import {
PaymentCancelledEvent,
PaymentEventType,
PaymentFailedEvent,
PaymentInitiatedEvent,
PaymentSuccessfulEvent,
} from "../payment-events";
import { createHash } from "crypto";
import { PaymentError, PaymentErrorType } from "../payment-error";
import { isBuffer, isObject, isString } from "lodash";
class PaydunyaPaymentProvider implements PaymentProvider {
private api: ApisauceInstance;
private eventEmitter?: EventEmitter2;
private masterKeySha512Hash: string;
constructor(private config: PaydunyaPaymentProviderConfig) {
this.api = create({
baseURL:
config.mode === "test"
? "https://app.sandbox.paydunya.com/api/"
: "https://app.paydunya.com/api/",
headers: {
"Content-Type": "application/json",
"PAYDUNYA-MASTER-KEY": config.masterKey,
"PAYDUNYA-PRIVATE-KEY": config.privateKey,
"PAYDUNYA-PUBLIC-KEY": config.publicKey,
"PAYDUNYA-TOKEN": config.token,
},
});
this.api.addResponseTransform((response) => {
if (
response.config?.url?.endsWith("/softpay/new-orange-money-senegal") &&
response.status === 422 &&
response.data?.message === "Invalid or expired OTP code!"
) {
throw new PaymentError(
response.data.message,
PaymentErrorType.INVALID_AUTHORIZATION_CODE
);
}
if (!response.ok) {
const defaultErrorMessage =
"Paydunya 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
);
}
});
const hash = createHash("sha512");
hash.update(this.config.masterKey, "utf-8");
this.masterKeySha512Hash = hash.digest("hex");
}
useEventEmitter(eventEmitter: EventEmitter2) {
this.eventEmitter = eventEmitter;
}
private determineOrangeMoneyApiType(
options: OrangeMoneyCheckoutOptions
): PaydunyaOrangeMoneyApiType {
return options.authorizationCode ? "OTPCODE" : "QRCODE";
}
private buildOrangeMoneyRequest(
options: OrangeMoneyCheckoutOptions,
invoiceToken: string,
parsedPhoneNumber: PhoneNumber
): PaydunyaOrangeMoneyRequest {
const apiType = this.determineOrangeMoneyApiType(options);
const basePayload = {
customer_name: `${options.customer.firstName || ""} ${
options.customer.lastName || ""
}`.trim(),
customer_email: `${options.customer.phoneNumber}@yopmail.com`,
phone_number: parsedPhoneNumber.nationalNumber,
invoice_token: invoiceToken,
api_type: apiType,
};
if (apiType === "OTPCODE") {
return {
...basePayload,
authorization_code: options.authorizationCode!,
api_type: "OTPCODE",
};
}
return {
...basePayload,
api_type: "QRCODE",
};
}
async checkout(
options: MobileMoneyCheckoutOptions | CreditCardCheckoutOptions
): Promise<CheckoutResult> {
if (options.currency !== Currency.XOF) {
throw new PaymentError(
"Paydunya does not support the currency: " + options.currency,
PaymentErrorType.UNSUPPORTED_PAYMENT_METHOD
);
}
const isMobileMoney =
options.paymentMethod === PaymentMethod.WAVE ||
options.paymentMethod === PaymentMethod.ORANGE_MONEY;
const createInvoiceResponse = await this.api.post<
PaydunyaCreateInvoiceSuccessResponse,
PaydunyaCreateInvoiceErrorResponse
>("v1/checkout-invoice/create", {
invoice: {
total_amount: options.amount,
description: options.description,
},
store: {
name: this.config.store.name,
},
channels:
options.paymentMethod === PaymentMethod.CREDIT_CARD ? ["card"] : null,
custom_data: {
transaction_id: options.transactionId,
...options.metadata,
},
actions: {
cancel_url: options.failureRedirectUrl,
return_url: options.successRedirectUrl,
},
});
const invoiceData = createInvoiceResponse.data;
if (!invoiceData) {
throw new PaymentError(
"Paydunya error: " + createInvoiceResponse.problem
);
}
if (invoiceData.response_code !== "00") {
throw new PaymentError("Paydunya error: " + invoiceData.response_text);
}
if (!("token" in invoiceData)) {
throw new PaymentError(
"Missing invoice token in Paydunya response: " +
invoiceData.response_text
);
}
const invoiceToken = invoiceData.token;
let paydunyaPaymentResponse:
| PaydunyaWavePaymentSuccessResponse
| PaydunyaOrangeMoneyPaymentSuccessResponse
| null = null;
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 (options.paymentMethod === PaymentMethod.WAVE) {
const paydunyaWaveResponse = await this.api.post<
PaydunyaWavePaymentSuccessResponse,
PaydunyaWavePaymentErrorResponse
>("/v1/softpay/wave-senegal", {
wave_senegal_fullName: `${options.customer.firstName || ""} ${
options.customer.lastName || ""
}`.trim(),
wave_senegal_email: `${options.customer.phoneNumber}@yopmail.com`,
wave_senegal_phone: parsedCustomerPhoneNumber.nationalNumber,
wave_senegal_payment_token: invoiceToken,
});
const waveData = paydunyaWaveResponse.data;
if (!waveData) {
throw new PaymentError(
"Paydunya error: " + paydunyaWaveResponse.problem
);
}
if (!waveData.success) {
throw new PaymentError("Paydunya error: " + waveData.message);
}
if (!waveData.url) {
throw new PaymentError(
"Missing wave payment url in Paydunya response: " + waveData.message
);
}
paydunyaPaymentResponse = waveData;
} else if (options.paymentMethod === PaymentMethod.ORANGE_MONEY) {
const requestPayload = this.buildOrangeMoneyRequest(
options,
invoiceToken,
parsedCustomerPhoneNumber
);
const paydunyaOrangeMoneyResponse = await this.api.post<
PaydunyaOrangeMoneyPaymentSuccessResponse,
PaydunyaOrangeMoneyPaymentErrorResponse
>("/v1/softpay/new-orange-money-senegal", requestPayload);
const orangeMoneyData = paydunyaOrangeMoneyResponse.data;
if (!orangeMoneyData) {
throw new PaymentError(
"Paydunya error: " + paydunyaOrangeMoneyResponse.problem
);
}
if (!orangeMoneyData.success) {
throw new PaymentError("Paydunya error: " + orangeMoneyData.message);
}
paydunyaPaymentResponse = orangeMoneyData;
}
}
if (
options.paymentMethod !== PaymentMethod.CREDIT_CARD &&
!paydunyaPaymentResponse
) {
throw new PaymentError("Paydunya error: no payment response data");
}
let redirectUrl = undefined;
if (options.paymentMethod === PaymentMethod.CREDIT_CARD) {
redirectUrl = invoiceData.response_text;
} else if (
paydunyaPaymentResponse &&
"other_url" in paydunyaPaymentResponse &&
paydunyaPaymentResponse.other_url &&
"maxit_url" in paydunyaPaymentResponse.other_url
) {
redirectUrl = paydunyaPaymentResponse.other_url.maxit_url;
} else if (paydunyaPaymentResponse && "url" in paydunyaPaymentResponse) {
redirectUrl = paydunyaPaymentResponse.url;
}
const result: CheckoutResult = {
transactionAmount: options.amount,
transactionCurrency: options.currency,
transactionId: options.transactionId,
transactionReference: invoiceToken,
transactionStatus: TransactionStatus.PENDING,
redirectUrl,
};
const paymentInitiatedEvent: PaymentInitiatedEvent = {
type: PaymentEventType.PAYMENT_INITIATED,
paymentMethod: options.paymentMethod,
transactionId: options.transactionId,
transactionAmount: options.amount,
transactionCurrency: options.currency,
transactionReference: invoiceToken,
redirectUrl,
metadata: options.metadata,
paymentProvider: PaydunyaPaymentProvider.name,
};
this.eventEmitter?.emit(
PaymentEventType.PAYMENT_INITIATED,
paymentInitiatedEvent
);
return result;
}
async checkoutMobileMoney(
options: MobileMoneyCheckoutOptions
): Promise<CheckoutResult> {
return this.checkout(options);
}
async checkoutCreditCard(
options: CreditCardCheckoutOptions
): Promise<CheckoutResult> {
return this.checkout(options);
}
async checkoutRedirect(
options: RedirectCheckoutOptions
): Promise<CheckoutResult> {
throw new PaymentError(
"Paydunya redirect checkout not yet implemented",
PaymentErrorType.UNSUPPORTED_PAYMENT_METHOD
);
}
async refund(options: RefundOptions): Promise<RefundResult> {
const transactionReference = options.refundedTransactionReference;
const getInvoiceResponse = await this.api.get<
PaydunyaGetInvoiceSuccessResponse,
PaydunyaGetInvoiceErrorResponse
>(`/v1/checkout-invoice/confirm/${transactionReference}`);
if (!getInvoiceResponse.data) {
throw new PaymentError("Paydunya error: " + getInvoiceResponse.problem);
}
if (getInvoiceResponse.data.response_code !== "00") {
throw new PaymentError(
"Paydunya error: " + getInvoiceResponse.data.response_text
);
}
if (!("invoice" in getInvoiceResponse.data)) {
throw new PaymentError(
"Missing invoice in Paydunya response: " +
getInvoiceResponse.data.response_text
);
}
const invoiceToRefund = getInvoiceResponse.data.invoice;
const amountToRefund =
options.refundedAmount || parseInt(invoiceToRefund.total_amount, 10);
const createDisburseInvoiceResponse = await this.api.post<
PaydunyaCreateDisburseInvoiceSuccessResponse,
PaydunyaCreateDisburseInvoiceErrorResponse
>("/v2/disburse/get-invoice", {
account_alias: getInvoiceResponse.data.customer.phone,
amount: amountToRefund,
withdraw_mode: getInvoiceResponse.data.customer.payment_method.replace(
/_/g,
"-"
),
disburse_id: options.transactionId,
callback_url: this.config.callbackUrl,
});
if (!createDisburseInvoiceResponse.data) {
throw new PaymentError(
"Paydunya error: " + createDisburseInvoiceResponse.problem
);
}
if (createDisburseInvoiceResponse.data.response_code !== "00") {
throw new PaymentError(
"Paydunya error: " + createDisburseInvoiceResponse.data.response_text
);
}
if (!("disburse_token" in createDisburseInvoiceResponse.data)) {
throw new PaymentError(
"Missing disburse token in Paydunya response: " +
createDisburseInvoiceResponse.data.response_text
);
}
const submitDisburseInvoiceResponse = await this.api.post<
PaydunyaSubmitDisburseInvoiceSuccessResponse,
PaydunyaSubmitDisburseInvoiceErrorResponse
>("/v2/disburse/submit-invoice", {
disburse_invoice: createDisburseInvoiceResponse.data.disburse_token,
disburse_id: options.transactionId,
});
if (!submitDisburseInvoiceResponse.data) {
throw new PaymentError(
"Paydunya error: " + submitDisburseInvoiceResponse.problem
);
}
if (submitDisburseInvoiceResponse.data.response_code !== "00") {
throw new PaymentError(
"Paydunya error: " + submitDisburseInvoiceResponse.data.response_text
);
}
return {
transactionAmount: amountToRefund,
transactionId: options.transactionId,
transactionReference: createDisburseInvoiceResponse.data.disburse_token,
transactionCurrency: Currency.XOF,
transactionStatus: TransactionStatus.SUCCESS,
};
}
async payoutMobileMoney(
options: MobileMoneyPayoutOptions
): Promise<PayoutResult> {
const paymentMethodToWithdrawMode: Record<
MobileMoneyPayoutOptions["paymentMethod"],
string
> = {
[PaymentMethod.WAVE]: "wave-senegal",
[PaymentMethod.ORANGE_MONEY]: "orange-money-senegal",
};
const parsedRecipientPhoneNumber = parsePhoneNumber(
options.recipient.phoneNumber,
"SN"
);
if (!parsedRecipientPhoneNumber.isValid()) {
throw new PaymentError(
"Invalid phone number: " + options.recipient.phoneNumber,
PaymentErrorType.INVALID_PHONE_NUMBER
);
}
if (!parsedRecipientPhoneNumber.isPossible()) {
throw new PaymentError(
"Phone number is not possible: " + options.recipient.phoneNumber,
PaymentErrorType.INVALID_PHONE_NUMBER
);
}
const createDisburseInvoiceResponse = await this.api.post<
PaydunyaCreateDisburseInvoiceSuccessResponse,
PaydunyaCreateDisburseInvoiceErrorResponse
>("/v2/disburse/get-invoice", {
account_alias: parsedRecipientPhoneNumber.nationalNumber,
amount: options.amount,
disburse_id: options.transactionId,
withdraw_mode: paymentMethodToWithdrawMode[options.paymentMethod],
callback_url: this.config.callbackUrl,
});
if (!createDisburseInvoiceResponse.data) {
throw new PaymentError(
"Paydunya error: " + createDisburseInvoiceResponse.problem
);
}
if (createDisburseInvoiceResponse.data.response_code !== "00") {
throw new PaymentError(
"Paydunya error: " + createDisburseInvoiceResponse.data.response_text
);
}
if (!("disburse_token" in createDisburseInvoiceResponse.data)) {
throw new PaymentError(
"Missing disburse token in Paydunya response: " +
createDisburseInvoiceResponse.data.response_text
);
}
const submitDisburseInvoiceResponse = await this.api.post<
PaydunyaSubmitDisburseInvoiceSuccessResponse,
PaydunyaSubmitDisburseInvoiceErrorResponse
>("/v2/disburse/submit-invoice", {
disburse_invoice: createDisburseInvoiceResponse.data.disburse_token,
disburse_id: options.transactionId,
});
if (!submitDisburseInvoiceResponse.data) {
throw new PaymentError(
"Paydunya error: " + submitDisburseInvoiceResponse.problem
);
}
if (submitDisburseInvoiceResponse.data.response_code !== "00") {
throw new PaymentError(
"Paydunya error: " + submitDisburseInvoiceResponse.data.response_text
);
}
return {
transactionAmount: options.amount,
transactionId: options.transactionId,
transactionReference: createDisburseInvoiceResponse.data.disburse_token,
transactionCurrency: options.currency,
transactionStatus: TransactionStatus.SUCCESS,
};
}
async handleWebhook(rawBody: Buffer | string | Record<string, unknown>) {
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 PaydunyaPaymentWebhookBody;
if (!body.hash) {
console.error("Missing hash in Paydunya webhook body");
return null;
}
if (body.hash !== this.masterKeySha512Hash) {
console.error("Invalid hash in Paydunya webhook body");
return null;
}
const paymentMethod =
body.customer.payment_method === "wave_senegal"
? PaymentMethod.WAVE
: body.customer.payment_method === "orange_money_senegal"
? PaymentMethod.ORANGE_MONEY
: null;
if (body.status === "completed" && body.response_code == "00") {
const paymentSuccessfulEvent: PaymentSuccessfulEvent = {
type: PaymentEventType.PAYMENT_SUCCESSFUL,
paymentMethod,
transactionAmount: Number(body.invoice.total_amount),
transactionCurrency: Currency.XOF,
transactionId: body.custom_data.transaction_id,
transactionReference: body.invoice.token,
metadata: body.custom_data,
paymentProvider: PaydunyaPaymentProvider.name,
};
this.eventEmitter?.emit(
PaymentEventType.PAYMENT_SUCCESSFUL,
paymentSuccessfulEvent
);
return paymentSuccessfulEvent;
} else if (body.status === "cancelled") {
const paymentCancelledEvent: PaymentCancelledEvent = {
type: PaymentEventType.PAYMENT_CANCELLED,
paymentMethod,
transactionAmount: Number(body.invoice.total_amount),
transactionCurrency: Currency.XOF,
transactionId: body.custom_data.transaction_id,
transactionReference: body.invoice.token,
metadata: body.custom_data,
reason: body.response_text,
paymentProvider: PaydunyaPaymentProvider.name,
};
this.eventEmitter?.emit(
PaymentEventType.PAYMENT_CANCELLED,
paymentCancelledEvent
);
return paymentCancelledEvent;
} else if (body.status === "failed") {
const paymentFailedEvent: PaymentFailedEvent = {
type: PaymentEventType.PAYMENT_FAILED,
paymentMethod,
transactionAmount: Number(body.invoice.total_amount),
transactionCurrency: Currency.XOF,
transactionId: body.custom_data.transaction_id,
transactionReference: body.invoice.token,
metadata: body.custom_data,
reason: body.response_text,
paymentProvider: PaydunyaPaymentProvider.name,
};
this.eventEmitter?.emit(
PaymentEventType.PAYMENT_FAILED,
paymentFailedEvent
);
return paymentFailedEvent;
}
return null;
}
}
export type PaydunyaPaymentProviderConfig = {
masterKey: string;
privateKey: string;
publicKey: string;
token: string;
mode: "test" | "live";
store: {
name: string;
};
callbackUrl: string;
};
export type PaydunyaCreateInvoiceSuccessResponse = {
response_code: "00";
response_text: string;
description: string;
token: string;
};
export type PaydunyaCreateInvoiceErrorResponse = {
response_code: Exclude<string, "00">;
response_text: string;
};
export type PaydunyaWavePaymentSuccessResponse = {
success: true;
message: string;
url: string;
};
export type PaydunyaWavePaymentErrorResponse = {
success: false | undefined;
message: string;
};
export type PaydunyaOrangeMoneyApiType = "OTPCODE" | "QRCODE";
export type PaydunyaOrangeMoneyCodeOtpRequest = {
customer_name: string;
customer_email: string;
phone_number: string;
authorization_code: string;
invoice_token: string;
api_type: "OTPCODE";
};
export type PaydunyaOrangeMoneyQrCodeRequest = {
customer_name: string;
customer_email: string;
phone_number: string;
invoice_token: string;
api_type: "QRCODE";
};
export type PaydunyaOrangeMoneyRequest =
| PaydunyaOrangeMoneyCodeOtpRequest
| PaydunyaOrangeMoneyQrCodeRequest;
export type PaydunyaOrangeMoneyPaymentSuccessResponse = {
success: true;
message: string;
fees?: number;
currency?: string;
url?: string;
other_url?: {
om_url?: string;
maxit_url?: string;
};
};
export type PaydunyaOrangeMoneyPaymentErrorResponse = {
success: false | undefined;
message: string;
};
export type PaydunyaPaymentWebhookBody = {
response_code: string;
response_text: string;
hash: string;
invoice: PaydunyaInvoice;
custom_data: Record<string, any>;
actions: {
cancel_url: string;
callback_url: string;
return_url: string;
};
mode: string;
status: string;
fail_reason: string;
customer: {
name: string;
phone: string;
email: string;
payment_method: string;
};
receipt_identifier: string;
receipt_url: string;
provider_reference: string;
};
export type PaydunyaInvoice = {
token: string;
pal_is_on: string;
total_amount: string;
total_amount_without_fees: string;
description: string;
expire_date: string;
};
export type PaydunyaGetInvoiceSuccessResponse = {
response_code: "00";
response_text: string;
hash: string;
invoice: PaydunyaInvoice;
custom_data: Record<string, any>;
actions: {
cancel_url: string;
callback_url: string;
return_url: string;
};
mode: string;
status: string;
fail_reason: string;
customer: {
name: string;
phone: string;
email: string;
payment_method: string;
};
receipt_url: string;
};
export type PaydunyaGetInvoiceErrorResponse = {
response_code: Exclude<string, "00">;
response_text: string;
};
export type PaydunyaCreateDisburseInvoiceSuccessResponse = {
response_code: "00";
response_text?: never;
disburse_token: string;
};
export type PaydunyaCreateDisburseInvoiceErrorResponse = {
response_code: Exclude<string, "00">;
response_text: string;
};
export type PaydunyaSubmitDisburseInvoiceSuccessResponse = {
response_code: "00";
response_text: string;
description: string;
transaction_id: string;
provider_ref?: string;
};
export type PaydunyaSubmitDisburseInvoiceErrorResponse = {
response_code: Exclude<string, "00">;
response_text: string;
};
export default PaydunyaPaymentProvider;