Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/payouts.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/CONNECTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand Down
19 changes: 17 additions & 2 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/connector-fedapay/QUIRKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
24 changes: 22 additions & 2 deletions packages/connector-fedapay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading