UNPKG

@tecafrik/africa-payment-sdk

Version:

A single SDK to integrate all african payment providers seamlessly

691 lines (625 loc) 22.9 kB
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(); }); });