From eac92033aa910370dbfa2b4246a4475a5155a301 Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:02:53 +0100 Subject: [PATCH 1/4] fix(connector-kkiapay): recognize Kkiapay's explicit "not found" answer The sandbox answers an unknown transaction id with HTTP 400 and `{ "status": "TRANSACTION_NOT_FOUND" }`, not 404 as assumed. Only this explicit answer now counts as "not found", whatever the HTTP status. Found by the opt-in sandbox test, which now passes. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- packages/connector-kkiapay/QUIRKS.md | 5 +++-- packages/connector-kkiapay/src/kkiapay.ts | 4 +++- packages/connector-kkiapay/test/kkiapay.test.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/connector-kkiapay/QUIRKS.md b/packages/connector-kkiapay/QUIRKS.md index 11cd945..f8b5e16 100644 --- a/packages/connector-kkiapay/QUIRKS.md +++ b/packages/connector-kkiapay/QUIRKS.md @@ -9,6 +9,9 @@ - **API**: `https://api-sandbox.kkiapay.me` / `https://api.kkiapay.me`, headers `x-api-key` (public key), `x-private-key`, `x-secret-key`. The sandbox answers HTTP 401 to invalid keys (observed). +- **Unknown transaction id** (verified in the sandbox, 2026-10-06): HTTP **400** with + `{ "status": "TRANSACTION_NOT_FOUND" }` (not 404). Only this explicit answer is + treated as "not found". - **Status response** fields include `status` (`SUCCESS`, `FAILED`…), `amount`, `fees`, `feeSupportedBy`, `failureCode`, `failureMessage`, `partnerId`, `transactionId`, `client.phone`. @@ -27,7 +30,5 @@ `payenv.confirm` relies on it: without it, confirmation is refused (safe). - That `amount` in status responses is the amount requested (not including fees), for both `feeSupportedBy` values. -- The HTTP status for an unknown `transactionId` (assumed 404 → "not found"; anything - else is treated as an error, which is safe). - The phone format the widget expects (sent today without `+`, e.g. `22961000000`). - Whether a `PENDING` status exists. diff --git a/packages/connector-kkiapay/src/kkiapay.ts b/packages/connector-kkiapay/src/kkiapay.ts index dbb9d2a..62726fa 100644 --- a/packages/connector-kkiapay/src/kkiapay.ts +++ b/packages/connector-kkiapay/src/kkiapay.ts @@ -137,7 +137,9 @@ export function kkiapay(options: KkiapayOptions): Connector { }); } const data = await readJson(response); - if (response.status === 404) return { found: false, raw: data }; + // Verified in the sandbox: HTTP 400 with { "status": "TRANSACTION_NOT_FOUND" }. + // Only this explicit answer counts as "not found", whatever the HTTP status. + if (data.status === 'TRANSACTION_NOT_FOUND') return { found: false, raw: data }; if (!response.ok) { throw new PayenvError( response.status === 401 || response.status === 403 diff --git a/packages/connector-kkiapay/test/kkiapay.test.ts b/packages/connector-kkiapay/test/kkiapay.test.ts index 7137ed1..cdeaf23 100644 --- a/packages/connector-kkiapay/test/kkiapay.test.ts +++ b/packages/connector-kkiapay/test/kkiapay.test.ts @@ -24,7 +24,7 @@ function fakeKkiapay(transactions: Record>) { const transaction = transactions[body.transactionId]; return transaction ? new Response(JSON.stringify(transaction), { status: 200 }) - : new Response(JSON.stringify({ reason: 'Transaction Not Found' }), { status: 404 }); + : new Response(JSON.stringify({ status: 'TRANSACTION_NOT_FOUND' }), { status: 400 }); }) as typeof globalThis.fetch; return { fetch, calls }; } From c192fce65ef2369220803eaf16798898fab21a66 Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:20:23 +0100 Subject: [PATCH 2/4] feat(core): add toE164 to convert phone numbers typed by customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payenv keeps requiring E.164 (a number without its country is ambiguous, and could charge the wrong person), but customers type local numbers. `toE164(phone, country)` converts them, with per-country rules: the leading 0 is a trunk prefix in Nigeria, Ghana, Kenya…, but part of the number in Benin (+22901…) and Côte d'Ivoire. demo: accepts local numbers (via toE164), pre-fills the Kkiapay test number when the FedaPay outage is simulated, and shows widget failures. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- .changeset/kkiapay-widget-flow.md | 3 +- CHANGELOG.md | 2 + docs/GETTING_STARTED.md | 8 +++ examples/fedapay-demo/server.ts | 27 ++++++++-- packages/core/src/index.ts | 1 + packages/core/src/phone.ts | 89 +++++++++++++++++++++++++++++++ packages/core/test/phone.test.ts | 33 ++++++++++++ 7 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/phone.ts create mode 100644 packages/core/test/phone.test.ts diff --git a/.changeset/kkiapay-widget-flow.md b/.changeset/kkiapay-widget-flow.md index 9d5669f..031d49c 100644 --- a/.changeset/kkiapay-widget-flow.md +++ b/.changeset/kkiapay-widget-flow.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd342b..d557ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 570fdeb..128af8b 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -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. diff --git a/examples/fedapay-demo/server.ts b/examples/fedapay-demo/server.ts index 72fe31a..ddb6992 100644 --- a/examples/fedapay-demo/server.ts +++ b/examples/fedapay-demo/server.ts @@ -28,6 +28,7 @@ import { PayenvError, type PayenvOptions, type Payment, + toE164, } from '@payenv/core'; const live = process.env.DEMO_LIVE === 'yes'; @@ -145,7 +146,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()}`, @@ -315,12 +322,12 @@ ${live ? `

⚠️ Mode LIVE : vrai argent. Monta - + ${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'} ${ withKkiapay ? ` - Widget Kkiapay (sandbox) : 61000000 ou 97000000 (MTN) = succès · 61000002 = fonds insuffisants` + Widget Kkiapay (sandbox) : MTN 61000000 · Moov 68000000 = succès · …02 = fonds insuffisants` : '' } @@ -345,6 +352,20 @@ ${withKkiapay ? '' : ''} // 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); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6b8a0e8..34afc65 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -39,6 +39,7 @@ export { } from './money.js'; export { createPayenv, type Payenv, type PayenvEvent, type PayenvOptions } from './payenv.js'; export type { Attempt, AttemptOutcome, Payment } from './payment.js'; +export { toE164 } from './phone.js'; export { priority, type RoutingStrategy, supports } from './router.js'; export { canTransition, isTerminal, type PaymentStatus, type TerminalStatus } from './status.js'; export { createMemoryStore, type Store } from './store.js'; diff --git a/packages/core/src/phone.ts b/packages/core/src/phone.ts new file mode 100644 index 0000000..e147f64 --- /dev/null +++ b/packages/core/src/phone.ts @@ -0,0 +1,89 @@ +import { PayenvError } from './errors.js'; +import type { CountryCode } from './method.js'; + +/** Country calling codes (ITU-T E.164) for the countries Payenv connectors serve first. */ +const CALLING_CODES: Readonly> = { + BF: '226', + BJ: '229', + CD: '243', + CG: '242', + CI: '225', + CM: '237', + FR: '33', + GA: '241', + GH: '233', + GN: '224', + KE: '254', + ML: '223', + NE: '227', + NG: '234', + RW: '250', + SN: '221', + TD: '235', + TG: '228', + TZ: '255', + UG: '256', + ZM: '260', +}; + +/** + * Countries where a leading 0 is a national trunk prefix, dropped in E.164 + * (e.g. Nigeria 0803… → +234803…). In Benin and Côte d'Ivoire, the leading 0 of + * 10-digit numbers is part of the number (+22901…, +22507…). + */ +const TRUNK_ZERO: ReadonlySet = new Set([ + 'CD', + 'FR', + 'GH', + 'KE', + 'NG', + 'RW', + 'TZ', + 'UG', + 'ZM', +]); + +const E164 = /^\+[1-9]\d{6,14}$/; + +/** + * Converts a phone number typed by a customer into E.164, for a given country. + * + * ```ts + * toE164('61 00 00 00', 'BJ'); // '+22961000000' + * toE164('0022961000000', 'BJ'); // '+22961000000' + * toE164('0803 123 4567', 'NG'); // '+2348031234567' + * ``` + * + * Numbers that already start with `+` (or `00`) are kept as they are. Throws + * `INVALID_PHONE` when the result is not a valid E.164 number, or the country is unknown. + * Payenv itself only accepts E.164: call this before `collect` with what the user typed. + */ +export function toE164(phone: string, country: CountryCode): string { + const compact = phone.replace(/[\s.\-()]/g, ''); + const international = compact.startsWith('00') ? `+${compact.slice(2)}` : compact; + + if (international.startsWith('+')) return assertE164(international, phone); + + const callingCode = CALLING_CODES[country]; + if (!callingCode) { + throw new PayenvError( + 'INVALID_PHONE', + `Unknown calling code for country "${country}": pass the number in E.164 (+…)`, + ); + } + if (!/^\d+$/.test(international)) { + throw new PayenvError('INVALID_PHONE', `Invalid phone number "${phone}"`); + } + const national = + TRUNK_ZERO.has(country) && international.startsWith('0') + ? international.slice(1) + : international; + return assertE164(`+${callingCode}${national}`, phone); +} + +function assertE164(candidate: string, input: string): string { + if (!E164.test(candidate)) { + throw new PayenvError('INVALID_PHONE', `Invalid phone number "${input}"`); + } + return candidate; +} diff --git a/packages/core/test/phone.test.ts b/packages/core/test/phone.test.ts new file mode 100644 index 0000000..1225def --- /dev/null +++ b/packages/core/test/phone.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { toE164 } from '../src/index.js'; + +describe('toE164', () => { + it.each([ + // Benin: legacy 8-digit and new 10-digit numbers (the leading 01 is part of the number) + ['61000000', 'BJ', '+22961000000'], + ['61 00 00 00', 'BJ', '+22961000000'], + ['01 59 52 12 11', 'BJ', '+2290159521211'], + ['0022961000000', 'BJ', '+22961000000'], + ['+229 61 00 00 00', 'BJ', '+22961000000'], + // Côte d'Ivoire: the leading 0 is part of the 10-digit number + ['07 07 07 07 07', 'CI', '+2250707070707'], + // Nigeria: the leading 0 is a trunk prefix + ['0803 123 4567', 'NG', '+2348031234567'], + ['(0803) 123-4567', 'NG', '+2348031234567'], + // Already international: kept, whatever the country + ['+221 77 000 00 00', 'BJ', '+221770000000'], + ])('%s (%s) → %s', (input, country, expected) => { + expect(toE164(input, country)).toBe(expected); + }); + + it.each([ + ['abc', 'BJ'], + ['12', 'BJ'], + ['61000000', 'XX'], + ['+0123456789', 'BJ'], + ])('rejects %s (%s)', (input, country) => { + expect(() => toE164(input, country)).toThrow( + expect.objectContaining({ code: 'INVALID_PHONE' }), + ); + }); +}); From fba5fc07cb4e35b592443125f547f6076a434036 Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:31:19 +0100 Subject: [PATCH 3/4] fix(examples): repair the demo page script and test it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An apostrophe in "n'a bougé" closed a JavaScript string inside the page template, so the whole inline script failed to load and the form was submitted natively (the page just reloaded). Use a typographic apostrophe. To catch this class of bug: the page moves to page.ts, and a test parses its inline script in every demo configuration (it fails on the original bug). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- examples/fedapay-demo/page.ts | 139 ++++++++++++++++++++++++ examples/fedapay-demo/server.ts | 136 +---------------------- examples/fedapay-demo/test/page.test.ts | 29 +++++ examples/fedapay-demo/tsconfig.json | 5 +- vitest.config.ts | 2 +- 5 files changed, 174 insertions(+), 137 deletions(-) create mode 100644 examples/fedapay-demo/page.ts create mode 100644 examples/fedapay-demo/test/page.test.ts diff --git a/examples/fedapay-demo/page.ts b/examples/fedapay-demo/page.ts new file mode 100644 index 0000000..77245a1 --- /dev/null +++ b/examples/fedapay-demo/page.ts @@ -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 ` + + + + +Payenv demo + + + +

Payenv demo

+

Paiement mobile money via FedaPay${withKkiapay ? ' et Kkiapay' : ''} (${live ? 'LIVE' : 'sandbox'})

+${live ? `

⚠️ Mode LIVE : vrai argent. Montant plafonné à ${maxLiveAmount} XOF.

` : ''} +
+ + + + ${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'} + ${ + withKkiapay + ? ` + Widget Kkiapay (sandbox) : MTN 61000000 · Moov 68000000 = succès · …02 = fonds insuffisants` + : '' + } + +
+
+
Statut :
+

+

+
+${withKkiapay ? '' : ''} + + +`; +} diff --git a/examples/fedapay-demo/server.ts b/examples/fedapay-demo/server.ts index ddb6992..67228c1 100644 --- a/examples/fedapay-demo/server.ts +++ b/examples/fedapay-demo/server.ts @@ -30,6 +30,7 @@ import { 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); @@ -121,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; } @@ -286,136 +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 ` - - - - -Payenv demo - - - -

Payenv demo

-

Paiement mobile money via FedaPay${withKkiapay ? ' et Kkiapay' : ''} (${live ? 'LIVE' : 'sandbox'})

-${live ? `

⚠️ Mode LIVE : vrai argent. Montant plafonné à ${maxLiveAmount} XOF.

` : ''} -
- - - - ${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'} - ${ - withKkiapay - ? ` - Widget Kkiapay (sandbox) : MTN 61000000 · Moov 68000000 = succès · …02 = fonds insuffisants` - : '' - } - -
-
-
Statut :
-

-

-
-${withKkiapay ? '' : ''} - - -`; -} diff --git a/examples/fedapay-demo/test/page.test.ts b/examples/fedapay-demo/test/page.test.ts new file mode 100644 index 0000000..8110b68 --- /dev/null +++ b/examples/fedapay-demo/test/page.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { page } from '../page.ts'; + +/** Inline