diff --git a/.env.example b/.env.example index 73eb64d..2ad6dc2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ # Copy to .env (git-ignored) to run the sandbox tests (pnpm test) and the demo (pnpm demo). -# Never put live keys here. FEDAPAY_SANDBOX_SECRET_KEY= + +# --- Live demo (REAL money). Leave empty unless you really mean it. --- +# DEMO_LIVE=yes +# FEDAPAY_LIVE_SECRET_KEY= +# DEMO_LIVE_MAX_AMOUNT=200 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bea21c..8db16fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ Before `1.0.0`, minor versions may contain breaking changes. ## [Unreleased] ### Added +- 2026-10-05 — `@payenv/core`: `PayenvError.providerCode` keeps the provider's own error + code in every error. +- 2026-10-05 — `@payenv/connector-fedapay`: maps FedaPay's `last_error_code` + (e.g. `INSUFFICIENT_FUND_ERROR` → `INSUFFICIENT_FUNDS`), including on transactions that + stay pending after an operator error. Verified on live transactions. +- 2026-10-05 — `examples/fedapay-demo`: opt-in live mode with safeguards; webhooks + trigger `payenv.refresh`. +- 2026-10-05 — `@payenv/connector-fedapay`: `parseFedaPayWebhook`, normalizing webhooks + from the transaction they carry (live shape). - 2026-10-05 — `examples/fedapay-demo`: runnable web demo (`pnpm demo`) collecting a sandbox payment and following its status live. - 2026-09-27 — `@payenv/connector-fedapay`: sandbox pushes go to FedaPay's `momo_test` diff --git a/examples/fedapay-demo/README.md b/examples/fedapay-demo/README.md index 7a1d326..bd81282 100644 --- a/examples/fedapay-demo/README.md +++ b/examples/fedapay-demo/README.md @@ -19,6 +19,23 @@ and watch its status change live. No framework, no dependency besides Payenv. Sandbox test numbers: `+22964000001` and `+22966000001` succeed; any other number simulates a failed payment. +## Live mode (real money) + +To check that a real USSD prompt reaches your phone, the demo has an opt-in live mode. +**It moves real money** on your FedaPay account (FedaPay fees apply). + +Add to `.env`: +``` +DEMO_LIVE=yes +FEDAPAY_LIVE_SECRET_KEY=sk_live_... +DEMO_LIVE_MAX_AMOUNT=200 +``` + +Safeguards: the live key lives in its own variable (a sandbox key is refused, and vice +versa), amounts are capped (200 XOF by default, 1000 at most), a warning is shown in the +terminal and the page, and the server only listens on `127.0.0.1`. The operator (MTN, +Moov, Celtiis) must be activated on your merchant account. Remove `DEMO_LIVE` when done. + ## What to look at - [server.ts](server.ts): `createPayenv` with the FedaPay connector, `payenv.collect` diff --git a/examples/fedapay-demo/server.ts b/examples/fedapay-demo/server.ts index 539ef13..4a75b94 100644 --- a/examples/fedapay-demo/server.ts +++ b/examples/fedapay-demo/server.ts @@ -5,25 +5,43 @@ * * Needs FEDAPAY_SANDBOX_SECRET_KEY in the root .env file. Then open http://localhost:3000. * Sandbox test numbers: +22964000001 and +22966000001 succeed, any other number fails. + * + * Live mode (REAL money) is opt-in: DEMO_LIVE=yes and FEDAPAY_LIVE_SECRET_KEY in .env. + * Amounts are capped by DEMO_LIVE_MAX_AMOUNT (default 200 XOF). */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; -import { fedapay, verifyFedaPayWebhook } from '@payenv/connector-fedapay'; +import { fedapay, parseFedaPayWebhook, verifyFedaPayWebhook } from '@payenv/connector-fedapay'; import { createPayenv, isPayenvError, type Payment } from '@payenv/core'; -const secretKey = process.env.FEDAPAY_SANDBOX_SECRET_KEY; -if (!secretKey) { - console.error('Missing FEDAPAY_SANDBOX_SECRET_KEY. Copy .env.example to .env and fill it in.'); - process.exit(1); -} -if (secretKey.startsWith('sk_live')) { - console.error('This demo only runs with a sandbox key (sk_sandbox_...).'); - process.exit(1); +const live = process.env.DEMO_LIVE === 'yes'; +const maxLiveAmount = Number(process.env.DEMO_LIVE_MAX_AMOUNT ?? 200); +const secretKey = live + ? process.env.FEDAPAY_LIVE_SECRET_KEY + : process.env.FEDAPAY_SANDBOX_SECRET_KEY; + +if (live) { + if (!secretKey?.startsWith('sk_live')) { + fail('DEMO_LIVE=yes requires FEDAPAY_LIVE_SECRET_KEY (sk_live_...) in .env.'); + } + if (!Number.isSafeInteger(maxLiveAmount) || maxLiveAmount <= 0 || maxLiveAmount > 1000) { + fail('DEMO_LIVE_MAX_AMOUNT must be an integer between 1 and 1000 XOF.'); + } +} else { + if (!secretKey) { + fail('Missing FEDAPAY_SANDBOX_SECRET_KEY. Copy .env.example to .env and fill it in.'); + } + if (!secretKey.startsWith('sk_sandbox')) { + fail('FEDAPAY_SANDBOX_SECRET_KEY must be a sandbox key (sk_sandbox_...).'); + } } const port = Number(process.env.PORT ?? 3000); +/** providerRef → idempotency key, to match webhooks with payments (a database in real life). */ +const keysByProviderRef = new Map(); + const payenv = createPayenv({ - connectors: [fedapay({ secretKey, environment: 'sandbox' })], + connectors: [fedapay({ secretKey, environment: live ? 'live' : 'sandbox' })], onEvent(event) { if (event.type === 'attempt.finished') { const { attempt } = event; @@ -43,8 +61,15 @@ createServer((request, response) => { console.error(error); send(response, 500, { error: 'Internal error' }); }); -}).listen(port, () => { - console.log(`Payenv demo running on http://localhost:${port}`); + // Only reachable from this computer, never from the network. +}).listen(port, '127.0.0.1', () => { + if (live) { + console.warn(''); + console.warn(' ⚠️ LIVE MODE — payments move REAL money on your FedaPay account.'); + console.warn(` ⚠️ Amounts are capped at ${maxLiveAmount} XOF. Stop with Ctrl+C.`); + console.warn(''); + } + console.log(`Payenv demo (${live ? 'LIVE' : 'sandbox'}) running on http://localhost:${port}`); }); async function route(request: IncomingMessage, response: ServerResponse): Promise { @@ -52,7 +77,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()); return; } @@ -62,14 +87,24 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis phone: string; network: string; }; + if (live && !(Number(body.amount) <= maxLiveAmount)) { + send(response, 400, { + error: { + code: 'INVALID_REQUEST', + message: `Live demo amounts are capped at ${maxLiveAmount} XOF`, + }, + }); + return; + } try { const payment = await payenv.collect({ amount: { value: Number(body.amount), currency: 'XOF' }, method: { type: 'mobile_money', network: body.network, country: 'BJ', phone: body.phone }, customer: { firstName: 'Demo', lastName: 'Payenv', email: 'demo@example.com' }, - description: 'Payenv demo', + description: live ? 'Payenv demo (live test)' : 'Payenv demo', idempotencyKey: `demo_${Date.now()}`, }); + if (payment.providerRef) keysByProviderRef.set(payment.providerRef, payment.idempotencyKey); send(response, 200, view(payment)); } catch (error) { // Invalid requests (bad phone, no route...) are thrown, not returned as payments. @@ -95,8 +130,22 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis Array.isArray(signature) ? signature[0] : signature, process.env.FEDAPAY_WEBHOOK_SECRET ?? '', ); - console.log(`[webhook] signature ${valid ? 'valid' : 'INVALID'}`); - send(response, valid ? 200 : 400, { received: valid }); + if (!valid) { + console.log('[webhook] INVALID signature — ignored'); + send(response, 400, { received: false }); + return; + } + const event = parseFedaPayWebhook(raw); + // A real application looks the payment up in its own database by providerRef. + const key = event && keysByProviderRef.get(event.providerRef); + if (event && key) { + // The webhook is a hint: ask FedaPay for the authoritative status. + const payment = await payenv.refresh(key); + console.log(`[webhook] ${event.name} → payment ${key} is now ${payment.status}`); + } else { + console.log(`[webhook] ${event?.name ?? 'unrecognized event'} — no matching payment`); + } + send(response, 200, { received: true }); return; } @@ -122,6 +171,11 @@ function view(payment: Payment) { }; } +function fail(message: string): never { + console.error(message); + process.exit(1); +} + function readBody(request: IncomingMessage): Promise { return new Promise((resolve, reject) => { let data = ''; @@ -139,7 +193,8 @@ function send(response: ServerResponse, status: number, body: unknown): void { response.end(JSON.stringify(body)); } -const PAGE = ` +function page(): string { + return ` @@ -159,19 +214,21 @@ const PAGE = ` .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; color: white; font-weight: 600; } .succeeded { background: var(--ok); } .failed, .canceled, .expired { background: var(--ko); } .pending, .requires_action, .created, .unknown { background: var(--wait); } + .live { background: var(--ko); color: white; padding: 10px 14px; border-radius: 8px; } pre { font-size: 12px; overflow-x: auto; background: #8881; padding: 8px; border-radius: 8px; }

Payenv demo

-

Paiement mobile money via FedaPay (sandbox)

+

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

+${live ? `

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

` : ''}
- + - - Sandbox : +22964000001 ou +22966000001 = succès · tout autre numéro = échec + + ${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'}
@@ -187,9 +244,13 @@ const PAGE = ` const badge = document.getElementById('status'); badge.textContent = payment.status; badge.className = 'badge ' + payment.status; + const reason = payment.error ? payment.error.code + ' — ' + payment.error.message : ''; document.getElementById('message').textContent = - payment.status === 'pending' ? 'Le client doit valider sur son téléphone… (vérification toutes les 2 s)' - : payment.error ? payment.error.code + ' — ' + payment.error.message : ''; + payment.status === 'pending' + ? (reason + ? 'FedaPay signale : ' + reason + '. Le statut reste « pending » chez FedaPay : Payenv ne devine pas.' + : 'Le client doit valider sur son téléphone… (vérification toutes les 2 s)') + : reason; document.getElementById('details').textContent = JSON.stringify(payment, null, 2); } async function poll(key) { @@ -199,6 +260,8 @@ const PAGE = ` 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(); @@ -218,3 +281,4 @@ const PAGE = ` `; +} diff --git a/packages/connector-fedapay/QUIRKS.md b/packages/connector-fedapay/QUIRKS.md index 8d4f3c7..03f3701 100644 --- a/packages/connector-fedapay/QUIRKS.md +++ b/packages/connector-fedapay/QUIRKS.md @@ -44,12 +44,45 @@ What differs from what you would expect when reading the FedaPay documentation. - Authentication with a sandbox key, transaction creation, token generation, and status lookup by transaction id all work as implemented. +## ✅ Verified live (2026-10-05) + +- Live pushes work on `/mtn_open` (MTN) and `/sbin` (Celtiis): the USSD prompt reaches + the phone. +- Benin's 10-digit numbers in E.164 (`+22901…`) are accepted. +- **Transactions carry a `last_error_code`** with the real reason, e.g. + `INSUFFICIENT_FUND_ERROR` on a canceled MTN payment. The connector maps it + (→ `INSUFFICIENT_FUNDS`) and always exposes it as `error.providerCode`. +- **A customer canceling the USSD prompt on Celtiis does not end the transaction**: + FedaPay keeps it `pending` and records `last_error_code: "API_ERROR"`. Its + `last_error_message` is the operator's raw SOAP answer (Huawei CPS): the request + succeeded and the operator still reports `TransactionStatus: "Initiated"`. In other + words, the cancellation was not propagated. Payenv keeps the payment `pending` (never + guessed) and exposes the reason (`UNKNOWN_ERROR` + `providerCode: "API_ERROR"`, raw + dump in `error.raw`). See "Payments stuck in pending" in the README. +- **Webhook shape**: `{ name, object: "transaction", object_id, account, entity }`, where + `entity` is the full transaction (with `status` and `last_error_code`). Events seen: + `transaction.created`, `transaction.canceled`. The connector derives the status from + `entity`, not from the event name, so unknown event names still work. + ⚠️ Webhooks contain the merchant account details and the payer's phone number (inside + `last_error_message`): never log them raw. +- **MTN's reason behind `INSUFFICIENT_FUND_ERROR`** is + `LOW_BALANCE_OR_PAYEE_LIMIT_REACHED_OR_NOT_ALLOWED`: low balance *or* a limit *or* not + allowed. Payenv's message says so. +- **FedaPay schedules an expiration** for pending transactions (`metadata.expire_schedule_jobid`, + `expired_at` field). Payenv maps `expired` to `CUSTOMER_TIMEOUT`. +- Fees may be charged to the customer: a 100 XOF payment showed `fees: 2` and + `amount_debited: 102`. +- Transactions have a `merchant_reference` field. Whether it can be set on creation and + used for lookups is still to verify (see below). + ## 🔍 Still to verify - The full list of live transaction statuses. Mapped today: `pending`, `approved`, `transferred`, `refunded` → succeeded, `declined` → failed, `canceled`, `expired`. Unknown values map to `unknown` (safe). -- That live pushes accept the E.164 phone format too (the sandbox does). +- After how long FedaPay's scheduled expiration turns a stuck `pending` into `expired`. +- The other `last_error_code` values (only `INSUFFICIENT_FUND_ERROR` and `API_ERROR` are + mapped; others are exposed as `providerCode` with a default code). - Whether a transaction can be looked up by merchant reference. That would let Payenv resolve a timeout on the *create* call. Today that case ends as `unknown` (safe but not ideal). diff --git a/packages/connector-fedapay/README.md b/packages/connector-fedapay/README.md index d44d81b..f38ee40 100644 --- a/packages/connector-fedapay/README.md +++ b/packages/connector-fedapay/README.md @@ -5,11 +5,11 @@ > 🚧 Pre-release (`0.x`). **Supported today:** mobile money collections in Benin (XOF): MTN, Moov, Celtiis. -Status lookup and webhook signature verification. Payouts and refunds are coming. +Status lookup, webhook signature verification and parsing. Payouts and refunds are coming. ```ts import { createPayenv } from '@payenv/core'; -import { fedapay, verifyFedaPayWebhook } from '@payenv/connector-fedapay'; +import { fedapay, parseFedaPayWebhook, verifyFedaPayWebhook } from '@payenv/connector-fedapay'; const payenv = createPayenv({ connectors: [ @@ -34,6 +34,11 @@ const valid = await verifyFedaPayWebhook( request.headers.get('x-fedapay-signature'), process.env.FEDAPAY_WEBHOOK_SECRET!, ); +if (valid) { + const event = parseFedaPayWebhook(rawBody); // { name, providerRef, status, error } + // Find your payment by event.providerRef, then confirm with FedaPay: + // await payenv.refresh(idempotencyKey); +} ``` ## Options @@ -52,6 +57,33 @@ The FedaPay sandbox simulates every operator with a single `momo_test` mode. Use `+22964000001` or `+22966000001` for a successful payment; any other number simulates a failure. Your code stays the same: just switch `environment` to `'live'` in production. +## Error reasons + +When FedaPay records why a payment failed, it is mapped to a Payenv code and the original +code is kept: + +```json +{ + "code": "INSUFFICIENT_FUNDS", + "message": "FedaPay transaction canceled — The customer has insufficient funds (INSUFFICIENT_FUND_ERROR)", + "providerCode": "INSUFFICIENT_FUND_ERROR" +} +``` + +## Payments stuck in pending + +Some operators don't report a cancellation: on Celtiis, a customer who cancels the USSD +prompt leaves the transaction `pending` at FedaPay, with an operator error recorded. +Payenv keeps it `pending` (it never guesses, since the payment could still complete) and +exposes the error in `payment.error`. Recommended handling: + +1. Show the customer that the payment is not confirmed yet, and let them retry with a + **new** idempotency key (a new payment). +2. Keep calling `payenv.refresh(key)` (or handle webhooks) for the old payment, so a late + success is not lost. +3. Apply your own business timeout (e.g. release the order after 15 minutes), and if the + old payment succeeds later, fulfil or refund it. + ## Safety Money can only move once the mobile money push is sent (step 3 of 3). diff --git a/packages/connector-fedapay/src/fedapay.ts b/packages/connector-fedapay/src/fedapay.ts index ccbe9b2..120a994 100644 --- a/packages/connector-fedapay/src/fedapay.ts +++ b/packages/connector-fedapay/src/fedapay.ts @@ -70,7 +70,7 @@ class FedaPayHttpError extends Error { } } -type Json = Record; +export type Json = Record; export function fedapay(options: FedaPayOptions): Connector { if (!options.secretKey) { @@ -233,7 +233,7 @@ export function fedapay(options: FedaPayOptions): Connector { throw toError('status', thrown); } const transaction = unwrap(data, 'transaction'); - const { status, error } = transactionStatus(transaction.status, id); + const { status, error } = transactionStatus(transaction, id); return { found: true, status, @@ -276,36 +276,71 @@ function pushStatus(status: unknown): PaymentStatus { return 'pending'; } -function transactionStatus( - status: unknown, +/** + * FedaPay `last_error_code` values observed on live transactions, mapped to Payenv codes. + * Unmapped codes are still exposed as `providerCode`. + */ +const FEDAPAY_ERROR_CODES: Readonly> = { + INSUFFICIENT_FUND_ERROR: 'INSUFFICIENT_FUNDS', + // Seen live: FedaPay queried the operator, which still answered "Initiated". + // It means "no final status from the operator", not that a service is down. + API_ERROR: 'UNKNOWN_ERROR', +}; + +const FEDAPAY_ERROR_MESSAGES: Readonly> = { + // MTN's own reason is LOW_BALANCE_OR_PAYEE_LIMIT_REACHED_OR_NOT_ALLOWED. + INSUFFICIENT_FUND_ERROR: 'Insufficient funds, or an operator limit was reached', + API_ERROR: 'FedaPay could not get a final status from the operator', +}; + +/** Maps a FedaPay transaction (API response or webhook entity) to a Payenv status. */ +export function transactionStatus( + transaction: Json, connectorId: string, ): { status: PaymentStatus; error?: PayenvError } { + const { + status, + last_error_code: lastErrorCode, + last_error_message: lastErrorMessage, + } = transaction; + const providerCode = + typeof lastErrorCode === 'string' && lastErrorCode !== '' ? lastErrorCode : undefined; + + /** The failure reason: FedaPay's own code when it gives one, a sensible default otherwise. */ + const reason = (fallback: PayenvErrorCode, what: string) => { + const code = (providerCode ? FEDAPAY_ERROR_CODES[providerCode] : undefined) ?? fallback; + const detail = providerCode + ? ` — ${FEDAPAY_ERROR_MESSAGES[providerCode] ?? 'FedaPay error'} (${providerCode})` + : ''; + // The customer was involved: another provider would not change the outcome. + return new PayenvError(code, `FedaPay transaction ${what}${detail}`, { + connectorId, + retryClass: 'do_not_retry', + ...(providerCode ? { providerCode } : {}), + // Raw operator output (often a SOAP dump): kept for debugging, never serialized. + raw: { last_error_code: lastErrorCode, last_error_message: lastErrorMessage }, + }); + }; + switch (status) { case 'pending': - return { status: 'pending' }; + // FedaPay may record an error while keeping the transaction pending (seen live: + // the customer canceled the USSD prompt on Celtiis, the operator still answered + // "Initiated"). The status stays pending (never guessed) and the reason is exposed. + // FedaPay schedules an expiration job for pending transactions. + return providerCode + ? { status: 'pending', error: reason('UNKNOWN_ERROR', 'still pending after an error') } + : { status: 'pending' }; case 'approved': case 'transferred': case 'refunded': return { status: 'succeeded' }; case 'declined': - return { - status: 'failed', - error: new PayenvError('CUSTOMER_DECLINED', 'FedaPay transaction declined', { - connectorId, - }), - }; + return { status: 'failed', error: reason('CUSTOMER_DECLINED', 'declined') }; case 'canceled': - return { - status: 'canceled', - error: new PayenvError('CUSTOMER_DECLINED', 'FedaPay transaction canceled', { - connectorId, - }), - }; + return { status: 'canceled', error: reason('CUSTOMER_DECLINED', 'canceled') }; case 'expired': - return { - status: 'expired', - error: new PayenvError('CUSTOMER_TIMEOUT', 'FedaPay transaction expired', { connectorId }), - }; + return { status: 'expired', error: reason('CUSTOMER_TIMEOUT', 'expired') }; default: return { status: 'unknown' }; } diff --git a/packages/connector-fedapay/src/index.ts b/packages/connector-fedapay/src/index.ts index e1d4cb7..73993ae 100644 --- a/packages/connector-fedapay/src/index.ts +++ b/packages/connector-fedapay/src/index.ts @@ -6,4 +6,9 @@ export { type FedaPayOptions, fedapay, } from './fedapay.js'; -export { type VerifyWebhookOptions, verifyFedaPayWebhook } from './webhook.js'; +export { + type FedaPayWebhookEvent, + parseFedaPayWebhook, + type VerifyWebhookOptions, + verifyFedaPayWebhook, +} from './webhook.js'; diff --git a/packages/connector-fedapay/src/webhook.ts b/packages/connector-fedapay/src/webhook.ts index db4cc6c..ebf6cca 100644 --- a/packages/connector-fedapay/src/webhook.ts +++ b/packages/connector-fedapay/src/webhook.ts @@ -1,3 +1,6 @@ +import type { PayenvError, PaymentStatus } from '@payenv/core'; +import { type Json, transactionStatus } from './fedapay.js'; + export interface VerifyWebhookOptions { /** Maximum age of the signature, in seconds. Defaults to 300 (5 minutes). */ toleranceSeconds?: number; @@ -61,3 +64,46 @@ function constantTimeEqual(a: string, b: string): boolean { } return difference === 0; } + +/** A FedaPay webhook, normalized. */ +export interface FedaPayWebhookEvent { + /** FedaPay's event name, e.g. `transaction.canceled`. Informational only. */ + name: string; + /** The FedaPay transaction id: the payment's `providerRef`. */ + providerRef: string; + /** Derived from the transaction itself, not from the event name. */ + status: PaymentStatus; + error?: PayenvError; +} + +/** + * Parses a FedaPay webhook body **after** {@link verifyFedaPayWebhook} succeeded. + * Returns `undefined` for events that are not about a transaction. + * + * Treat the result as a hint: confirm with `payenv.refresh(idempotencyKey)`, which + * asks FedaPay for the authoritative status. + */ +export function parseFedaPayWebhook( + rawBody: string, + connectorId = 'fedapay', +): FedaPayWebhookEvent | undefined { + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return undefined; + } + if (body === null || typeof body !== 'object') return undefined; + const { name, object, entity } = body as Json; + if (object !== 'transaction' || entity === null || typeof entity !== 'object') return undefined; + const transaction = entity as Json; + if (transaction.id === undefined || transaction.id === null) return undefined; + + const { status, error } = transactionStatus(transaction, connectorId); + return { + name: typeof name === 'string' ? name : 'unknown', + providerRef: String(transaction.id), + status, + ...(error ? { error } : {}), + }; +} diff --git a/packages/connector-fedapay/test/fedapay.test.ts b/packages/connector-fedapay/test/fedapay.test.ts index a271e9e..9e086d8 100644 --- a/packages/connector-fedapay/test/fedapay.test.ts +++ b/packages/connector-fedapay/test/fedapay.test.ts @@ -244,6 +244,69 @@ describe('fedapay connector — status', () => { if (result.found) expect(result.error?.code).toBe(code); }); + // Reproduces live transactions observed on 2026-10-05 (personal data removed). + const lookup = async (transaction: Record) => { + const server = fakeFedaPay({ + 'GET /transactions/1234': () => ({ status: 200, json: { 'v1/transaction': transaction } }), + }); + return fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }).getStatus( + { operation: 'collect', reference: 'att_1', providerRef: '1234' }, + { signal: new AbortController().signal }, + ); + }; + + it('exposes the real reason of a canceled payment (insufficient funds, MTN live)', async () => { + const result = await lookup({ + id: 1234, + status: 'canceled', + mode: 'mtn_open', + last_error_code: 'INSUFFICIENT_FUND_ERROR', + }); + expect(result).toMatchObject({ found: true, status: 'canceled' }); + if (!result.found) throw new Error('expected found'); + expect(result.error?.toJSON()).toEqual({ + code: 'INSUFFICIENT_FUNDS', + message: + 'FedaPay transaction canceled — Insufficient funds, or an operator limit was reached (INSUFFICIENT_FUND_ERROR)', + retryClass: 'do_not_retry', + connectorId: 'fedapay', + providerCode: 'INSUFFICIENT_FUND_ERROR', + }); + }); + + it('keeps a pending payment pending but exposes why (Celtiis live)', async () => { + const operatorDump = '{"Envelope"=>{"Body"=>{"TransactionStatus"=>"Initiated"}}}'; + const result = await lookup({ + id: 1234, + status: 'pending', + mode: 'sbin', + last_error_code: 'API_ERROR', + last_error_message: operatorDump, + metadata: { expire_schedule_jobid: 'job_1' }, + expired_at: null, + }); + expect(result).toMatchObject({ found: true, status: 'pending' }); + if (!result.found) throw new Error('expected found'); + expect(result.error?.code).toBe('UNKNOWN_ERROR'); + expect(result.error?.providerCode).toBe('API_ERROR'); + expect(result.error?.message).toContain('could not get a final status from the operator'); + // The raw operator dump is available for debugging but never serialized. + expect(result.error?.raw).toMatchObject({ last_error_message: operatorDump }); + expect(JSON.stringify(result.error)).not.toContain('Envelope'); + }); + + it('keeps unknown FedaPay error codes visible as providerCode', async () => { + const result = await lookup({ id: 1234, status: 'declined', last_error_code: 'NEW_CODE' }); + if (!result.found) throw new Error('expected found'); + expect(result.error).toMatchObject({ code: 'CUSTOMER_DECLINED', providerCode: 'NEW_CODE' }); + }); + + it('reports no error on a plain pending payment', async () => { + const result = await lookup({ id: 1234, status: 'pending', last_error_code: null }); + if (!result.found) throw new Error('expected found'); + expect(result.error).toBeUndefined(); + }); + it('never claims "not found" without a transaction id', async () => { const server = fakeFedaPay({}); await expect( diff --git a/packages/connector-fedapay/test/webhook.test.ts b/packages/connector-fedapay/test/webhook.test.ts index d0a6c00..ea738ba 100644 --- a/packages/connector-fedapay/test/webhook.test.ts +++ b/packages/connector-fedapay/test/webhook.test.ts @@ -1,6 +1,6 @@ import { createHmac } from 'node:crypto'; import { describe, expect, it } from 'vitest'; -import { verifyFedaPayWebhook } from '../src/index.js'; +import { parseFedaPayWebhook, verifyFedaPayWebhook } from '../src/index.js'; const secret = 'whsec_test'; const body = '{"name":"transaction.approved","entity":{"id":1234}}'; @@ -42,3 +42,59 @@ describe('verifyFedaPayWebhook', () => { expect(await verifyFedaPayWebhook(body, sign(body), '', { now })).toBe(false); }); }); + +// Shape of a live `transaction.canceled` webhook (2026-10-05), with the account, +// the payer's phone number and tokens removed. +const canceledWebhook = { + name: 'transaction.canceled', + object: 'transaction', + object_id: 113507195, + account: { id: 1, klass: 'v1/account' }, + entity: { + id: 113507195, + reference: 'trx_test', + amount: 100, + status: 'canceled', + mode: 'mtn_open', + last_error_code: 'INSUFFICIENT_FUND_ERROR', + last_error_message: + '{"status":"FAILED","reason":"LOW_BALANCE_OR_PAYEE_LIMIT_REACHED_OR_NOT_ALLOWED"}', + metadata: { expire_schedule_jobid: 'job_1' }, + expired_at: null, + }, +}; + +describe('parseFedaPayWebhook', () => { + it('normalizes a canceled transaction (live shape)', () => { + const event = parseFedaPayWebhook(JSON.stringify(canceledWebhook)); + expect(event).toMatchObject({ + name: 'transaction.canceled', + providerRef: '113507195', + status: 'canceled', + }); + expect(event?.error?.toJSON()).toMatchObject({ + code: 'INSUFFICIENT_FUNDS', + providerCode: 'INSUFFICIENT_FUND_ERROR', + }); + }); + + it('derives the status from the transaction, not from the event name', () => { + const approved = { + ...canceledWebhook, + name: 'transaction.some_future_name', + entity: { ...canceledWebhook.entity, status: 'approved', last_error_code: null }, + }; + const event = parseFedaPayWebhook(JSON.stringify(approved)); + expect(event).toMatchObject({ status: 'succeeded', providerRef: '113507195' }); + expect(event?.error).toBeUndefined(); + }); + + it.each([ + 'not json', + 'null', + JSON.stringify({ name: 'payout.sent', object: 'payout', entity: { id: 1 } }), + JSON.stringify({ name: 'transaction.created', object: 'transaction' }), + ])('ignores bodies that are not transaction events: %s', (body) => { + expect(parseFedaPayWebhook(body)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 4a1718e..03b0af1 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -57,6 +57,8 @@ export interface PayenvErrorOptions { retryClass?: RetryClass; /** Connector that produced the error, if any. */ connectorId?: string; + /** The provider's own error code (e.g. FedaPay's `INSUFFICIENT_FUND_ERROR`), for support and logs. */ + providerCode?: string; /** Original provider response or error, kept for debugging. Never shown to end users. */ raw?: unknown; cause?: unknown; @@ -67,6 +69,7 @@ export class PayenvError extends Error { readonly code: PayenvErrorCode; readonly retryClass: RetryClass; readonly connectorId: string | undefined; + readonly providerCode: string | undefined; readonly raw: unknown; constructor(code: PayenvErrorCode, message: string, options: PayenvErrorOptions = {}) { @@ -74,6 +77,7 @@ export class PayenvError extends Error { this.code = code; this.retryClass = options.retryClass ?? DEFAULT_RETRY_CLASS[code]; this.connectorId = options.connectorId; + this.providerCode = options.providerCode; this.raw = options.raw; } @@ -84,6 +88,7 @@ export class PayenvError extends Error { message: this.message, retryClass: this.retryClass, ...(this.connectorId === undefined ? {} : { connectorId: this.connectorId }), + ...(this.providerCode === undefined ? {} : { providerCode: this.providerCode }), }; } } @@ -93,6 +98,7 @@ export interface SerializedError { message: string; retryClass: RetryClass; connectorId?: string; + providerCode?: string; } export function isPayenvError(value: unknown): value is PayenvError { diff --git a/packages/core/test/payenv.test.ts b/packages/core/test/payenv.test.ts index 1eefcae..905a1dd 100644 --- a/packages/core/test/payenv.test.ts +++ b/packages/core/test/payenv.test.ts @@ -173,6 +173,27 @@ describe('refresh', () => { expect(refreshed.error).toBeUndefined(); }); + it('shows a provider error on a payment that stays pending', async () => { + const a = fakeConnector('a', pending, async () => ({ + found: true, + status: 'pending', + error: new PayenvError('PROVIDER_UNAVAILABLE', 'operator error', { + providerCode: 'API_ERROR', + }), + })); + const payenv = createPayenv({ connectors: [a], ...fast }); + const request = mtnRequest(); + await payenv.collect(request); + + const refreshed = await payenv.refresh(request.idempotencyKey); + + expect(refreshed.status).toBe('pending'); + expect(refreshed.error).toMatchObject({ + code: 'PROVIDER_UNAVAILABLE', + providerCode: 'API_ERROR', + }); + }); + it('never changes a terminal payment', async () => { const a = fakeConnector( 'a',