afripay
Version:
A TypeScript library to simplify integration with African payment processors (Orange Money, Wave, Paytech, Paydunya, etc).
410 lines (404 loc) • 15.8 kB
JavaScript
//#region src/utils/error.ts
var AfriError = class extends Error {
reason;
message;
constructor(reason, message) {
super(message);
this.reason = reason;
this.message = message;
}
};
//#endregion
//#region src/processors/orange-money/payWithOrangeMoney.ts
const OM_PROD_BASE_URL = "https://api.orange-sonatel.com";
const OM_SANDBOX_BASE_URL = "https://api.sandbox.orange-sonatel.com";
const VALIDITY_LIMIT = 86400;
/**
* Creates an Orange Money checkout (QR or deeplink).
*
* @remarks
* Sends a request to Orange Money (Sonatel) to generate a payment intent that
* returns either:
*
* - a **MAXIT / Orange Money deeplink** (string), or
* - a **QR code** as a Base64 string (`qrCode`) that you need to manually parse
* to display to the user (e.g., `data:image/png;base64,${qrCode}`).
*
* This function chooses the base URL depending on `mode`:
* - `"test"` → {@link OM_SANDBOX_BASE_URL}
* - `"prod"` → {@link OM_PROD_BASE_URL}
*
* It requires the following environment variables:
* - `OM_CLIENT_ID`
* - `OM_CLIENT_SECRET`
*
* If `request.validity` exceeds {@link VALIDITY_LIMIT}, it is clamped to the
* maximum allowed.
*
* @param request - The Orange Money request payload. See {@link OMRequest}.
* @param mode - `"test"` for sandbox, `"prod"` for production. Defaults to `"test"`.
*
* @returns The Orange Money API response. See {@link OMResponse}.
*
* @throws AfriError
* - `missing_api_key` if required environment variables are not set
* - `request_failed` if the Orange Money API responds with a non-2xx status
*
* @example
* ```ts
* const res = await payWithOrangeMoney(
* {
* amount: { unit: 'XOF', value: 5000 },
* callbackCancelUrl: 'https://your.app/pay/cancel',
* callbackSuccessUrl: 'https://your.app/pay/success',
* ipnUrl: 'https://your.app/webhooks/ipn',
* code: 221, // country code
* name: 'Order #1234',
* validity: 600, // seconds
* metadata: { orderId: '1234' },
* },
* 'prod',
* )
*
* // If the response contains a deeplink (MAXIT / OM link), open it
* if (res.deeplink) {
* window.location.href = res.deeplink
* }
*
* // If the response contains a Base64 QR code, render it
* if (res.qrCode) {
* const src = `data:image/png;base64,${res.qrCode}`
* // <img src={src} alt="Pay with Orange Money" />
* }
* ```
*/
const payWithOrangeMoney = async (request, mode = "test") => {
if (!process.env.OM_CLIENT_ID) throw new AfriError("missing_api_key", "OM_CLIENT_ID is not set");
if (!process.env.OM_CLIENT_SECRET) throw new AfriError("missing_api_key", "OM_CLIENT_SECRET is not set");
if (request.metadata && Object.keys(request.metadata).length > 10) throw new AfriError("request_failed", "Metadata can't be more than 10 items");
if (request.validity > VALIDITY_LIMIT) throw new AfriError("request_failed", `Validity can't be more than ${VALIDITY_LIMIT} seconds`);
const tokenRes = await fetch(`${mode === "test" ? OM_SANDBOX_BASE_URL : OM_PROD_BASE_URL}/oauth/v1/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.OM_CLIENT_ID,
client_secret: process.env.OM_CLIENT_SECRET
})
});
if (!tokenRes.ok) throw new AfriError("request_failed", "Failed to get access token from Orange Money");
const { access_token } = await tokenRes.json();
const payload = {
amount: {
unit: "XOF",
value: mode === "test" ? 10 : request.amount.value
},
callbackCancelUrl: request.callbackCancelUrl,
callbackSuccessUrl: request.callbackSuccessUrl,
code: request.code,
metadata: request.metadata,
name: request.name,
validity: request.validity
};
const res = await fetch(`${mode === "test" ? OM_SANDBOX_BASE_URL : OM_PROD_BASE_URL}/api/eWallet/v4/qrcode`, {
method: "POST",
headers: {
Authorization: `Bearer ${access_token}`,
"Content-Type": "application/json",
"X-Callback-Url": request.ipnUrl
},
body: JSON.stringify(payload)
});
if (!res.ok) throw new AfriError("request_failed", "Failed to create Orange Money QR code");
const data = await res.json();
return data;
};
//#endregion
//#region src/processors/paydunya/payWithPaydunya.ts
const PAYDUNYA_TEST_URL = "https://app.paydunya.com/sandbox-api/v1/checkout-invoice/create";
const PAYDUNYA_PROD_URL = "https://app.paydunya.com/api/v1/checkout-invoice/create";
/**
* Create a PayDunya payment session (checkout / invoice).
*
* @remarks
* Sends a request to PayDunya to generate a hosted payment session. The API
* returns a short payload containing:
* - `response_code` — Gateway status code.
* - `response_text` — The redirect URL to the hosted checkout page.
* - `description` — Human-readable description of the transaction.
* - `token` — Unique token identifying the transaction (used for later verification).
*
* The runtime environment is selected via the `mode` parameter:
* - `"test"` → sandbox behavior
* - `"prod"` → live/production behavior
*
* Required environment variables:
* - `PAYDUNYA_PUBLIC_KEY`
* - `PAYDUNYA_PRIVATE_KEY`
* - `PAYDUNYA_TOKEN`
*
* @param request - The PayDunya request payload. See {@link PaydunyaRequest}.
* Typical fields include: amount, currency, label/description,
* success/cancel callbacks, IPN URL, and an optional
* idempotent `client_reference`.
* @param mode - `"test"` for sandbox or `"prod"` for production.
*
* @returns The PayDunya API response. See {@link PaydunyaResponse}:
* ```ts
* type PaydunyaResponse = {
* response_code: string // Status code from PayDunya
* response_text: string // Redirect URL to hosted checkout
* description: string // Description of the transaction
* token: string // Unique transaction token
* }
* ```
*
* @throws AfriError
* The function throws the following `AfriError` codes:
* - **`missing_api_key`**
* - Thrown when one of `PAYDUNYA_PUBLIC_KEY`, `PAYDUNYA_PRIVATE_KEY`, or
* `PAYDUNYA_TOKEN` is not set in the environment.
* - **`request_failed`**
* - Thrown when the PayDunya API responds with a non-2xx HTTP status.
* - The `details` (if available) contain the gateway’s error payload.
*
* @example
* ```ts
* const res = await payWithPaydunya(
* {
* amount: 5000,
* currency: 'XOF',
* description: 'Order #1234',
* success_url: 'https://app.example.com/pay/success',
* cancel_url: 'https://app.example.com/pay/cancel',
* ipn_url: 'https://app.example.com/api/ipn/paydunya',
* client_reference: 'ORDER_1234_2025-08-13',
* },
* 'prod',
* )
*
* // Redirect user to the hosted PayDunya checkout
* window.location.href = res.response_text
*
* // Optionally store the token for later verification
* saveTransactionToken(res.token)
* ```
*/
const payWithPaydunya = async (request, mode = "test") => {
if (!process.env.PAYDUNYA_MASTER_KEY) throw new AfriError("missing_api_key", "PAYDUNYA_MASTER_KEY is not set");
if (!process.env.PAYDUNYA_PRIVATE_KEY) throw new AfriError("missing_api_key", "PAYDUNYA_PRIVATE_KEY is not set");
if (!process.env.PAYDUNYA_TOKEN) throw new AfriError("missing_api_key", "PAYDUNYA_TOKEN is not set");
const url = mode === "test" ? PAYDUNYA_TEST_URL : PAYDUNYA_PROD_URL;
const payload = {
...request,
mode: mode === "test" ? "test" : "live"
};
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYDUNYA-MASTER-KEY": process.env.PAYDUNYA_MASTER_KEY,
"PAYDUNYA-PRIVATE-KEY": process.env.PAYDUNYA_PRIVATE_KEY,
"PAYDUNYA-TOKEN": process.env.PAYDUNYA_TOKEN
},
body: JSON.stringify(payload)
});
if (!res.ok) throw new AfriError("request_failed", `Failed to create Paydunya payment request: ${res.statusText}`);
const data = await res.json();
return data;
};
//#endregion
//#region src/processors/paytech/payWithPaytech.ts
const PAYTECH_BASE_URL = "https://paytech.sn/api";
/**
* Create a PayTech payment session.
*
* @remarks
* Sends a request to PayTech to generate a payment session for the user. The
* response typically contains a **redirect URL** that you should send the user
* to in order to complete payment.
*
* The **environment** is selected via {@link PaytechRequest.env}:
* - `"test"` → sandbox behavior
* - `"prod"` → live/production behavior
*
* Required environment variables:
* - `PAYTECH_API_KEY`
* - `PAYTECH_API_SECRET`
*
* Notes:
* - `client_reference` should be **unique per attempt**. Reusing it may cause a
* **409 Conflict** from PayTech (duplicate reference).
*
* @param request - The PayTech request payload. At minimum, include:
* - `amount: number` — The amount to charge (in the currency’s minor unit if that’s how your integration is set up).
* - `currency: string` — e.g. `"XOF"`.
* - `success_url: string` — Where PayTech should redirect the user after a successful payment.
* - `error_url: string` — Where PayTech should redirect the user on failure/cancel.
* - `client_reference?: string` — Your internal idempotency/reference key (recommended to be unique).
* - `env: 'test' | 'prod'` — Selects sandbox vs production behavior.
* - (plus any other optional fields supported by your {@link PaytechRequest} type).
*
* @returns The PayTech API response with session details. See {@link PaytechResponse}.
* Implementations commonly expose a `redirect_url` you can send the user to.
*
* @throws AfriError
* The function throws the following `AfriError` codes:
* - **`missing_api_key`**
* - Thrown when `PAYTECH_API_KEY` or `PAYTECH_API_SECRET` is not set in the environment.
* - **`request_failed`**
* - Thrown when the PayTech API responds with a non‑2xx HTTP status.
* - If the status is **409**, the error message clarifies that there was a conflict,
* most likely because of a duplicated `ref_command`.
*
* @example
* ```ts
* const res = await payWithPaytech({
* amount: 5000,
* currency: 'XOF',
* success_url: 'https://app.example.com/pay/success',
* error_url: 'https://app.example.com/pay/error',
* client_reference: 'ORDER_1234_2025-08-13',
* env: 'prod',
* })
*
* // Typical usage: redirect the customer to PayTech’s hosted page
* if ((res as any).redirect_url) {
* // e.g., in a web context:
* // window.location.href = (res as any).redirect_url
* }
* ```
*/
const payWithPaytech = async (request) => {
if (!process.env.PAYTECH_API_KEY) throw new AfriError("missing_api_key", "PAYTECH_API_KEY is not set");
if (!process.env.PAYTECH_API_SECRET) throw new AfriError("missing_api_key", "PAYTECH_API_SECRET is not set");
const payload = {
...request,
target_payment: request.target_payment?.join(", ")
};
const res = await fetch(`${PAYTECH_BASE_URL}/payment/request-payment`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
API_KEY: process.env.PAYTECH_API_KEY,
API_SECRET: process.env.PAYTECH_API_SECRET
},
body: JSON.stringify(payload)
});
if (!res.ok) {
if (res.statusText.includes("Conflict")) throw new AfriError("request_failed", "There was a conflict. Probably because of ref_command");
throw new AfriError("request_failed", "Failed to create Paytech payment request");
}
const data = await res.json();
return data;
};
//#endregion
//#region src/processors/paytech/types.ts
const PAYMENT_METHODS = [
"Orange Money",
"Orange Money CI",
"Orange Money ML",
"Mtn Money CI",
"Moov Money CI",
"Moov Money ML",
"Wave",
"Wave CI",
"Wizall",
"Carte Bancaire",
"Emoney",
"Tigo Cash",
"Free Money",
"Moov Money BJ",
"Mtn Money BJ"
];
//#endregion
//#region src/processors/wave/payWithWave.ts
const WAVE_BASE_URL = "https://api.wave.com";
/**
* Initiates a Wave checkout session to process a payment.
*
* @remarks
* This function sends a POST request to Wave's `/v1/checkout/sessions` endpoint.
* If `mode` is `'test'`, it will always charge 1 unit of the currency (for test purposes).
* In `'prod'` mode, it uses the actual `amount` provided in the `request` object.
*
* The `client_reference` optional field in the request is an arbitrary string that your application
* can use to tie this payment back to some entity on your side (for example, a user ID,
* an order ID, or any other reference you need). Wave will return this same value in the response,
* so you can match incoming webhooks or redirect callbacks to your internal records.
*
* When the checkout session is successfully created, Wave returns a `wave_launch_url` in the response.
* You should redirect the user’s browser to that URL so they can complete the payment flow.
*
* @param {WaveRequest} request
* - `amount`: The payment amount (in smallest currency unit) to process. Ignored if `mode` is `'test'`.
* - `currency`: The three-letter currency code (only XOF is supported at the moment).
* - `error_url`: The URL to which Wave will redirect the user if the payment fails.
* - `success_url`: The URL to which Wave will redirect the user if the payment succeeds.
* - `client_reference`: An arbitrary identifier (e.g., userId or orderId) that Wave will return
* in the response. This allows you to correlate Wave’s response with your own records.
* @param {'test' | 'prod'} [mode='test']
* - `'test'`: Creates a checkout session with a nominal amount (1 unit) for testing.
* - `'prod'`: Uses the actual `request.amount` when creating the checkout session.
*
* @returns {Promise<WaveResponse>}
* A promise that resolves to the `WaveResponse` object returned by Wave. Key fields include:
* - `wave_launch_url`: The URL where you must redirect the user to complete payment.
* - `client_reference`: Echoes back the same reference you sent, so you can match it on your end.
*
* @throws {AfriError}
* - If `process.env.WAVE_API_KEY` is not set, an error is thrown with the reason `'missing_api_key'`.
* - If the HTTP response from Wave is not OK (status code not in the 2xx range),
* an error with message with the reason `'request_failed'` is thrown.
*
* @example
* ```ts
* import { payWithWave } from './payments'
*
* // Somewhere in your server-side code (e.g., an Express handler):
* app.post('/create-payment', async (req, res) => {
* const waveRequest: WaveRequest = {
* amount: 5000, // e.g., $50.00 in cents
* currency: 'XOF',
* error_url: 'https://example.com/payment-error',
* success_url: 'https://example.com/payment-success',
* client_reference: 'user_1234'
* }
*
* try {
* const waveResponse = await payWithWave(waveRequest, 'prod')
* // Redirect user to Wave’s payment page:
* res.redirect(waveResponse.wave_launch_url)
* } catch (err) {
* console.error('Payment failed to initialize:', err)
* res.status(500).send('Unable to start payment.')
* }
* })
* ```
*/
const payWithWave = async (request, mode = "test") => {
if (!process.env.WAVE_API_KEY) throw new AfriError("missing_api_key", "WAVE_API_KEY is not set");
const res = await fetch(`${WAVE_BASE_URL}/v1/checkout/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WAVE_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
amount: mode === "test" ? 1 : request.amount,
currency: request.currency,
error_url: request.error_url,
success_url: request.success_url,
client_reference: request.client_reference
})
});
if (!res.ok) {
console.log("request failed", res.statusText);
throw new AfriError("request_failed", "Failed to create Wave checkout session");
}
const data = await res.json();
return data;
};
//#endregion
export { OM_PROD_BASE_URL, OM_SANDBOX_BASE_URL, PAYDUNYA_PROD_URL, PAYDUNYA_TEST_URL, PAYMENT_METHODS, VALIDITY_LIMIT, WAVE_BASE_URL, payWithOrangeMoney, payWithPaydunya, payWithPaytech, payWithWave };