From 8ca58675301c4806ac6932f9021c325285256608 Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:08:13 +0100 Subject: [PATCH 1/4] test(connector-fedapay): make the sandbox operator configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real sandbox run confirmed authentication, transaction creation and token generation, and that a non-activated operator fails at the push with "Opération non autorisée", correctly mapped to ROUTE_UNSUPPORTED. - FEDAPAY_SANDBOX_NETWORK selects the operator (mtn, moov, celtiis) - Clear failure message when the operator is not activated - QUIRKS.md: record what the sandbox verified Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- .env.example | 3 +++ packages/connector-fedapay/QUIRKS.md | 10 +++++++++- packages/connector-fedapay/test/sandbox.test.ts | 12 ++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index dc5a54b..278be14 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ FEDAPAY_SANDBOX_SECRET_KEY= # A FedaPay sandbox test phone number, in E.164 format (e.g. +229...) FEDAPAY_SANDBOX_PHONE= +# Operator to test: mtn (default), moov or celtiis. It must be activated on the +# sandbox account (Dashboard → Configuration → Payment methods). +FEDAPAY_SANDBOX_NETWORK=mtn diff --git a/packages/connector-fedapay/QUIRKS.md b/packages/connector-fedapay/QUIRKS.md index 17f8181..8ddd65c 100644 --- a/packages/connector-fedapay/QUIRKS.md +++ b/packages/connector-fedapay/QUIRKS.md @@ -29,12 +29,20 @@ 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.) +## ✅ Verified in the sandbox (2026-09-27) + +- Authentication with a sandbox key, transaction creation (with an E.164 phone number + such as `+229…` in `customer.phone_number.number`), and token generation work. +- **Operator activation also applies to the sandbox**: an operator that is not + activated fails at the push step with HTTP 400 "Opération non autorisée". Payenv + correctly maps it to `ROUTE_UNSUPPORTED` (safe to fall back). + ## 🔍 To verify in the sandbox - The full list of 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). +- Whether the push itself accepts the E.164 phone format (creation 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). diff --git a/packages/connector-fedapay/test/sandbox.test.ts b/packages/connector-fedapay/test/sandbox.test.ts index 1b8697f..16a3e27 100644 --- a/packages/connector-fedapay/test/sandbox.test.ts +++ b/packages/connector-fedapay/test/sandbox.test.ts @@ -6,21 +6,29 @@ import { fedapay } from '../src/index.js'; // See .env.example at the repository root. Never use live keys here. const secretKey = process.env.FEDAPAY_SANDBOX_SECRET_KEY; const phone = process.env.FEDAPAY_SANDBOX_PHONE; +const network = process.env.FEDAPAY_SANDBOX_NETWORK || 'mtn'; describe.skipIf(!secretKey || !phone)('fedapay sandbox (real API)', () => { - it('sends an MTN push and reads the status back', { timeout: 60_000 }, async () => { + it(`sends a ${network} push and reads the status back`, { timeout: 60_000 }, async () => { const connector = fedapay({ secretKey: secretKey as string, environment: 'sandbox' }); const payenv = createPayenv({ connectors: [connector] }); const idempotencyKey = `sandbox_${Date.now()}`; const payment = await payenv.collect({ amount: { value: 100, currency: 'XOF' }, - method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: phone as string }, + method: { type: 'mobile_money', network, country: 'BJ', phone: phone as string }, customer: { firstName: 'Payenv', lastName: 'Sandbox', email: 'sandbox@example.com' }, description: 'Payenv sandbox test', idempotencyKey, }); + if (payment.error?.code === 'ROUTE_UNSUPPORTED') { + throw new Error( + `"${network}" is not activated on this FedaPay sandbox account. Activate it in ` + + 'Dashboard (sandbox mode) → Configuration → Payment methods, or set ' + + 'FEDAPAY_SANDBOX_NETWORK to an activated operator (mtn, moov, celtiis).', + ); + } expect(payment.error).toBeUndefined(); expect(['pending', 'succeeded']).toContain(payment.status); expect(payment.providerRef).toBeDefined(); From 07c6485c4d8b38021a07228dfa6d0b25a0121d7b Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Sun, 27 Sep 2026 01:21:22 +0100 Subject: [PATCH 2/4] feat(connector-fedapay): use the momo_test mode in the sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FedaPay sandbox removed its per-operator test servers (MTN, Moov...): every push must go to the single `momo_test` mode. Pushing to `/mtn_open` in the sandbox fails with "Opération non autorisée", which an earlier commit wrongly attributed to operator activation. - Sandbox maps every network to `momo_test` by default, so application code is identical in sandbox and live - Sandbox tests use the documented test numbers and wait for the final status: +22964000001 settles as succeeded, any other number as failed (declined → CUSTOMER_DECLINED). Both pass against the real API. - QUIRKS.md corrected and updated with what the sandbox verified Source: https://docs.fedapay.com/fr/integration-api/sending-requests#serveur-de-test Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- .env.example | 5 +- packages/connector-fedapay/QUIRKS.md | 28 ++++--- packages/connector-fedapay/README.md | 10 ++- packages/connector-fedapay/src/fedapay.ts | 20 ++++- packages/connector-fedapay/src/index.ts | 1 + .../connector-fedapay/test/fedapay.test.ts | 77 +++++++++++++------ .../connector-fedapay/test/sandbox.test.ts | 69 ++++++++++------- 7 files changed, 139 insertions(+), 71 deletions(-) diff --git a/.env.example b/.env.example index 278be14..770d042 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,5 @@ # Copy to .env (git-ignored) to run the opt-in sandbox tests: pnpm test # Never put live keys here. FEDAPAY_SANDBOX_SECRET_KEY= -# A FedaPay sandbox test phone number, in E.164 format (e.g. +229...) +# Optional. Defaults to the documented success test number +22964000001. FEDAPAY_SANDBOX_PHONE= -# Operator to test: mtn (default), moov or celtiis. It must be activated on the -# sandbox account (Dashboard → Configuration → Payment methods). -FEDAPAY_SANDBOX_NETWORK=mtn diff --git a/packages/connector-fedapay/QUIRKS.md b/packages/connector-fedapay/QUIRKS.md index 8ddd65c..8d4f3c7 100644 --- a/packages/connector-fedapay/QUIRKS.md +++ b/packages/connector-fedapay/QUIRKS.md @@ -31,22 +31,28 @@ What differs from what you would expect when reading the FedaPay documentation. ## ✅ Verified in the sandbox (2026-09-27) -- Authentication with a sandbox key, transaction creation (with an E.164 phone number - such as `+229…` in `customer.phone_number.number`), and token generation work. -- **Operator activation also applies to the sandbox**: an operator that is not - activated fails at the push step with HTTP 400 "Opération non autorisée". Payenv - correctly maps it to `ROUTE_UNSUPPORTED` (safe to fall back). - -## 🔍 To verify in the sandbox - -- The full list of transaction statuses. Mapped today: `pending`, `approved`, +- **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). -- Whether the push itself accepts the E.164 phone format (creation does). +- 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 diff --git a/packages/connector-fedapay/README.md b/packages/connector-fedapay/README.md index 4cd0300..d44d81b 100644 --- a/packages/connector-fedapay/README.md +++ b/packages/connector-fedapay/README.md @@ -41,11 +41,17 @@ const valid = await verifyFedaPayWebhook( | Option | Default | Description | |---|---|---| | `secretKey` | — | FedaPay secret key. Required. | -| `environment` | `'sandbox'` | `'sandbox'` or `'live'`. | -| `operators` | Benin: MTN, Moov, Celtiis | Operators to expose, per country: `{ BJ: { mtn: 'mtn_open' } }`. List only the operators activated on your merchant account. | +| `environment` | `'sandbox'` | `'sandbox'` or `'live'`. In the sandbox, every network is sent to FedaPay's `momo_test` mode. | +| `operators` | Benin: MTN, Moov, Celtiis (`momo_test` in the sandbox) | Operators to expose, per country: `{ BJ: { mtn: 'mtn_open' } }`. List only the operators activated on your merchant account. | | `id` | `'fedapay'` | Connector id, e.g. to register two FedaPay accounts. | | `fetch` | global `fetch` | Custom fetch implementation. | +## Testing in the sandbox + +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. + ## Safety Money can only move once the mobile money push is sent (step 3 of 3). diff --git a/packages/connector-fedapay/src/fedapay.ts b/packages/connector-fedapay/src/fedapay.ts index 37f2956..ccbe9b2 100644 --- a/packages/connector-fedapay/src/fedapay.ts +++ b/packages/connector-fedapay/src/fedapay.ts @@ -26,14 +26,24 @@ export const DEFAULT_OPERATORS: FedaPayOperators = { BJ: { mtn: 'mtn_open', moov: 'moov', celtiis: 'sbin' }, }; +/** + * The FedaPay sandbox no longer has per-operator test servers: every push goes to the + * single `momo_test` mode. Test numbers 64000001 and 66000001 succeed; any other + * number simulates a failed payment. + */ +export const DEFAULT_SANDBOX_OPERATORS: FedaPayOperators = { + BJ: { mtn: 'momo_test', moov: 'momo_test', celtiis: 'momo_test' }, +}; + export interface FedaPayOptions { /** Secret API key (sandbox or live). Keep it in an environment variable. */ secretKey: string; /** Defaults to `sandbox`. */ environment?: FedaPayEnvironment; /** - * Operators to expose, per country. Defaults to {@link DEFAULT_OPERATORS}. Only list - * operators activated on your FedaPay merchant account (Dashboard → Payment methods). + * Operators to expose, per country. Defaults to {@link DEFAULT_OPERATORS} in live mode + * and {@link DEFAULT_SANDBOX_OPERATORS} in the sandbox. Only list operators activated + * on your FedaPay merchant account (Dashboard → Payment methods). */ operators?: FedaPayOperators; /** Connector id used in routing and payments. Defaults to `fedapay`. */ @@ -67,8 +77,10 @@ export function fedapay(options: FedaPayOptions): Connector { throw new PayenvError('INVALID_REQUEST', 'FedaPay secretKey is required'); } const id = options.id ?? 'fedapay'; - const baseUrl = BASE_URLS[options.environment ?? 'sandbox']; - const operators = options.operators ?? DEFAULT_OPERATORS; + const environment = options.environment ?? 'sandbox'; + const baseUrl = BASE_URLS[environment]; + const operators = + options.operators ?? (environment === 'live' ? DEFAULT_OPERATORS : DEFAULT_SANDBOX_OPERATORS); const doFetch = options.fetch ?? globalThis.fetch; const capabilities: Capability[] = Object.entries(operators).map(([country, networks]) => ({ diff --git a/packages/connector-fedapay/src/index.ts b/packages/connector-fedapay/src/index.ts index 19258e6..e1d4cb7 100644 --- a/packages/connector-fedapay/src/index.ts +++ b/packages/connector-fedapay/src/index.ts @@ -1,5 +1,6 @@ export { DEFAULT_OPERATORS, + DEFAULT_SANDBOX_OPERATORS, type FedaPayEnvironment, type FedaPayOperators, type FedaPayOptions, diff --git a/packages/connector-fedapay/test/fedapay.test.ts b/packages/connector-fedapay/test/fedapay.test.ts index fd465a3..a271e9e 100644 --- a/packages/connector-fedapay/test/fedapay.test.ts +++ b/packages/connector-fedapay/test/fedapay.test.ts @@ -73,15 +73,19 @@ function context(): AttemptContext & { reported: string[] } { describe('fedapay connector — collect', () => { it('creates the transaction, gets a token, then sends the push', async () => { const server = fakeFedaPay(happyRoutes); - const connector = fedapay({ secretKey: 'sk_sandbox_test', fetch: server.fetch }); + const connector = fedapay({ + secretKey: 'sk_live_test', + environment: 'live', + fetch: server.fetch, + }); const ctx = context(); const result = await connector.collect(request(), ctx); expect(server.calls.map((call) => `${call.method} ${call.url}`)).toEqual([ - 'POST https://sandbox-api.fedapay.com/v1/transactions', - 'POST https://sandbox-api.fedapay.com/v1/transactions/1234/token', - 'POST https://sandbox-api.fedapay.com/v1/mtn_open', + 'POST https://api.fedapay.com/v1/transactions', + 'POST https://api.fedapay.com/v1/transactions/1234/token', + 'POST https://api.fedapay.com/v1/mtn_open', ]); expect(server.calls[0]?.body).toEqual({ description: 'Payment att_1', @@ -95,7 +99,7 @@ describe('fedapay connector — collect', () => { }, }); expect(server.calls[2]?.body).toEqual({ token: 'jwt_token' }); - expect(server.calls[0]?.headers.Authorization).toBe('Bearer sk_sandbox_test'); + expect(server.calls[0]?.headers.Authorization).toBe('Bearer sk_live_test'); expect(result).toMatchObject({ status: 'pending', providerRef: '1234', @@ -104,13 +108,26 @@ describe('fedapay connector — collect', () => { expect(ctx.reported).toEqual(['1234']); }); - it('uses the live API in live mode', async () => { - const server = fakeFedaPay(happyRoutes); - await fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }).collect( - request(), - context(), + it('uses the sandbox by default and sends every push to momo_test', async () => { + const server = fakeFedaPay({ + ...happyRoutes, + 'POST /momo_test': () => ({ + status: 200, + json: { 'v1/payment_intent': { status: 'pending' } }, + }), + }); + const connector = fedapay({ secretKey: 'sk', fetch: server.fetch }); + await connector.collect(request(), context()); + + expect(server.calls.map((call) => call.url)).toEqual([ + 'https://sandbox-api.fedapay.com/v1/transactions', + 'https://sandbox-api.fedapay.com/v1/transactions/1234/token', + 'https://sandbox-api.fedapay.com/v1/momo_test', + ]); + // Same capabilities as live: application code doesn't change between environments. + expect(connector.capabilities()).toEqual( + fedapay({ secretKey: 'sk', environment: 'live' }).capabilities(), ); - expect(server.calls[0]?.url).toBe('https://api.fedapay.com/v1/transactions'); }); it('maps Payenv networks to FedaPay slugs', async () => { @@ -118,7 +135,7 @@ describe('fedapay connector — collect', () => { ...happyRoutes, 'POST /sbin': () => ({ status: 200, json: { 'v1/payment_intent': { status: 'pending' } } }), }); - await fedapay({ secretKey: 'sk', fetch: server.fetch }).collect( + await fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }).collect( request({ method: { type: 'mobile_money', network: 'celtiis', country: 'BJ', phone: '+22990000000' }, }), @@ -149,7 +166,10 @@ describe('fedapay connector — errors before the push are safe to fall back', ( ])('HTTP %i on create → %s', async (status, code) => { const server = fakeFedaPay({ 'POST /transactions': () => ({ status, json: {} }) }); await expect( - fedapay({ secretKey: 'sk', fetch: server.fetch }).collect(request(), context()), + fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }).collect( + request(), + context(), + ), ).rejects.toMatchObject({ code, retryClass: 'safe_to_fallback' }); }); @@ -159,7 +179,10 @@ describe('fedapay connector — errors before the push are safe to fall back', ( 'POST /transactions/1234/token': () => Promise.reject(new TypeError('fetch failed')), }); await expect( - fedapay({ secretKey: 'sk', fetch: server.fetch }).collect(request(), context()), + fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }).collect( + request(), + context(), + ), ).rejects.toMatchObject({ code: 'PROVIDER_UNAVAILABLE', retryClass: 'safe_to_fallback' }); }); @@ -169,7 +192,10 @@ describe('fedapay connector — errors before the push are safe to fall back', ( 'POST /mtn_open': () => ({ status: 400, json: { message: 'Opération non autorisée' } }), }); await expect( - fedapay({ secretKey: 'sk', fetch: server.fetch }).collect(request(), context()), + fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }).collect( + request(), + context(), + ), ).rejects.toMatchObject({ code: 'ROUTE_UNSUPPORTED', retryClass: 'safe_to_fallback' }); }); }); @@ -180,10 +206,11 @@ describe('fedapay connector — errors during the push are ambiguous', () => { ['network failure', () => Promise.reject(new TypeError('socket hang up'))], ] as const)('%s on push → unknown with the transaction id', async (_label, handler) => { const server = fakeFedaPay({ ...happyRoutes, 'POST /mtn_open': handler as Handler }); - const result = await fedapay({ secretKey: 'sk', fetch: server.fetch }).collect( - request(), - context(), - ); + const result = await fedapay({ + secretKey: 'sk', + environment: 'live', + fetch: server.fetch, + }).collect(request(), context()); expect(result.status).toBe('unknown'); expect(result.providerRef).toBe('1234'); expect(result.error?.retryClass).toBe('ambiguous'); @@ -205,7 +232,11 @@ describe('fedapay connector — status', () => { json: { 'v1/transaction': { id: 1234, status: fedapayStatus } }, }), }); - const result = await fedapay({ secretKey: 'sk', fetch: server.fetch }).getStatus( + const result = await fedapay({ + secretKey: 'sk', + environment: 'live', + fetch: server.fetch, + }).getStatus( { operation: 'collect', reference: 'att_1', providerRef: '1234' }, { signal: new AbortController().signal }, ); @@ -216,7 +247,7 @@ describe('fedapay connector — status', () => { it('never claims "not found" without a transaction id', async () => { const server = fakeFedaPay({}); await expect( - fedapay({ secretKey: 'sk', fetch: server.fetch }).getStatus( + fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }).getStatus( { operation: 'collect', reference: 'att_1' }, { signal: new AbortController().signal }, ), @@ -246,7 +277,7 @@ describe('fedapay connector — inside Payenv', () => { const server = fakeFedaPay({ 'POST /transactions': () => ({ status: 503, json: {} }) }); const other = backup(); const payenv = createPayenv({ - connectors: [fedapay({ secretKey: 'sk', fetch: server.fetch }), other], + connectors: [fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }), other], statusCheckDelaysMs: [0], }); @@ -267,7 +298,7 @@ describe('fedapay connector — inside Payenv', () => { }); const other = backup(); const payenv = createPayenv({ - connectors: [fedapay({ secretKey: 'sk', fetch: server.fetch }), other], + connectors: [fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }), other], statusCheckDelaysMs: [0], }); diff --git a/packages/connector-fedapay/test/sandbox.test.ts b/packages/connector-fedapay/test/sandbox.test.ts index 16a3e27..65c273e 100644 --- a/packages/connector-fedapay/test/sandbox.test.ts +++ b/packages/connector-fedapay/test/sandbox.test.ts @@ -1,39 +1,54 @@ -import { createPayenv } from '@payenv/core'; +import { createPayenv, isTerminal, type Payenv, type Payment } from '@payenv/core'; import { describe, expect, it } from 'vitest'; import { fedapay } from '../src/index.js'; -// Opt-in: runs only when sandbox credentials are set in a local .env file. +// Opt-in: runs only when a sandbox key is set in a local .env file. // See .env.example at the repository root. Never use live keys here. const secretKey = process.env.FEDAPAY_SANDBOX_SECRET_KEY; -const phone = process.env.FEDAPAY_SANDBOX_PHONE; -const network = process.env.FEDAPAY_SANDBOX_NETWORK || 'mtn'; -describe.skipIf(!secretKey || !phone)('fedapay sandbox (real API)', () => { - it(`sends a ${network} push and reads the status back`, { timeout: 60_000 }, async () => { - const connector = fedapay({ secretKey: secretKey as string, environment: 'sandbox' }); - const payenv = createPayenv({ connectors: [connector] }); - const idempotencyKey = `sandbox_${Date.now()}`; +// FedaPay sandbox (`momo_test` mode): 64000001 and 66000001 succeed, any other +// number simulates a failed payment. https://docs.fedapay.com/fr/integration-api/sending-requests +const SUCCESS_PHONE = process.env.FEDAPAY_SANDBOX_PHONE || '+22964000001'; +const FAILURE_PHONE = '+22964000009'; - const payment = await payenv.collect({ - amount: { value: 100, currency: 'XOF' }, - method: { type: 'mobile_money', network, country: 'BJ', phone: phone as string }, - customer: { firstName: 'Payenv', lastName: 'Sandbox', email: 'sandbox@example.com' }, - description: 'Payenv sandbox test', - idempotencyKey, - }); +async function collect(payenv: Payenv, phone: string): Promise { + const payment = await payenv.collect({ + amount: { value: 100, currency: 'XOF' }, + method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone }, + customer: { firstName: 'Payenv', lastName: 'Sandbox', email: 'sandbox@example.com' }, + description: 'Payenv sandbox test', + idempotencyKey: `sandbox_${Date.now()}_${phone}`, + }); + expect(payment.error).toBeUndefined(); + expect(payment.providerRef).toBeDefined(); + return payment; +} + +/** Refreshes until the payment reaches a terminal status, or gives up. */ +async function settle(payenv: Payenv, payment: Payment): Promise { + let current = payment; + for (let check = 0; check < 15 && !isTerminal(current.status); check++) { + await new Promise((resolve) => setTimeout(resolve, 2_000)); + current = await payenv.refresh(current.idempotencyKey); + } + return current; +} - if (payment.error?.code === 'ROUTE_UNSUPPORTED') { - throw new Error( - `"${network}" is not activated on this FedaPay sandbox account. Activate it in ` + - 'Dashboard (sandbox mode) → Configuration → Payment methods, or set ' + - 'FEDAPAY_SANDBOX_NETWORK to an activated operator (mtn, moov, celtiis).', - ); - } - expect(payment.error).toBeUndefined(); +describe.skipIf(!secretKey)('fedapay sandbox (real API)', () => { + const payenv = () => createPayenv({ connectors: [fedapay({ secretKey: secretKey as string })] }); + + it('a success test number ends as succeeded', { timeout: 60_000 }, async () => { + const client = payenv(); + const payment = await collect(client, SUCCESS_PHONE); expect(['pending', 'succeeded']).toContain(payment.status); - expect(payment.providerRef).toBeDefined(); + expect((await settle(client, payment)).status).toBe('succeeded'); + }); - const refreshed = await payenv.refresh(idempotencyKey); - expect(refreshed.status).not.toBe('unknown'); + it('any other number ends as failed', { timeout: 60_000 }, async () => { + const client = payenv(); + const payment = await collect(client, FAILURE_PHONE); + const settled = await settle(client, payment); + expect(settled.status).toBe('failed'); + expect(settled.error?.code).toBe('CUSTOMER_DECLINED'); }); }); From 60aab1a21d39948628ad1e8fd7537f02a3380957 Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:00:50 +0100 Subject: [PATCH 3/4] feat(examples): add a runnable FedaPay demo `pnpm demo` starts a small web page (Node built-in http, no framework) that collects a sandbox mobile money payment through Payenv and follows its status live. It masks phone numbers, refuses live keys, and shows the webhook verification endpoint. Verified against the FedaPay sandbox: +22964000001 settles as succeeded, another number as failed. test(connector-fedapay): always use the documented sandbox success number, so a leftover FEDAPAY_SANDBOX_PHONE in .env can no longer break the test. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- .env.example | 4 +- CHANGELOG.md | 4 + CONTRIBUTING.md | 2 + README.fr.md | 5 + README.md | 5 + docs/ROADMAP.md | 5 +- examples/fedapay-demo/README.md | 30 +++ examples/fedapay-demo/package.json | 18 ++ examples/fedapay-demo/server.ts | 220 ++++++++++++++++++ examples/fedapay-demo/tsconfig.json | 12 + package.json | 3 +- .../connector-fedapay/test/sandbox.test.ts | 2 +- pnpm-lock.yaml | 25 ++ pnpm-workspace.yaml | 1 + 14 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 examples/fedapay-demo/README.md create mode 100644 examples/fedapay-demo/package.json create mode 100644 examples/fedapay-demo/server.ts create mode 100644 examples/fedapay-demo/tsconfig.json diff --git a/.env.example b/.env.example index 770d042..73eb64d 100644 --- a/.env.example +++ b/.env.example @@ -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= -# Optional. Defaults to the documented success test number +22964000001. -FEDAPAY_SANDBOX_PHONE= diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4c401..1bea21c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86597fa..dc1ddec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.fr.md b/README.fr.md index a1f581a..3700bff 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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 diff --git a/README.md b/README.md index 92f7c5c..199298b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d6c873b..c33a1f5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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`) diff --git a/examples/fedapay-demo/README.md b/examples/fedapay-demo/README.md new file mode 100644 index 0000000..7a1d326 --- /dev/null +++ b/examples/fedapay-demo/README.md @@ -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). diff --git a/examples/fedapay-demo/package.json b/examples/fedapay-demo/package.json new file mode 100644 index 0000000..bd7fa53 --- /dev/null +++ b/examples/fedapay-demo/package.json @@ -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" + } +} diff --git a/examples/fedapay-demo/server.ts b/examples/fedapay-demo/server.ts new file mode 100644 index 0000000..539ef13 --- /dev/null +++ b/examples/fedapay-demo/server.ts @@ -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 { + 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 { + 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 = ` + + + + +Payenv demo + + + +

Payenv demo

+

Paiement mobile money via FedaPay (sandbox)

+
+ + + + Sandbox : +22964000001 ou +22966000001 = succès · tout autre numéro = échec + +
+
+
Statut :
+

+

+
+ + +`; diff --git a/examples/fedapay-demo/tsconfig.json b/examples/fedapay-demo/tsconfig.json new file mode 100644 index 0000000..607191f --- /dev/null +++ b/examples/fedapay-demo/tsconfig.json @@ -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"] +} diff --git a/package.json b/package.json index de37b75..62843f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/connector-fedapay/test/sandbox.test.ts b/packages/connector-fedapay/test/sandbox.test.ts index 65c273e..0f68739 100644 --- a/packages/connector-fedapay/test/sandbox.test.ts +++ b/packages/connector-fedapay/test/sandbox.test.ts @@ -8,7 +8,7 @@ const secretKey = process.env.FEDAPAY_SANDBOX_SECRET_KEY; // FedaPay sandbox (`momo_test` mode): 64000001 and 66000001 succeed, any other // number simulates a failed payment. https://docs.fedapay.com/fr/integration-api/sending-requests -const SUCCESS_PHONE = process.env.FEDAPAY_SANDBOX_PHONE || '+22964000001'; +const SUCCESS_PHONE = '+22964000001'; const FAILURE_PHONE = '+22964000009'; async function collect(payenv: Payenv, phone: string): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2aa32d..246acf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,19 @@ importers: specifier: ^5.0.2 version: 5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.27.7)) + examples/fedapay-demo: + dependencies: + '@payenv/connector-fedapay': + specifier: workspace:* + version: link:../../packages/connector-fedapay + '@payenv/core': + specifier: workspace:* + version: link:../../packages/core + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + packages/connector-fedapay: devDependencies: '@payenv/core': @@ -355,6 +368,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + '@types/node@26.6.3': resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} @@ -655,6 +671,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} @@ -929,6 +948,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + '@types/node@26.6.3': dependencies: undici-types: 8.9.0 @@ -1179,6 +1202,8 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + undici-types@7.24.6: {} + undici-types@8.9.0: {} vite@8.3.1(@types/node@26.6.3)(esbuild@0.27.7): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee51e9..89723f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - "packages/*" + - "examples/*" From 14edb004d8a80e97f85e235932d1e17e885f4cfb Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:06:50 +0100 Subject: [PATCH 4/4] fix(core): keep attempt outcome and payment error consistent on refresh A payment accepted as pending and later declined showed `status: failed` with `outcome: accepted` on its attempt (found while testing the FedaPay demo). `refresh` now updates the attempt outcome (failed / accepted), and clears the payment error once the provider confirms an unresolved payment. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- packages/core/src/payenv.ts | 9 +++++++ packages/core/test/payenv.test.ts | 42 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/packages/core/src/payenv.ts b/packages/core/src/payenv.ts index 8af2f8d..89963f6 100644 --- a/packages/core/src/payenv.ts +++ b/packages/core/src/payenv.ts @@ -319,6 +319,7 @@ export function createPayenv(options: PayenvOptions): Payenv { { connectorId: connector.id }, ).toJSON(); attempt.status = 'failed'; + attempt.outcome = 'failed'; attempt.error = error; payment.status = 'failed'; payment.error = error; @@ -326,6 +327,14 @@ export function createPayenv(options: PayenvOptions): Payenv { const next = status.status === 'created' ? 'pending' : status.status; if (!canTransition(payment.status, next)) return payment; attempt.status = next; + // Keep the attempt's conclusion consistent with what the provider now reports. + if (next === 'failed' || next === 'canceled' || next === 'expired') { + attempt.outcome = 'failed'; + } else if (next !== 'unknown') { + attempt.outcome = 'accepted'; + // A timeout recorded while the outcome was unknown no longer describes the payment. + delete payment.error; + } payment.status = next; if (status.providerRef !== undefined) { attempt.providerRef = status.providerRef; diff --git a/packages/core/test/payenv.test.ts b/packages/core/test/payenv.test.ts index fd661cd..1eefcae 100644 --- a/packages/core/test/payenv.test.ts +++ b/packages/core/test/payenv.test.ts @@ -131,6 +131,48 @@ describe('refresh', () => { expect((await payenv.getPayment(request.idempotencyKey))?.status).toBe('succeeded'); }); + it('updates the attempt outcome when a pending payment is declined', async () => { + const a = fakeConnector('a', pending, async () => ({ + found: true, + status: 'failed', + error: new PayenvError('CUSTOMER_DECLINED', 'declined'), + })); + const payenv = createPayenv({ connectors: [a], ...fast }); + const request = mtnRequest(); + expect((await payenv.collect(request)).attempts[0]?.outcome).toBe('accepted'); + + const refreshed = await payenv.refresh(request.idempotencyKey); + + expect(refreshed.status).toBe('failed'); + expect(refreshed.error?.code).toBe('CUSTOMER_DECLINED'); + expect(refreshed.attempts[0]).toMatchObject({ status: 'failed', outcome: 'failed' }); + }); + + it('marks an unresolved attempt as accepted once the provider confirms it', async () => { + let checks = 0; + const a = fakeConnector( + 'a', + async () => { + throw new PayenvError('TIMEOUT', 'timeout'); + }, + async () => { + checks += 1; + return checks === 1 + ? { found: true, status: 'unknown' } + : { found: true, status: 'succeeded' }; + }, + ); + const payenv = createPayenv({ connectors: [a], ...fast }); + const request = mtnRequest(); + expect((await payenv.collect(request)).attempts[0]?.outcome).toBe('unresolved'); + + const refreshed = await payenv.refresh(request.idempotencyKey); + + expect(refreshed.status).toBe('succeeded'); + expect(refreshed.attempts[0]?.outcome).toBe('accepted'); + expect(refreshed.error).toBeUndefined(); + }); + it('never changes a terminal payment', async () => { const a = fakeConnector( 'a',