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
3 changes: 2 additions & 1 deletion .changeset/kkiapay-widget-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Kkiapay, and payments started by the customer in a widget.
- `@payenv/core`: `nextAction.type === 'widget'`, `supportedWidgets` and
`Capability.widget` for providers whose payments start in a widget; `payenv.confirm`
verifies the provider transaction's merchant reference and amount before linking it.
New error code `REFERENCE_MISMATCH`.
New error code `REFERENCE_MISMATCH`. `toE164` converts phone numbers typed by customers
to E.164.
- `@payenv/connector-kkiapay` (new): Kkiapay widget payments in Benin (MTN, Moov),
server-side verification, webhook verification and parsing.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Before `1.0.0`, minor versions may contain breaking changes.
## [Unreleased]

### Added
- 2026-10-06 — `@payenv/core`: `toE164(phone, country)` converts numbers typed by customers
(e.g. `61 00 00 00` in Benin) to E.164, with per-country trunk-prefix rules.
- 2026-10-05 — `@payenv/core`: payments started by the customer in a widget
(`nextAction.type === 'widget'`, `supportedWidgets`, `Capability.widget`) and
`payenv.confirm`, which verifies the provider transaction's merchant reference and
Expand Down
8 changes: 8 additions & 0 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ console.log(payment.status); // 'pending': the customer confirms on their phone
In the FedaPay sandbox, `+22964000001` and `+22966000001` succeed; any other number simulates
a failed payment. In live mode, the customer receives a USSD prompt and confirms with their PIN.

Payenv only accepts E.164 phone numbers. Convert what the customer typed with `toE164`:

```ts
import { toE164 } from '@payenv/core';

toE164('61 00 00 00', 'BJ'); // '+22961000000'
```

Invalid requests throw a `PayenvError` (`INVALID_PHONE`, `NO_ROUTE`, …). Everything that happens
with the provider is returned in the payment instead.

Expand Down
139 changes: 139 additions & 0 deletions examples/fedapay-demo/page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/** The demo page. Kept apart from the server so a test can check its inline script. */
export interface PageOptions {
live: boolean;
maxLiveAmount: number;
withKkiapay: boolean;
}

export function page({ live, maxLiveAmount, withKkiapay }: PageOptions): string {
return `<!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); }
.live { background: var(--ko); color: white; padding: 10px 14px; border-radius: 8px; }
.check { display: flex; gap: 8px; align-items: center; font-weight: 400; }
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${withKkiapay ? ' et Kkiapay' : ''} (${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" ${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="${live ? '' : '64000001'}" placeholder="61 00 00 00 ou +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>
${
withKkiapay
? `<label class="check"><input type="checkbox" name="outage"> Simuler une panne FedaPay (Payenv bascule vers le widget Kkiapay)</label>
<span class="hint">Widget Kkiapay (sandbox) : MTN 61000000 · Moov 68000000 = succès · …02 = fonds insuffisants</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>
${withKkiapay ? '<script src="https://cdn.kkiapay.me/k.js"></script>' : ''}
<script>
const form = document.getElementById('pay');
let currentKey = null;
async function confirmWidget(transactionId) {
const response = await fetch('/api/payments/' + currentKey + '/confirm', {
method: 'POST', body: JSON.stringify({ transactionId }),
});
const result = await response.json();
show(response.ok ? result : { status: 'failed', error: result.error });
}
if (window.addSuccessListener) {
// The widget reports a transaction id; the server verifies it before trusting it.
addSuccessListener((response) => confirmWidget(response.transactionId));
}
if (window.addFailedListener) {
// No money moved: the payment stays open, the customer can retry in the widget.
addFailedListener(() => {
document.getElementById('message').textContent =
'Le paiement a échoué dans le widget. Aucun argent n’a bougé : le client peut réessayer.';
});
}
const outage = form.querySelector('[name="outage"]');
if (outage) {
// Each provider has its own sandbox test numbers.
outage.addEventListener('change', () => {
form.querySelector('[name="phone"]').value = outage.checked ? '61000000' : '64000001';
});
}
function openWidget(action) {
if (action.provider === 'kkiapay' && window.openKkiapayWidget) openKkiapayWidget(action.params);
}
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;
const reason = payment.error ? payment.error.code + ' — ' + payment.error.message : '';
document.getElementById('message').textContent =
payment.status === 'requires_action'
? 'Le client paie dans le widget ' + (payment.nextAction?.provider ?? '') + '…' +
(payment.attempts?.length > 1 ? ' (bascule après : ' + payment.attempts[0].error + ')' : '')
: 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) {
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;
}
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();
const button = form.querySelector('button');
button.disabled = true;
try {
const data = Object.fromEntries(new FormData(form));
data.widgets = window.openKkiapayWidget ? ['kkiapay'] : [];
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);
currentKey = payment.key;
if (payment.nextAction?.type === 'widget') openWidget(payment.nextAction);
if (!terminal.includes(payment.status)) await poll(payment.key);
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>`;
}
131 changes: 10 additions & 121 deletions examples/fedapay-demo/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ import {
PayenvError,
type PayenvOptions,
type Payment,
toE164,
} from '@payenv/core';
import { page } from './page.ts';

const live = process.env.DEMO_LIVE === 'yes';
const maxLiveAmount = Number(process.env.DEMO_LIVE_MAX_AMOUNT ?? 200);
Expand Down Expand Up @@ -120,7 +122,7 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis

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

Expand All @@ -145,7 +147,13 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis
const client = body.outage === 'on' ? payenvWithOutage : payenv;
const payment = await client.collect({
amount: { value: Number(body.amount), currency: 'XOF' },
method: { type: 'mobile_money', network: body.network, country: 'BJ', phone: body.phone },
// Customers type local numbers: convert to E.164 before calling Payenv.
method: {
type: 'mobile_money',
network: body.network,
country: 'BJ',
phone: toE164(body.phone, 'BJ'),
},
customer: { firstName: 'Demo', lastName: 'Payenv', email: 'demo@example.com' },
description: live ? 'Payenv demo (live test)' : 'Payenv demo',
idempotencyKey: `demo_${Date.now()}`,
Expand Down Expand Up @@ -279,122 +287,3 @@ function send(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(body));
}

function page(): string {
return `<!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); }
.live { background: var(--ko); color: white; padding: 10px 14px; border-radius: 8px; }
.check { display: flex; gap: 8px; align-items: center; font-weight: 400; }
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${withKkiapay ? ' et Kkiapay' : ''} (${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" ${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="${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>
${
withKkiapay
? `<label class="check"><input type="checkbox" name="outage"> Simuler une panne FedaPay (Payenv bascule vers le widget Kkiapay)</label>
<span class="hint">Widget Kkiapay (sandbox) : 61000000 ou 97000000 (MTN) = succès · 61000002 = fonds insuffisants</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>
${withKkiapay ? '<script src="https://cdn.kkiapay.me/k.js"></script>' : ''}
<script>
const form = document.getElementById('pay');
let currentKey = null;
async function confirmWidget(transactionId) {
const response = await fetch('/api/payments/' + currentKey + '/confirm', {
method: 'POST', body: JSON.stringify({ transactionId }),
});
const result = await response.json();
show(response.ok ? result : { status: 'failed', error: result.error });
}
if (window.addSuccessListener) {
// The widget reports a transaction id; the server verifies it before trusting it.
addSuccessListener((response) => confirmWidget(response.transactionId));
}
function openWidget(action) {
if (action.provider === 'kkiapay' && window.openKkiapayWidget) openKkiapayWidget(action.params);
}
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;
const reason = payment.error ? payment.error.code + ' — ' + payment.error.message : '';
document.getElementById('message').textContent =
payment.status === 'requires_action'
? 'Le client paie dans le widget ' + (payment.nextAction?.provider ?? '') + '…' +
(payment.attempts?.length > 1 ? ' (bascule après : ' + payment.attempts[0].error + ')' : '')
: 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) {
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;
}
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();
const button = form.querySelector('button');
button.disabled = true;
try {
const data = Object.fromEntries(new FormData(form));
data.widgets = window.openKkiapayWidget ? ['kkiapay'] : [];
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);
currentKey = payment.key;
if (payment.nextAction?.type === 'widget') openWidget(payment.nextAction);
if (!terminal.includes(payment.status)) await poll(payment.key);
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>`;
}
29 changes: 29 additions & 0 deletions examples/fedapay-demo/test/page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { page } from '../page.ts';

/** Inline <script> blocks of the page (external scripts are skipped). */
function scripts(html: string): string[] {
return [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((match) => match[1] ?? '');
}

describe('demo page', () => {
it.each([
{ live: false, maxLiveAmount: 200, withKkiapay: false },
{ live: false, maxLiveAmount: 200, withKkiapay: true },
{ live: true, maxLiveAmount: 200, withKkiapay: false },
])('has a valid inline script (%o)', (options) => {
const inline = scripts(page(options));
expect(inline.length).toBeGreaterThan(0);
// Parses without running: a quote or escape mistake in the template throws here.
for (const script of inline) expect(() => new Function(script)).not.toThrow();
});

it('only offers the outage switch when Kkiapay is configured', () => {
expect(page({ live: false, maxLiveAmount: 200, withKkiapay: true })).toContain(
'type="checkbox" name="outage"',
);
expect(page({ live: false, maxLiveAmount: 200, withKkiapay: false })).not.toContain(
'type="checkbox" name="outage"',
);
});
});
5 changes: 3 additions & 2 deletions examples/fedapay-demo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"@payenv/core": ["../../packages/core/src/index.ts"],
"@payenv/connector-fedapay": ["../../packages/connector-fedapay/src/index.ts"],
"@payenv/connector-kkiapay": ["../../packages/connector-kkiapay/src/index.ts"]
}
},
"allowImportingTsExtensions": true
},
"include": ["*.ts"]
"include": ["*.ts", "test/*.ts"]
}
Loading
Loading