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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copy to .env (git-ignored) to run the opt-in sandbox tests: pnpm test
# Never put live keys here.
FEDAPAY_SANDBOX_SECRET_KEY=
# A FedaPay sandbox test phone number, in E.164 format (e.g. +229...)
FEDAPAY_SANDBOX_PHONE=
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-09-26 — `@payenv/connector-fedapay`: mobile money collections in Benin (MTN,
Moov, Celtiis), status lookup, webhook signature verification, sandbox tests (opt-in).
- 2026-09-26 — `@payenv/core`: `AttemptContext.reportProviderRef`, so an attempt
that times out after the provider created the transaction can still be resolved.
- 2026-09-26 — `@payenv/core`: money model (integer minor units, ISO 4217 exponents),
payment methods, unified statuses, error taxonomy with retry classes, connector and
capability interfaces, capability-aware router with the `priority` strategy, the
Expand Down
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,13 @@ Layout:

```
packages/
core/ @payenv/core — model, router, attempt engine (zero runtime dependencies)
core/ @payenv/core — model, router, attempt engine (zero runtime dependencies)
src/
test/
connector-fedapay/ @payenv/connector-fedapay
```

Sandbox tests are opt-in: copy `.env.example` to `.env` (git-ignored) and fill in
**sandbox** credentials. Without them, those tests are skipped.

The stack is TypeScript 7 (`tsc`), Vitest for tests, and Biome for lint and format.
2 changes: 1 addition & 1 deletion docs/CONNECTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A connector is an adapter for one payment aggregator. See

| Connector | Region | Collect | Payout | Refund | Webhooks | Status | Maintainer |
|---|---|---|---|---|---|---|---|
| FedaPay | Benin / West Africa | — | — | — | — | 🟡 In design (first connector) | TBD |
| FedaPay | Benin / West Africa | ✅ MTN, Moov, Celtiis | — | — | ✅ Signature | 🟡 In progress | @jules-mahounou |
| Kkiapay | West Africa | — | — | — | — | ⚪ Planned | — |
| CinetPay | West & Central Africa | — | — | — | — | ⚪ Planned | — |
| PayDunya | West Africa | — | — | — | — | ⚪ Planned | — |
Expand Down
16 changes: 14 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
- [x] Router with the `priority` strategy + capability filtering
- [x] Attempt engine with the safe-fallback invariant, fully tested
- [x] In-memory store (built into `@payenv/core`)
- [ ] `@payenv/connector-fedapay` (collect mobile money, status, webhooks)
- [x] `@payenv/connector-fedapay` (collect mobile money, status, webhook signature)
- [ ] FedaPay: verify the open points of `QUIRKS.md` in the sandbox
- [ ] Getting-started guide and one runnable example

## Phase 2 — Real fallback (`v0.2.0`)
Expand All @@ -43,8 +44,19 @@
- [ ] At least 6 stable connectors with named maintainers
- [ ] Documentation website

## Next ecosystem: PHP & Laravel (after `v0.2.0`)

Laravel is the most used back-end framework among our target developers after
JavaScript. Once the model is proven by 2–3 connectors:

- [ ] Language-neutral specification + shared JSON test scenarios (both implementations
must pass the same double-charge scenarios)
- [ ] `payenv/core` — native PHP port of the model and the attempt engine
- [ ] `payenv/laravel` — config file, facade, Eloquent store, webhook route, events,
artisan command to reconcile `unknown` payments

## Later

- Optional self-hosted server mode (HTTP API) built on the same core
- SDKs in other languages (PHP, Python, Go, Java/Kotlin, Dart)
- SDKs in other languages (Python, Go, Java/Kotlin, Dart)
- Reconciliation tooling
47 changes: 47 additions & 0 deletions packages/connector-fedapay/QUIRKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# FedaPay quirks

What differs from what you would expect when reading the FedaPay documentation.

## ✅ Verified (production integration)

- **The mobile money push takes two calls**, not one:
1. `POST /v1/transactions/{id}/token` returns a token;
2. `POST /v1/{operator}` with body `{ "token": "..." }` sends the USSD prompt to the
phone number stored on the transaction's customer.

The documentation suggests `/transactions/{id}/{mode}`. The working path is `/{mode}`
(same as the official PHP SDK's `Transaction::sendNowWithToken`).
- **Operator slugs differ between collections and payouts** (Benin):

| Network | Collection | Payout |
|---|---|---|
| MTN | `mtn_open` | `mtn` |
| Moov | `moov` | `moov` |
| Celtiis | `sbin` | `sbin` |

- **Resources are wrapped** under `v1/<resource>` (e.g. `"v1/transaction"`,
`"v1/payment_intent"`), sometimes `<resource>`, sometimes not at all.
- **Operators must be activated** on the merchant account (Dashboard → Configuration →
Payment methods). Otherwise the push fails with "Opération non autorisée", which the
connector maps to `ROUTE_UNSUPPORTED`, so another connector can be tried.
- **Webhook signature**: header `x-fedapay-signature: t=<timestamp>,s=<hex>`, where
`s = HMAC-SHA256(secret, "<timestamp>.<raw body>")`.
- **Payouts** are created with `POST /v1/payouts`, then started with
`PUT /v1/payouts/start` and body `{ "payouts": [{ "id": ... }] }`. (Not implemented yet.)

## 🔍 To verify in the sandbox

- The full list of transaction statuses. Mapped today: `pending`, `approved`,
`transferred`, `refunded` → succeeded, `declined` → failed, `canceled`, `expired`.
Unknown values map to `unknown` (safe).
- The phone number format FedaPay expects (E.164 `+229…` is sent today).
- Whether a transaction can be looked up by merchant reference. That would let Payenv
resolve a timeout on the *create* call. Today that case ends as `unknown` (safe but
not ideal).
- Sandbox test phone numbers for each operator.

## Safety decisions

- An HTTP 404 on a status lookup is **not** treated as "the transaction does not exist":
it is not authoritative enough to allow a fallback.
- The connector never logs. Secrets and phone numbers never appear in error messages.
59 changes: 59 additions & 0 deletions packages/connector-fedapay/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# @payenv/connector-fedapay

[FedaPay](https://fedapay.com) connector for [Payenv](https://github.com/payenv-dev/payenv).

> 🚧 Pre-release (`0.x`).

**Supported today:** mobile money collections in Benin (XOF): MTN, Moov, Celtiis.
Status lookup and webhook signature verification. Payouts and refunds are coming.

```ts
import { createPayenv } from '@payenv/core';
import { fedapay, verifyFedaPayWebhook } from '@payenv/connector-fedapay';

const payenv = createPayenv({
connectors: [
fedapay({
secretKey: process.env.FEDAPAY_SECRET_KEY!,
environment: 'sandbox', // or 'live'
}),
],
});

const payment = await payenv.collect({
amount: { value: 5000, currency: 'XOF' },
method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22990000000' },
customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' },
idempotencyKey: 'order_1234',
});
// payment.status === 'pending': the customer received a USSD prompt to confirm with their PIN.

// Webhook endpoint: pass the RAW body.
const valid = await verifyFedaPayWebhook(
rawBody,
request.headers.get('x-fedapay-signature'),
process.env.FEDAPAY_WEBHOOK_SECRET!,
);
```

## Options

| Option | Default | Description |
|---|---|---|
| `secretKey` | — | FedaPay secret key. Required. |
| `environment` | `'sandbox'` | `'sandbox'` or `'live'`. |
| `operators` | Benin: MTN, Moov, Celtiis | Operators to expose, per country: `{ BJ: { mtn: 'mtn_open' } }`. List only the operators activated on your merchant account. |
| `id` | `'fedapay'` | Connector id, e.g. to register two FedaPay accounts. |
| `fetch` | global `fetch` | Custom fetch implementation. |

## Safety

Money can only move once the mobile money push is sent (step 3 of 3).

- Errors while creating the transaction or its token are **safe to fall back**.
- Errors during the push are **ambiguous**: Payenv checks the transaction status with
FedaPay before doing anything else, and never falls back while it could still succeed.

See [QUIRKS.md](QUIRKS.md) for provider behaviors and what remains to verify.

License: Apache-2.0
53 changes: 53 additions & 0 deletions packages/connector-fedapay/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@payenv/connector-fedapay",
"version": "0.0.0",
"description": "FedaPay connector for Payenv (mobile money in Benin: MTN, Moov, Celtiis).",
"license": "Apache-2.0",
"author": "Jules Mahounou (https://github.com/jules-mahounou)",
"homepage": "https://github.com/payenv-dev/payenv/tree/main/packages/connector-fedapay#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/payenv-dev/payenv.git",
"directory": "packages/connector-fedapay"
},
"bugs": "https://github.com/payenv-dev/payenv/issues",
"keywords": [
"payenv",
"fedapay",
"mobile-money",
"benin",
"mtn",
"moov",
"celtiis"
],
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"QUIRKS.md"
],
"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"
}
}
Loading
Loading