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 .changeset/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changesets

Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a tool to manage versioning and changelogs. Read the full documentation at https://changesets.dev.

We also have a quick list of common questions to get you started engaging with this project in our [FAQ](https://changesets.dev/faq).
9 changes: 9 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$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"]],
"updateInternalDependencies": "patch",
"privatePackages": { "version": false, "tag": false },
"ignore": []
}
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ jobs:
run: |
missing=0
for sha in $(git rev-list --no-merges "$BASE..$HEAD"); do
# Release bot commits ("chore: version packages") only bump versions.
if [ "$(git log -1 --format=%ae "$sha")" = "41898282+github-actions[bot]@users.noreply.github.com" ]; then
continue
fi
if ! git log -1 --format=%B "$sha" | grep -q '^Signed-off-by: '; then
echo "::error::Commit $sha is missing a Signed-off-by line (use git commit -s)"
missing=1
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Release

# Opens a "Version packages" PR when changesets are merged, and publishes to npm
# when that PR is merged. Uses npm trusted publishing (OIDC): no npm token is stored.
#
# Disabled until the repository variable NPM_TRUSTED_PUBLISHING is set to "true"
# (Settings → Secrets and variables → Actions → Variables), after the first manual
# publish and the trusted publisher setup on npmjs.com. See CONTRIBUTING.md.

on:
push:
branches: [main]

concurrency: release-${{ github.ref }}

permissions:
contents: write
pull-requests: write
id-token: write

jobs:
release:
if: vars.NPM_TRUSTED_PUBLISHING == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
registry-url: https://registry.npmjs.org
# Trusted publishing requires npm 11.5.1 or later.
- run: npm install -g npm@latest
- run: pnpm install --frozen-lockfile
- run: pnpm check
- uses: changesets/action@v1
with:
version: pnpm exec changeset version
publish: pnpm release
commit: 'chore: version packages'
title: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Before `1.0.0`, minor versions may contain breaking changes.
## [Unreleased]

### Added
- 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
code in every error.
- 2026-10-05 — `@payenv/connector-fedapay`: maps FedaPay's `last_error_code`
Expand Down
16 changes: 16 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,19 @@ Sandbox tests are opt-in: copy `.env.example` to `.env` (git-ignored) and fill i
**sandbox** credentials. Without them, those tests are skipped.

The stack is TypeScript 7 (`tsc`), Vitest for tests, and Biome for lint and format.

## Releases

Versions are managed with [Changesets](https://github.com/changesets/changesets).
`@payenv/core` and the connectors share the same version.

1. In your PR, run `pnpm changeset`, choose the bump, and describe the change for users.
Commit the generated file in `.changeset/`.
2. When it is merged, the Release workflow opens a "chore: version packages" PR.
3. Merging that PR publishes to npm through
[trusted publishing](https://docs.npmjs.com/trusted-publishers) (no npm token stored),
with provenance.

The workflow runs only when the repository variable `NPM_TRUSTED_PUBLISHING` is `true`.
Each package must be registered once on npmjs.com (package → Settings → Trusted publisher:
GitHub Actions, repository `payenv-dev/payenv`, workflow `release.yml`).
12 changes: 8 additions & 4 deletions README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

🇬🇧 [Read in English](README.md) — *la version anglaise fait référence.*

**Statut :** 🌱 Début du développement — le moteur existe, pas encore de connecteur ni de version publiée. Démarré le **26/09/2026**.
**Statut :** 🌱 Pré-version `0.1.0` — moteur et connecteur FedaPay (mobile money au Bénin). L'API peut encore changer. Démarré le **26/09/2026**.
**Licence :** [Apache 2.0](LICENSE) — gratuit pour un usage personnel et commercial, pour toujours.

---
Expand Down Expand Up @@ -48,10 +48,14 @@ const payment = await payenv.collect({
// sur le suivant uniquement s'il a échoué *avant* tout mouvement d'argent.
```

## Essayer
## Démarrer

Une démo prête à lancer encaisse un vrai paiement sandbox via FedaPay en une minute :
voir [examples/fedapay-demo](examples/fedapay-demo).
```sh
npm install @payenv/core @payenv/connector-fedapay
```

Suivez le [guide de démarrage](docs/GETTING_STARTED.md) (en anglais), ou lancez la
[démo web](examples/fedapay-demo) qui encaisse un paiement sandbox via FedaPay.

## Principes

Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

🇫🇷 [Lire en français](README.fr.md)

**Status:** 🌱 Early development — the core engine exists, no connector or release yet. Started on **2026-09-26**.
**Status:** 🌱 Pre-release `0.1.0` — core engine and FedaPay connector (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.

---
Expand Down Expand Up @@ -59,10 +59,14 @@ What Payenv gives you:
| **Normalized webhooks** | Signature verification per provider, one event format for your app. |
| **Normalized statuses & errors** | `succeeded`, `failed`, `pending`… and a shared error taxonomy. |

## Try it
## Get started

A runnable demo collects a real sandbox payment through FedaPay in a minute:
see [examples/fedapay-demo](examples/fedapay-demo).
```sh
npm install @payenv/core @payenv/connector-fedapay
```

Follow the [5-minute guide](docs/GETTING_STARTED.md), or run the
[web demo](examples/fedapay-demo) that collects a sandbox payment through FedaPay.

## Principles

Expand All @@ -78,6 +82,7 @@ see [examples/fedapay-demo](examples/fedapay-demo).

## Documentation

- [Getting started](docs/GETTING_STARTED.md) — your first payment in 5 minutes
- [Vision](docs/VISION.md) — why this project exists and where it's going
- [Architecture](docs/ARCHITECTURE.md) — core concepts: connectors, router, statuses, webhooks
- [Connectors](docs/CONNECTORS.md) — supported and planned aggregators
Expand Down
147 changes: 147 additions & 0 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Getting started

Collect your first mobile money payment with Payenv in about five minutes.

> Payenv is a pre-release (`0.x`): the API may still change between minor versions.

## 1. Install

```sh
npm install @payenv/core @payenv/connector-fedapay
```

Requires Node.js 20.19+ (or Deno, Bun, or any runtime with `fetch` and Web Crypto).
Works with `import` and `require`.

## 2. Configure

Get a **sandbox** secret key from your [FedaPay](https://fedapay.com) dashboard and keep it in
an environment variable.

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

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

## 3. Collect a payment

```ts
const payment = await payenv.collect({
amount: { value: 100, currency: 'XOF' }, // integer, in the currency's minor unit
method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22964000001' },
customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' },
idempotencyKey: 'order_1234', // your order id: retries never charge twice
});

console.log(payment.status); // 'pending': the customer confirms on their phone
```

In the FedaPay sandbox, `+22964000001` and `+22966000001` succeed; any other number simulates
a failed payment. In live mode, the customer receives a USSD prompt and confirms with their PIN.

Invalid requests throw a `PayenvError` (`INVALID_PHONE`, `NO_ROUTE`, …). Everything that happens
with the provider is returned in the payment instead.

## 4. Follow the payment

The customer confirms asynchronously. Follow the payment with webhooks, polling, or both.

**Polling**

```ts
const latest = await payenv.refresh('order_1234'); // asks the provider
```

**Webhooks**: set your endpoint URL in the FedaPay dashboard, then:

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

// rawBody: the raw, unparsed request body (string)
if (await verifyFedaPayWebhook(rawBody, signatureHeader, process.env.FEDAPAY_WEBHOOK_SECRET!)) {
const event = parseFedaPayWebhook(rawBody); // { name, providerRef, status, error }
// Find your payment by event.providerRef, then confirm with the provider:
// await payenv.refresh(idempotencyKey);
}
```

Webhooks are hints: always confirm with `refresh` before delivering an order.

## 5. Handle the result

| `payment.status` | Meaning | What to do |
|---|---|---|
| `pending` / `requires_action` | Waiting for the customer | Wait for a webhook, or `refresh` later |
| `succeeded` | Paid | Deliver |
| `failed` / `canceled` / `expired` | Not paid | Show `payment.error` and offer a new payment |
| `unknown` | Payenv could not tell whether money moved | **Do not** retry with a new key. Keep calling `refresh` until it settles |

`payment.error` tells you why:

```json
{
"code": "INSUFFICIENT_FUNDS",
"message": "FedaPay transaction canceled — Insufficient funds, or an operator limit was reached (INSUFFICIENT_FUND_ERROR)",
"retryClass": "do_not_retry",
"connectorId": "fedapay",
"providerCode": "INSUFFICIENT_FUND_ERROR"
}
```

`code` is the same across all providers; `providerCode` is the provider's own code, for support.

Some payments stay `pending` for a long time (for example when an operator does not report a
cancellation). See
[Payments stuck in pending](../packages/connector-fedapay/README.md#payments-stuck-in-pending).

## 6. 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.

```ts
import { createPayenv, priority } from '@payenv/core';

const payenv = createPayenv({
connectors: [fedapay({ ... }), otherConnector({ ... })],
routing: priority(['fedapay', 'other']),
});
```

More connectors are on the way: see [CONNECTORS.md](CONNECTORS.md).

## 7. 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
is not shared between instances. Implement the `Store` interface on your database. Its
`create` must be atomic, for example with a unique constraint on the idempotency key:

```ts
import type { Store } from '@payenv/core';

const store: Store = {
// INSERT ... ON CONFLICT (idempotency_key) DO NOTHING → true if a row was inserted
async create(payment) { /* ... */ },
async get(idempotencyKey) { /* SELECT ... */ },
async update(payment) { /* UPDATE ... */ },
};
createPayenv({ connectors, store });
```
- [ ] Verify every webhook signature and pass the **raw** body.
- [ ] Never log raw provider responses or webhooks: they contain personal data.
- [ ] Reconcile `pending` and `unknown` payments regularly with `refresh`.

## Try the demo

The repository includes a runnable web demo: see
[examples/fedapay-demo](../examples/fedapay-demo).
5 changes: 3 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@
## Phase 1 — Core + first connector (`v0.1.0`)

- [x] TypeScript monorepo, CI, lint, tests
- [ ] Release automation
- [x] Release automation (Changesets + npm trusted publishing)
- [x] `@payenv/core`: unified model, error taxonomy, statuses
- [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`)
- [x] `@payenv/connector-fedapay` (collect mobile money, status, webhook signature)
- [x] FedaPay: verified end to end in the sandbox (success and failure)
- [x] One runnable example (`examples/fedapay-demo`)
- [ ] Getting-started guide
- [x] Getting-started guide
- [ ] Publish `0.1.0` to npm

## Phase 2 — Real fallback (`v0.2.0`)

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
"lint": "biome check .",
"format": "biome check --write .",
"check": "pnpm lint && pnpm typecheck && pnpm test",
"demo": "pnpm --filter \"./packages/*\" build && pnpm --filter @payenv/example-fedapay-demo start"
"demo": "pnpm --filter \"./packages/*\" build && pnpm --filter @payenv/example-fedapay-demo start",
"changeset": "changeset",
"release": "pnpm build && changeset publish"
},
"devDependencies": {
"@biomejs/biome": "^2.5.14",
"@changesets/cli": "^3.0.3",
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
Expand Down
20 changes: 20 additions & 0 deletions packages/connector-fedapay/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# @payenv/connector-fedapay

## 0.1.0

### Minor Changes

- First public pre-release.

- `@payenv/core`: unified payment model, error taxonomy with retry classes and provider
codes, capability-aware router, and the attempt engine that never falls back while a
previous attempt could still succeed. Idempotency, `refresh`, lifecycle events, and an
in-memory store.
- `@payenv/connector-fedapay`: mobile money collections in Benin (MTN, Moov, Celtiis),
status lookup with FedaPay's real error reasons, webhook verification and parsing.
Verified in the sandbox and on live payments.

### Patch Changes

- Updated dependencies
- @payenv/core@0.1.0
Loading
Loading