diff --git a/.changeset/config.json b/.changeset/config.json index f64e3c6..ec3485c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,8 +2,11 @@ "$schema": "../node_modules/.pnpm/@changesets+config@4.0.1/node_modules/@changesets/config/schema.json", "baseBranch": "main", "access": "public", - "fixed": [["@payenv/core", "@payenv/connector-fedapay"]], + "fixed": [["@payenv/core", "@payenv/connector-fedapay", "@payenv/connector-kkiapay"]], "updateInternalDependencies": "patch", - "privatePackages": { "version": false, "tag": false }, + "privatePackages": { + "version": false, + "tag": false + }, "ignore": [] } diff --git a/.changeset/kkiapay-widget-flow.md b/.changeset/kkiapay-widget-flow.md new file mode 100644 index 0000000..9d5669f --- /dev/null +++ b/.changeset/kkiapay-widget-flow.md @@ -0,0 +1,14 @@ +--- +'@payenv/core': minor +'@payenv/connector-fedapay': minor +'@payenv/connector-kkiapay': minor +--- + +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`. +- `@payenv/connector-kkiapay` (new): Kkiapay widget payments in Benin (MTN, Moov), + server-side verification, webhook verification and parsing. diff --git a/.env.example b/.env.example index 2ad6dc2..8402a7f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ # Copy to .env (git-ignored) to run the sandbox tests (pnpm test) and the demo (pnpm demo). FEDAPAY_SANDBOX_SECRET_KEY= +# Kkiapay sandbox keys (dashboard → Developers → API keys, test mode). +KKIAPAY_SANDBOX_PUBLIC_KEY= +KKIAPAY_SANDBOX_PRIVATE_KEY= +KKIAPAY_SANDBOX_SECRET_KEY= + # --- Live demo (REAL money). Leave empty unless you really mean it. --- # DEMO_LIVE=yes # FEDAPAY_LIVE_SECRET_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8be79..dfd342b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ Before `1.0.0`, minor versions may contain breaking changes. ## [Unreleased] ### Added +- 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 + amount (ADR 0005). New error code `REFERENCE_MISMATCH`. +- 2026-10-05 — `@payenv/connector-kkiapay`: Kkiapay widget payments (Benin, MTN and Moov), + server-side verification, webhook verification and parsing. +- 2026-10-05 — demo: Kkiapay widget and a "simulate a FedaPay outage" switch to watch the + fallback. - 2026-10-05 — Release tooling (Changesets, trusted publishing workflow), getting-started guide. Packages versioned `0.1.0`; LICENSE, NOTICE and sources shipped in each package. - 2026-10-05 — `@payenv/core`: `PayenvError.providerCode` keeps the provider's own error diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35ea286..c6ae1b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,7 @@ packages/ src/ test/ connector-fedapay/ @payenv/connector-fedapay + connector-kkiapay/ @payenv/connector-kkiapay examples/ fedapay-demo/ runnable demo: pnpm demo ``` diff --git a/README.md b/README.md index 2f72d40..729c1cc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 🇫🇷 [Lire en français](README.fr.md) -**Status:** 🌱 Pre-release `0.1.0` — core engine and FedaPay connector (Benin mobile money). The API may still change. Started on **2026-09-26**. +**Status:** 🌱 Pre-release `0.x` — core engine, FedaPay and Kkiapay connectors (Benin mobile money). The API may still change. Started on **2026-09-26**. **License:** [Apache 2.0](LICENSE) — free for personal and commercial use, forever. --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8a54f13..77b99f4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -166,6 +166,18 @@ Idempotency: - Where a provider supports idempotency keys natively, the connector forwards a derived key per attempt. +## 4b. Payments started by the customer (widgets) + +Some providers (Kkiapay, most checkout-style aggregators) cannot start a payment from the +server: the customer starts it in the provider's widget. See +[ADR 0005](adr/0005-customer-started-payments.md). + +- Such routes declare `Capability.widget` and are only used when the request lists the + widget in `supportedWidgets` (the front end can open it). +- `collect` returns `requires_action` with `nextAction: { type: 'widget', provider, params }`. +- `payenv.confirm(key, providerRef)` links the provider transaction after verifying with + the provider that it carries this payment's merchant reference and amount. + ## 5. Webhooks - The app exposes one endpoint (or one per provider) and passes the **raw body** @@ -197,7 +209,7 @@ The integrator owns the store and its data. @payenv/core unified model, router, attempt engine, interfaces, and an in-memory store for development and tests @payenv/connector-fedapay one package per connector -@payenv/connector-kkiapay +@payenv/connector-kkiapay payments started in the Kkiapay widget, verified server-side @payenv/store-postgres persistent stores, one package each ``` diff --git a/docs/CONNECTORS.md b/docs/CONNECTORS.md index d6013a3..30e0ff3 100644 --- a/docs/CONNECTORS.md +++ b/docs/CONNECTORS.md @@ -8,7 +8,7 @@ A connector is an adapter for one payment aggregator. See | Connector | Region | Collect | Payout | Refund | Webhooks | Status | Maintainer | |---|---|---|---|---|---|---|---| | FedaPay | Benin / West Africa | ✅ MTN, Moov, Celtiis | — | — | ✅ Signature | 🟡 In progress | @jules-mahounou | -| Kkiapay | West Africa | — | — | — | — | ⚪ Planned | — | +| Kkiapay | West Africa | ✅ MTN, Moov (widget + server verification) | — | — | ✅ Secret header | 🟡 In progress | @jules-mahounou | | CinetPay | West & Central Africa | — | — | — | — | ⚪ Planned | — | | PayDunya | West Africa | — | — | — | — | ⚪ Planned | — | | Flutterwave | Pan-African | — | — | — | — | ⚪ Planned | — | diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b40769d..570fdeb 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -117,6 +117,21 @@ const payenv = createPayenv({ }); ``` +Some providers start payments in a widget on the customer's side (e.g. Kkiapay). They are +used only if your front end can open their widget, and can serve as a fallback: + +```ts +const payment = await payenv.collect({ ...request, supportedWidgets: ['kkiapay'] }); +if (payment.nextAction?.type === 'widget') { + // Front end: open the widget with payment.nextAction.params, then send the transaction + // id it returns to your server, which calls: + await payenv.confirm(payment.idempotencyKey, transactionId); +} +``` + +`confirm` verifies with the provider that the transaction belongs to this payment and has +the right amount. See [@payenv/connector-kkiapay](../packages/connector-kkiapay). + More connectors are on the way: see [CONNECTORS.md](CONNECTORS.md). ## 7. Before going live diff --git a/docs/adr/0005-customer-started-payments.md b/docs/adr/0005-customer-started-payments.md new file mode 100644 index 0000000..c32cc77 --- /dev/null +++ b/docs/adr/0005-customer-started-payments.md @@ -0,0 +1,39 @@ +# 0005. Payments started by the customer in a widget + +- **Status:** Accepted +- **Date:** 2026-10-05 + +## Context + +Some providers cannot start a payment from the server. With Kkiapay (and most +checkout-style aggregators), the customer starts the payment in the provider's widget, +and the server only verifies the resulting transaction. Payenv's first connector, +FedaPay, starts payments from the server (USSD push). + +A front end may also be unable to open a given widget (server-only flows, USSD apps, +another platform). + +## Decision + +1. A connector declares widget routes with `Capability.widget`. They are only eligible + when the request lists that widget in `CollectRequest.supportedWidgets`. +2. `collect` on such a route returns `requires_action` with + `nextAction: { type: 'widget', provider, params }` and a `merchantReference`. +3. `payenv.confirm(idempotencyKey, providerRef)` links the provider transaction **after** + checking with the provider that it carries the payment's merchant reference and + amount. Otherwise it throws `REFERENCE_MISMATCH` and leaves the payment unchanged. +4. Widget routes can serve as a fallback after a server-push provider failed safely + (ADR 0004 still applies: only a proven "no money moved" allows it). + +## Consequences + +- One API for both kinds of providers; most checkout-style aggregators fit this model. +- Front ends opt in explicitly, so no payment is handed to a widget nobody can open. +- `confirm` closes a classic fraud: confirming an order with another, cheaper transaction. +- Connectors whose provider does not echo a merchant reference cannot support `confirm`. + +## Alternatives considered + +- **Trusting the transaction id sent by the front end**: rejected, trivially abusable. +- **Calling undocumented widget endpoints from the server**: rejected, fragile and + possibly against the provider's terms. diff --git a/docs/adr/README.md b/docs/adr/README.md index f8a719b..9f5b6e0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,5 +10,6 @@ ADR that *supersedes* the old one. | [0002](0002-library-first-no-custody.md) | Library first, no custody of funds or secrets | Accepted | 2026-09-26 | | [0003](0003-typescript-first.md) | TypeScript as the first implementation | Accepted | 2026-09-26 | | [0004](0004-no-fallback-on-ambiguous-state.md) | Never fall back on an ambiguous state | Accepted | 2026-09-26 | +| [0005](0005-customer-started-payments.md) | Payments started by the customer in a widget | Accepted | 2026-10-05 | To propose one, copy [template.md](template.md) to `NNNN-short-title.md` and open a PR. diff --git a/examples/fedapay-demo/README.md b/examples/fedapay-demo/README.md index bd81282..7f455a3 100644 --- a/examples/fedapay-demo/README.md +++ b/examples/fedapay-demo/README.md @@ -19,6 +19,20 @@ 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. +## Fallback to Kkiapay (sandbox) + +Add your Kkiapay sandbox keys to `.env`: +``` +KKIAPAY_SANDBOX_PUBLIC_KEY=... +KKIAPAY_SANDBOX_PRIVATE_KEY=... +KKIAPAY_SANDBOX_SECRET_KEY=... +``` + +The page then shows **"Simuler une panne FedaPay"**. Tick it and pay: FedaPay fails before +any money can move, Payenv falls back to Kkiapay, and the page opens the Kkiapay widget. +Pay with a Kkiapay test number (e.g. `61000000`): the transaction id goes to the server, +which calls `payenv.confirm` to verify it with Kkiapay. + ## Live mode (real money) To check that a real USSD prompt reaches your phone, the demo has an opt-in live mode. diff --git a/examples/fedapay-demo/package.json b/examples/fedapay-demo/package.json index bd7fa53..6db6c15 100644 --- a/examples/fedapay-demo/package.json +++ b/examples/fedapay-demo/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "@payenv/connector-fedapay": "workspace:*", - "@payenv/core": "workspace:*" + "@payenv/core": "workspace:*", + "@payenv/connector-kkiapay": "workspace:*" }, "devDependencies": { "@types/node": "^24.0.0" diff --git a/examples/fedapay-demo/server.ts b/examples/fedapay-demo/server.ts index 4a75b94..72fe31a 100644 --- a/examples/fedapay-demo/server.ts +++ b/examples/fedapay-demo/server.ts @@ -8,10 +8,27 @@ * * 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). + * + * Kkiapay (sandbox) is added when KKIAPAY_SANDBOX_PUBLIC_KEY, _PRIVATE_KEY and _SECRET_KEY + * are set. Tick "Simuler une panne FedaPay" to watch Payenv fall back to its widget. */ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { fedapay, parseFedaPayWebhook, verifyFedaPayWebhook } from '@payenv/connector-fedapay'; -import { createPayenv, isPayenvError, type Payment } from '@payenv/core'; +import { + KKIAPAY_WIDGET, + kkiapay, + parseKkiapayWebhook, + verifyKkiapayWebhook, +} from '@payenv/connector-kkiapay'; +import { + type Connector, + createMemoryStore, + createPayenv, + isPayenvError, + PayenvError, + type PayenvOptions, + type Payment, +} from '@payenv/core'; const live = process.env.DEMO_LIVE === 'yes'; const maxLiveAmount = Number(process.env.DEMO_LIVE_MAX_AMOUNT ?? 200); @@ -40,8 +57,31 @@ 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: live ? 'live' : 'sandbox' })], +const kkiapayKeys = { + publicKey: process.env.KKIAPAY_SANDBOX_PUBLIC_KEY ?? '', + privateKey: process.env.KKIAPAY_SANDBOX_PRIVATE_KEY ?? '', + secretKey: process.env.KKIAPAY_SANDBOX_SECRET_KEY ?? '', +}; +// Kkiapay is sandbox-only in this demo. +const withKkiapay = + !live && Boolean(kkiapayKeys.publicKey && kkiapayKeys.privateKey && kkiapayKeys.secretKey); + +const feda = fedapay({ secretKey, environment: live ? 'live' : 'sandbox' }); +const kkia = withKkiapay ? [kkiapay(kkiapayKeys)] : []; + +/** FedaPay as if it were down: rejects before any money can move, so fallback is safe. */ +const fedaDown: Connector = { + ...feda, + async collect() { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'Simulated FedaPay outage (demo)', { + connectorId: feda.id, + }); + }, +}; + +const shared: Omit = { + // Both instances share one store, so refresh and confirm work for every payment. + store: createMemoryStore(), onEvent(event) { if (event.type === 'attempt.finished') { const { attempt } = event; @@ -54,7 +94,9 @@ const payenv = createPayenv({ console.log(`[payenv] fallback ${event.from} → ${event.to} (${event.reason.code})`); } }, -}); +}; +const payenv = createPayenv({ ...shared, connectors: [feda, ...kkia] }); +const payenvWithOutage = createPayenv({ ...shared, connectors: [fedaDown, ...kkia] }); createServer((request, response) => { route(request, response).catch((error: unknown) => { @@ -70,6 +112,7 @@ createServer((request, response) => { console.warn(''); } console.log(`Payenv demo (${live ? 'LIVE' : 'sandbox'}) running on http://localhost:${port}`); + console.log(`Connectors: fedapay${withKkiapay ? ', kkiapay (widget)' : ''}`); }); async function route(request: IncomingMessage, response: ServerResponse): Promise { @@ -86,6 +129,8 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis amount: number; phone: string; network: string; + outage?: string; + widgets?: string[]; }; if (live && !(Number(body.amount) <= maxLiveAmount)) { send(response, 400, { @@ -97,12 +142,15 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis return; } try { - const payment = await payenv.collect({ + 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 }, customer: { firstName: 'Demo', lastName: 'Payenv', email: 'demo@example.com' }, description: live ? 'Payenv demo (live test)' : 'Payenv demo', idempotencyKey: `demo_${Date.now()}`, + // The page declares which widgets it can open. + supportedWidgets: (body.widgets ?? []).filter((widget) => widget === KKIAPAY_WIDGET), }); if (payment.providerRef) keysByProviderRef.set(payment.providerRef, payment.idempotencyKey); send(response, 200, view(payment)); @@ -114,6 +162,19 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis return; } + const confirmMatch = /^\/api\/payments\/([\w-]+)\/confirm$/.exec(url.pathname); + if (request.method === 'POST' && confirmMatch?.[1]) { + const { transactionId } = JSON.parse(await readBody(request)) as { transactionId: string }; + try { + // Verified with Kkiapay: right payment (partnerId) and right amount. + send(response, 200, view(await payenv.confirm(confirmMatch[1], transactionId))); + } catch (error) { + 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]); @@ -149,6 +210,31 @@ async function route(request: IncomingMessage, response: ServerResponse): Promis return; } + if (request.method === 'POST' && url.pathname === '/webhooks/kkiapay') { + const raw = await readBody(request); + const header = request.headers['x-kkiapay-secret']; + if ( + !verifyKkiapayWebhook( + Array.isArray(header) ? header[0] : header, + process.env.KKIAPAY_WEBHOOK_SECRET ?? '', + ) + ) { + console.log('[webhook] Kkiapay: INVALID secret — ignored'); + send(response, 400, { received: false }); + return; + } + const event = parseKkiapayWebhook(raw); + if (event?.merchantReference) { + // partnerId is the payment's idempotency key; confirm verifies with Kkiapay. + const payment = await payenv.confirm(event.merchantReference, event.providerRef); + console.log( + `[webhook] ${event.name} → payment ${payment.idempotencyKey} is now ${payment.status}`, + ); + } + send(response, 200, { received: true }); + return; + } + send(response, 404, { error: 'Not found' }); } @@ -162,6 +248,7 @@ function view(payment: Payment) { providerRef: payment.providerRef, phone: phone.replace(/^(\+\d{3})\d+(\d{2})$/, '$1••••••$2'), error: payment.error, + nextAction: payment.nextAction, attempts: payment.attempts.map(({ connectorId, status, outcome, error }) => ({ connectorId, status, @@ -215,12 +302,13 @@ function page(): string { .succeeded { background: var(--ok); } .failed, .canceled, .expired { background: var(--ko); } .pending, .requires_action, .created, .unknown { background: var(--wait); } .live { background: var(--ko); color: white; padding: 10px 14px; border-radius: 8px; } + .check { display: flex; gap: 8px; align-items: center; font-weight: 400; } pre { font-size: 12px; overflow-x: auto; background: #8881; padding: 8px; border-radius: 8px; }

Payenv demo

-

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

+

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

${live ? `

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

` : ''}
@@ -229,6 +317,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` + : '' + }

@@ -236,8 +330,24 @@ ${live ? `

⚠️ Mode LIVE : vrai argent. Monta


 
+${withKkiapay ? '' : ''} + +``` + +Only the **public** key is ever sent to the browser. + +## 3. Confirm (server) + +```ts +const confirmed = await payenv.confirm('order_1234', transactionId); +``` + +`confirm` asks Kkiapay for the transaction and checks that it carries this payment's +reference (`partnerId`) **and** amount. A customer cannot confirm an order with another, +cheaper transaction: `REFERENCE_MISMATCH` is thrown and the payment is left unchanged. + +## Webhooks + +The widget's `partnerId` is the payment's idempotency key, so webhooks map directly: + +```ts +import { parseKkiapayWebhook, verifyKkiapayWebhook } from '@payenv/connector-kkiapay'; + +if (verifyKkiapayWebhook(secretHeader, process.env.KKIAPAY_WEBHOOK_SECRET!)) { + const event = parseKkiapayWebhook(rawBody); // { name, providerRef, merchantReference, status } + if (event?.merchantReference) await payenv.confirm(event.merchantReference, event.providerRef); +} +``` + +## Options + +| Option | Default | Description | +|---|---|---| +| `publicKey`, `privateKey`, `secretKey` | — | Kkiapay API keys. Required. | +| `environment` | `'sandbox'` | `'sandbox'` or `'live'`. | +| `routes` | `{ BJ: ['mtn', 'moov'] }` | Mobile money networks per country. | +| `id` | `'kkiapay'` | Connector id. | +| `fetch` | global `fetch` | Custom fetch implementation. | + +See [QUIRKS.md](QUIRKS.md) for what is verified and what remains to verify. + +License: Apache-2.0 diff --git a/packages/connector-kkiapay/package.json b/packages/connector-kkiapay/package.json new file mode 100644 index 0000000..69f994b --- /dev/null +++ b/packages/connector-kkiapay/package.json @@ -0,0 +1,55 @@ +{ + "name": "@payenv/connector-kkiapay", + "version": "0.1.0", + "description": "Kkiapay connector for Payenv (mobile money via the Kkiapay widget, verified server-side).", + "license": "Apache-2.0", + "author": "Jules Mahounou (https://github.com/jules-mahounou)", + "homepage": "https://github.com/payenv-dev/payenv/tree/main/packages/connector-kkiapay#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/payenv-dev/payenv.git", + "directory": "packages/connector-kkiapay" + }, + "bugs": "https://github.com/payenv-dev/payenv/issues", + "keywords": [ + "payenv", + "kkiapay", + "mobile-money", + "benin", + "mtn", + "moov" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "README.md", + "QUIRKS.md", + "LICENSE", + "NOTICE" + ], + "engines": { + "node": ">=20.19" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "peerDependencies": { + "@payenv/core": "workspace:^" + }, + "devDependencies": { + "@payenv/core": "workspace:^", + "@types/node": "^26.6.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/connector-kkiapay/src/index.ts b/packages/connector-kkiapay/src/index.ts new file mode 100644 index 0000000..67863ae --- /dev/null +++ b/packages/connector-kkiapay/src/index.ts @@ -0,0 +1,13 @@ +export { + DEFAULT_ROUTES, + KKIAPAY_WIDGET, + type KkiapayEnvironment, + type KkiapayOptions, + type KkiapayRoutes, + kkiapay, +} from './kkiapay.js'; +export { + type KkiapayWebhookEvent, + parseKkiapayWebhook, + verifyKkiapayWebhook, +} from './webhook.js'; diff --git a/packages/connector-kkiapay/src/kkiapay.ts b/packages/connector-kkiapay/src/kkiapay.ts new file mode 100644 index 0000000..dbb9d2a --- /dev/null +++ b/packages/connector-kkiapay/src/kkiapay.ts @@ -0,0 +1,232 @@ +import { + type Capability, + type Connector, + type CountryCode, + type MobileMoneyNetwork, + PayenvError, + type PayenvErrorCode, + type PaymentStatus, + type ProviderResult, + type StatusResult, +} from '@payenv/core'; + +export type KkiapayEnvironment = 'sandbox' | 'live'; + +/** Mobile money networks to expose, per country. */ +export type KkiapayRoutes = Readonly>; + +/** Networks with Kkiapay sandbox test numbers in Benin. Add others with the `routes` option. */ +export const DEFAULT_ROUTES: KkiapayRoutes = { BJ: ['mtn', 'moov'] }; + +export interface KkiapayOptions { + /** Public API key. Sent to the front end to open the widget. */ + publicKey: string; + /** Private API key. Server-side only. */ + privateKey: string; + /** Secret API key. Server-side only. */ + secretKey: string; + /** Defaults to `sandbox`. */ + environment?: KkiapayEnvironment; + /** Defaults to {@link DEFAULT_ROUTES}. */ + routes?: KkiapayRoutes; + /** Connector id used in routing and payments. Defaults to `kkiapay`. */ + id?: string; + /** Custom `fetch`, e.g. for tests or proxies. Defaults to the global `fetch`. */ + fetch?: typeof fetch; +} + +/** The widget name to list in `supportedWidgets` when the front end can open Kkiapay. */ +export const KKIAPAY_WIDGET = 'kkiapay'; + +const BASE_URLS: Record = { + sandbox: 'https://api-sandbox.kkiapay.me', + live: 'https://api.kkiapay.me', +}; + +export type Json = Record; + +/** + * Kkiapay connector. + * + * Kkiapay payments start in its widget, in the customer's browser or app: `collect` + * returns a `widget` next action with everything the front end needs. Once the widget + * reports a transaction id, call `payenv.confirm(idempotencyKey, transactionId)`: the + * transaction is verified server-side with your private keys. + * + * The widget's `partnerId` is set to the payment's idempotency key, so Kkiapay webhooks + * can be matched to payments directly. + */ +export function kkiapay(options: KkiapayOptions): Connector { + for (const key of ['publicKey', 'privateKey', 'secretKey'] as const) { + if (!options[key]) throw new PayenvError('INVALID_REQUEST', `Kkiapay ${key} is required`); + } + const id = options.id ?? 'kkiapay'; + const environment = options.environment ?? 'sandbox'; + const baseUrl = BASE_URLS[environment]; + const routes = options.routes ?? DEFAULT_ROUTES; + const doFetch = options.fetch ?? globalThis.fetch; + + const capabilities: Capability[] = Object.entries(routes).map(([country, networks]) => ({ + operation: 'collect', + method: 'mobile_money', + currencies: ['XOF'], + countries: [country], + networks, + widget: KKIAPAY_WIDGET, + })); + + return { + id, + capabilities: () => capabilities, + + async collect(request): Promise { + const { method, customer } = request; + if (method.type !== 'mobile_money') { + throw new PayenvError('ROUTE_UNSUPPORTED', 'Kkiapay connector only supports mobile money', { + connectorId: id, + }); + } + const name = [customer?.firstName, customer?.lastName].filter(Boolean).join(' '); + // Nothing is sent to Kkiapay here: the customer starts the payment in the widget. + return { + status: 'requires_action', + merchantReference: request.idempotencyKey, + nextAction: { + type: 'widget', + provider: KKIAPAY_WIDGET, + params: { + key: options.publicKey, + amount: request.amount.value, + sandbox: environment === 'sandbox', + partnerId: request.idempotencyKey, + phone: method.phone.replace(/^\+/, ''), + paymentmethod: 'momo', + countries: [method.country], + ...(name ? { name } : {}), + ...(customer?.email ? { email: customer.email } : {}), + }, + }, + }; + }, + + async getStatus(query, context): Promise { + if (query.providerRef === undefined) { + // Kkiapay transactions can only be looked up by their id. + throw new PayenvError('UNKNOWN_ERROR', 'Kkiapay status lookup needs the transaction id', { + connectorId: id, + }); + } + let response: Response; + try { + response = await doFetch(`${baseUrl}/api/v1/transactions/status`, { + method: 'POST', + signal: context.signal, + headers: { + 'x-api-key': options.publicKey, + 'x-private-key': options.privateKey, + 'x-secret-key': options.secretKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ transactionId: query.providerRef }), + }); + } catch (thrown) { + throw new PayenvError('NETWORK_ERROR', 'Kkiapay status lookup failed', { + connectorId: id, + cause: thrown, + }); + } + const data = await readJson(response); + if (response.status === 404) return { found: false, raw: data }; + if (!response.ok) { + throw new PayenvError( + response.status === 401 || response.status === 403 + ? 'AUTHENTICATION_FAILED' + : 'PROVIDER_UNAVAILABLE', + `Kkiapay status lookup failed with HTTP ${response.status}`, + { connectorId: id, raw: data }, + ); + } + return toStatusResult(data, query.providerRef, id); + }, + }; +} + +/** Maps a Kkiapay transaction (status lookup or webhook) to a Payenv status result. */ +export function toStatusResult( + transaction: Json, + providerRef: string, + connectorId: string, +): StatusResult { + const { status, error } = transactionStatus(transaction, connectorId); + const partnerId = transaction.partnerId; + const amount = transaction.amount; + return { + found: true, + status, + providerRef, + ...(typeof partnerId === 'string' && partnerId !== '' ? { merchantReference: partnerId } : {}), + ...(typeof amount === 'number' ? { amount: { value: amount, currency: 'XOF' } } : {}), + ...(error ? { error } : {}), + raw: transaction, + }; +} + +/** Kkiapay `failureCode` values (sandbox scenarios and documentation), mapped to Payenv codes. */ +const FAILURE_CODES: Readonly> = { + insufficient_fund: 'INSUFFICIENT_FUNDS', + payment_declined: 'CUSTOMER_DECLINED', + card_declined: 'CUSTOMER_DECLINED', + card_fraudulent: 'FRAUD_SUSPECTED', + processing_error: 'UNKNOWN_ERROR', + transaction_error: 'UNKNOWN_ERROR', +}; + +function transactionStatus( + transaction: Json, + connectorId: string, +): { status: PaymentStatus; error?: PayenvError } { + const status = typeof transaction.status === 'string' ? transaction.status.toUpperCase() : ''; + switch (status) { + case 'SUCCESS': + return { status: 'succeeded' }; + case 'PENDING': + return { status: 'pending' }; + case 'FAILED': { + const failureCode = + typeof transaction.failureCode === 'string' && transaction.failureCode !== '' + ? transaction.failureCode + : undefined; + const failureMessage = + typeof transaction.failureMessage === 'string' ? transaction.failureMessage : undefined; + const code = (failureCode ? FAILURE_CODES[failureCode] : undefined) ?? 'CUSTOMER_DECLINED'; + return { + status: 'failed', + error: new PayenvError( + code, + `Kkiapay transaction failed${failureMessage ? ` — ${failureMessage}` : ''}${ + failureCode ? ` (${failureCode})` : '' + }`, + { + connectorId, + // The customer was involved: another provider would not change the outcome. + retryClass: 'do_not_retry', + ...(failureCode ? { providerCode: failureCode } : {}), + }, + ), + }; + } + default: + return { status: 'unknown' }; + } +} + +async function readJson(response: Response): Promise { + const text = await response.text(); + try { + const data: unknown = text ? JSON.parse(text) : {}; + return data !== null && typeof data === 'object' ? (data as Json) : { value: data }; + } catch { + return { raw: text }; + } +} diff --git a/packages/connector-kkiapay/src/webhook.ts b/packages/connector-kkiapay/src/webhook.ts new file mode 100644 index 0000000..482fae6 --- /dev/null +++ b/packages/connector-kkiapay/src/webhook.ts @@ -0,0 +1,75 @@ +import type { PayenvError, PaymentStatus } from '@payenv/core'; +import { type Json, toStatusResult } from './kkiapay.js'; + +/** + * Verifies a Kkiapay webhook. + * + * Kkiapay sends the hash secret configured in its dashboard, as is, in the + * `x-kkiapay-secret` header (it does not sign the body). Compare it in constant time, + * and only trust the body as a hint: confirm with `payenv.confirm`. + */ +export function verifyKkiapayWebhook( + secretHeader: string | null | undefined, + secret: string, +): boolean { + if (!secret || !secretHeader) return false; + return constantTimeEqual(secretHeader, secret); +} + +/** A Kkiapay webhook, normalized. */ +export interface KkiapayWebhookEvent { + /** Kkiapay's event name, e.g. `transaction.success`. Informational only. */ + name: string; + /** The Kkiapay transaction id: pass it to `payenv.confirm`. */ + providerRef: string; + /** The widget's `partnerId`: the payment's idempotency key when it was set by Payenv. */ + merchantReference?: string; + status: PaymentStatus; + error?: PayenvError; +} + +/** + * Parses a Kkiapay webhook body **after** {@link verifyKkiapayWebhook} succeeded. + * Then call `payenv.confirm(event.merchantReference, event.providerRef)`, which + * verifies the transaction with Kkiapay before updating the payment. + */ +export function parseKkiapayWebhook( + rawBody: string, + connectorId = 'kkiapay', +): KkiapayWebhookEvent | undefined { + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return undefined; + } + if (body === null || typeof body !== 'object') return undefined; + const event = body as Json; + const { transactionId } = event; + if (typeof transactionId !== 'string' || transactionId === '') return undefined; + + // The documented field is spelled `isPaymentSucces`. + const succeeded = event.isPaymentSucces ?? event.isPaymentSuccess; + const result = toStatusResult( + { ...event, status: succeeded === true ? 'SUCCESS' : succeeded === false ? 'FAILED' : '' }, + transactionId, + connectorId, + ); + if (!result.found) return undefined; + return { + name: typeof event.event === 'string' ? event.event : 'unknown', + providerRef: transactionId, + status: result.status, + ...(result.merchantReference ? { merchantReference: result.merchantReference } : {}), + ...(result.error ? { error: result.error } : {}), + }; +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index++) { + difference |= a.charCodeAt(index) ^ b.charCodeAt(index); + } + return difference === 0; +} diff --git a/packages/connector-kkiapay/test/kkiapay.test.ts b/packages/connector-kkiapay/test/kkiapay.test.ts new file mode 100644 index 0000000..7137ed1 --- /dev/null +++ b/packages/connector-kkiapay/test/kkiapay.test.ts @@ -0,0 +1,257 @@ +import { type CollectRequest, createPayenv, PayenvError } from '@payenv/core'; +import { describe, expect, it } from 'vitest'; +import { + KKIAPAY_WIDGET, + kkiapay, + parseKkiapayWebhook, + verifyKkiapayWebhook, +} from '../src/index.js'; + +const keys = { publicKey: 'pk_test', privateKey: 'tpk_test', secretKey: 'tsk_test' }; + +interface Call { + url: string; + headers: Record; + body: unknown; +} + +/** A fake Kkiapay status API answering from a table of transactions. */ +function fakeKkiapay(transactions: Record>) { + const calls: Call[] = []; + const fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { transactionId: string }; + calls.push({ url: String(input), headers: init?.headers as Record, body }); + const transaction = transactions[body.transactionId]; + return transaction + ? new Response(JSON.stringify(transaction), { status: 200 }) + : new Response(JSON.stringify({ reason: 'Transaction Not Found' }), { status: 404 }); + }) as typeof globalThis.fetch; + return { fetch, calls }; +} + +function request(overrides: Partial = {}): CollectRequest { + return { + amount: { value: 5000, currency: 'XOF' }, + method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22961000000' }, + customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' }, + idempotencyKey: 'order_1', + supportedWidgets: [KKIAPAY_WIDGET], + ...overrides, + }; +} + +/** Shape of a Kkiapay status response (documentation), for the given payment. */ +function transaction(overrides: Record = {}) { + return { + performed_at: '2026-10-05T15:01:49.499Z', + type: 'DEBIT', + status: 'SUCCESS', + source: 'MOBILE_MONEY', + source_common_name: 'mtn-benin', + amount: 5000, + fees: 95, + failureCode: '', + failureMessage: '', + partnerId: 'order_1', + feeSupportedBy: 'customer', + transactionId: 'kk_1', + ...overrides, + }; +} + +describe('kkiapay connector — collect', () => { + it('returns the widget parameters, without calling Kkiapay', async () => { + const server = fakeKkiapay({}); + const connector = kkiapay({ ...keys, fetch: server.fetch }); + + const result = await connector.collect(request(), { + paymentId: 'pay_1', + attemptId: 'att_1', + reference: 'att_1', + idempotencyKey: 'att_1', + signal: new AbortController().signal, + reportProviderRef: () => {}, + }); + + expect(result).toEqual({ + status: 'requires_action', + merchantReference: 'order_1', + nextAction: { + type: 'widget', + provider: 'kkiapay', + params: { + key: 'pk_test', + amount: 5000, + sandbox: true, + partnerId: 'order_1', + phone: '22961000000', + paymentmethod: 'momo', + countries: ['BJ'], + name: 'Ada Lovelace', + email: 'ada@example.com', + }, + }, + }); + expect(server.calls).toHaveLength(0); + }); + + it('never exposes the private or secret keys to the front end', async () => { + const payenv = createPayenv({ connectors: [kkiapay(keys)] }); + const payment = await payenv.collect(request()); + const exposed = JSON.stringify(payment.nextAction); + expect(exposed).not.toContain('tpk_test'); + expect(exposed).not.toContain('tsk_test'); + }); + + it('is only routed when the front end can open the Kkiapay widget', async () => { + const payenv = createPayenv({ connectors: [kkiapay(keys)] }); + await expect(payenv.collect(request({ supportedWidgets: [] }))).rejects.toMatchObject({ + code: 'NO_ROUTE', + }); + }); +}); + +describe('kkiapay connector — confirm through Payenv', () => { + it('confirms a genuine transaction', async () => { + const server = fakeKkiapay({ kk_1: transaction() }); + const payenv = createPayenv({ connectors: [kkiapay({ ...keys, fetch: server.fetch })] }); + await payenv.collect(request()); + + const payment = await payenv.confirm('order_1', 'kk_1'); + + expect(payment.status).toBe('succeeded'); + expect(payment.providerRef).toBe('kk_1'); + expect(server.calls[0]).toMatchObject({ + url: 'https://api-sandbox.kkiapay.me/api/v1/transactions/status', + body: { transactionId: 'kk_1' }, + headers: { 'x-api-key': 'pk_test', 'x-private-key': 'tpk_test', 'x-secret-key': 'tsk_test' }, + }); + }); + + it('rejects a transaction made for another order (fraud attempt)', async () => { + const server = fakeKkiapay({ kk_other: transaction({ partnerId: 'order_999' }) }); + const payenv = createPayenv({ connectors: [kkiapay({ ...keys, fetch: server.fetch })] }); + await payenv.collect(request()); + + await expect(payenv.confirm('order_1', 'kk_other')).rejects.toMatchObject({ + code: 'REFERENCE_MISMATCH', + }); + }); + + it('rejects a transaction for a smaller amount (fraud attempt)', async () => { + const server = fakeKkiapay({ kk_cheap: transaction({ amount: 100 }) }); + const payenv = createPayenv({ connectors: [kkiapay({ ...keys, fetch: server.fetch })] }); + await payenv.collect(request()); + + await expect(payenv.confirm('order_1', 'kk_cheap')).rejects.toMatchObject({ + code: 'REFERENCE_MISMATCH', + }); + }); + + it('rejects an unknown transaction id', async () => { + const payenv = createPayenv({ + connectors: [kkiapay({ ...keys, fetch: fakeKkiapay({}).fetch })], + }); + await payenv.collect(request()); + await expect(payenv.confirm('order_1', 'kk_bogus')).rejects.toMatchObject({ + code: 'PAYMENT_NOT_FOUND', + }); + }); + + it.each([ + ['insufficient_fund', 'INSUFFICIENT_FUNDS'], + ['payment_declined', 'CUSTOMER_DECLINED'], + ['processing_error', 'UNKNOWN_ERROR'], + ])('maps a failed transaction (%s → %s)', async (failureCode, code) => { + const server = fakeKkiapay({ + kk_1: transaction({ status: 'FAILED', failureCode, failureMessage: failureCode }), + }); + const payenv = createPayenv({ connectors: [kkiapay({ ...keys, fetch: server.fetch })] }); + await payenv.collect(request()); + + const payment = await payenv.confirm('order_1', 'kk_1'); + + expect(payment.status).toBe('failed'); + expect(payment.error).toMatchObject({ code, providerCode: failureCode }); + }); + + it('is the fallback when a server-push provider is down', async () => { + const down = { + id: 'push', + capabilities: () => + kkiapay(keys) + .capabilities() + .map(({ widget, ...route }) => route), + async collect(): Promise { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'down'); + }, + async getStatus() { + return { found: false as const }; + }, + }; + const server = fakeKkiapay({ kk_1: transaction() }); + const payenv = createPayenv({ + connectors: [down, kkiapay({ ...keys, fetch: server.fetch })], + statusCheckDelaysMs: [0], + }); + + const started = await payenv.collect(request()); + expect(started).toMatchObject({ connectorId: 'kkiapay', status: 'requires_action' }); + expect(started.nextAction?.type).toBe('widget'); + + expect((await payenv.confirm('order_1', 'kk_1')).status).toBe('succeeded'); + }); +}); + +describe('kkiapay webhooks', () => { + it('verifies the shared secret header', () => { + expect(verifyKkiapayWebhook('my_hash_secret', 'my_hash_secret')).toBe(true); + expect(verifyKkiapayWebhook('wrong', 'my_hash_secret')).toBe(false); + expect(verifyKkiapayWebhook(undefined, 'my_hash_secret')).toBe(false); + expect(verifyKkiapayWebhook('anything', '')).toBe(false); + }); + + it('parses a successful payment (documented shape)', () => { + const event = parseKkiapayWebhook( + JSON.stringify({ + transactionId: '3iH6wjHJ3', + isPaymentSucces: true, + account: '22996000000', + method: 'MOBILE_MONEY', + amount: 1000, + fees: 19, + partnerId: 'order_1', + performedAt: '2024-03-20T08:55:22.883Z', + event: 'transaction.success', + }), + ); + expect(event).toEqual({ + name: 'transaction.success', + providerRef: '3iH6wjHJ3', + merchantReference: 'order_1', + status: 'succeeded', + }); + }); + + it('parses a failed payment with its reason', () => { + const event = parseKkiapayWebhook( + JSON.stringify({ + transactionId: 'erjEU5P9o', + isPaymentSucces: false, + failureCode: 'processing_error', + failureMessage: 'processing_error', + partnerId: 'order_1', + event: 'transaction.failed', + }), + ); + expect(event).toMatchObject({ status: 'failed', merchantReference: 'order_1' }); + expect(event?.error?.providerCode).toBe('processing_error'); + }); + + it.each(['not json', 'null', JSON.stringify({ event: 'transaction.success' })])( + 'ignores invalid bodies: %s', + (body) => { + expect(parseKkiapayWebhook(body)).toBeUndefined(); + }, + ); +}); diff --git a/packages/connector-kkiapay/test/sandbox.test.ts b/packages/connector-kkiapay/test/sandbox.test.ts new file mode 100644 index 0000000..9d84a99 --- /dev/null +++ b/packages/connector-kkiapay/test/sandbox.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { kkiapay } from '../src/index.js'; + +// Opt-in: runs only when Kkiapay sandbox keys are set in a local .env file. +// Kkiapay payments start in the widget, so a transaction id can only come from a real +// widget payment (e.g. the demo): set KKIAPAY_SANDBOX_TRANSACTION_ID to check it. +const publicKey = process.env.KKIAPAY_SANDBOX_PUBLIC_KEY; +const privateKey = process.env.KKIAPAY_SANDBOX_PRIVATE_KEY; +const secretKey = process.env.KKIAPAY_SANDBOX_SECRET_KEY; +const transactionId = process.env.KKIAPAY_SANDBOX_TRANSACTION_ID; + +const configured = Boolean(publicKey && privateKey && secretKey); + +describe.skipIf(!configured)('kkiapay sandbox (real API)', () => { + const connector = () => + kkiapay({ + publicKey: publicKey as string, + privateKey: privateKey as string, + secretKey: secretKey as string, + }); + const lookup = (providerRef: string) => + connector().getStatus( + { operation: 'collect', reference: 'sandbox', providerRef }, + { signal: AbortSignal.timeout(20_000) }, + ); + + it('reports an unknown transaction id as not found', { timeout: 30_000 }, async () => { + expect(await lookup(`payenv_unknown_${Date.now()}`)).toMatchObject({ found: false }); + }); + + it.skipIf(!transactionId)('reads a real widget transaction', { timeout: 30_000 }, async () => { + const result = await lookup(transactionId as string); + expect(result.found).toBe(true); + if (!result.found) return; + expect(['succeeded', 'failed', 'pending']).toContain(result.status); + expect(result.amount?.currency).toBe('XOF'); + }); +}); diff --git a/packages/connector-kkiapay/tsconfig.build.json b/packages/connector-kkiapay/tsconfig.build.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/connector-kkiapay/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/connector-kkiapay/tsconfig.json b/packages/connector-kkiapay/tsconfig.json new file mode 100644 index 0000000..2c81ede --- /dev/null +++ b/packages/connector-kkiapay/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "paths": { + "@payenv/core": ["../core/src/index.ts"] + } + }, + "include": ["src", "test"] +} diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts index c689b4f..7de9d7d 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -29,6 +29,11 @@ export interface CollectRequest { */ idempotencyKey: string; metadata?: Readonly>; + /** + * Widgets the application's front end can open (e.g. `['kkiapay']`). Connectors whose + * payments start in a widget are only used when their widget is listed here. + */ + supportedWidgets?: readonly string[]; } /** What a connector declares it can serve. Omitted lists mean "any". */ @@ -39,12 +44,22 @@ export interface Capability { countries?: readonly CountryCode[]; /** Only meaningful for `mobile_money`. */ networks?: readonly MobileMoneyNetwork[]; + /** + * Set when the customer starts the payment in this provider's widget rather than the + * server. The route is only used when the request lists it in `supportedWidgets`. + */ + widget?: string; } /** An instruction the application must follow to complete the payment. */ export type NextAction = | { type: 'redirect'; url: string } - | { type: 'customer_confirmation'; channel?: 'ussd' | 'sms' | 'app'; message?: string }; + | { type: 'customer_confirmation'; channel?: 'ussd' | 'sms' | 'app'; message?: string } + /** + * Open the provider's widget with these parameters. When it completes, send the + * provider's transaction id to `payenv.confirm`. + */ + | { type: 'widget'; provider: string; params: Readonly> }; /** * Result of a provider call, normalized by the connector. @@ -56,6 +71,14 @@ export interface ProviderResult { status: PaymentStatus; /** The provider's identifier for this transaction. */ providerRef?: string; + /** + * The merchant reference attached to the provider transaction (sent by the connector + * in `collect`, echoed by the provider in status lookups). Used by `payenv.confirm` + * to prove that a provider transaction belongs to this payment. + */ + merchantReference?: string; + /** The amount the provider transaction is for, as reported by the provider. */ + amount?: Money; nextAction?: NextAction; /** Required when `status` is `failed`, `canceled` or `expired`, to classify the failure. */ error?: PayenvError; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 03b0af1..710187a 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -27,6 +27,7 @@ export type PayenvErrorCode = | 'NO_ROUTE' | 'IDEMPOTENCY_CONFLICT' | 'PAYMENT_NOT_FOUND' + | 'REFERENCE_MISMATCH' // ambiguous | 'TIMEOUT' | 'NETWORK_ERROR' @@ -47,6 +48,7 @@ export const DEFAULT_RETRY_CLASS: Readonly> NO_ROUTE: 'do_not_retry', IDEMPOTENCY_CONFLICT: 'do_not_retry', PAYMENT_NOT_FOUND: 'do_not_retry', + REFERENCE_MISMATCH: 'do_not_retry', TIMEOUT: 'ambiguous', NETWORK_ERROR: 'ambiguous', UNKNOWN_ERROR: 'ambiguous', diff --git a/packages/core/src/payenv.ts b/packages/core/src/payenv.ts index 89963f6..f1a6152 100644 --- a/packages/core/src/payenv.ts +++ b/packages/core/src/payenv.ts @@ -44,6 +44,13 @@ export interface Payenv { getPayment(idempotencyKey: string): Promise; /** Asks the provider for the latest status of a non-terminal payment. */ refresh(idempotencyKey: string): Promise; + /** + * Links a provider transaction to a payment started in a widget (`nextAction.type === + * 'widget'`), then updates its status. The transaction is first checked with the + * provider: it must carry this payment's merchant reference and amount, so a customer + * cannot confirm an order with another transaction. + */ + confirm(idempotencyKey: string, providerRef: string): Promise; } const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000; @@ -175,6 +182,9 @@ export function createPayenv(options: PayenvOptions): Payenv { attempt.status = status; attempt.outcome = 'accepted'; if (result.providerRef !== undefined) attempt.providerRef = result.providerRef; + if (result.merchantReference !== undefined) { + attempt.merchantReference = result.merchantReference; + } payment.status = status; setOptional(payment, 'providerRef', attempt.providerRef); setOptional(payment, 'nextAction', result.nextAction); @@ -287,31 +297,71 @@ export function createPayenv(options: PayenvOptions): Payenv { return payment; } - async function refresh(idempotencyKey: string): Promise { + async function load(idempotencyKey: string) { const payment = await store.get(idempotencyKey); if (!payment) { throw new PayenvError('PAYMENT_NOT_FOUND', `No payment with key "${idempotencyKey}"`); } const attempt = payment.attempts.at(-1); - if (isTerminal(payment.status) || !attempt) return payment; - - const connector = connectors.find((candidate) => candidate.id === attempt.connectorId); - if (!connector) { + const connector = + attempt && connectors.find((candidate) => candidate.id === attempt.connectorId); + if (attempt && !connector) { throw new PayenvError( 'INVALID_REQUEST', - `Connector "${attempt.connectorId}" is not configured; cannot refresh this payment`, + `Connector "${attempt.connectorId}" is not configured; cannot update this payment`, ); } + return { payment, attempt, connector }; + } - let status: StatusResult; + async function lookup(connector: Connector, attempt: Attempt, providerRef?: string) { + const query: StatusQuery = { operation: 'collect', reference: attempt.reference }; + const ref = providerRef ?? attempt.providerRef; + if (ref !== undefined) query.providerRef = ref; try { - const query: StatusQuery = { operation: 'collect', reference: attempt.reference }; - if (attempt.providerRef !== undefined) query.providerRef = attempt.providerRef; - status = await checkStatus(connector, query); + return await checkStatus(connector, query); } catch (thrown) { throw toPayenvError(connector, thrown); } + } + /** Applies a provider status to a payment. Returns false if the transition is not allowed. */ + function applyStatus( + payment: Payment, + attempt: Attempt, + status: Extract, + ): boolean { + const next = status.status === 'created' ? 'pending' : status.status; + if (!canTransition(payment.status, next)) return false; + 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; + payment.providerRef = status.providerRef; + } + setOptional(payment, 'nextAction', status.nextAction); + if (status.error) { + attempt.error = status.error.toJSON(); + payment.error = attempt.error; + } + return true; + } + + async function refresh(idempotencyKey: string): Promise { + const { payment, attempt, connector } = await load(idempotencyKey); + if (isTerminal(payment.status) || !attempt || !connector) return payment; + // A widget payment has nothing to look up until the customer completes it (confirm). + if (payment.status === 'requires_action' && attempt.providerRef === undefined) return payment; + + const status = await lookup(connector, attempt); if (!status.found) { const error = new PayenvError( 'PAYMENT_NOT_FOUND', @@ -323,29 +373,70 @@ export function createPayenv(options: PayenvOptions): Payenv { attempt.error = error; payment.status = 'failed'; payment.error = error; - } else { - 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; - payment.providerRef = status.providerRef; - } - setOptional(payment, 'nextAction', status.nextAction); - if (status.error) { - attempt.error = status.error.toJSON(); - payment.error = attempt.error; - } + } else if (!applyStatus(payment, attempt, status)) { + return payment; + } + await save(payment); + return payment; + } + + async function confirm(idempotencyKey: string, providerRef: string): Promise { + if (typeof providerRef !== 'string' || providerRef === '') { + throw new PayenvError('INVALID_REQUEST', 'providerRef must be a non-empty string'); + } + const { payment, attempt, connector } = await load(idempotencyKey); + if (!attempt || !connector) { + throw new PayenvError('INVALID_REQUEST', 'This payment has no attempt to confirm'); + } + if (attempt.providerRef !== undefined) { + // Confirming twice with the same transaction is harmless; another one is not. + if (attempt.providerRef === providerRef) return refresh(idempotencyKey); + throw new PayenvError( + 'REFERENCE_MISMATCH', + 'This payment is already linked to another provider transaction', + { connectorId: connector.id }, + ); } + if (attempt.merchantReference === undefined) { + throw new PayenvError( + 'INVALID_REQUEST', + `Connector "${connector.id}" did not attach a merchant reference; this payment cannot be confirmed`, + ); + } + + const status = await lookup(connector, attempt, providerRef); + if (!status.found) { + throw new PayenvError( + 'PAYMENT_NOT_FOUND', + `The provider has no transaction "${providerRef}"`, + { + connectorId: connector.id, + }, + ); + } + // Anti-fraud: the provider transaction must be this payment's, for this amount. + if (status.merchantReference !== attempt.merchantReference) { + throw new PayenvError( + 'REFERENCE_MISMATCH', + 'The provider transaction does not belong to this payment', + { connectorId: connector.id }, + ); + } + if ( + status.amount === undefined || + status.amount.value !== payment.amount.value || + status.amount.currency !== payment.amount.currency + ) { + throw new PayenvError( + 'REFERENCE_MISMATCH', + 'The provider transaction amount does not match this payment', + { connectorId: connector.id }, + ); + } + + attempt.providerRef = providerRef; + payment.providerRef = providerRef; + applyStatus(payment, attempt, { ...status, providerRef }); await save(payment); return payment; } @@ -354,6 +445,7 @@ export function createPayenv(options: PayenvOptions): Payenv { collect, getPayment: (idempotencyKey) => store.get(idempotencyKey), refresh, + confirm, }; } diff --git a/packages/core/src/payment.ts b/packages/core/src/payment.ts index ab89023..043c52c 100644 --- a/packages/core/src/payment.ts +++ b/packages/core/src/payment.ts @@ -22,6 +22,8 @@ export interface Attempt { status: PaymentStatus; outcome?: AttemptOutcome; providerRef?: string; + /** Merchant reference sent to the provider, when the connector reports one. */ + merchantReference?: string; error?: SerializedError; startedAt: string; endedAt?: string; diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts index e37aaa0..356e568 100644 --- a/packages/core/src/router.ts +++ b/packages/core/src/router.ts @@ -27,7 +27,9 @@ export function supports( (capability.countries === undefined || (country !== undefined && capability.countries.includes(country))) && (capability.networks === undefined || - (network !== undefined && capability.networks.includes(network))), + (network !== undefined && capability.networks.includes(network))) && + (capability.widget === undefined || + (request.supportedWidgets?.includes(capability.widget) ?? false)), ); } diff --git a/packages/core/test/widget.test.ts b/packages/core/test/widget.test.ts new file mode 100644 index 0000000..ca8ce59 --- /dev/null +++ b/packages/core/test/widget.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { + type Capability, + createPayenv, + PayenvError, + type ProviderResult, + type StatusResult, +} from '../src/index.js'; +import { fakeConnector, MTN_BJ_XOF, mtnRequest, pending } from './helpers.js'; + +const fast = { statusCheckDelaysMs: [0], attemptTimeoutMs: 50 }; +const WIDGET_ROUTE: Capability = { ...MTN_BJ_XOF, widget: 'acme' }; + +/** A connector whose payments start in the "acme" widget, like Kkiapay. */ +function widgetConnector( + transaction: (providerRef: string) => StatusResult = () => ({ found: false }), +) { + return fakeConnector( + 'acme', + async (request): Promise => ({ + status: 'requires_action', + merchantReference: request.idempotencyKey, + nextAction: { type: 'widget', provider: 'acme', params: { amount: request.amount.value } }, + }), + async (query) => transaction(query.providerRef ?? ''), + [WIDGET_ROUTE], + ); +} + +/** What the provider reports for a genuine transaction of this payment. */ +const genuine = + (key: string, value = 5000) => + (providerRef: string): StatusResult => ({ + found: true, + status: 'succeeded', + providerRef, + merchantReference: key, + amount: { value, currency: 'XOF' }, + }); + +describe('widget routes', () => { + it('are not used unless the front end supports the widget', async () => { + const payenv = createPayenv({ connectors: [widgetConnector()], ...fast }); + await expect(payenv.collect(mtnRequest())).rejects.toMatchObject({ code: 'NO_ROUTE' }); + }); + + it('return a widget action when the front end supports it', async () => { + const acme = widgetConnector(); + const payenv = createPayenv({ connectors: [acme], ...fast }); + const request = mtnRequest({ supportedWidgets: ['acme'] }); + + const payment = await payenv.collect(request); + + expect(payment.status).toBe('requires_action'); + expect(payment.nextAction).toEqual({ + type: 'widget', + provider: 'acme', + params: { amount: 5000 }, + }); + expect(payment.attempts[0]?.merchantReference).toBe(request.idempotencyKey); + // Nothing to look up until the customer completes the widget. + expect((await payenv.refresh(request.idempotencyKey)).status).toBe('requires_action'); + expect(acme.statusCalls).toHaveLength(0); + }); + + it('are a fallback when a server-push provider fails safely', async () => { + const down = fakeConnector('push', async () => { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'down'); + }); + const payenv = createPayenv({ connectors: [down, widgetConnector()], ...fast }); + + const payment = await payenv.collect(mtnRequest({ supportedWidgets: ['acme'] })); + + expect(payment.connectorId).toBe('acme'); + expect(payment.status).toBe('requires_action'); + expect(payment.attempts.map((attempt) => attempt.outcome)).toEqual(['fell_back', 'accepted']); + }); + + it('come after server-push providers when both work', async () => { + const push = fakeConnector('push', pending); + const payenv = createPayenv({ connectors: [push, widgetConnector()], ...fast }); + const payment = await payenv.collect(mtnRequest({ supportedWidgets: ['acme'] })); + expect(payment.connectorId).toBe('push'); + }); +}); + +describe('confirm', () => { + async function started(transaction?: (providerRef: string) => StatusResult) { + const request = mtnRequest({ supportedWidgets: ['acme'] }); + const payenv = createPayenv({ + connectors: [widgetConnector(transaction ?? genuine(request.idempotencyKey))], + ...fast, + }); + await payenv.collect(request); + return { payenv, key: request.idempotencyKey }; + } + + it('links the provider transaction and updates the status', async () => { + const { payenv, key } = await started(); + + const payment = await payenv.confirm(key, 'tx_1'); + + expect(payment.status).toBe('succeeded'); + expect(payment.providerRef).toBe('tx_1'); + expect(payment.attempts[0]).toMatchObject({ providerRef: 'tx_1', outcome: 'accepted' }); + expect(payment.nextAction).toBeUndefined(); + }); + + it('is idempotent with the same transaction', async () => { + const { payenv, key } = await started(); + await payenv.confirm(key, 'tx_1'); + expect((await payenv.confirm(key, 'tx_1')).status).toBe('succeeded'); + }); + + it('rejects a transaction of another payment (fraud attempt)', async () => { + const { payenv, key } = await started(genuine('someone_elses_order')); + + await expect(payenv.confirm(key, 'tx_other')).rejects.toMatchObject({ + code: 'REFERENCE_MISMATCH', + }); + const payment = await payenv.getPayment(key); + expect(payment?.status).toBe('requires_action'); + expect(payment?.providerRef).toBeUndefined(); + }); + + it('rejects a transaction for a smaller amount (fraud attempt)', async () => { + const request = mtnRequest({ supportedWidgets: ['acme'] }); + const payenv = createPayenv({ + connectors: [widgetConnector(genuine(request.idempotencyKey, 100))], + ...fast, + }); + await payenv.collect(request); + + await expect(payenv.confirm(request.idempotencyKey, 'tx_cheap')).rejects.toMatchObject({ + code: 'REFERENCE_MISMATCH', + }); + expect((await payenv.getPayment(request.idempotencyKey))?.status).toBe('requires_action'); + }); + + it('rejects a transaction the provider does not know', async () => { + const { payenv, key } = await started(() => ({ found: false })); + await expect(payenv.confirm(key, 'tx_bogus')).rejects.toMatchObject({ + code: 'PAYMENT_NOT_FOUND', + }); + expect((await payenv.getPayment(key))?.status).toBe('requires_action'); + }); + + it('rejects a second, different transaction once linked', async () => { + const { payenv, key } = await started(); + await payenv.confirm(key, 'tx_1'); + await expect(payenv.confirm(key, 'tx_2')).rejects.toMatchObject({ + code: 'REFERENCE_MISMATCH', + }); + }); + + it('refuses payments whose connector attached no merchant reference', async () => { + const payenv = createPayenv({ + connectors: [fakeConnector('push', async () => ({ status: 'requires_action' }))], + ...fast, + }); + const request = mtnRequest(); + await payenv.collect(request); + await expect(payenv.confirm(request.idempotencyKey, 'tx_1')).rejects.toMatchObject({ + code: 'INVALID_REQUEST', + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35eda1d..e131b6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@payenv/connector-fedapay': specifier: workspace:* version: link:../../packages/connector-fedapay + '@payenv/connector-kkiapay': + specifier: workspace:* + version: link:../../packages/connector-kkiapay '@payenv/core': specifier: workspace:* version: link:../../packages/core @@ -43,6 +46,15 @@ importers: specifier: ^26.6.3 version: 26.6.3 + packages/connector-kkiapay: + devDependencies: + '@payenv/core': + specifier: workspace:^ + version: link:../core + '@types/node': + specifier: ^26.6.3 + version: 26.6.3 + packages/core: {} packages: