Repository navigation
chore: version packages - #12
Closed
github-actions[bot] wants to merge 42 commits into
Closed
github-actions[bot] wants to merge 42 commits into
github-actions[bot] wants to merge 42 commits into
Conversation
Vision, architecture draft, connectors list, roadmap, Apache 2.0 license, contributing guide (DCO), code of conduct, security policy, governance, architecture decision records 0001-0004, and GitHub issue/PR templates. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
docs: set project contact address for security and conduct reports
npm rejects the unscoped `payenv` name (too similar to an existing package), so all packages will be published under the `@payenv` scope. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
docs(roadmap): mark phase 0 setup steps as done
- TypeScript monorepo (pnpm workspaces, TypeScript 7, Vitest, Biome) - Money model in integer minor units with ISO 4217 exponents - Payment methods, unified statuses and transitions - Error taxonomy with retry classes (safe_to_fallback, do_not_retry, ambiguous) - Connector and capability interfaces - Capability-aware router with the priority strategy - Attempt engine enforcing ADR 0004: never try another connector while a previous attempt could still succeed; ambiguous attempts are resolved by checking the status with the same provider - Idempotency (atomic store create), refresh, lifecycle events - In-memory store for development and tests - CI on Node 20/22/24 with a DCO sign-off check The randomized invariant test tracks independently whether each provider proved that no money moved, and was checked against injected double-charge bugs. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
- Collections in Benin (XOF): MTN (mtn_open), Moov (moov), Celtiis (sbin), using the verified 3-step flow: create transaction, token, push - Errors before the push are safe to fall back (no customer prompt exists); errors during the push are ambiguous and resolved by a status lookup - Status mapping, HTTP error mapping, configurable operators per country - Webhook signature verification (HMAC-SHA256, anti-replay, constant-time comparison) using Web Crypto, so it runs on any runtime - Opt-in sandbox test driven by a local .env file - QUIRKS.md separating verified behaviors from points to verify core: add AttemptContext.reportProviderRef so an attempt that times out after the provider created the transaction can still be resolved. docs: roadmap entry for the PHP / Laravel ecosystem. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
feat(core): bootstrap @payenv/core with the safe-fallback attempt engine
feat(connector-fedapay): add FedaPay mobile money connector
The first real sandbox run confirmed authentication, transaction creation and token generation, and that a non-activated operator fails at the push with "Opération non autorisée", correctly mapped to ROUTE_UNSUPPORTED. - FEDAPAY_SANDBOX_NETWORK selects the operator (mtn, moov, celtiis) - Clear failure message when the operator is not activated - QUIRKS.md: record what the sandbox verified Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
The FedaPay sandbox removed its per-operator test servers (MTN, Moov...): every push must go to the single `momo_test` mode. Pushing to `/mtn_open` in the sandbox fails with "Opération non autorisée", which an earlier commit wrongly attributed to operator activation. - Sandbox maps every network to `momo_test` by default, so application code is identical in sandbox and live - Sandbox tests use the documented test numbers and wait for the final status: +22964000001 settles as succeeded, any other number as failed (declined → CUSTOMER_DECLINED). Both pass against the real API. - QUIRKS.md corrected and updated with what the sandbox verified Source: https://docs.fedapay.com/fr/integration-api/sending-requests#serveur-de-test Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
`pnpm demo` starts a small web page (Node built-in http, no framework) that collects a sandbox mobile money payment through Payenv and follows its status live. It masks phone numbers, refuses live keys, and shows the webhook verification endpoint. Verified against the FedaPay sandbox: +22964000001 settles as succeeded, another number as failed. test(connector-fedapay): always use the documented sandbox success number, so a leftover FEDAPAY_SANDBOX_PHONE in .env can no longer break the test. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
A payment accepted as pending and later declined showed `status: failed` with `outcome: accepted` on its attempt (found while testing the FedaPay demo). `refresh` now updates the attempt outcome (failed / accepted), and clears the payment error once the provider confirms an unresolved payment. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Feat/connector fedapay
To check that a real USSD prompt reaches a phone. Real money, so: - explicit opt-in (DEMO_LIVE=yes) and a separate FEDAPAY_LIVE_SECRET_KEY; a sandbox key is refused in live mode and a live key in sandbox mode - amounts capped by DEMO_LIVE_MAX_AMOUNT (default 200, at most 1000 XOF) - warning in the terminal and on the page - the server now only listens on 127.0.0.1 Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Found with the founder's first live payments (2026-10-05): - a canceled MTN payment carried `last_error_code: INSUFFICIENT_FUND_ERROR`, but Payenv only reported a generic CUSTOMER_DECLINED; - a Celtiis payment canceled on the phone stayed `pending` at FedaPay with `last_error_code: API_ERROR`, and Payenv showed no reason at all. core: PayenvError gains `providerCode`, serialized with the error, so every connector can keep the provider's own code. connector-fedapay: map `last_error_code` (INSUFFICIENT_FUND_ERROR → INSUFFICIENT_FUNDS, API_ERROR → PROVIDER_UNAVAILABLE, others kept as providerCode), also on transactions that stay pending. Status is never changed from what FedaPay reports. Tests reproduce both live transactions; verified against them read-only. docs: QUIRKS (verified live), README guidance for payments stuck in pending. demo: shows the reason while pending and what to do after a minute. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
The full live transaction shows that `last_error_message` is the operator's raw SOAP answer: the status query succeeded and the operator still reported `TransactionStatus: "Initiated"`. API_ERROR therefore means "no final status from the operator", not an outage: map it to UNKNOWN_ERROR with an accurate message. The raw dump is kept in `error.raw` (never serialized). QUIRKS: FedaPay schedules an expiration for pending transactions (`metadata.expire_schedule_jobid`, `expired_at`). Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
A live `transaction.canceled` webhook showed the shape
`{ name, object, object_id, account, entity }`, where `entity` is the
full transaction. `parseFedaPayWebhook` normalizes it with the same
mapping as status lookups, deriving the status from the transaction
rather than the event name, so unknown event names still work.
- INSUFFICIENT_FUND_ERROR message now reflects MTN's actual reason
(low balance, or a limit reached, or not allowed)
- demo: a verified webhook triggers payenv.refresh (webhooks are hints);
verified end to end with a signed and a forged webhook
- QUIRKS: webhook shape, and a warning that webhooks carry merchant and
payer personal data
Test fixtures are scrubbed of account details, phone numbers and tokens.
Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Feat/fedapay error reasons
- Changesets (fixed versioning for core and connectors); packages bumped to 0.1.0 with generated changelogs - Release workflow publishing through npm trusted publishing (OIDC, no token, provenance), disabled until NPM_TRUSTED_PUBLISHING is "true" - DCO check skips the release bot's version commits - Each package ships LICENSE, NOTICE (Apache 2.0 section 4) and its sources (source maps resolve) - docs/GETTING_STARTED.md: install, first payment, webhooks, statuses, errors, fallback, production checklist - README: install command and guide link; CONTRIBUTING: release process Verified by installing the packed tarballs in a fresh project: ESM import, CJS require, and strict TypeScript without skipLibCheck. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
chore(release): prepare v0.1.0
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.
Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
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. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Feat/kkiapay widget flow
The sandbox answers an unknown transaction id with HTTP 400 and
`{ "status": "TRANSACTION_NOT_FOUND" }`, not 404 as assumed. Only this
explicit answer now counts as "not found", whatever the HTTP status.
Found by the opt-in sandbox test, which now passes.
Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Payenv keeps requiring E.164 (a number without its country is ambiguous, and could charge the wrong person), but customers type local numbers. `toE164(phone, country)` converts them, with per-country rules: the leading 0 is a trunk prefix in Nigeria, Ghana, Kenya…, but part of the number in Benin (+22901…) and Côte d'Ivoire. demo: accepts local numbers (via toE164), pre-fills the Kkiapay test number when the FedaPay outage is simulated, and shows widget failures. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
An apostrophe in "n'a bougé" closed a JavaScript string inside the page template, so the whole inline script failed to load and the form was submitted natively (the page just reloaded). Use a typographic apostrophe. To catch this class of bug: the page moves to page.ts, and a test parses its inline script in every demo configuration (it fails on the original bug). Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
FedaPay (simulated outage) → Kkiapay widget → payenv.confirm succeeded. The status response echoes the widget's partnerId and reports the amount requested (fees apart), which is what confirm's anti-fraud checks rely on. The opt-in sandbox tests pass against the real API. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Feat/kkiapay widget flow
chore: version packages
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
Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
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. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Feat/payouts
A status lookup that still reports requires_action without a new next action no longer erases the one the customer has to follow (e.g. a hosted page URL). Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Collections with CinetPay's API v1 (api.cinetpay.net / api.cinetpay.co) in nine countries, by direct push or CinetPay's hosted page. Status lookups use a merchant reference derived from the attempt, so a push that timed out is checked before any fallback. Built from the official SDK; not yet verified in the sandbox (see QUIRKS.md). Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
Feat/connector cinetpay
github-actions
Bot
force-pushed
the
changeset-release/main
branch
from
October 16, 2026 14:13
247bc99 to
ce734a5
Compare
Payouts by CinetPay transfer (POST /v1/transfer, API v1) from the balance of
the recipient's country account, with networks from a separate payout methods
table (Orange Money and Wave included: the recipient has nothing to do).
Same guarantees as collections: the transfer is a single call, so an uncertain
answer (timeout, server error, unreadable status) is ambiguous and Payenv looks
the transfer up by its merchant reference (GET /v1/transfer/{id}) before any
fallback. INSUFFICIENT_BALANCE is the merchant's CinetPay balance here and maps
to INSUFFICIENT_BALANCE, safe to fall back. Built from the official SDK; not yet
verified in the sandbox (see QUIRKS.md).
Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
feat(connector-cinetpay): add payouts
github-actions
Bot
force-pushed
the
changeset-release/main
branch
from
October 16, 2026 14:16
ce734a5 to
ee0abf0
Compare
Rewrite both READMEs around the packages, features and commands that exist now (the example used a fallback() helper that does not exist and listed strategies and refunds that are only planned). Mark the planned parts of the architecture, refresh the roadmap, and document CinetPay in the guides. Signed-off-by: Jules Mahounou <234069818+jules-mahounou@users.noreply.github.com>
docs: describe what is available today
github-actions
Bot
force-pushed
the
changeset-release/main
branch
from
October 16, 2026 16:54
ee0abf0 to
c43fafa
Compare
jules-mahounou
force-pushed
the
changeset-release/main
branch
from
October 16, 2026 17:00
c43fafa to
db167e9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
@payenv/connector-cinetpay@0.3.0
Minor Changes
d5f1486: CinetPay payouts.
POST /v1/transfer) from the balance of the recipient'scountry account: MTN, Moov, Orange Money, Wave, Free, T-Money and Airtel depending on
the country (
payoutMethodsoption,DEFAULT_PAYOUT_METHODS).before any fallback. A too-low CinetPay balance maps to
INSUFFICIENT_BALANCE, safe tofall back.
a5d32f6: CinetPay connector.
@payenv/connector-cinetpay(new): collections with CinetPay's API v1 in Benin, BurkinaFaso, Côte d'Ivoire, Mali, Niger, Senegal, Togo, Cameroon and Guinea, by direct push
(
mobile_money) or CinetPay's hosted page (hosted_page,redirectnext action). Statuslookups by merchant reference, so a push that timed out is checked before any fallback.
@payenv/core:refreshkeeps a payment's next action (e.g. a redirect URL) while theprovider still reports
requires_actionwithout a new one.Patch Changes
@payenv/connector-fedapay@0.3.0
Minor Changes
4daec16: Payouts.
@payenv/core:payenv.payout({ amount, recipient, idempotencyKey })sends money with thesame 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 statuslookups. "Opération non autorisée" with HTTP 403 now maps to
ROUTE_UNSUPPORTED.Patch Changes
@payenv/core@0.3.0
Minor Changes
a5d32f6: CinetPay connector.
@payenv/connector-cinetpay(new): collections with CinetPay's API v1 in Benin, BurkinaFaso, Côte d'Ivoire, Mali, Niger, Senegal, Togo, Cameroon and Guinea, by direct push
(
mobile_money) or CinetPay's hosted page (hosted_page,redirectnext action). Statuslookups by merchant reference, so a push that timed out is checked before any fallback.
@payenv/core:refreshkeeps a payment's next action (e.g. a redirect URL) while theprovider still reports
requires_actionwithout a new one.4daec16: Payouts.
@payenv/core:payenv.payout({ amount, recipient, idempotencyKey })sends money with thesame 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 statuslookups. "Opération non autorisée" with HTTP 403 now maps to
ROUTE_UNSUPPORTED.@payenv/connector-kkiapay@0.3.0
Patch Changes