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
4 changes: 1 addition & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# Copy to .env (git-ignored) to run the opt-in sandbox tests: pnpm test
# 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=
# A FedaPay sandbox test phone number, in E.164 format (e.g. +229...)
FEDAPAY_SANDBOX_PHONE=
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Before `1.0.0`, minor versions may contain breaking changes.
## [Unreleased]

### Added
- 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`
mode; sandbox tests verify success and failure end to end.
- 2026-09-26 — `@payenv/connector-fedapay`: mobile money collections in Benin (MTN,
Moov, Celtiis), status lookup, webhook signature verification, sandbox tests (opt-in).
- 2026-09-26 — `@payenv/core`: `AttemptContext.reportProviderRef`, so an attempt
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ packages/
src/
test/
connector-fedapay/ @payenv/connector-fedapay
examples/
fedapay-demo/ runnable demo: pnpm demo
```

Sandbox tests are opt-in: copy `.env.example` to `.env` (git-ignored) and fill in
Expand Down
5 changes: 5 additions & 0 deletions README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ const payment = await payenv.collect({
// sur le suivant uniquement s'il a échoué *avant* tout mouvement d'argent.
```

## Essayer

Une démo prête à lancer encaisse un vrai paiement sandbox via FedaPay en une minute :
voir [examples/fedapay-demo](examples/fedapay-demo).

## Principes

1. **Bibliothèque d'abord.** Payenv tourne *dans votre application*. Aucun service
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ What Payenv gives you:
| **Normalized webhooks** | Signature verification per provider, one event format for your app. |
| **Normalized statuses & errors** | `succeeded`, `failed`, `pending`… and a shared error taxonomy. |

## Try it

A runnable demo collects a real sandbox payment through FedaPay in a minute:
see [examples/fedapay-demo](examples/fedapay-demo).

## Principles

1. **Library first.** Payenv runs *inside your app*. No hosted service required, no
Expand Down
5 changes: 3 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
- [x] Attempt engine with the safe-fallback invariant, fully tested
- [x] In-memory store (built into `@payenv/core`)
- [x] `@payenv/connector-fedapay` (collect mobile money, status, webhook signature)
- [ ] FedaPay: verify the open points of `QUIRKS.md` in the sandbox
- [ ] Getting-started guide and one runnable example
- [x] FedaPay: verified end to end in the sandbox (success and failure)
- [x] One runnable example (`examples/fedapay-demo`)
- [ ] Getting-started guide

## Phase 2 — Real fallback (`v0.2.0`)

Expand Down
30 changes: 30 additions & 0 deletions examples/fedapay-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Payenv × FedaPay demo

A tiny web page to collect a mobile money payment through Payenv and FedaPay's sandbox,
and watch its status change live. No framework, no dependency besides Payenv.

## Run it

1. At the repository root, copy `.env.example` to `.env` and set your **sandbox** key:
```
FEDAPAY_SANDBOX_SECRET_KEY=sk_sandbox_...
```
2. From the repository root:
```sh
pnpm install
pnpm demo
```
3. Open http://localhost:3000

Sandbox test numbers: `+22964000001` and `+22966000001` succeed; any other number
simulates a failed payment.

## What to look at

- [server.ts](server.ts): `createPayenv` with the FedaPay connector, `payenv.collect`
to start a payment, `payenv.refresh` to follow it, and `verifyFedaPayWebhook` for
webhooks (reachable only through a tunnel such as ngrok).
- The terminal logs every attempt: connector, status, and outcome.
- Phone numbers are masked in the page and in logs.

Requires Node.js 22.18+ (it runs `server.ts` directly with Node's built-in type stripping).
18 changes: 18 additions & 0 deletions examples/fedapay-demo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@payenv/example-fedapay-demo",
"private": true,
"description": "Runnable demo: collect a FedaPay mobile money payment with Payenv.",
"license": "Apache-2.0",
"type": "module",
"scripts": {
"start": "node --env-file-if-exists=../../.env server.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@payenv/connector-fedapay": "workspace:*",
"@payenv/core": "workspace:*"
},
"devDependencies": {
"@types/node": "^24.0.0"
}
}
220 changes: 220 additions & 0 deletions examples/fedapay-demo/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/**
* Payenv × FedaPay demo.
*
* pnpm demo (from the repository root)
*
* 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.
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { fedapay, 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 port = Number(process.env.PORT ?? 3000);

const payenv = createPayenv({
connectors: [fedapay({ secretKey, environment: 'sandbox' })],
onEvent(event) {
if (event.type === 'attempt.finished') {
const { attempt } = event;
console.log(
`[payenv] ${attempt.connectorId}: ${attempt.status} (${attempt.outcome})` +
(attempt.error ? ` — ${attempt.error.code}: ${attempt.error.message}` : ''),
);
}
if (event.type === 'fallback') {
console.log(`[payenv] fallback ${event.from} → ${event.to} (${event.reason.code})`);
}
},
});

createServer((request, response) => {
route(request, response).catch((error: unknown) => {
console.error(error);
send(response, 500, { error: 'Internal error' });
});
}).listen(port, () => {
console.log(`Payenv demo 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);
return;
}

if (request.method === 'POST' && url.pathname === '/api/pay') {
const body = JSON.parse(await readBody(request)) as {
amount: number;
phone: string;
network: string;
};
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',
idempotencyKey: `demo_${Date.now()}`,
});
send(response, 200, view(payment));
} catch (error) {
// Invalid requests (bad phone, no route...) are thrown, not returned as payments.
if (isPayenvError(error)) send(response, 400, { error: error.toJSON() });
else throw error;
}
return;
}

const match = /^\/api\/payments\/([\w-]+)$/.exec(url.pathname);
if (request.method === 'GET' && match?.[1]) {
const payment = await payenv.refresh(match[1]);
send(response, 200, view(payment));
return;
}

if (request.method === 'POST' && url.pathname === '/webhooks/fedapay') {
// Reachable only through a tunnel (e.g. ngrok) set as the webhook URL in FedaPay.
const raw = await readBody(request);
const signature = request.headers['x-fedapay-signature'];
const valid = await verifyFedaPayWebhook(
raw,
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 });
return;
}

send(response, 404, { error: 'Not found' });
}

/** What the page shows. The phone number is masked, as it should be in any log or UI. */
function view(payment: Payment) {
const phone = payment.method.type === 'mobile_money' ? payment.method.phone : '';
return {
key: payment.idempotencyKey,
status: payment.status,
connector: payment.connectorId,
providerRef: payment.providerRef,
phone: phone.replace(/^(\+\d{3})\d+(\d{2})$/, '$1••••••$2'),
error: payment.error,
attempts: payment.attempts.map(({ connectorId, status, outcome, error }) => ({
connectorId,
status,
outcome,
error: error?.code,
})),
};
}

function readBody(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
request.setEncoding('utf8');
request.on('data', (chunk: string) => {
data += chunk;
});
request.on('end', () => resolve(data));
request.on('error', reject);
});
}

function send(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(body));
}

const PAGE = `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Payenv demo</title>
<style>
:root { color-scheme: light dark; --accent: #2563eb; --ok: #16a34a; --ko: #dc2626; --wait: #d97706; }
body { font: 16px/1.5 system-ui, sans-serif; max-width: 520px; margin: 40px auto; padding: 0 16px; }
h1 { margin-bottom: 0; } p.sub { margin-top: 4px; opacity: .7; }
form { display: grid; gap: 12px; margin: 24px 0; }
label { display: grid; gap: 4px; font-weight: 600; font-size: 14px; }
input, select, button { font: inherit; padding: 10px 12px; border-radius: 8px; border: 1px solid #8884; }
button { background: var(--accent); color: white; border: 0; font-weight: 600; cursor: pointer; }
button:disabled { opacity: .5; cursor: wait; }
.hint { font-size: 13px; opacity: .7; }
#result { border: 1px solid #8884; border-radius: 12px; padding: 16px; display: none; }
.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); }
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>
<form id="pay">
<label>Montant (XOF) <input name="amount" type="number" min="1" 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>
<button>Payer</button>
</form>
<div id="result">
<div>Statut : <span id="status" class="badge"></span></div>
<p id="message"></p>
<pre id="details"></pre>
</div>
<script>
const form = document.getElementById('pay');
const terminal = ['succeeded', 'failed', 'canceled', 'expired'];
function show(payment) {
document.getElementById('result').style.display = 'block';
const badge = document.getElementById('status');
badge.textContent = payment.status;
badge.className = 'badge ' + payment.status;
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 : '';
document.getElementById('details').textContent = JSON.stringify(payment, null, 2);
}
async function poll(key) {
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 2000));
const payment = await (await fetch('/api/payments/' + key)).json();
show(payment);
if (terminal.includes(payment.status)) return;
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = form.querySelector('button');
button.disabled = true;
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/pay', { method: 'POST', body: JSON.stringify(data) });
const payment = await response.json();
if (!response.ok) { show({ status: 'failed', error: payment.error }); return; }
show(payment);
if (!terminal.includes(payment.status)) await poll(payment.key);
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>`;
12 changes: 12 additions & 0 deletions examples/fedapay-demo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"noEmit": true,
"paths": {
"@payenv/core": ["../../packages/core/src/index.ts"],
"@payenv/connector-fedapay": ["../../packages/connector-fedapay/src/index.ts"]
}
},
"include": ["*.ts"]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"test:watch": "vitest",
"lint": "biome check .",
"format": "biome check --write .",
"check": "pnpm lint && pnpm typecheck && pnpm test"
"check": "pnpm lint && pnpm typecheck && pnpm test",
"demo": "pnpm --filter \"./packages/*\" build && pnpm --filter @payenv/example-fedapay-demo start"
},
"devDependencies": {
"@biomejs/biome": "^2.5.14",
Expand Down
24 changes: 19 additions & 5 deletions packages/connector-fedapay/QUIRKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,30 @@ What differs from what you would expect when reading the FedaPay documentation.
- **Payouts** are created with `POST /v1/payouts`, then started with
`PUT /v1/payouts/start` and body `{ "payouts": [{ "id": ... }] }`. (Not implemented yet.)

## 🔍 To verify in the sandbox

- The full list of transaction statuses. Mapped today: `pending`, `approved`,
## ✅ Verified in the sandbox (2026-09-27)

- **The sandbox has no per-operator test servers anymore.** MTN, Moov, etc. were removed;
every push goes to the single `momo_test` mode (`POST /v1/momo_test` with the token).
Pushing to `/mtn_open` in the sandbox fails with HTTP 400 "Opération non autorisée".
The connector therefore maps every network to `momo_test` when `environment` is
`sandbox`, so application code is identical in both environments.
Source: https://docs.fedapay.com/fr/integration-api/sending-requests#serveur-de-test
- **Test numbers**: `64000001` and `66000001` succeed; any other number simulates a
failed payment. Sent as E.164 (`+22964000001`), they are accepted end to end.
- **Statuses observed**: a successful payment settles as succeeded; a failed one as
`declined` → `failed` / `CUSTOMER_DECLINED`.
- Authentication with a sandbox key, transaction creation, token generation, and status
lookup by transaction id all work as implemented.

## 🔍 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).
- The phone number format FedaPay expects (E.164 `+229…` is sent today).
- That live pushes accept the E.164 phone format too (the sandbox does).
- 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).
- Sandbox test phone numbers for each operator.

## Safety decisions

Expand Down
Loading
Loading