From 7198338025f74c686ff75a69fcf4c46a33dd1434 Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:40:37 +0100 Subject: [PATCH 1/2] feat(core): support payments started by the customer in a widget Some providers (Kkiapay, and most checkout-style aggregators) cannot start a payment from the server: the customer starts it in the provider's widget, and the server verifies it afterwards. - NextAction `widget` ({ provider, params }) tells the front end which widget to open - Capability `widget` + CollectRequest `supportedWidgets`: widget routes are only used when the front end can open them; they can serve as a fallback after a server-push provider failed safely - ProviderResult `merchantReference` and `amount`, recorded on attempts - payenv.confirm(key, providerRef) links the provider transaction after checking with the provider that it carries this payment's merchant reference and amount, so a customer cannot confirm an order with another (e.g. cheaper) transaction; rejected confirmations leave the payment unchanged - refresh leaves a widget payment untouched until it is confirmed - new error code REFERENCE_MISMATCH Anti-fraud tests were checked against removing each verification. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- packages/core/src/connector.ts | 25 ++++- packages/core/src/errors.ts | 2 + packages/core/src/payenv.ts | 156 ++++++++++++++++++++++------ packages/core/src/payment.ts | 2 + packages/core/src/router.ts | 4 +- packages/core/test/widget.test.ts | 167 ++++++++++++++++++++++++++++++ 6 files changed, 322 insertions(+), 34 deletions(-) create mode 100644 packages/core/test/widget.test.ts 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', + }); + }); +}); From a80b939ab449edf9a73e47f11534d75c15d1c93e Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:51:43 +0100 Subject: [PATCH 2/2] feat(connector-kkiapay): add the Kkiapay connector and demo fallback Kkiapay payments start in its widget; the official server SDK can only verify (`/api/v1/transactions/status`) and refund. The connector: - returns a `widget` next action with the widget parameters (public key only; partnerId = the payment's idempotency key), only when the front end lists `kkiapay` in supportedWidgets - verifies transactions server-side (status, amount, partnerId, failure codes) for payenv.confirm - verifies (constant-time shared secret) and parses webhooks - opt-in sandbox tests driven by .env demo: loads the Kkiapay widget, "Simuler une panne FedaPay" makes FedaPay fail before any money can move so Payenv falls back to the widget, and the widget's transaction id is confirmed server-side. Verified with the server: fallback, refusal without widget support, rejected confirmation leaves the payment unchanged, normal path stays on FedaPay. docs: ADR 0005, architecture, getting started, connectors, quirks, and a changeset (minor) adding the connector to the fixed version group. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- .changeset/config.json | 7 +- .changeset/kkiapay-widget-flow.md | 14 + .env.example | 5 + CHANGELOG.md | 8 + CONTRIBUTING.md | 1 + README.md | 2 +- docs/ARCHITECTURE.md | 14 +- docs/CONNECTORS.md | 2 +- docs/GETTING_STARTED.md | 15 + docs/adr/0005-customer-started-payments.md | 39 +++ docs/adr/README.md | 1 + examples/fedapay-demo/README.md | 14 + examples/fedapay-demo/package.json | 3 +- examples/fedapay-demo/server.ts | 130 ++++++++- examples/fedapay-demo/tsconfig.json | 3 +- packages/connector-kkiapay/LICENSE | 202 ++++++++++++++ packages/connector-kkiapay/NOTICE | 11 + packages/connector-kkiapay/QUIRKS.md | 33 +++ packages/connector-kkiapay/README.md | 101 +++++++ packages/connector-kkiapay/package.json | 55 ++++ packages/connector-kkiapay/src/index.ts | 13 + packages/connector-kkiapay/src/kkiapay.ts | 232 ++++++++++++++++ packages/connector-kkiapay/src/webhook.ts | 75 +++++ .../connector-kkiapay/test/kkiapay.test.ts | 257 ++++++++++++++++++ .../connector-kkiapay/test/sandbox.test.ts | 38 +++ .../connector-kkiapay/tsconfig.build.json | 8 + packages/connector-kkiapay/tsconfig.json | 10 + pnpm-lock.yaml | 12 + 28 files changed, 1291 insertions(+), 14 deletions(-) create mode 100644 .changeset/kkiapay-widget-flow.md create mode 100644 docs/adr/0005-customer-started-payments.md create mode 100644 packages/connector-kkiapay/LICENSE create mode 100644 packages/connector-kkiapay/NOTICE create mode 100644 packages/connector-kkiapay/QUIRKS.md create mode 100644 packages/connector-kkiapay/README.md create mode 100644 packages/connector-kkiapay/package.json create mode 100644 packages/connector-kkiapay/src/index.ts create mode 100644 packages/connector-kkiapay/src/kkiapay.ts create mode 100644 packages/connector-kkiapay/src/webhook.ts create mode 100644 packages/connector-kkiapay/test/kkiapay.test.ts create mode 100644 packages/connector-kkiapay/test/sandbox.test.ts create mode 100644 packages/connector-kkiapay/tsconfig.build.json create mode 100644 packages/connector-kkiapay/tsconfig.json 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/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: