From aa8bfd29d28ff2becdbf93dc20f563f238461350 Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:50:40 +0100 Subject: [PATCH 1/2] feat(core): add payouts with the same guarantees as collections payenv.payout({ amount, recipient, idempotencyKey, ... }) sends money from the merchant's provider balance to a recipient. It runs through the same engine as collections: idempotency (atomic, concurrent-safe), and fallback to another connector only when it is proven that no money was sent. A double payout is money lost, so the double-charge invariant applies unchanged. - Operation 'payout', PayoutRequest, optional Connector.payout; only connectors implementing it are used for payouts - status lookups and refresh use the payment's operation - new code INSUFFICIENT_BALANCE (the merchant's balance at a provider): safe to fall back, another provider's balance may suffice - an idempotency key cannot be reused across collect and payout - confirm is reserved to widget collections 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 | 22 ++++- packages/core/src/errors.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/payenv.ts | 114 +++++++++++++++++----- packages/core/test/payout.test.ts | 154 ++++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 27 deletions(-) create mode 100644 packages/core/test/payout.test.ts diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts index 7de9d7d..de8a54d 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -1,6 +1,7 @@ import type { PayenvError } from './errors.js'; import type { CountryCode, + MobileMoneyMethod, MobileMoneyNetwork, PaymentMethod, PaymentMethodType, @@ -8,7 +9,7 @@ import type { import type { CurrencyCode, Money } from './money.js'; import type { PaymentStatus } from './status.js'; -export type Operation = 'collect'; +export type Operation = 'collect' | 'payout'; export interface Customer { firstName?: string; @@ -36,6 +37,19 @@ export interface CollectRequest { supportedWidgets?: readonly string[]; } +/** Sends money from the merchant's provider balance to a recipient (disbursement). */ +export interface PayoutRequest { + amount: Money; + /** Where the money goes. */ + recipient: MobileMoneyMethod; + /** The recipient's identity (some providers require a name and an email). */ + customer?: Customer; + description?: string; + /** Same guarantees as for collections: the same key never sends money twice. */ + idempotencyKey: string; + metadata?: Readonly>; +} + /** What a connector declares it can serve. Omitted lists mean "any". */ export interface Capability { operation: Operation; @@ -129,6 +143,12 @@ export interface Connector { readonly id: string; capabilities(): readonly Capability[]; collect(request: CollectRequest, context: AttemptContext): Promise; + /** + * Sends a payout. Optional: connectors without it are never used for payouts. It must + * follow the same rules as `collect`: report the provider id before the call that moves + * money, and return `unknown` (not throw) when that call's outcome is uncertain. + */ + payout?(request: PayoutRequest, context: AttemptContext): Promise; getStatus(query: StatusQuery, context: StatusContext): Promise; /** * Maps an error thrown by `collect` / `getStatus` to a {@link PayenvError}. diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 710187a..20ce2e8 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -16,6 +16,7 @@ export type PayenvErrorCode = | 'RATE_LIMITED' | 'ROUTE_UNSUPPORTED' | 'AUTHENTICATION_FAILED' + | 'INSUFFICIENT_BALANCE' // do_not_retry | 'INSUFFICIENT_FUNDS' | 'CUSTOMER_DECLINED' @@ -38,6 +39,8 @@ export const DEFAULT_RETRY_CLASS: Readonly> RATE_LIMITED: 'safe_to_fallback', ROUTE_UNSUPPORTED: 'safe_to_fallback', AUTHENTICATION_FAILED: 'safe_to_fallback', + // The merchant's balance at this provider: another provider's balance may suffice. + INSUFFICIENT_BALANCE: 'safe_to_fallback', INSUFFICIENT_FUNDS: 'do_not_retry', CUSTOMER_DECLINED: 'do_not_retry', CUSTOMER_TIMEOUT: 'do_not_retry', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 34afc65..d798876 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,7 @@ export type { Customer, NextAction, Operation, + PayoutRequest, ProviderResult, StatusContext, StatusQuery, diff --git a/packages/core/src/payenv.ts b/packages/core/src/payenv.ts index f1a6152..e35a659 100644 --- a/packages/core/src/payenv.ts +++ b/packages/core/src/payenv.ts @@ -1,6 +1,8 @@ import type { CollectRequest, Connector, + Operation, + PayoutRequest, ProviderResult, StatusQuery, StatusResult, @@ -41,6 +43,11 @@ export interface PayenvOptions { export interface Payenv { /** Collects a payment, falling back between connectors only when it is proven safe. */ collect(request: CollectRequest): Promise; + /** + * Sends money to a recipient, with the same guarantees as `collect`: idempotency, and + * fallback to another connector only when it is proven that no money was sent. + */ + payout(request: PayoutRequest): Promise; getPayment(idempotencyKey: string): Promise; /** Asks the provider for the latest status of a non-terminal payment. */ refresh(idempotencyKey: string): Promise; @@ -134,30 +141,31 @@ export function createPayenv(options: PayenvOptions): Payenv { async function runAttempt( connector: Connector, - request: CollectRequest, + request: CollectRequest | PayoutRequest, payment: Payment, attempt: Attempt, ): Promise { const controller = new AbortController(); - const query: StatusQuery = { operation: 'collect', reference: attempt.reference }; + const query: StatusQuery = { operation: payment.operation, reference: attempt.reference }; const recordProviderRef = (providerRef: string) => { attempt.providerRef = providerRef; query.providerRef = providerRef; }; let classification: Classification; try { - const result = await withTimeout( - connector.collect(request, { - paymentId: payment.id, - attemptId: attempt.id, - reference: attempt.reference, - idempotencyKey: attempt.id, - signal: controller.signal, - reportProviderRef: recordProviderRef, - }), - attemptTimeoutMs, - controller, - ); + const context = { + paymentId: payment.id, + attemptId: attempt.id, + reference: attempt.reference, + idempotencyKey: attempt.id, + signal: controller.signal, + reportProviderRef: recordProviderRef, + }; + const call = + payment.operation === 'payout' + ? callPayout(connector, request as PayoutRequest, context) + : connector.collect(request as CollectRequest, context); + const result = await withTimeout(call, attemptTimeoutMs, controller); if (result.providerRef !== undefined) recordProviderRef(result.providerRef); classification = classify(result, connector); } catch (thrown) { @@ -215,19 +223,32 @@ export function createPayenv(options: PayenvOptions): Payenv { } } - async function collect(request: CollectRequest): Promise { + /** + * Runs a collection or a payout. `route` is the request as the router sees it (for + * payouts, `method` is the recipient); `original` is what the connector receives. + */ + async function start( + operation: Operation, + route: CollectRequest, + original: CollectRequest | PayoutRequest, + ): Promise { + const request = route; validate(request); - const fingerprint = fingerprintOf(request); + const fingerprint = fingerprintOf(operation, request); const existing = await store.get(request.idempotencyKey); if (existing) return assertSameRequest(existing, fingerprint); - const eligible = connectors.filter((connector) => supports(connector, 'collect', request)); + const eligible = connectors.filter( + (connector) => + supports(connector, operation, request) && + (operation === 'collect' || typeof connector.payout === 'function'), + ); const candidates = eligible.length === 0 ? [] : await routing(eligible, request); if (candidates.length === 0) { throw new PayenvError( 'NO_ROUTE', - `No connector supports ${describeRoute(request)}. Check the connectors' capabilities.`, + `No connector supports ${operation} ${describeRoute(request)}. Check the connectors' capabilities.`, ); } @@ -235,7 +256,7 @@ export function createPayenv(options: PayenvOptions): Payenv { const payment: Payment = { id: `pay_${crypto.randomUUID()}`, idempotencyKey: request.idempotencyKey, - operation: 'collect', + operation, status: 'created', amount: request.amount, method: request.method, @@ -270,7 +291,7 @@ export function createPayenv(options: PayenvOptions): Payenv { await save(payment); emit({ type: 'attempt.started', payment, attempt }); - const decision = await runAttempt(connector, request, payment, attempt); + const decision = await runAttempt(connector, original, payment, attempt); apply(payment, attempt, decision); await save(payment); emit({ type: 'attempt.finished', payment, attempt }); @@ -314,8 +335,13 @@ export function createPayenv(options: PayenvOptions): Payenv { return { payment, attempt, connector }; } - async function lookup(connector: Connector, attempt: Attempt, providerRef?: string) { - const query: StatusQuery = { operation: 'collect', reference: attempt.reference }; + async function lookup( + connector: Connector, + payment: Payment, + attempt: Attempt, + providerRef?: string, + ) { + const query: StatusQuery = { operation: payment.operation, reference: attempt.reference }; const ref = providerRef ?? attempt.providerRef; if (ref !== undefined) query.providerRef = ref; try { @@ -361,7 +387,7 @@ export function createPayenv(options: PayenvOptions): Payenv { // 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); + const status = await lookup(connector, payment, attempt); if (!status.found) { const error = new PayenvError( 'PAYMENT_NOT_FOUND', @@ -388,6 +414,12 @@ export function createPayenv(options: PayenvOptions): Payenv { if (!attempt || !connector) { throw new PayenvError('INVALID_REQUEST', 'This payment has no attempt to confirm'); } + if (payment.operation !== 'collect') { + throw new PayenvError( + 'INVALID_REQUEST', + 'Only collections started in a widget can be confirmed', + ); + } if (attempt.providerRef !== undefined) { // Confirming twice with the same transaction is harmless; another one is not. if (attempt.providerRef === providerRef) return refresh(idempotencyKey); @@ -404,7 +436,7 @@ export function createPayenv(options: PayenvOptions): Payenv { ); } - const status = await lookup(connector, attempt, providerRef); + const status = await lookup(connector, payment, attempt, providerRef); if (!status.found) { throw new PayenvError( 'PAYMENT_NOT_FOUND', @@ -441,8 +473,25 @@ export function createPayenv(options: PayenvOptions): Payenv { return payment; } + const collect = (request: CollectRequest) => start('collect', request, request); + + const payout = (request: PayoutRequest) => + start( + 'payout', + { + amount: request.amount, + method: request.recipient, + idempotencyKey: request.idempotencyKey, + ...(request.customer ? { customer: request.customer } : {}), + ...(request.description ? { description: request.description } : {}), + ...(request.metadata ? { metadata: request.metadata } : {}), + }, + request, + ); + return { collect, + payout, getPayment: (idempotencyKey) => store.get(idempotencyKey), refresh, confirm, @@ -533,7 +582,20 @@ function validate(request: CollectRequest): void { } } -function fingerprintOf(request: CollectRequest): string { +function callPayout( + connector: Connector, + request: PayoutRequest, + context: Parameters>[1], +): Promise { + if (!connector.payout) { + return Promise.reject( + new PayenvError('ROUTE_UNSUPPORTED', `Connector "${connector.id}" does not support payouts`), + ); + } + return connector.payout(request, context); +} + +function fingerprintOf(operation: Operation, request: CollectRequest): string { const { amount, method } = request; const target = method.type === 'mobile_money' @@ -541,7 +603,7 @@ function fingerprintOf(request: CollectRequest): string { : method.type === 'card' ? [method.token] : [method.returnUrl]; - return JSON.stringify([amount.value, amount.currency, method.type, ...target]); + return JSON.stringify([operation, amount.value, amount.currency, method.type, ...target]); } function assertSameRequest(payment: Payment, fingerprint: string): Payment { diff --git a/packages/core/test/payout.test.ts b/packages/core/test/payout.test.ts new file mode 100644 index 0000000..d5a6077 --- /dev/null +++ b/packages/core/test/payout.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { + type AttemptContext, + type Capability, + type Connector, + createPayenv, + PayenvError, + type PayoutRequest, + type ProviderResult, + type StatusQuery, + type StatusResult, +} from '../src/index.js'; +import { MTN_BJ_XOF, mtnRequest, pending } from './helpers.js'; + +const fast = { statusCheckDelaysMs: [0], attemptTimeoutMs: 50 }; +const PAYOUT_ROUTE: Capability = { ...MTN_BJ_XOF, operation: 'payout' }; + +interface PayoutConnector extends Connector { + payoutCalls: AttemptContext[]; + statusQueries: StatusQuery[]; +} + +function payoutConnector( + id: string, + payout: (context: AttemptContext) => Promise, + getStatus: (query: StatusQuery) => Promise = async () => ({ found: false }), +): PayoutConnector { + const connector: PayoutConnector = { + id, + payoutCalls: [], + statusQueries: [], + capabilities: () => [MTN_BJ_XOF, PAYOUT_ROUTE], + collect: pending, + async payout(_request, context) { + connector.payoutCalls.push(context); + return payout(context); + }, + async getStatus(query) { + connector.statusQueries.push(query); + return getStatus(query); + }, + }; + return connector; +} + +let counter = 0; +function payoutRequest(overrides: Partial = {}): PayoutRequest { + counter += 1; + return { + amount: { value: 2000, currency: 'XOF' }, + recipient: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22990000000' }, + customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' }, + idempotencyKey: `payout_${counter}`, + ...overrides, + }; +} + +const sent = async (): Promise => ({ status: 'pending', providerRef: 'po_1' }); + +describe('payouts', () => { + it('are sent through the connector payout method', async () => { + const a = payoutConnector('a', sent); + const payment = await createPayenv({ connectors: [a], ...fast }).payout(payoutRequest()); + + expect(payment).toMatchObject({ operation: 'payout', status: 'pending', providerRef: 'po_1' }); + expect(payment.method).toMatchObject({ type: 'mobile_money', phone: '+22990000000' }); + expect(a.payoutCalls).toHaveLength(1); + }); + + it('never use connectors that cannot send payouts', async () => { + const collectOnly: Connector = { + id: 'collect-only', + // Even if it wrongly declares a payout route, it has no payout method. + capabilities: () => [MTN_BJ_XOF, PAYOUT_ROUTE], + collect: pending, + getStatus: async () => ({ found: false }), + }; + const payenv = createPayenv({ connectors: [collectOnly], ...fast }); + await expect(payenv.payout(payoutRequest())).rejects.toMatchObject({ code: 'NO_ROUTE' }); + }); + + it('fall back when the merchant balance at the first provider is insufficient', async () => { + const empty = payoutConnector('empty', async () => { + throw new PayenvError('INSUFFICIENT_BALANCE', 'Solde insuffisant'); + }); + const funded = payoutConnector('funded', sent); + const payment = await createPayenv({ connectors: [empty, funded], ...fast }).payout( + payoutRequest(), + ); + + expect(payment.connectorId).toBe('funded'); + expect(payment.attempts.map((attempt) => attempt.outcome)).toEqual(['fell_back', 'accepted']); + }); + + it('never send twice when the provider does not answer (double-payout protection)', async () => { + const slow = payoutConnector( + 'slow', + (context) => { + context.reportProviderRef('po_42'); + return new Promise(() => {}); // the "send" call never answers + }, + async (query) => ({ found: true, status: 'pending', providerRef: query.providerRef }), + ); + const other = payoutConnector('other', sent); + const payment = await createPayenv({ connectors: [slow, other], ...fast }).payout( + payoutRequest(), + ); + + expect(payment.status).toBe('pending'); + expect(payment.connectorId).toBe('slow'); + expect(slow.statusQueries[0]).toMatchObject({ operation: 'payout', providerRef: 'po_42' }); + expect(other.payoutCalls).toHaveLength(0); + }); + + it('are idempotent', async () => { + const a = payoutConnector('a', sent); + const payenv = createPayenv({ connectors: [a], ...fast }); + const request = payoutRequest(); + + const [first, second] = await Promise.all([payenv.payout(request), payenv.payout(request)]); + + expect(second.id).toBe(first.id); + expect(a.payoutCalls).toHaveLength(1); + }); + + it('cannot reuse the key of a collection', async () => { + const payenv = createPayenv({ connectors: [payoutConnector('a', sent)], ...fast }); + const collected = mtnRequest({ amount: { value: 2000, currency: 'XOF' } }); + await payenv.collect(collected); + + await expect( + payenv.payout(payoutRequest({ idempotencyKey: collected.idempotencyKey })), + ).rejects.toMatchObject({ code: 'IDEMPOTENCY_CONFLICT' }); + }); + + it('are refreshed with a payout status lookup', async () => { + const a = payoutConnector('a', sent, async () => ({ found: true, status: 'succeeded' })); + const payenv = createPayenv({ connectors: [a], ...fast }); + const request = payoutRequest(); + await payenv.payout(request); + + expect((await payenv.refresh(request.idempotencyKey)).status).toBe('succeeded'); + expect(a.statusQueries.at(-1)?.operation).toBe('payout'); + }); + + it('cannot be confirmed like widget collections', async () => { + const payenv = createPayenv({ connectors: [payoutConnector('a', sent)], ...fast }); + const request = payoutRequest(); + await payenv.payout(request); + await expect(payenv.confirm(request.idempotencyKey, 'po_x')).rejects.toBeInstanceOf( + PayenvError, + ); + }); +}); From 4daec160a3d97f23566b8cc2cbab1739237e833c Mon Sep 17 00:00:00 2001 From: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:11:24 +0100 Subject: [PATCH 2/2] feat(connector-fedapay): add payouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses the flow verified on live payouts: POST /payouts (payout modes mtn, moov, sbin — not the collection slugs), then PUT /payouts/start. Money can only leave the balance at `start`: - errors before it are safe to fall back; "Solde insuffisant" on start maps to INSUFFICIENT_BALANCE (safe: another provider's balance may do) - a timeout or 5xx on start returns `unknown` with the payout id, so Payenv checks GET /payouts/:id and never sends it again elsewhere - payout statuses mapped (sent → succeeded; pending/started/... → pending; failed/declined; canceled), never guessed as failed "Opération non autorisée" with HTTP 403 (the real sandbox answer when payouts are not enabled) now maps to ROUTE_UNSUPPORTED instead of AUTHENTICATION_FAILED; both are safe to fall back. Kkiapay has no API to send money to customers (its "payouts" are the merchant's own scheduled withdrawals): documented, not implemented. docs: READMEs, quirks, getting started, connectors, roadmap, changeset. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com> --- .changeset/payouts.md | 12 + CHANGELOG.md | 4 + docs/CONNECTORS.md | 4 +- docs/GETTING_STARTED.md | 19 +- docs/ROADMAP.md | 3 +- packages/connector-fedapay/QUIRKS.md | 16 ++ packages/connector-fedapay/README.md | 24 +- packages/connector-fedapay/src/fedapay.ts | 216 +++++++++++++++--- packages/connector-fedapay/src/index.ts | 2 + .../connector-fedapay/test/fedapay.test.ts | 155 ++++++++++++- packages/connector-kkiapay/QUIRKS.md | 9 + 11 files changed, 421 insertions(+), 43 deletions(-) create mode 100644 .changeset/payouts.md diff --git a/.changeset/payouts.md b/.changeset/payouts.md new file mode 100644 index 0000000..c8ea80d --- /dev/null +++ b/.changeset/payouts.md @@ -0,0 +1,12 @@ +--- +'@payenv/core': minor +'@payenv/connector-fedapay': minor +--- + +Payouts. + +- `@payenv/core`: `payenv.payout({ amount, recipient, idempotencyKey })` sends money with the + same guarantees as collections (idempotency, fallback only when it is proven that no money + was sent). New error code `INSUFFICIENT_BALANCE`. +- `@payenv/connector-fedapay`: payouts in Benin (MTN, Moov, Celtiis) and payout status + lookups. "Opération non autorisée" with HTTP 403 now maps to `ROUTE_UNSUPPORTED`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d557ad7..95ffea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Before `1.0.0`, minor versions may contain breaking changes. ## [Unreleased] ### Added +- 2026-10-06 — `@payenv/core`: payouts (`payenv.payout`) with the same guarantees as + collections; new error code `INSUFFICIENT_BALANCE` (safe to fall back). +- 2026-10-06 — `@payenv/connector-fedapay`: payouts (MTN, Moov, Celtiis) and payout status + lookups. Kkiapay has no payout API (documented in its QUIRKS). - 2026-10-06 — `@payenv/core`: `toE164(phone, country)` converts numbers typed by customers (e.g. `61 00 00 00` in Benin) to E.164, with per-country trunk-prefix rules. - 2026-10-05 — `@payenv/core`: payments started by the customer in a widget diff --git a/docs/CONNECTORS.md b/docs/CONNECTORS.md index 30e0ff3..ec94030 100644 --- a/docs/CONNECTORS.md +++ b/docs/CONNECTORS.md @@ -7,8 +7,8 @@ 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 | ✅ MTN, Moov (widget + server verification) | — | — | ✅ Secret header | 🟡 In progress | @jules-mahounou | +| FedaPay | Benin / West Africa | ✅ MTN, Moov, Celtiis | ✅ MTN, Moov, Celtiis | — | ✅ Signature | 🟡 In progress | @jules-mahounou | +| Kkiapay | West Africa | ✅ MTN, Moov (widget + server verification) | ❌ no API | — | ✅ 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 128af8b..3bb8e65 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -111,7 +111,22 @@ Some payments stay `pending` for a long time (for example when an operator does cancellation). See [Payments stuck in pending](../packages/connector-fedapay/README.md#payments-stuck-in-pending). -## 6. Add a second provider (fallback) +## 6. Send money (payouts) + +```ts +const payout = await payenv.payout({ + amount: { value: 2000, currency: 'XOF' }, + recipient: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22990000000' }, + customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' }, + idempotencyKey: 'withdrawal_42', +}); +``` + +Payouts have the same guarantees as collections: the same key never sends money twice, and +Payenv only falls back to another provider when it is proven that no money was sent (for +example, `INSUFFICIENT_BALANCE` on the first provider). Follow them with `refresh`. + +## 7. Add a second provider (fallback) Register several connectors: Payenv tries them in order and falls back **only when it is proven that no money moved** with the previous one. @@ -142,7 +157,7 @@ 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 +## 8. Before going live - [ ] Use `environment: 'live'` and a live key from a secret manager or environment variable. - [ ] **Use a persistent store.** The default in-memory store loses idempotency on restart and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1c43c14..1f8e528 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -28,7 +28,8 @@ ## Phase 2 — Real fallback (`v0.2.0`) - [ ] Second and third connectors (e.g. Kkiapay, CinetPay) -- [ ] Payouts and refunds in the unified API +- [x] Payouts in the unified API (FedaPay) +- [ ] Refunds in the unified API - [ ] `@payenv/store-postgres` - [ ] Circuit breaker - [ ] Normalized webhook handling with deduplication diff --git a/packages/connector-fedapay/QUIRKS.md b/packages/connector-fedapay/QUIRKS.md index 03f3701..e4f9c07 100644 --- a/packages/connector-fedapay/QUIRKS.md +++ b/packages/connector-fedapay/QUIRKS.md @@ -75,6 +75,22 @@ What differs from what you would expect when reading the FedaPay documentation. - Transactions have a `merchant_reference` field. Whether it can be set on creation and used for lookups is still to verify (see below). +## Payouts + +- ✅ **Verified on live payouts** (founder's production integration): create with + `POST /v1/payouts` (`mode`, `customer.phone_number`), then start with + `PUT /v1/payouts/start` and `{ "payouts": [{ "id": ... }] }`. Payout modes differ from + collection slugs: `mtn` (not `mtn_open`), `moov`, `sbin`. An empty merchant balance + answers "Solde insuffisant" → `INSUFFICIENT_BALANCE` (safe to fall back). +- ✅ **Sandbox (2026-10-06)**: with payouts not enabled on the account, `POST /v1/payouts` + answers HTTP **403** `{ "message": "Opération non autorisée" }` → `ROUTE_UNSUPPORTED`. +- Money can only leave the balance at the `start` call: errors before it are safe to fall + back; a timeout or 5xx on `start` is ambiguous and resolved with `GET /v1/payouts/:id`. +- 🔍 To verify once payouts are enabled in the sandbox: the sandbox payout mode (assumed + `momo_test`, like collections), the payout statuses (mapped: `sent` → succeeded; + `pending`, `scheduled`, `started`, `processing` → pending; `failed`, `declined` → + failed; `canceled`), and the `start` response shape. + ## 🔍 Still to verify - The full list of live transaction statuses. Mapped today: `pending`, `approved`, diff --git a/packages/connector-fedapay/README.md b/packages/connector-fedapay/README.md index f38ee40..0c77921 100644 --- a/packages/connector-fedapay/README.md +++ b/packages/connector-fedapay/README.md @@ -4,8 +4,8 @@ > 🚧 Pre-release (`0.x`). -**Supported today:** mobile money collections in Benin (XOF): MTN, Moov, Celtiis. -Status lookup, webhook signature verification and parsing. Payouts and refunds are coming. +**Supported today:** mobile money collections and **payouts** in Benin (XOF): MTN, Moov, +Celtiis. Status lookup, webhook signature verification and parsing. Refunds are coming. ```ts import { createPayenv } from '@payenv/core'; @@ -57,6 +57,26 @@ The FedaPay sandbox simulates every operator with a single `momo_test` mode. Use `+22964000001` or `+22966000001` for a successful payment; any other number simulates a failure. Your code stays the same: just switch `environment` to `'live'` in production. +## Payouts + +Send money from your FedaPay balance to a mobile money account. Payouts must be enabled +on your FedaPay account. + +```ts +const payout = await payenv.payout({ + amount: { value: 2000, currency: 'XOF' }, + recipient: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22990000000' }, + customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' }, + idempotencyKey: 'withdrawal_42', // the same key never sends money twice +}); +// payout.status === 'pending' → follow it with payenv.refresh('withdrawal_42') +``` + +If the FedaPay balance is insufficient (`INSUFFICIENT_BALANCE`) or payouts are not enabled +(`ROUTE_UNSUPPORTED`), Payenv falls back to another connector that supports payouts. If the +call that sends the money times out, Payenv checks the payout with FedaPay and never sends +it a second time elsewhere. + ## Error reasons When FedaPay records why a payment failed, it is mapped to a Payenv code and the original diff --git a/packages/connector-fedapay/src/fedapay.ts b/packages/connector-fedapay/src/fedapay.ts index 120a994..d5647a4 100644 --- a/packages/connector-fedapay/src/fedapay.ts +++ b/packages/connector-fedapay/src/fedapay.ts @@ -7,6 +7,7 @@ import { PayenvError, type PayenvErrorCode, type PaymentStatus, + type PayoutRequest, type ProviderResult, type StatusResult, } from '@payenv/core'; @@ -35,6 +36,19 @@ export const DEFAULT_SANDBOX_OPERATORS: FedaPayOperators = { BJ: { mtn: 'momo_test', moov: 'momo_test', celtiis: 'momo_test' }, }; +/** + * FedaPay payout modes (verified on live payouts in Benin). They differ from the + * collection slugs: MTN is `mtn` here, `mtn_open` for collections. + */ +export const DEFAULT_PAYOUT_OPERATORS: FedaPayOperators = { + BJ: { mtn: 'mtn', moov: 'moov', celtiis: 'sbin' }, +}; + +/** Sandbox payout modes, by analogy with collections (to verify: see QUIRKS.md). */ +export const DEFAULT_SANDBOX_PAYOUT_OPERATORS: FedaPayOperators = { + BJ: { mtn: 'momo_test', moov: 'momo_test', celtiis: 'momo_test' }, +}; + export interface FedaPayOptions { /** Secret API key (sandbox or live). Keep it in an environment variable. */ secretKey: string; @@ -46,6 +60,12 @@ export interface FedaPayOptions { * on your FedaPay merchant account (Dashboard → Payment methods). */ operators?: FedaPayOperators; + /** + * Payout modes to expose, per country. Defaults to {@link DEFAULT_PAYOUT_OPERATORS} in + * live mode and {@link DEFAULT_SANDBOX_PAYOUT_OPERATORS} in the sandbox. Pass `{}` to + * disable payouts. Payouts must be enabled on your FedaPay account. + */ + payoutOperators?: FedaPayOperators; /** Connector id used in routing and payments. Defaults to `fedapay`. */ id?: string; /** Custom `fetch`, e.g. for tests or proxies. Defaults to the global `fetch`. */ @@ -57,8 +77,13 @@ const BASE_URLS: Record = { live: 'https://api.fedapay.com/v1', }; -/** Steps of a collection. Money can only move once the `push` step is reached. */ -type Step = 'create' | 'token' | 'push' | 'status'; +/** + * Steps of a collection (create → token → push) and of a payout (payout → send). + * Money can only move once `push` or `send` is reached. + */ +type Step = 'create' | 'token' | 'push' | 'payout' | 'send' | 'status'; + +const BEFORE_MONEY: ReadonlySet = new Set(['create', 'token', 'payout']); class FedaPayHttpError extends Error { constructor( @@ -81,15 +106,20 @@ export function fedapay(options: FedaPayOptions): Connector { const baseUrl = BASE_URLS[environment]; const operators = options.operators ?? (environment === 'live' ? DEFAULT_OPERATORS : DEFAULT_SANDBOX_OPERATORS); + const payoutOperators = + options.payoutOperators ?? + (environment === 'live' ? DEFAULT_PAYOUT_OPERATORS : DEFAULT_SANDBOX_PAYOUT_OPERATORS); const doFetch = options.fetch ?? globalThis.fetch; - const capabilities: Capability[] = Object.entries(operators).map(([country, networks]) => ({ - operation: 'collect', - method: 'mobile_money', - currencies: ['XOF'], - countries: [country], - networks: Object.keys(networks), - })); + const routes = (operation: 'collect' | 'payout', table: FedaPayOperators): Capability[] => + Object.entries(table).map(([country, networks]) => ({ + operation, + method: 'mobile_money', + currencies: ['XOF'], + countries: [country], + networks: Object.keys(networks), + })); + const capabilities = [...routes('collect', operators), ...routes('payout', payoutOperators)]; async function call(step: Step, method: string, path: string, signal: AbortSignal, body?: Json) { const init: RequestInit = { @@ -116,8 +146,8 @@ export function fedapay(options: FedaPayOptions): Connector { function toError(step: Step, thrown: unknown): PayenvError { if (thrown instanceof PayenvError) return thrown; - // Before the push, no customer prompt exists: money cannot move. - const beforeMoney = step === 'create' || step === 'token'; + // Before the push (or the payout send), money cannot move. + const beforeMoney = BEFORE_MONEY.has(step); if (thrown instanceof FedaPayHttpError) { const code = httpCode(thrown, beforeMoney); @@ -219,6 +249,53 @@ export function fedapay(options: FedaPayOptions): Connector { } }, + async payout(request, context): Promise { + const { recipient } = request; + const mode = payoutOperators[recipient.country]?.[recipient.network]; + if (!mode) { + throw new PayenvError( + 'ROUTE_UNSUPPORTED', + `FedaPay payout mode not configured for ${recipient.network} / ${recipient.country}`, + { connectorId: id }, + ); + } + + // 1. Create the payout. Nothing is sent yet. + let payoutId: string; + try { + const data = await call( + 'payout', + 'POST', + '/payouts', + context.signal, + payoutBody(request, mode), + ); + const payout = unwrap(data, 'payout'); + if (payout.id === undefined || payout.id === null) { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'FedaPay returned no payout id', { + connectorId: id, + raw: data, + }); + } + payoutId = String(payout.id); + } catch (thrown) { + throw toError('payout', thrown); + } + context.reportProviderRef(payoutId); + + // 2. Start it. From here on, money may leave the merchant's balance. + try { + const data = await call('send', 'PUT', '/payouts/start', context.signal, { + payouts: [{ id: payoutId }], + }); + return { status: 'pending', providerRef: payoutId, raw: data }; + } catch (thrown) { + const error = toError('send', thrown); + if (error.retryClass !== 'ambiguous') throw error; + return { status: 'unknown', providerRef: payoutId, error, raw: error.raw }; + } + }, + async getStatus(query, context): Promise { if (query.providerRef === undefined) { // Without the transaction id we cannot prove anything: stay ambiguous. @@ -226,14 +303,17 @@ export function fedapay(options: FedaPayOptions): Connector { connectorId: id, }); } + const resource = query.operation === 'payout' ? 'payout' : 'transaction'; let data: Json; try { - data = await call('status', 'GET', `/transactions/${query.providerRef}`, context.signal); + data = await call('status', 'GET', `/${resource}s/${query.providerRef}`, context.signal); } catch (thrown) { throw toError('status', thrown); } - const transaction = unwrap(data, 'transaction'); - const { status, error } = transactionStatus(transaction, id); + const { status, error } = + resource === 'payout' + ? payoutStatus(unwrap(data, 'payout'), id) + : transactionStatus(unwrap(data, 'transaction'), id); return { found: true, status, @@ -245,6 +325,24 @@ export function fedapay(options: FedaPayOptions): Connector { }; } +function payoutBody(request: PayoutRequest, mode: string): Json { + const { customer, recipient, amount } = request; + const fedapayCustomer: Json = { + // The money is sent to this phone number. + phone_number: { number: recipient.phone, country: recipient.country }, + }; + if (customer?.firstName) fedapayCustomer.firstname = customer.firstName; + if (customer?.lastName) fedapayCustomer.lastname = customer.lastName; + if (customer?.email) fedapayCustomer.email = customer.email; + return { + amount: amount.value, + currency: { iso: amount.currency }, + mode, + customer: fedapayCustomer, + ...(request.description ? { description: request.description } : {}), + }; +} + function transactionBody(request: CollectRequest, reference: string): Json { const { customer, method, amount } = request; const body: Json = { @@ -303,24 +401,9 @@ export function transactionStatus( last_error_code: lastErrorCode, last_error_message: lastErrorMessage, } = transaction; - const providerCode = - typeof lastErrorCode === 'string' && lastErrorCode !== '' ? lastErrorCode : undefined; - - /** The failure reason: FedaPay's own code when it gives one, a sensible default otherwise. */ - const reason = (fallback: PayenvErrorCode, what: string) => { - const code = (providerCode ? FEDAPAY_ERROR_CODES[providerCode] : undefined) ?? fallback; - const detail = providerCode - ? ` — ${FEDAPAY_ERROR_MESSAGES[providerCode] ?? 'FedaPay error'} (${providerCode})` - : ''; - // The customer was involved: another provider would not change the outcome. - return new PayenvError(code, `FedaPay transaction ${what}${detail}`, { - connectorId, - retryClass: 'do_not_retry', - ...(providerCode ? { providerCode } : {}), - // Raw operator output (often a SOAP dump): kept for debugging, never serialized. - raw: { last_error_code: lastErrorCode, last_error_message: lastErrorMessage }, - }); - }; + const providerCode = errorCode(lastErrorCode); + const reason = (fallback: PayenvErrorCode, what: string) => + failure('transaction', what, fallback, lastErrorCode, lastErrorMessage, connectorId); switch (status) { case 'pending': @@ -346,6 +429,61 @@ export function transactionStatus( } } +/** Maps a FedaPay payout to a Payenv status. Statuses to verify: see QUIRKS.md. */ +export function payoutStatus( + payout: Json, + connectorId: string, +): { status: PaymentStatus; error?: PayenvError } { + const { status, last_error_code: lastErrorCode, last_error_message: lastErrorMessage } = payout; + const reason = (fallback: PayenvErrorCode, what: string) => + failure('payout', what, fallback, lastErrorCode, lastErrorMessage, connectorId); + switch (status) { + case 'sent': + return { status: 'succeeded' }; + // Created, scheduled or being processed: never guessed as failed. + case 'pending': + case 'scheduled': + case 'started': + case 'processing': + return { status: 'pending' }; + case 'failed': + case 'declined': + return { status: 'failed', error: reason('UNKNOWN_ERROR', String(status)) }; + case 'canceled': + return { status: 'canceled', error: reason('UNKNOWN_ERROR', 'canceled') }; + default: + return { status: 'unknown' }; + } +} + +function errorCode(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +/** A failure reason: FedaPay's own code when it gives one, a sensible default otherwise. */ +function failure( + resource: 'transaction' | 'payout', + what: string, + fallback: PayenvErrorCode, + lastErrorCode: unknown, + lastErrorMessage: unknown, + connectorId: string, +): PayenvError { + const providerCode = errorCode(lastErrorCode); + const code = (providerCode ? FEDAPAY_ERROR_CODES[providerCode] : undefined) ?? fallback; + const detail = providerCode + ? ` — ${FEDAPAY_ERROR_MESSAGES[providerCode] ?? 'FedaPay error'} (${providerCode})` + : ''; + // Reported after the fact: never a reason to try another provider automatically. + return new PayenvError(code, `FedaPay ${resource} ${what}${detail}`, { + connectorId, + retryClass: 'do_not_retry', + ...(providerCode ? { providerCode } : {}), + // Raw operator output (often a SOAP dump): kept for debugging, never serialized. + raw: { last_error_code: lastErrorCode, last_error_message: lastErrorMessage }, + }); +} + function httpCode( error: FedaPayHttpError, beforeMoney: boolean, @@ -353,15 +491,23 @@ function httpCode( const { status } = error; const message = (providerMessage(error.body) ?? '').toLowerCase(); + // "Opération non autorisée" (also sent with HTTP 403): the operator, or payouts, are not + // enabled on the merchant account. Another provider may serve it. + if (status < 500 && (message.includes('non autoris') || message.includes('not authorized'))) { + return { code: 'ROUTE_UNSUPPORTED' }; + } if (status === 401 || status === 403) return { code: 'AUTHENTICATION_FAILED' }; if (status === 429) return { code: 'RATE_LIMITED' }; if (status >= 500) { return beforeMoney ? { code: 'PROVIDER_UNAVAILABLE' } : { code: 'NETWORK_ERROR' }; } // 4xx: the request was rejected, nothing was processed. - if (message.includes('non autoris') || message.includes('not authorized')) { - // Operator not activated on the merchant account: another provider may serve it. - return { code: 'ROUTE_UNSUPPORTED' }; + if ( + (error.step === 'payout' || error.step === 'send') && + (message.includes('solde insuffisant') || message.includes('insufficient balance')) + ) { + // The merchant's FedaPay balance: another provider's balance may suffice. + return { code: 'INSUFFICIENT_BALANCE' }; } if (status === 404 && error.step === 'status') { // Not authoritative enough to claim "no money moved". diff --git a/packages/connector-fedapay/src/index.ts b/packages/connector-fedapay/src/index.ts index 73993ae..45f7431 100644 --- a/packages/connector-fedapay/src/index.ts +++ b/packages/connector-fedapay/src/index.ts @@ -1,6 +1,8 @@ export { DEFAULT_OPERATORS, + DEFAULT_PAYOUT_OPERATORS, DEFAULT_SANDBOX_OPERATORS, + DEFAULT_SANDBOX_PAYOUT_OPERATORS, type FedaPayEnvironment, type FedaPayOperators, type FedaPayOptions, diff --git a/packages/connector-fedapay/test/fedapay.test.ts b/packages/connector-fedapay/test/fedapay.test.ts index 9e086d8..dc06185 100644 --- a/packages/connector-fedapay/test/fedapay.test.ts +++ b/packages/connector-fedapay/test/fedapay.test.ts @@ -3,6 +3,7 @@ import { type CollectRequest, type Connector, createPayenv, + type PayoutRequest, type ProviderResult, } from '@payenv/core'; import { describe, expect, it } from 'vitest'; @@ -145,7 +146,11 @@ describe('fedapay connector — collect', () => { }); it('declares only configured operators as capabilities', () => { - const connector = fedapay({ secretKey: 'sk', operators: { BJ: { mtn: 'mtn_open' } } }); + const connector = fedapay({ + secretKey: 'sk', + operators: { BJ: { mtn: 'mtn_open' } }, + payoutOperators: {}, + }); expect(connector.capabilities()).toEqual([ { operation: 'collect', @@ -319,6 +324,154 @@ describe('fedapay connector — status', () => { }); }); +describe('fedapay connector — payouts', () => { + const payoutRoutes: Record = { + 'POST /payouts': () => ({ + status: 200, + json: { 'v1/payout': { id: 777, reference: 'po_ref', status: 'pending' } }, + }), + 'PUT /payouts/start': () => ({ status: 200, json: { message: 'ok' } }), + }; + const payoutRequest: PayoutRequest = { + amount: { value: 2000, currency: 'XOF' }, + recipient: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22990000000' }, + customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' }, + idempotencyKey: 'payout_1', + }; + const payout = (connector: ReturnType, ctx = context()) => { + if (!connector.payout) throw new Error('payout missing'); + return connector.payout(payoutRequest, ctx); + }; + + it('creates the payout with the payout mode, then starts it', async () => { + const server = fakeFedaPay(payoutRoutes); + const ctx = context(); + + const result = await payout( + fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }), + ctx, + ); + + expect(server.calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + 'POST https://api.fedapay.com/v1/payouts', + 'PUT https://api.fedapay.com/v1/payouts/start', + ]); + expect(server.calls[0]?.body).toEqual({ + amount: 2000, + currency: { iso: 'XOF' }, + mode: 'mtn', // not mtn_open: payout slugs differ from collection slugs + customer: { + phone_number: { number: '+22990000000', country: 'BJ' }, + firstname: 'Ada', + lastname: 'Lovelace', + email: 'ada@example.com', + }, + }); + expect(server.calls[1]?.body).toEqual({ payouts: [{ id: '777' }] }); + expect(result).toMatchObject({ status: 'pending', providerRef: '777' }); + expect(ctx.reported).toEqual(['777']); + }); + + it('uses momo_test in the sandbox', async () => { + const server = fakeFedaPay(payoutRoutes); + await payout(fedapay({ secretKey: 'sk', fetch: server.fetch })); + expect(server.calls[0]?.body).toMatchObject({ mode: 'momo_test' }); + }); + + it('payouts not enabled on the account → ROUTE_UNSUPPORTED (real sandbox answer)', async () => { + const server = fakeFedaPay({ + 'POST /payouts': () => ({ + status: 403, + json: { message: 'Opération non autorisée', errors: {}, model: null }, + }), + }); + await expect(payout(fedapay({ secretKey: 'sk', fetch: server.fetch }))).rejects.toMatchObject({ + code: 'ROUTE_UNSUPPORTED', + retryClass: 'safe_to_fallback', + }); + }); + + it('insufficient merchant balance when starting → INSUFFICIENT_BALANCE (safe)', async () => { + const server = fakeFedaPay({ + ...payoutRoutes, + 'PUT /payouts/start': () => ({ status: 400, json: { message: 'Solde insuffisant' } }), + }); + await expect(payout(fedapay({ secretKey: 'sk', fetch: server.fetch }))).rejects.toMatchObject({ + code: 'INSUFFICIENT_BALANCE', + retryClass: 'safe_to_fallback', + }); + }); + + it.each([ + ['HTTP 502', () => ({ status: 502, json: {} })], + ['network failure', () => Promise.reject(new TypeError('socket hang up'))], + ] as const)('%s when starting → unknown with the payout id', async (_label, handler) => { + const server = fakeFedaPay({ ...payoutRoutes, 'PUT /payouts/start': handler as Handler }); + const result = await payout(fedapay({ secretKey: 'sk', fetch: server.fetch })); + expect(result).toMatchObject({ status: 'unknown', providerRef: '777' }); + expect(result.error?.retryClass).toBe('ambiguous'); + }); + + it.each([ + ['sent', 'succeeded'], + ['pending', 'pending'], + ['started', 'pending'], + ['failed', 'failed'], + ['something_new', 'unknown'], + ])('payout status "%s" → %s', async (fedapayStatus, status) => { + const server = fakeFedaPay({ + 'GET /payouts/777': () => ({ + status: 200, + json: { 'v1/payout': { id: 777, status: fedapayStatus } }, + }), + }); + const result = await fedapay({ secretKey: 'sk', fetch: server.fetch }).getStatus( + { operation: 'payout', reference: 'att_1', providerRef: '777' }, + { signal: new AbortController().signal }, + ); + expect(result).toMatchObject({ found: true, status }); + }); + + it('never sends twice through Payenv when the start call times out', async () => { + const server = fakeFedaPay({ + ...payoutRoutes, + 'PUT /payouts/start': () => ({ status: 504, json: {} }), + 'GET /payouts/777': () => ({ + status: 200, + json: { 'v1/payout': { id: 777, status: 'started' } }, + }), + }); + let otherCalls = 0; + const other: Connector = { + id: 'other', + capabilities: () => fedapay({ secretKey: 'sk' }).capabilities(), + async collect(): Promise { + return { status: 'pending' }; + }, + async payout(): Promise { + otherCalls += 1; + return { status: 'pending', providerRef: 'other_1' }; + }, + async getStatus() { + return { found: false as const }; + }, + }; + const payenv = createPayenv({ + connectors: [fedapay({ secretKey: 'sk', fetch: server.fetch }), other], + statusCheckDelaysMs: [0], + }); + + const payment = await payenv.payout(payoutRequest); + + expect(payment).toMatchObject({ + status: 'pending', + connectorId: 'fedapay', + providerRef: '777', + }); + expect(otherCalls).toBe(0); + }); +}); + describe('fedapay connector — inside Payenv', () => { const backup = (): Connector & { calls: number } => { const connector = { diff --git a/packages/connector-kkiapay/QUIRKS.md b/packages/connector-kkiapay/QUIRKS.md index f11937c..c2e4673 100644 --- a/packages/connector-kkiapay/QUIRKS.md +++ b/packages/connector-kkiapay/QUIRKS.md @@ -34,6 +34,15 @@ includes `country`, `source_common_name` (`mtn-benin`) and balances. - First fallback seen end to end: FedaPay (simulated outage) → Kkiapay widget → confirmed. +## Payouts: not available + +Kkiapay "payouts" (*reversements*) are the merchant withdrawing their own balance, on a +schedule configured in the dashboard (every 3, 7 or 30 days, or above a threshold). There +is no documented API to send money to a customer: the SDK's `/merchant/payouts/schedule` +call is commented out. The connector therefore does not implement `payout`, and Payenv +never routes payouts to it. Refunds of collected payments (`/api/v1/transactions/revert`) +are possible and planned. + ## 🔍 To verify in the sandbox - That webhooks also echo `partnerId` (status responses do).