Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Copy to .env (git-ignored) to run the sandbox tests (pnpm test) and the demo (pnpm demo).
# Never put live keys here.
FEDAPAY_SANDBOX_SECRET_KEY=

# --- Live demo (REAL money). Leave empty unless you really mean it. ---
# DEMO_LIVE=yes
# FEDAPAY_LIVE_SECRET_KEY=
# DEMO_LIVE_MAX_AMOUNT=200
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ Before `1.0.0`, minor versions may contain breaking changes.
## [Unreleased]

### Added
- 2026-10-05 — `@payenv/core`: `PayenvError.providerCode` keeps the provider's own error
code in every error.
- 2026-10-05 — `@payenv/connector-fedapay`: maps FedaPay's `last_error_code`
(e.g. `INSUFFICIENT_FUND_ERROR` → `INSUFFICIENT_FUNDS`), including on transactions that
stay pending after an operator error. Verified on live transactions.
- 2026-10-05 — `examples/fedapay-demo`: opt-in live mode with safeguards; webhooks
trigger `payenv.refresh`.
- 2026-10-05 — `@payenv/connector-fedapay`: `parseFedaPayWebhook`, normalizing webhooks
from the transaction they carry (live shape).
- 2026-10-05 — `examples/fedapay-demo`: runnable web demo (`pnpm demo`) collecting a
sandbox payment and following its status live.
- 2026-09-27 — `@payenv/connector-fedapay`: sandbox pushes go to FedaPay's `momo_test`
Expand Down
17 changes: 17 additions & 0 deletions examples/fedapay-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ and watch its status change live. No framework, no dependency besides Payenv.
Sandbox test numbers: `+22964000001` and `+22966000001` succeed; any other number
simulates a failed payment.

## Live mode (real money)

To check that a real USSD prompt reaches your phone, the demo has an opt-in live mode.
**It moves real money** on your FedaPay account (FedaPay fees apply).

Add to `.env`:
```
DEMO_LIVE=yes
FEDAPAY_LIVE_SECRET_KEY=sk_live_...
DEMO_LIVE_MAX_AMOUNT=200
```

Safeguards: the live key lives in its own variable (a sandbox key is refused, and vice
versa), amounts are capped (200 XOF by default, 1000 at most), a warning is shown in the
terminal and the page, and the server only listens on `127.0.0.1`. The operator (MTN,
Moov, Celtiis) must be activated on your merchant account. Remove `DEMO_LIVE` when done.

## What to look at

- [server.ts](server.ts): `createPayenv` with the FedaPay connector, `payenv.collect`
Expand Down
110 changes: 87 additions & 23 deletions examples/fedapay-demo/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,43 @@
*
* Needs FEDAPAY_SANDBOX_SECRET_KEY in the root .env file. Then open http://localhost:3000.
* Sandbox test numbers: +22964000001 and +22966000001 succeed, any other number fails.
*
* Live mode (REAL money) is opt-in: DEMO_LIVE=yes and FEDAPAY_LIVE_SECRET_KEY in .env.
* Amounts are capped by DEMO_LIVE_MAX_AMOUNT (default 200 XOF).
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { fedapay, verifyFedaPayWebhook } from '@payenv/connector-fedapay';
import { fedapay, parseFedaPayWebhook, verifyFedaPayWebhook } from '@payenv/connector-fedapay';
import { createPayenv, isPayenvError, type Payment } from '@payenv/core';

const secretKey = process.env.FEDAPAY_SANDBOX_SECRET_KEY;
if (!secretKey) {
console.error('Missing FEDAPAY_SANDBOX_SECRET_KEY. Copy .env.example to .env and fill it in.');
process.exit(1);
}
if (secretKey.startsWith('sk_live')) {
console.error('This demo only runs with a sandbox key (sk_sandbox_...).');
process.exit(1);
const live = process.env.DEMO_LIVE === 'yes';
const maxLiveAmount = Number(process.env.DEMO_LIVE_MAX_AMOUNT ?? 200);
const secretKey = live
? process.env.FEDAPAY_LIVE_SECRET_KEY
: process.env.FEDAPAY_SANDBOX_SECRET_KEY;

if (live) {
if (!secretKey?.startsWith('sk_live')) {
fail('DEMO_LIVE=yes requires FEDAPAY_LIVE_SECRET_KEY (sk_live_...) in .env.');
}
if (!Number.isSafeInteger(maxLiveAmount) || maxLiveAmount <= 0 || maxLiveAmount > 1000) {
fail('DEMO_LIVE_MAX_AMOUNT must be an integer between 1 and 1000 XOF.');
}
} else {
if (!secretKey) {
fail('Missing FEDAPAY_SANDBOX_SECRET_KEY. Copy .env.example to .env and fill it in.');
}
if (!secretKey.startsWith('sk_sandbox')) {
fail('FEDAPAY_SANDBOX_SECRET_KEY must be a sandbox key (sk_sandbox_...).');
}
}

const port = Number(process.env.PORT ?? 3000);

/** providerRef → idempotency key, to match webhooks with payments (a database in real life). */
const keysByProviderRef = new Map<string, string>();

const payenv = createPayenv({
connectors: [fedapay({ secretKey, environment: 'sandbox' })],
connectors: [fedapay({ secretKey, environment: live ? 'live' : 'sandbox' })],
onEvent(event) {
if (event.type === 'attempt.finished') {
const { attempt } = event;
Expand All @@ -43,16 +61,23 @@ createServer((request, response) => {
console.error(error);
send(response, 500, { error: 'Internal error' });
});
}).listen(port, () => {
console.log(`Payenv demo running on http://localhost:${port}`);
// Only reachable from this computer, never from the network.
}).listen(port, '127.0.0.1', () => {
if (live) {
console.warn('');
console.warn(' ⚠️ LIVE MODE — payments move REAL money on your FedaPay account.');
console.warn(` ⚠️ Amounts are capped at ${maxLiveAmount} XOF. Stop with Ctrl+C.`);
console.warn('');
}
console.log(`Payenv demo (${live ? 'LIVE' : 'sandbox'}) running on http://localhost:${port}`);
});

async function route(request: IncomingMessage, response: ServerResponse): Promise<void> {
const url = new URL(request.url ?? '/', `http://${request.headers.host}`);

if (request.method === 'GET' && url.pathname === '/') {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
response.end(PAGE);
response.end(page());
return;
}

Expand All @@ -62,14 +87,24 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis
phone: string;
network: string;
};
if (live && !(Number(body.amount) <= maxLiveAmount)) {
send(response, 400, {
error: {
code: 'INVALID_REQUEST',
message: `Live demo amounts are capped at ${maxLiveAmount} XOF`,
},
});
return;
}
try {
const payment = await payenv.collect({
amount: { value: Number(body.amount), currency: 'XOF' },
method: { type: 'mobile_money', network: body.network, country: 'BJ', phone: body.phone },
customer: { firstName: 'Demo', lastName: 'Payenv', email: 'demo@example.com' },
description: 'Payenv demo',
description: live ? 'Payenv demo (live test)' : 'Payenv demo',
idempotencyKey: `demo_${Date.now()}`,
});
if (payment.providerRef) keysByProviderRef.set(payment.providerRef, payment.idempotencyKey);
send(response, 200, view(payment));
} catch (error) {
// Invalid requests (bad phone, no route...) are thrown, not returned as payments.
Expand All @@ -95,8 +130,22 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis
Array.isArray(signature) ? signature[0] : signature,
process.env.FEDAPAY_WEBHOOK_SECRET ?? '',
);
console.log(`[webhook] signature ${valid ? 'valid' : 'INVALID'}`);
send(response, valid ? 200 : 400, { received: valid });
if (!valid) {
console.log('[webhook] INVALID signature — ignored');
send(response, 400, { received: false });
return;
}
const event = parseFedaPayWebhook(raw);
// A real application looks the payment up in its own database by providerRef.
const key = event && keysByProviderRef.get(event.providerRef);
if (event && key) {
// The webhook is a hint: ask FedaPay for the authoritative status.
const payment = await payenv.refresh(key);
console.log(`[webhook] ${event.name} → payment ${key} is now ${payment.status}`);
} else {
console.log(`[webhook] ${event?.name ?? 'unrecognized event'} — no matching payment`);
}
send(response, 200, { received: true });
return;
}

Expand All @@ -122,6 +171,11 @@ function view(payment: Payment) {
};
}

function fail(message: string): never {
console.error(message);
process.exit(1);
}

function readBody(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
Expand All @@ -139,7 +193,8 @@ function send(response: ServerResponse, status: number, body: unknown): void {
response.end(JSON.stringify(body));
}

const PAGE = `<!doctype html>
function page(): string {
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
Expand All @@ -159,19 +214,21 @@ const PAGE = `<!doctype html>
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; color: white; font-weight: 600; }
.succeeded { background: var(--ok); } .failed, .canceled, .expired { background: var(--ko); }
.pending, .requires_action, .created, .unknown { background: var(--wait); }
.live { background: var(--ko); color: white; padding: 10px 14px; border-radius: 8px; }
pre { font-size: 12px; overflow-x: auto; background: #8881; padding: 8px; border-radius: 8px; }
</style>
</head>
<body>
<h1>Payenv demo</h1>
<p class="sub">Paiement mobile money via FedaPay (sandbox)</p>
<p class="sub">Paiement mobile money via FedaPay (${live ? 'LIVE' : 'sandbox'})</p>
${live ? `<p class="live">⚠️ Mode LIVE : <strong>vrai argent</strong>. Montant plafonné à ${maxLiveAmount} XOF.</p>` : ''}
<form id="pay">
<label>Montant (XOF) <input name="amount" type="number" min="1" value="100" required></label>
<label>Montant (XOF) <input name="amount" type="number" min="1" ${live ? `max="${maxLiveAmount}"` : ''} value="100" required></label>
<label>Réseau
<select name="network"><option value="mtn">MTN</option><option value="moov">Moov</option><option value="celtiis">Celtiis</option></select>
</label>
<label>Téléphone <input name="phone" value="+22964000001" required></label>
<span class="hint">Sandbox : +22964000001 ou +22966000001 = succès · tout autre numéro = échec</span>
<label>Téléphone <input name="phone" value="${live ? '' : '+22964000001'}" placeholder="+229…" required></label>
<span class="hint">${live ? 'Votre vrai numéro, au format international (+229…). Vous recevrez une demande de validation USSD.' : 'Sandbox : +22964000001 ou +22966000001 = succès · tout autre numéro = échec'}</span>
<button>Payer</button>
</form>
<div id="result">
Expand All @@ -187,9 +244,13 @@ const PAGE = `<!doctype html>
const badge = document.getElementById('status');
badge.textContent = payment.status;
badge.className = 'badge ' + payment.status;
const reason = payment.error ? payment.error.code + ' — ' + payment.error.message : '';
document.getElementById('message').textContent =
payment.status === 'pending' ? 'Le client doit valider sur son téléphone… (vérification toutes les 2 s)'
: payment.error ? payment.error.code + ' — ' + payment.error.message : '';
payment.status === 'pending'
? (reason
? 'FedaPay signale : ' + reason + '. Le statut reste « pending » chez FedaPay : Payenv ne devine pas.'
: 'Le client doit valider sur son téléphone… (vérification toutes les 2 s)')
: reason;
document.getElementById('details').textContent = JSON.stringify(payment, null, 2);
}
async function poll(key) {
Expand All @@ -199,6 +260,8 @@ const PAGE = `<!doctype html>
show(payment);
if (terminal.includes(payment.status)) return;
}
document.getElementById('message').textContent +=
' — Toujours en attente après 1 minute. Une vraie application revérifie plus tard avec payenv.refresh().';
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
Expand All @@ -218,3 +281,4 @@ const PAGE = `<!doctype html>
</script>
</body>
</html>`;
}
35 changes: 34 additions & 1 deletion packages/connector-fedapay/QUIRKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,45 @@ What differs from what you would expect when reading the FedaPay documentation.
- Authentication with a sandbox key, transaction creation, token generation, and status
lookup by transaction id all work as implemented.

## ✅ Verified live (2026-10-05)

- Live pushes work on `/mtn_open` (MTN) and `/sbin` (Celtiis): the USSD prompt reaches
the phone.
- Benin's 10-digit numbers in E.164 (`+22901…`) are accepted.
- **Transactions carry a `last_error_code`** with the real reason, e.g.
`INSUFFICIENT_FUND_ERROR` on a canceled MTN payment. The connector maps it
(→ `INSUFFICIENT_FUNDS`) and always exposes it as `error.providerCode`.
- **A customer canceling the USSD prompt on Celtiis does not end the transaction**:
FedaPay keeps it `pending` and records `last_error_code: "API_ERROR"`. Its
`last_error_message` is the operator's raw SOAP answer (Huawei CPS): the request
succeeded and the operator still reports `TransactionStatus: "Initiated"`. In other
words, the cancellation was not propagated. Payenv keeps the payment `pending` (never
guessed) and exposes the reason (`UNKNOWN_ERROR` + `providerCode: "API_ERROR"`, raw
dump in `error.raw`). See "Payments stuck in pending" in the README.
- **Webhook shape**: `{ name, object: "transaction", object_id, account, entity }`, where
`entity` is the full transaction (with `status` and `last_error_code`). Events seen:
`transaction.created`, `transaction.canceled`. The connector derives the status from
`entity`, not from the event name, so unknown event names still work.
⚠️ Webhooks contain the merchant account details and the payer's phone number (inside
`last_error_message`): never log them raw.
- **MTN's reason behind `INSUFFICIENT_FUND_ERROR`** is
`LOW_BALANCE_OR_PAYEE_LIMIT_REACHED_OR_NOT_ALLOWED`: low balance *or* a limit *or* not
allowed. Payenv's message says so.
- **FedaPay schedules an expiration** for pending transactions (`metadata.expire_schedule_jobid`,
`expired_at` field). Payenv maps `expired` to `CUSTOMER_TIMEOUT`.
- Fees may be charged to the customer: a 100 XOF payment showed `fees: 2` and
`amount_debited: 102`.
- Transactions have a `merchant_reference` field. Whether it can be set on creation and
used for lookups is still to verify (see below).

## 🔍 Still to verify

- The full list of live transaction statuses. Mapped today: `pending`, `approved`,
`transferred`, `refunded` → succeeded, `declined` → failed, `canceled`, `expired`.
Unknown values map to `unknown` (safe).
- That live pushes accept the E.164 phone format too (the sandbox does).
- After how long FedaPay's scheduled expiration turns a stuck `pending` into `expired`.
- The other `last_error_code` values (only `INSUFFICIENT_FUND_ERROR` and `API_ERROR` are
mapped; others are exposed as `providerCode` with a default code).
- Whether a transaction can be looked up by merchant reference. That would let Payenv
resolve a timeout on the *create* call. Today that case ends as `unknown` (safe but
not ideal).
Expand Down
36 changes: 34 additions & 2 deletions packages/connector-fedapay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
> 🚧 Pre-release (`0.x`).

**Supported today:** mobile money collections in Benin (XOF): MTN, Moov, Celtiis.
Status lookup and webhook signature verification. Payouts and refunds are coming.
Status lookup, webhook signature verification and parsing. Payouts and refunds are coming.

```ts
import { createPayenv } from '@payenv/core';
import { fedapay, verifyFedaPayWebhook } from '@payenv/connector-fedapay';
import { fedapay, parseFedaPayWebhook, verifyFedaPayWebhook } from '@payenv/connector-fedapay';

const payenv = createPayenv({
connectors: [
Expand All @@ -34,6 +34,11 @@ const valid = await verifyFedaPayWebhook(
request.headers.get('x-fedapay-signature'),
process.env.FEDAPAY_WEBHOOK_SECRET!,
);
if (valid) {
const event = parseFedaPayWebhook(rawBody); // { name, providerRef, status, error }
// Find your payment by event.providerRef, then confirm with FedaPay:
// await payenv.refresh(idempotencyKey);
}
```

## Options
Expand All @@ -52,6 +57,33 @@ The FedaPay sandbox simulates every operator with a single `momo_test` mode. Use
`+22964000001` or `+22966000001` for a successful payment; any other number simulates a
failure. Your code stays the same: just switch `environment` to `'live'` in production.

## Error reasons

When FedaPay records why a payment failed, it is mapped to a Payenv code and the original
code is kept:

```json
{
"code": "INSUFFICIENT_FUNDS",
"message": "FedaPay transaction canceled — The customer has insufficient funds (INSUFFICIENT_FUND_ERROR)",
"providerCode": "INSUFFICIENT_FUND_ERROR"
}
```

## Payments stuck in pending

Some operators don't report a cancellation: on Celtiis, a customer who cancels the USSD
prompt leaves the transaction `pending` at FedaPay, with an operator error recorded.
Payenv keeps it `pending` (it never guesses, since the payment could still complete) and
exposes the error in `payment.error`. Recommended handling:

1. Show the customer that the payment is not confirmed yet, and let them retry with a
**new** idempotency key (a new payment).
2. Keep calling `payenv.refresh(key)` (or handle webhooks) for the old payment, so a late
success is not lost.
3. Apply your own business timeout (e.g. release the order after 15 minutes), and if the
old payment succeeds later, fulfil or refund it.

## Safety

Money can only move once the mobile money push is sent (step 3 of 3).
Expand Down
Loading
Loading