diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dc5a54b --- /dev/null +++ b/.env.example @@ -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= diff --git a/CHANGELOG.md b/CHANGELOG.md index ffec1e0..6b4c401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bff2160..86597fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/docs/CONNECTORS.md b/docs/CONNECTORS.md index b1b36da..d6013a3 100644 --- a/docs/CONNECTORS.md +++ b/docs/CONNECTORS.md @@ -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 | — | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7f9edae..d6c873b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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`) @@ -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 diff --git a/packages/connector-fedapay/QUIRKS.md b/packages/connector-fedapay/QUIRKS.md new file mode 100644 index 0000000..17f8181 --- /dev/null +++ b/packages/connector-fedapay/QUIRKS.md @@ -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/` (e.g. `"v1/transaction"`, + `"v1/payment_intent"`), sometimes ``, 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=,s=`, where + `s = HMAC-SHA256(secret, ".")`. +- **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. diff --git a/packages/connector-fedapay/README.md b/packages/connector-fedapay/README.md new file mode 100644 index 0000000..4cd0300 --- /dev/null +++ b/packages/connector-fedapay/README.md @@ -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 diff --git a/packages/connector-fedapay/package.json b/packages/connector-fedapay/package.json new file mode 100644 index 0000000..18e2acf --- /dev/null +++ b/packages/connector-fedapay/package.json @@ -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" + } +} diff --git a/packages/connector-fedapay/src/fedapay.ts b/packages/connector-fedapay/src/fedapay.ts new file mode 100644 index 0000000..37f2956 --- /dev/null +++ b/packages/connector-fedapay/src/fedapay.ts @@ -0,0 +1,332 @@ +import { + type Capability, + type CollectRequest, + type Connector, + type CountryCode, + type MobileMoneyNetwork, + PayenvError, + type PayenvErrorCode, + type PaymentStatus, + type ProviderResult, + type StatusResult, +} from '@payenv/core'; + +export type FedaPayEnvironment = 'sandbox' | 'live'; + +/** FedaPay collection slugs, per country and Payenv network. */ +export type FedaPayOperators = Readonly< + Record>>> +>; + +/** + * Operators verified against the live API (Benin). + * Other countries can be added with the `operators` option. + */ +export const DEFAULT_OPERATORS: FedaPayOperators = { + BJ: { mtn: 'mtn_open', moov: 'moov', celtiis: 'sbin' }, +}; + +export interface FedaPayOptions { + /** Secret API key (sandbox or live). Keep it in an environment variable. */ + secretKey: string; + /** Defaults to `sandbox`. */ + environment?: FedaPayEnvironment; + /** + * Operators to expose, per country. Defaults to {@link DEFAULT_OPERATORS}. Only list + * operators activated on your FedaPay merchant account (Dashboard → Payment methods). + */ + operators?: 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`. */ + fetch?: typeof fetch; +} + +const BASE_URLS: Record = { + sandbox: 'https://sandbox-api.fedapay.com/v1', + 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'; + +class FedaPayHttpError extends Error { + constructor( + readonly step: Step, + readonly status: number, + readonly body: unknown, + ) { + super(`FedaPay ${step} failed with HTTP ${status}: ${providerMessage(body) ?? 'no message'}`); + } +} + +type Json = Record; + +export function fedapay(options: FedaPayOptions): Connector { + if (!options.secretKey) { + throw new PayenvError('INVALID_REQUEST', 'FedaPay secretKey is required'); + } + const id = options.id ?? 'fedapay'; + const baseUrl = BASE_URLS[options.environment ?? 'sandbox']; + const operators = options.operators ?? DEFAULT_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), + })); + + async function call(step: Step, method: string, path: string, signal: AbortSignal, body?: Json) { + const init: RequestInit = { + method, + signal, + headers: { + Authorization: `Bearer ${options.secretKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }; + if (body !== undefined) init.body = JSON.stringify(body); + const response = await doFetch(`${baseUrl}${path}`, init); + const text = await response.text(); + let data: unknown = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!response.ok) throw new FedaPayHttpError(step, response.status, data); + return (data ?? {}) as Json; + } + + 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'; + + if (thrown instanceof FedaPayHttpError) { + const code = httpCode(thrown, beforeMoney); + return new PayenvError(code.code, thrown.message, { + connectorId: id, + raw: thrown.body, + ...(code.retryClass ? { retryClass: code.retryClass } : {}), + }); + } + const message = thrown instanceof Error ? thrown.message : String(thrown); + return beforeMoney + ? new PayenvError('PROVIDER_UNAVAILABLE', `FedaPay unreachable (${step}): ${message}`, { + connectorId: id, + cause: thrown, + }) + : new PayenvError('NETWORK_ERROR', `FedaPay ${step} did not complete: ${message}`, { + connectorId: id, + cause: thrown, + }); + } + + return { + id, + capabilities: () => capabilities, + + async collect(request, context): Promise { + const { method } = request; + if (method.type !== 'mobile_money') { + throw new PayenvError('ROUTE_UNSUPPORTED', 'FedaPay connector only supports mobile money', { + connectorId: id, + }); + } + const operator = operators[method.country]?.[method.network]; + if (!operator) { + throw new PayenvError( + 'ROUTE_UNSUPPORTED', + `FedaPay operator not configured for ${method.network} / ${method.country}`, + { connectorId: id }, + ); + } + + // 1. Create the transaction. No customer prompt exists yet. + let transactionId: string; + try { + const data = await call( + 'create', + 'POST', + '/transactions', + context.signal, + transactionBody(request, context.reference), + ); + const transaction = unwrap(data, 'transaction'); + if (transaction.id === undefined || transaction.id === null) { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'FedaPay returned no transaction id', { + connectorId: id, + raw: data, + }); + } + transactionId = String(transaction.id); + } catch (thrown) { + throw toError('create', thrown); + } + context.reportProviderRef(transactionId); + + // 2. Get a payment token for the transaction. + let token: string; + try { + const data = await call( + 'token', + 'POST', + `/transactions/${transactionId}/token`, + context.signal, + {}, + ); + if (typeof data.token !== 'string' || data.token === '') { + throw new PayenvError('PROVIDER_UNAVAILABLE', 'FedaPay returned no payment token', { + connectorId: id, + }); + } + token = data.token; + } catch (thrown) { + throw toError('token', thrown); + } + + // 3. Send the mobile money push. From here on, money may move. + try { + const data = await call('push', 'POST', `/${operator}`, context.signal, { token }); + const intent = unwrap(data, 'payment_intent'); + return { + status: pushStatus(intent.status), + providerRef: transactionId, + nextAction: { type: 'customer_confirmation', channel: 'ussd' }, + raw: data, + }; + } catch (thrown) { + const error = toError('push', thrown); + if (error.retryClass !== 'ambiguous') throw error; + return { status: 'unknown', providerRef: transactionId, error, raw: error.raw }; + } + }, + + async getStatus(query, context): Promise { + if (query.providerRef === undefined) { + // Without the transaction id we cannot prove anything: stay ambiguous. + throw new PayenvError('UNKNOWN_ERROR', 'FedaPay status lookup needs the transaction id', { + connectorId: id, + }); + } + let data: Json; + try { + data = await call('status', 'GET', `/transactions/${query.providerRef}`, context.signal); + } catch (thrown) { + throw toError('status', thrown); + } + const transaction = unwrap(data, 'transaction'); + const { status, error } = transactionStatus(transaction.status, id); + return { + found: true, + status, + providerRef: query.providerRef, + ...(error ? { error } : {}), + raw: data, + }; + }, + }; +} + +function transactionBody(request: CollectRequest, reference: string): Json { + const { customer, method, amount } = request; + const body: Json = { + description: request.description ?? `Payment ${reference}`, + amount: amount.value, + currency: { iso: amount.currency }, + }; + const fedapayCustomer: Json = {}; + if (customer?.firstName) fedapayCustomer.firstname = customer.firstName; + if (customer?.lastName) fedapayCustomer.lastname = customer.lastName; + if (customer?.email) fedapayCustomer.email = customer.email; + if (method.type === 'mobile_money') { + // The push is sent to the phone number stored on the transaction's customer. + fedapayCustomer.phone_number = { number: method.phone, country: method.country }; + } + body.customer = fedapayCustomer; + return body; +} + +/** FedaPay wraps resources under `v1/`, sometimes ``, sometimes not at all. */ +function unwrap(data: Json, name: string): Json { + const wrapped = data[`v1/${name}`] ?? data[name]; + return wrapped !== null && typeof wrapped === 'object' ? (wrapped as Json) : data; +} + +function pushStatus(status: unknown): PaymentStatus { + if (status === 'approved' || status === 'transferred') return 'succeeded'; + // The customer still has to confirm with their PIN. + return 'pending'; +} + +function transactionStatus( + status: unknown, + connectorId: string, +): { status: PaymentStatus; error?: PayenvError } { + switch (status) { + case 'pending': + return { status: 'pending' }; + case 'approved': + case 'transferred': + case 'refunded': + return { status: 'succeeded' }; + case 'declined': + return { + status: 'failed', + error: new PayenvError('CUSTOMER_DECLINED', 'FedaPay transaction declined', { + connectorId, + }), + }; + case 'canceled': + return { + status: 'canceled', + error: new PayenvError('CUSTOMER_DECLINED', 'FedaPay transaction canceled', { + connectorId, + }), + }; + case 'expired': + return { + status: 'expired', + error: new PayenvError('CUSTOMER_TIMEOUT', 'FedaPay transaction expired', { connectorId }), + }; + default: + return { status: 'unknown' }; + } +} + +function httpCode( + error: FedaPayHttpError, + beforeMoney: boolean, +): { code: PayenvErrorCode; retryClass?: 'safe_to_fallback' | 'do_not_retry' | 'ambiguous' } { + const { status } = error; + const message = (providerMessage(error.body) ?? '').toLowerCase(); + + 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 (status === 404 && error.step === 'status') { + // Not authoritative enough to claim "no money moved". + return { code: 'UNKNOWN_ERROR' }; + } + return { code: 'INVALID_REQUEST' }; +} + +function providerMessage(body: unknown): string | undefined { + if (body === null || typeof body !== 'object') return undefined; + const { message, error } = body as Json; + if (typeof message === 'string') return message; + if (typeof error === 'string') return error; + return undefined; +} diff --git a/packages/connector-fedapay/src/index.ts b/packages/connector-fedapay/src/index.ts new file mode 100644 index 0000000..19258e6 --- /dev/null +++ b/packages/connector-fedapay/src/index.ts @@ -0,0 +1,8 @@ +export { + DEFAULT_OPERATORS, + type FedaPayEnvironment, + type FedaPayOperators, + type FedaPayOptions, + fedapay, +} from './fedapay.js'; +export { type VerifyWebhookOptions, verifyFedaPayWebhook } from './webhook.js'; diff --git a/packages/connector-fedapay/src/webhook.ts b/packages/connector-fedapay/src/webhook.ts new file mode 100644 index 0000000..db4cc6c --- /dev/null +++ b/packages/connector-fedapay/src/webhook.ts @@ -0,0 +1,63 @@ +export interface VerifyWebhookOptions { + /** Maximum age of the signature, in seconds. Defaults to 300 (5 minutes). */ + toleranceSeconds?: number; + /** Current time in milliseconds. Defaults to `Date.now()`. */ + now?: number; +} + +/** + * Verifies a FedaPay webhook signature. + * + * Header `x-fedapay-signature`: `t=,s=`, where the + * signature is `HMAC-SHA256(secret, ".")`. + * + * Pass the **raw, unparsed** request body: re-serialized JSON will not match. + */ +export async function verifyFedaPayWebhook( + rawBody: string, + signatureHeader: string | null | undefined, + secret: string, + options: VerifyWebhookOptions = {}, +): Promise { + if (!secret || !signatureHeader) return false; + + const parts = new Map(); + for (const part of signatureHeader.split(',')) { + const separator = part.indexOf('='); + if (separator > 0) parts.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim()); + } + const timestamp = parts.get('t'); + const signature = parts.get('s'); + if (!timestamp || !signature || !/^\d+$/.test(timestamp)) return false; + + const tolerance = options.toleranceSeconds ?? 300; + const nowSeconds = (options.now ?? Date.now()) / 1000; + if (Math.abs(nowSeconds - Number(timestamp)) > tolerance) return false; + + const expected = await hmacSha256Hex(secret, `${timestamp}.${rawBody}`); + return constantTimeEqual(expected, signature.toLowerCase()); +} + +async function hmacSha256Hex(secret: string, message: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); + return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index++) { + difference |= a.charCodeAt(index) ^ b.charCodeAt(index); + } + return difference === 0; +} diff --git a/packages/connector-fedapay/test/fedapay.test.ts b/packages/connector-fedapay/test/fedapay.test.ts new file mode 100644 index 0000000..fd465a3 --- /dev/null +++ b/packages/connector-fedapay/test/fedapay.test.ts @@ -0,0 +1,281 @@ +import { + type AttemptContext, + type CollectRequest, + type Connector, + createPayenv, + type ProviderResult, +} from '@payenv/core'; +import { describe, expect, it } from 'vitest'; +import { fedapay } from '../src/index.js'; + +type Handler = (body: unknown) => { status: number; json?: unknown } | Promise; + +interface Call { + method: string; + url: string; + headers: Record; + body: unknown; +} + +function fakeFedaPay(routes: Record) { + const calls: Call[] = []; + const fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const path = url.replace(/^https:\/\/[^/]+\/v1/, ''); + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + calls.push({ method, url, headers: init?.headers as Record, body }); + const handler = routes[`${method} ${path}`]; + if (!handler) return new Response(JSON.stringify({ message: 'Not found' }), { status: 404 }); + const result = await handler(body); + return new Response(result.json === undefined ? '' : JSON.stringify(result.json), { + status: result.status, + }); + }) as typeof globalThis.fetch; + return { fetch, calls }; +} + +const happyRoutes: Record = { + 'POST /transactions': () => ({ + status: 200, + json: { 'v1/transaction': { id: 1234, reference: 'trx_abc', status: 'pending' } }, + }), + 'POST /transactions/1234/token': () => ({ status: 200, json: { token: 'jwt_token', url: 'x' } }), + 'POST /mtn_open': () => ({ + status: 200, + json: { 'v1/payment_intent': { id: 9, status: 'pending' } }, + }), +}; + +function request(overrides: Partial = {}): CollectRequest { + return { + 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_1', + ...overrides, + }; +} + +function context(): AttemptContext & { reported: string[] } { + const reported: string[] = []; + return { + paymentId: 'pay_1', + attemptId: 'att_1', + reference: 'att_1', + idempotencyKey: 'att_1', + signal: new AbortController().signal, + reportProviderRef: (ref) => reported.push(ref), + reported, + }; +} + +describe('fedapay connector — collect', () => { + it('creates the transaction, gets a token, then sends the push', async () => { + const server = fakeFedaPay(happyRoutes); + const connector = fedapay({ secretKey: 'sk_sandbox_test', fetch: server.fetch }); + const ctx = context(); + + const result = await connector.collect(request(), ctx); + + expect(server.calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + 'POST https://sandbox-api.fedapay.com/v1/transactions', + 'POST https://sandbox-api.fedapay.com/v1/transactions/1234/token', + 'POST https://sandbox-api.fedapay.com/v1/mtn_open', + ]); + expect(server.calls[0]?.body).toEqual({ + description: 'Payment att_1', + amount: 5000, + currency: { iso: 'XOF' }, + customer: { + firstname: 'Ada', + lastname: 'Lovelace', + email: 'ada@example.com', + phone_number: { number: '+22990000000', country: 'BJ' }, + }, + }); + expect(server.calls[2]?.body).toEqual({ token: 'jwt_token' }); + expect(server.calls[0]?.headers.Authorization).toBe('Bearer sk_sandbox_test'); + expect(result).toMatchObject({ + status: 'pending', + providerRef: '1234', + nextAction: { type: 'customer_confirmation', channel: 'ussd' }, + }); + expect(ctx.reported).toEqual(['1234']); + }); + + it('uses the live API in live mode', async () => { + const server = fakeFedaPay(happyRoutes); + await fedapay({ secretKey: 'sk', environment: 'live', fetch: server.fetch }).collect( + request(), + context(), + ); + expect(server.calls[0]?.url).toBe('https://api.fedapay.com/v1/transactions'); + }); + + it('maps Payenv networks to FedaPay slugs', async () => { + const server = fakeFedaPay({ + ...happyRoutes, + 'POST /sbin': () => ({ status: 200, json: { 'v1/payment_intent': { status: 'pending' } } }), + }); + await fedapay({ secretKey: 'sk', fetch: server.fetch }).collect( + request({ + method: { type: 'mobile_money', network: 'celtiis', country: 'BJ', phone: '+22990000000' }, + }), + context(), + ); + expect(server.calls[2]?.url).toMatch(/\/v1\/sbin$/); + }); + + it('declares only configured operators as capabilities', () => { + const connector = fedapay({ secretKey: 'sk', operators: { BJ: { mtn: 'mtn_open' } } }); + expect(connector.capabilities()).toEqual([ + { + operation: 'collect', + method: 'mobile_money', + currencies: ['XOF'], + countries: ['BJ'], + networks: ['mtn'], + }, + ]); + }); +}); + +describe('fedapay connector — errors before the push are safe to fall back', () => { + it.each([ + [503, 'PROVIDER_UNAVAILABLE'], + [401, 'AUTHENTICATION_FAILED'], + [429, 'RATE_LIMITED'], + ])('HTTP %i on create → %s', async (status, code) => { + const server = fakeFedaPay({ 'POST /transactions': () => ({ status, json: {} }) }); + await expect( + fedapay({ secretKey: 'sk', fetch: server.fetch }).collect(request(), context()), + ).rejects.toMatchObject({ code, retryClass: 'safe_to_fallback' }); + }); + + it('network failure on token → PROVIDER_UNAVAILABLE', async () => { + const server = fakeFedaPay({ + ...happyRoutes, + 'POST /transactions/1234/token': () => Promise.reject(new TypeError('fetch failed')), + }); + await expect( + fedapay({ secretKey: 'sk', fetch: server.fetch }).collect(request(), context()), + ).rejects.toMatchObject({ code: 'PROVIDER_UNAVAILABLE', retryClass: 'safe_to_fallback' }); + }); + + it('operator not activated on the account → ROUTE_UNSUPPORTED', async () => { + const server = fakeFedaPay({ + ...happyRoutes, + 'POST /mtn_open': () => ({ status: 400, json: { message: 'Opération non autorisée' } }), + }); + await expect( + fedapay({ secretKey: 'sk', fetch: server.fetch }).collect(request(), context()), + ).rejects.toMatchObject({ code: 'ROUTE_UNSUPPORTED', retryClass: 'safe_to_fallback' }); + }); +}); + +describe('fedapay connector — errors during the push are ambiguous', () => { + it.each([ + ['HTTP 502', () => ({ status: 502, json: {} })], + ['network failure', () => Promise.reject(new TypeError('socket hang up'))], + ] as const)('%s on push → unknown with the transaction id', async (_label, handler) => { + const server = fakeFedaPay({ ...happyRoutes, 'POST /mtn_open': handler as Handler }); + const result = await fedapay({ secretKey: 'sk', fetch: server.fetch }).collect( + request(), + context(), + ); + expect(result.status).toBe('unknown'); + expect(result.providerRef).toBe('1234'); + expect(result.error?.retryClass).toBe('ambiguous'); + }); +}); + +describe('fedapay connector — status', () => { + it.each([ + ['pending', 'pending', undefined], + ['approved', 'succeeded', undefined], + ['declined', 'failed', 'CUSTOMER_DECLINED'], + ['canceled', 'canceled', 'CUSTOMER_DECLINED'], + ['expired', 'expired', 'CUSTOMER_TIMEOUT'], + ['something_new', 'unknown', undefined], + ])('FedaPay "%s" → %s', async (fedapayStatus, status, code) => { + const server = fakeFedaPay({ + 'GET /transactions/1234': () => ({ + status: 200, + json: { 'v1/transaction': { id: 1234, status: fedapayStatus } }, + }), + }); + const result = await fedapay({ secretKey: 'sk', fetch: server.fetch }).getStatus( + { operation: 'collect', reference: 'att_1', providerRef: '1234' }, + { signal: new AbortController().signal }, + ); + expect(result).toMatchObject({ found: true, status }); + if (result.found) expect(result.error?.code).toBe(code); + }); + + it('never claims "not found" without a transaction id', async () => { + const server = fakeFedaPay({}); + await expect( + fedapay({ secretKey: 'sk', fetch: server.fetch }).getStatus( + { operation: 'collect', reference: 'att_1' }, + { signal: new AbortController().signal }, + ), + ).rejects.toBeDefined(); + expect(server.calls).toHaveLength(0); + }); +}); + +describe('fedapay connector — inside Payenv', () => { + const backup = (): Connector & { calls: number } => { + const connector = { + id: 'backup', + calls: 0, + capabilities: () => fedapay({ secretKey: 'sk' }).capabilities(), + async collect(): Promise { + connector.calls += 1; + return { status: 'pending', providerRef: 'backup_1' }; + }, + async getStatus() { + return { found: false as const }; + }, + }; + return connector; + }; + + it('falls back to the next connector when FedaPay is down before the push', async () => { + const server = fakeFedaPay({ 'POST /transactions': () => ({ status: 503, json: {} }) }); + const other = backup(); + const payenv = createPayenv({ + connectors: [fedapay({ secretKey: 'sk', fetch: server.fetch }), other], + statusCheckDelaysMs: [0], + }); + + const payment = await payenv.collect(request({ idempotencyKey: 'fallback_1' })); + + expect(payment.connectorId).toBe('backup'); + expect(other.calls).toBe(1); + }); + + it('does not fall back when the push outcome is unknown and FedaPay says pending', async () => { + const server = fakeFedaPay({ + ...happyRoutes, + 'POST /mtn_open': () => ({ status: 504, json: {} }), + 'GET /transactions/1234': () => ({ + status: 200, + json: { 'v1/transaction': { id: 1234, status: 'pending' } }, + }), + }); + const other = backup(); + const payenv = createPayenv({ + connectors: [fedapay({ secretKey: 'sk', fetch: server.fetch }), other], + statusCheckDelaysMs: [0], + }); + + const payment = await payenv.collect(request({ idempotencyKey: 'ambiguous_1' })); + + expect(payment.status).toBe('pending'); + expect(payment.connectorId).toBe('fedapay'); + expect(payment.providerRef).toBe('1234'); + expect(other.calls).toBe(0); + }); +}); diff --git a/packages/connector-fedapay/test/sandbox.test.ts b/packages/connector-fedapay/test/sandbox.test.ts new file mode 100644 index 0000000..1b8697f --- /dev/null +++ b/packages/connector-fedapay/test/sandbox.test.ts @@ -0,0 +1,31 @@ +import { createPayenv } from '@payenv/core'; +import { describe, expect, it } from 'vitest'; +import { fedapay } from '../src/index.js'; + +// Opt-in: runs only when sandbox credentials are set in a local .env file. +// See .env.example at the repository root. Never use live keys here. +const secretKey = process.env.FEDAPAY_SANDBOX_SECRET_KEY; +const phone = process.env.FEDAPAY_SANDBOX_PHONE; + +describe.skipIf(!secretKey || !phone)('fedapay sandbox (real API)', () => { + it('sends an MTN push and reads the status back', { timeout: 60_000 }, async () => { + const connector = fedapay({ secretKey: secretKey as string, environment: 'sandbox' }); + const payenv = createPayenv({ connectors: [connector] }); + const idempotencyKey = `sandbox_${Date.now()}`; + + const payment = await payenv.collect({ + amount: { value: 100, currency: 'XOF' }, + method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: phone as string }, + customer: { firstName: 'Payenv', lastName: 'Sandbox', email: 'sandbox@example.com' }, + description: 'Payenv sandbox test', + idempotencyKey, + }); + + expect(payment.error).toBeUndefined(); + expect(['pending', 'succeeded']).toContain(payment.status); + expect(payment.providerRef).toBeDefined(); + + const refreshed = await payenv.refresh(idempotencyKey); + expect(refreshed.status).not.toBe('unknown'); + }); +}); diff --git a/packages/connector-fedapay/test/webhook.test.ts b/packages/connector-fedapay/test/webhook.test.ts new file mode 100644 index 0000000..d0a6c00 --- /dev/null +++ b/packages/connector-fedapay/test/webhook.test.ts @@ -0,0 +1,44 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { verifyFedaPayWebhook } from '../src/index.js'; + +const secret = 'whsec_test'; +const body = '{"name":"transaction.approved","entity":{"id":1234}}'; +const now = 1_790_000_000_000; +const timestamp = String(Math.floor(now / 1000)); + +function sign(payload: string, t = timestamp, key = secret) { + return `t=${t},s=${createHmac('sha256', key).update(`${t}.${payload}`).digest('hex')}`; +} + +describe('verifyFedaPayWebhook', () => { + it('accepts a valid signature', async () => { + expect(await verifyFedaPayWebhook(body, sign(body), secret, { now })).toBe(true); + }); + + it('rejects a modified body', async () => { + expect(await verifyFedaPayWebhook(`${body} `, sign(body), secret, { now })).toBe(false); + }); + + it('rejects a signature made with another secret', async () => { + expect(await verifyFedaPayWebhook(body, sign(body, timestamp, 'other'), secret, { now })).toBe( + false, + ); + }); + + it('rejects signatures older than the tolerance (replay protection)', async () => { + const old = String(Number(timestamp) - 301); + expect(await verifyFedaPayWebhook(body, sign(body, old), secret, { now })).toBe(false); + }); + + it.each([undefined, null, '', 'garbage', 't=abc,s=00', `s=${'0'.repeat(64)}`])( + 'rejects malformed header %s', + async (header) => { + expect(await verifyFedaPayWebhook(body, header, secret, { now })).toBe(false); + }, + ); + + it('rejects everything when the secret is missing', async () => { + expect(await verifyFedaPayWebhook(body, sign(body), '', { now })).toBe(false); + }); +}); diff --git a/packages/connector-fedapay/tsconfig.build.json b/packages/connector-fedapay/tsconfig.build.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/connector-fedapay/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/connector-fedapay/tsconfig.json b/packages/connector-fedapay/tsconfig.json new file mode 100644 index 0000000..2c81ede --- /dev/null +++ b/packages/connector-fedapay/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"], + "paths": { + "@payenv/core": ["../core/src/index.ts"] + } + }, + "include": ["src", "test"] +} diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts index 4475b2d..c689b4f 100644 --- a/packages/core/src/connector.ts +++ b/packages/core/src/connector.ts @@ -46,7 +46,12 @@ export type NextAction = | { type: 'redirect'; url: string } | { type: 'customer_confirmation'; channel?: 'ussd' | 'sms' | 'app'; message?: string }; -/** Result of a provider call, normalized by the connector. */ +/** + * Result of a provider call, normalized by the connector. + * + * When the outcome is uncertain but the provider's transaction id is known, return + * `{ status: 'unknown', providerRef, error }` rather than throwing. + */ export interface ProviderResult { status: PaymentStatus; /** The provider's identifier for this transaction. */ @@ -78,6 +83,12 @@ export interface AttemptContext { idempotencyKey: string; /** Aborted when the attempt times out. Pass it to `fetch`. */ signal: AbortSignal; + /** + * Reports the provider's transaction id as soon as it is known, before the call + * that could move money. If that call then times out, Payenv can still check + * the status with the provider instead of leaving the payment `unknown`. + */ + reportProviderRef(providerRef: string): void; } export interface StatusQuery { diff --git a/packages/core/src/payenv.ts b/packages/core/src/payenv.ts index 3c96a4b..8af2f8d 100644 --- a/packages/core/src/payenv.ts +++ b/packages/core/src/payenv.ts @@ -133,6 +133,10 @@ export function createPayenv(options: PayenvOptions): Payenv { ): Promise { const controller = new AbortController(); const query: StatusQuery = { operation: 'collect', reference: attempt.reference }; + const recordProviderRef = (providerRef: string) => { + attempt.providerRef = providerRef; + query.providerRef = providerRef; + }; let classification: Classification; try { const result = await withTimeout( @@ -142,14 +146,12 @@ export function createPayenv(options: PayenvOptions): Payenv { reference: attempt.reference, idempotencyKey: attempt.id, signal: controller.signal, + reportProviderRef: recordProviderRef, }), attemptTimeoutMs, controller, ); - if (result.providerRef !== undefined) { - attempt.providerRef = result.providerRef; - query.providerRef = result.providerRef; - } + if (result.providerRef !== undefined) recordProviderRef(result.providerRef); classification = classify(result, connector); } catch (thrown) { const error = toPayenvError(connector, thrown); diff --git a/packages/core/test/fallback.test.ts b/packages/core/test/fallback.test.ts index fb20e78..d251c8b 100644 --- a/packages/core/test/fallback.test.ts +++ b/packages/core/test/fallback.test.ts @@ -167,6 +167,25 @@ describe('ambiguous attempts (double-charge protection)', () => { expect(b.collectCalls).toHaveLength(0); }); + it('checks the status by provider ref reported before the call timed out', async () => { + const a = fakeConnector( + 'a', + (_request, context) => { + context.reportProviderRef('tx_42'); + return new Promise(() => {}); + }, + async (query) => + query.providerRef === 'tx_42' ? { found: true, status: 'pending' } : { found: false }, + ); + const b = fakeConnector('b', pending); + const payment = await createPayenv({ connectors: [a, b], ...fast }).collect(mtnRequest()); + + expect(a.statusCalls[0]?.providerRef).toBe('tx_42'); + expect(payment.status).toBe('pending'); + expect(payment.providerRef).toBe('tx_42'); + expect(b.collectCalls).toHaveLength(0); + }); + it('never starts a new attempt while a previous one could still succeed (randomized)', async () => { // `proven` tells, independently of the engine, whether the provider proved that // no money moved. The engine's own labels are deliberately not trusted here. diff --git a/packages/core/test/helpers.ts b/packages/core/test/helpers.ts index 4bd4533..dd4a52a 100644 --- a/packages/core/test/helpers.ts +++ b/packages/core/test/helpers.ts @@ -4,6 +4,7 @@ import type { CollectRequest, Connector, ProviderResult, + StatusQuery, StatusResult, } from '../src/index.js'; @@ -11,11 +12,11 @@ type CollectBehavior = ( request: CollectRequest, context: AttemptContext, ) => Promise; -type StatusBehavior = (reference: string) => Promise; +type StatusBehavior = (query: StatusQuery) => Promise; export interface FakeConnector extends Connector { collectCalls: AttemptContext[]; - statusCalls: string[]; + statusCalls: StatusQuery[]; } export const MTN_BJ_XOF: Capability = { @@ -42,8 +43,8 @@ export function fakeConnector( return collect(request, context); }, async getStatus(query) { - connector.statusCalls.push(query.reference); - return getStatus(query.reference); + connector.statusCalls.push(query); + return getStatus(query); }, }; return connector; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d406e7..e2aa32d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,18 @@ importers: version: 7.0.2 vitest: specifier: ^5.0.2 - version: 5.0.2(vite@8.3.1(esbuild@0.27.7)) + version: 5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.27.7)) + + packages/connector-fedapay: + devDependencies: + '@payenv/core': + specifier: workspace:^ + version: link:../core + '@types/node': + specifier: ^26.6.3 + version: 26.6.3 + + packages/core: {} packages: @@ -344,6 +355,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -641,6 +655,9 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -912,6 +929,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@26.6.3': + dependencies: + undici-types: 8.9.0 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -972,14 +993,14 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/mocker@5.0.2(vite@8.3.1(esbuild@0.27.7))': + '@vitest/mocker@5.0.2(vite@8.3.1(@types/node@26.6.3)(esbuild@0.27.7))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.2 estree-walker: 3.0.3 magic-string: 1.4.2 optionalDependencies: - vite: 8.3.1(esbuild@0.27.7) + vite: 8.3.1(@types/node@26.6.3)(esbuild@0.27.7) '@vitest/spy@5.0.2': {} @@ -1158,7 +1179,9 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - vite@8.3.1(esbuild@0.27.7): + undici-types@8.9.0: {} + + vite@8.3.1(@types/node@26.6.3)(esbuild@0.27.7): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1166,13 +1189,14 @@ snapshots: rolldown: 1.2.11 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.6.3 esbuild: 0.27.7 fsevents: 2.3.3 - vitest@5.0.2(vite@8.3.1(esbuild@0.27.7)): + vitest@5.0.2(@types/node@26.6.3)(vite@8.3.1(@types/node@26.6.3)(esbuild@0.27.7)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.2(vite@8.3.1(esbuild@0.27.7)) + '@vitest/mocker': 5.0.2(vite@8.3.1(@types/node@26.6.3)(esbuild@0.27.7)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -1183,8 +1207,10 @@ snapshots: tinybench: 6.2.0 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.1(esbuild@0.27.7) + vite: 8.3.1(@types/node@26.6.3)(esbuild@0.27.7) why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 26.6.3 transitivePeerDependencies: - msw diff --git a/tsconfig.base.json b/tsconfig.base.json index ec7f8c9..3e5ee90 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM"], + "types": [], "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, diff --git a/vitest.config.ts b/vitest.config.ts index 6e6e3bc..ceb90a6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,20 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +// Opt-in sandbox tests read their credentials from a local, git-ignored .env file. +try { + process.loadEnvFile('.env'); +} catch { + // No .env file: sandbox tests are skipped. +} + export default defineConfig({ + resolve: { + alias: { + // Test packages against each other's sources, without building first. + '@payenv/core': fileURLToPath(new URL('./packages/core/src/index.ts', import.meta.url)), + }, + }, test: { include: ['packages/*/test/**/*.test.ts'], },