@tecafrik/africa-payment-sdk
Version:
A single SDK to integrate all african payment providers seamlessly
691 lines (625 loc) • 22.9 kB
text/typescript
import {
Currency,
PaymentMethod,
TransactionStatus,
} from "../../payment-provider.interface";
import {
createDisburseInvoiceSuccessResponse,
createInvoiceSuccessResponse,
creditCardInvoiceSuccessResponse,
getWaveInvoiceSuccessResponse,
orangeMoneySuccessResponse,
orangeMoneyQrCodeSuccessResponseWithUrl,
orangeMoneyQrCodeSuccessResponseWithoutUrl,
submitDisburseInvoiceSuccessResponse,
wavePaymentSuccessResponse,
} from "../fixtures/paydunya.fixtures";
import PaydunyaPaymentProvider from "../paydunya";
import apisauce from "apisauce";
import MockAdapter from "axios-mock-adapter";
let paydunyaPaymentProvider: PaydunyaPaymentProvider;
let mockApi: MockAdapter;
jest.mock<typeof apisauce>("apisauce", () => {
const realApisauce = jest.requireActual<typeof apisauce>("apisauce");
return {
...realApisauce,
create: jest.fn((config) => {
const api = realApisauce.create(config);
mockApi = new MockAdapter(api.axiosInstance);
return api;
}),
};
});
beforeEach(() => {
paydunyaPaymentProvider = new PaydunyaPaymentProvider({
masterKey: "masterKey",
mode: "live",
privateKey: "privateKey",
publicKey: "publicKey",
store: {
name: "store-name",
},
token: "token",
callbackUrl: "https://example.com/callback",
});
});
afterEach(() => {
mockApi.reset();
});
test("calls wave API and returns a checkout result on success", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/wave-senegal")
.replyOnce(200, wavePaymentSuccessResponse);
const checkoutResult = await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.WAVE,
transactionId: "transactionId",
successRedirectUrl: "https://example.com/success",
failureRedirectUrl: "https://example.com/failure",
metadata: {
text_meta: "value",
number_meta: 1,
boolean_meta: true,
},
});
expect(mockApi.history).toMatchSnapshot();
expect(checkoutResult).toMatchSnapshot();
});
test("calls orange money API and returns a checkout result on success", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneySuccessResponse);
const checkoutResult = await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "transactionId",
authorizationCode: "12345",
successRedirectUrl: "https://example.com/success",
failureRedirectUrl: "https://example.com/failure",
metadata: {
text_meta: "value",
number_meta: 1,
boolean_meta: true,
},
});
expect(mockApi.history).toMatchSnapshot();
expect(checkoutResult).toMatchSnapshot();
});
test("calls paydunya cc API and returns a checkout result on success", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, creditCardInvoiceSuccessResponse);
const checkoutResult = await paydunyaPaymentProvider.checkoutCreditCard({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
email: "mamadou.diallo@yopmail.com",
},
description: "description",
paymentMethod: PaymentMethod.CREDIT_CARD,
transactionId: "transactionId",
successRedirectUrl: "https://example.com/success",
failureRedirectUrl: "https://example.com/failure",
metadata: {
text_meta: "value",
number_meta: 1,
boolean_meta: true,
},
});
expect(mockApi.history).toMatchSnapshot();
expect(checkoutResult).toMatchSnapshot();
});
test("emits a payment initiated event when given an event emitter", async () => {
const eventEmitter = {
emit: jest.fn(),
};
paydunyaPaymentProvider.useEventEmitter(eventEmitter as any);
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/wave-senegal")
.replyOnce(200, wavePaymentSuccessResponse);
await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.WAVE,
transactionId: "transactionId",
metadata: {
text_meta: "value",
number_meta: 1,
boolean_meta: true,
},
});
expect(eventEmitter.emit).toMatchSnapshot();
});
test("throws unsupported payment method error when using checkout redirect", async () => {
await expect(
paydunyaPaymentProvider.checkoutRedirect({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
email: "mamadou.diallo@yopmail.com",
},
description: "description",
paymentMethod: PaymentMethod.CREDIT_CARD,
transactionId: "transactionId",
successRedirectUrl: "https://example.com/success",
failureRedirectUrl: "https://example.com/failure",
})
).rejects.toThrowErrorMatchingSnapshot();
});
test("refunds a wave transaction properly", async () => {
mockApi
.onGet(/\/v1\/checkout-invoice\/confirm\/.+/)
.reply(200, getWaveInvoiceSuccessResponse);
mockApi
.onPost("/v2/disburse/get-invoice")
.reply(200, createDisburseInvoiceSuccessResponse);
mockApi
.onPost("/v2/disburse/submit-invoice")
.reply(200, submitDisburseInvoiceSuccessResponse);
const refundResult = await paydunyaPaymentProvider.refund({
transactionId: "transactionId",
refundedTransactionReference: "wave-transaction-reference",
});
expect(mockApi.history).toMatchSnapshot();
expect(refundResult).toMatchSnapshot();
});
test("makes a payout to the provided customer", async () => {
mockApi
.onPost("/v2/disburse/get-invoice")
.reply(200, createDisburseInvoiceSuccessResponse);
mockApi
.onPost("/v2/disburse/submit-invoice")
.reply(200, submitDisburseInvoiceSuccessResponse);
const payoutResult = await paydunyaPaymentProvider.payoutMobileMoney({
amount: 150,
currency: Currency.XOF,
paymentMethod: PaymentMethod.WAVE,
recipient: {
phoneNumber: "+221781234567",
},
transactionId: "transactionId",
transactionReference: "transactionReference",
metadata: {
text_meta: "value",
number_meta: 1,
boolean_meta: true,
},
});
expect(mockApi.history).toMatchSnapshot();
expect(payoutResult).toMatchSnapshot();
});
describe("Orange Money flow detection", () => {
test("selects OTPCODE flow when authorizationCode is provided", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneySuccessResponse);
await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "transactionId",
authorizationCode: "123456",
});
const orangeMoneyRequest = mockApi.history.post.find(
(req) => req.url === "/v1/softpay/new-orange-money-senegal"
);
const requestData = JSON.parse(orangeMoneyRequest?.data || "{}");
expect(requestData.api_type).toBe("OTPCODE");
expect(requestData.authorization_code).toBe("123456");
});
test("selects QRCODE flow when authorizationCode is undefined", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneySuccessResponse);
await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "transactionId",
// authorizationCode is undefined
});
const orangeMoneyRequest = mockApi.history.post.find(
(req) => req.url === "/v1/softpay/new-orange-money-senegal"
);
const requestData = JSON.parse(orangeMoneyRequest?.data || "{}");
expect(requestData.api_type).toBe("QRCODE");
expect(requestData.authorization_code).toBeUndefined();
});
test("selects QRCODE flow when authorizationCode is empty string", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneySuccessResponse);
await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "transactionId",
authorizationCode: "",
});
const orangeMoneyRequest = mockApi.history.post.find(
(req) => req.url === "/v1/softpay/new-orange-money-senegal"
);
const requestData = JSON.parse(orangeMoneyRequest?.data || "{}");
expect(requestData.api_type).toBe("QRCODE");
expect(requestData.authorization_code).toBeUndefined();
});
});
describe("Orange Money request payload builder", () => {
test("OTPCODE request includes all common fields correctly", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneySuccessResponse);
await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "transactionId",
authorizationCode: "123456",
});
const orangeMoneyRequest = mockApi.history.post.find(
(req) => req.url === "/v1/softpay/new-orange-money-senegal"
);
const requestData = JSON.parse(orangeMoneyRequest?.data || "{}");
expect(requestData.customer_name).toBe("Mamadou Diallo");
expect(requestData.customer_email).toBe("+221781234567@yopmail.com");
expect(requestData.phone_number).toBe("781234567");
expect(requestData.invoice_token).toBe(createInvoiceSuccessResponse.token);
expect(requestData.api_type).toBe("OTPCODE");
expect(requestData.authorization_code).toBe("123456");
});
test("QRCODE request includes all common fields correctly and excludes authorization_code", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneySuccessResponse);
await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "transactionId",
// authorizationCode not provided
});
const orangeMoneyRequest = mockApi.history.post.find(
(req) => req.url === "/v1/softpay/new-orange-money-senegal"
);
const requestData = JSON.parse(orangeMoneyRequest?.data || "{}");
expect(requestData.customer_name).toBe("Mamadou Diallo");
expect(requestData.customer_email).toBe("+221781234567@yopmail.com");
expect(requestData.phone_number).toBe("781234567");
expect(requestData.invoice_token).toBe(createInvoiceSuccessResponse.token);
expect(requestData.api_type).toBe("QRCODE");
expect(requestData).not.toHaveProperty("authorization_code");
});
});
describe("Orange Money QR code payment flow", () => {
test("successful QR code payment with redirect URL in response", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneyQrCodeSuccessResponseWithUrl);
const checkoutResult = await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 5000,
currency: Currency.XOF,
customer: {
firstName: "Alioune",
lastName: "Faye",
phoneNumber: "+221774563209",
},
description: "QR code payment test",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "test-txn-qr-001",
// No authorizationCode - triggers QRCODE flow
});
expect(checkoutResult.redirectUrl).toBe(
"https://qr.paydunya.com/checkout?token=qr-token-123"
);
expect(checkoutResult.transactionStatus).toBe(TransactionStatus.PENDING);
expect(checkoutResult.transactionAmount).toBe(5000);
expect(checkoutResult.transactionCurrency).toBe(Currency.XOF);
expect(checkoutResult.transactionId).toBe("test-txn-qr-001");
});
test("successful QR code payment without redirect URL", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneyQrCodeSuccessResponseWithoutUrl);
const checkoutResult = await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 3000,
currency: Currency.XOF,
customer: {
firstName: "Alioune",
lastName: "Faye",
phoneNumber: "+221774563209",
},
description: "QR code payment without URL",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "test-txn-qr-002",
// No authorizationCode - triggers QRCODE flow
});
expect(checkoutResult.redirectUrl).toBeUndefined();
expect(checkoutResult.transactionStatus).toBe(TransactionStatus.PENDING);
expect(checkoutResult.transactionAmount).toBe(3000);
expect(checkoutResult.transactionCurrency).toBe(Currency.XOF);
expect(checkoutResult.transactionId).toBe("test-txn-qr-002");
});
test("QR code payment error handling", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi.onPost("/v1/softpay/new-orange-money-senegal").replyOnce(500, {
success: false,
message: "Payment processing failed",
});
await expect(
paydunyaPaymentProvider.checkoutMobileMoney({
amount: 2000,
currency: Currency.XOF,
customer: {
firstName: "Alioune",
lastName: "Faye",
phoneNumber: "+221774563209",
},
description: "QR code payment error test",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "test-txn-qr-error",
// No authorizationCode - triggers QRCODE flow
})
).rejects.toThrow("Payment processing failed");
});
test("CheckoutResult includes redirectUrl when provided by API", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneyQrCodeSuccessResponseWithUrl);
const checkoutResult = await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 7500,
currency: Currency.XOF,
customer: {
firstName: "Alioune",
lastName: "Faye",
phoneNumber: "+221774563209",
},
description: "QR code with redirect URL",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "test-txn-qr-003",
});
// Verify that redirectUrl is properly included in CheckoutResult
expect(checkoutResult).toHaveProperty("redirectUrl");
expect(checkoutResult.redirectUrl).toBe(
"https://qr.paydunya.com/checkout?token=qr-token-123"
);
// Verify all other required fields are present
expect(checkoutResult).toHaveProperty("transactionId");
expect(checkoutResult).toHaveProperty("transactionReference");
expect(checkoutResult).toHaveProperty("transactionStatus");
expect(checkoutResult).toHaveProperty("transactionAmount");
expect(checkoutResult).toHaveProperty("transactionCurrency");
});
});
describe("Backward compatibility", () => {
test("existing code with authorizationCode still works correctly", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneySuccessResponse);
// This is how existing code would call the API with authorizationCode
const checkoutResult = await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 100,
currency: Currency.XOF,
customer: {
firstName: "Mamadou",
lastName: "Diallo",
phoneNumber: "+221781234567",
},
description: "description",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "transactionId",
authorizationCode: "12345",
successRedirectUrl: "https://example.com/success",
failureRedirectUrl: "https://example.com/failure",
metadata: {
text_meta: "value",
number_meta: 1,
boolean_meta: true,
},
});
// Verify the result structure is consistent with previous implementation
expect(checkoutResult).toHaveProperty("transactionId");
expect(checkoutResult).toHaveProperty("transactionReference");
expect(checkoutResult).toHaveProperty("transactionStatus");
expect(checkoutResult).toHaveProperty("transactionAmount");
expect(checkoutResult).toHaveProperty("transactionCurrency");
expect(checkoutResult.transactionId).toBe("transactionId");
expect(checkoutResult.transactionStatus).toBe(TransactionStatus.PENDING);
expect(checkoutResult.transactionAmount).toBe(100);
expect(checkoutResult.transactionCurrency).toBe(Currency.XOF);
});
test("OTPCODE flow produces same results as before", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi
.onPost("/v1/softpay/new-orange-money-senegal")
.replyOnce(200, orangeMoneySuccessResponse);
const checkoutResult = await paydunyaPaymentProvider.checkoutMobileMoney({
amount: 5000,
currency: Currency.XOF,
customer: {
firstName: "Alioune",
lastName: "Faye",
phoneNumber: "+221774563209",
},
description: "Test payment",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "test-backward-compat-001",
authorizationCode: "654321",
});
// Verify the request was made with OTPCODE api_type
const orangeMoneyRequest = mockApi.history.post.find(
(req) => req.url === "/v1/softpay/new-orange-money-senegal"
);
const requestData = JSON.parse(orangeMoneyRequest?.data || "{}");
expect(requestData.api_type).toBe("OTPCODE");
expect(requestData.authorization_code).toBe("654321");
// Verify the result structure matches expected format
expect(checkoutResult.transactionId).toBe("test-backward-compat-001");
expect(checkoutResult.transactionReference).toBe(
createInvoiceSuccessResponse.token
);
expect(checkoutResult.transactionStatus).toBe(TransactionStatus.PENDING);
expect(checkoutResult.transactionAmount).toBe(5000);
expect(checkoutResult.transactionCurrency).toBe(Currency.XOF);
// OTPCODE flow should not have redirectUrl
expect(checkoutResult.redirectUrl).toBeUndefined();
});
test("error handling remains consistent for invalid authorization code", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi.onPost("/v1/softpay/new-orange-money-senegal").replyOnce(422, {
success: false,
message: "Invalid or expired OTP code!",
});
await expect(
paydunyaPaymentProvider.checkoutMobileMoney({
amount: 1000,
currency: Currency.XOF,
customer: {
firstName: "Test",
lastName: "User",
phoneNumber: "+221781234567",
},
description: "Test invalid OTP",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "test-invalid-otp",
authorizationCode: "invalid-code",
})
).rejects.toThrow("Invalid or expired OTP code!");
});
test("error handling remains consistent for general API errors", async () => {
mockApi
.onPost("/v1/checkout-invoice/create")
.replyOnce(200, createInvoiceSuccessResponse);
mockApi.onPost("/v1/softpay/new-orange-money-senegal").replyOnce(500, {
success: false,
message: "Internal server error",
});
await expect(
paydunyaPaymentProvider.checkoutMobileMoney({
amount: 2000,
currency: Currency.XOF,
customer: {
firstName: "Test",
lastName: "User",
phoneNumber: "+221781234567",
},
description: "Test server error",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "test-server-error",
authorizationCode: "123456",
})
).rejects.toThrow("Internal server error");
});
test("error handling remains consistent for invalid phone number", async () => {
await expect(
paydunyaPaymentProvider.checkoutMobileMoney({
amount: 1000,
currency: Currency.XOF,
customer: {
firstName: "Test",
lastName: "User",
phoneNumber: "invalid-phone",
},
description: "Test invalid phone",
paymentMethod: PaymentMethod.ORANGE_MONEY,
transactionId: "test-invalid-phone",
authorizationCode: "123456",
})
).rejects.toThrow();
});
});