@momorail/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Momorail contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Mobile money operators, each scoped by its ISO 3166-1 alpha-2 country code so
3
+ * that the same brand in two countries (e.g. Orange in Mali vs Senegal) stays
4
+ * distinct. Adapters advertise the subset they support via {@link ProviderCapabilities}.
5
+ */
6
+ declare const OPERATORS: readonly ["orange_ml", "moov_ml", "wave_ml", "orange_ci", "mtn_ci", "moov_ci", "wave_ci", "orange_sn", "free_sn", "wave_sn", "expresso_sn", "orange_bf", "moov_bf", "mtn_bj", "moov_bj", "orange_ne", "moov_ne", "airtel_ne", "moov_tg", "togocom_tg", "orange_cd", "airtel_cd", "vodacom_cd"];
7
+ type Operator = (typeof OPERATORS)[number];
8
+ declare function isOperator(value: string): value is Operator;
9
+ declare const CURRENCIES: readonly ["XOF", "XAF", "GHS", "NGN"];
10
+ type Currency = (typeof CURRENCIES)[number];
11
+ /**
12
+ * Currencies with no minor unit. Amounts are always integers in Momorail, but for
13
+ * these the integer is a whole franc rather than a hundredth. Adapters use this
14
+ * to format amounts for providers that still expect a decimal string.
15
+ */
16
+ declare const ZERO_DECIMAL_CURRENCIES: ReadonlySet<Currency>;
17
+
18
+ interface Money {
19
+ /**
20
+ * Amount as an integer. For zero-decimal currencies (XOF, XAF) this is a whole
21
+ * unit; for the others it is the minor unit (e.g. pesewa, kobo).
22
+ */
23
+ amount: number;
24
+ currency: Currency;
25
+ }
26
+ type TransactionType = 'collection' | 'disbursement';
27
+ type TransactionStatus = 'pending' | 'processing' | 'succeeded' | 'failed' | 'cancelled' | 'expired' | 'unknown';
28
+ /** Statuses that never change again once reached. */
29
+ declare const TERMINAL_STATUSES: ReadonlySet<TransactionStatus>;
30
+ declare function isTerminal(status: TransactionStatus): boolean;
31
+ type FailureReason = 'insufficient_funds' | 'invalid_number' | 'user_declined' | 'timeout' | 'provider_error' | 'limit_exceeded' | 'duplicate_reference' | 'other';
32
+ interface Party {
33
+ /** E.164 phone number, e.g. "+22370000000". */
34
+ phone: string;
35
+ operator?: Operator;
36
+ firstName?: string;
37
+ lastName?: string;
38
+ email?: string;
39
+ }
40
+ interface CollectionInput {
41
+ amount: Money;
42
+ customer: Party;
43
+ /** Caller-supplied unique reference. Doubles as the idempotency key. */
44
+ reference: string;
45
+ description?: string;
46
+ /** Server-to-server webhook URL the provider should call with status updates. */
47
+ callbackUrl?: string;
48
+ /** Browser redirect target after a hosted-checkout flow succeeds. */
49
+ returnUrl?: string;
50
+ /** Browser redirect target after a hosted-checkout flow is cancelled. */
51
+ cancelUrl?: string;
52
+ metadata?: Record<string, string>;
53
+ }
54
+ interface DisbursementInput {
55
+ amount: Money;
56
+ recipient: Party;
57
+ /** Caller-supplied unique reference. Doubles as the idempotency key. */
58
+ reference: string;
59
+ description?: string;
60
+ callbackUrl?: string;
61
+ metadata?: Record<string, string>;
62
+ }
63
+ interface TransactionRef {
64
+ /** Provider-side id, when known. */
65
+ id?: string;
66
+ /** Caller-supplied reference. */
67
+ reference?: string;
68
+ }
69
+ interface Transaction {
70
+ provider: string;
71
+ /** Provider-side id. */
72
+ id: string;
73
+ /** Caller-supplied reference echoed back. */
74
+ reference: string;
75
+ type: TransactionType;
76
+ status: TransactionStatus;
77
+ amount: Money;
78
+ fees?: Money;
79
+ operator?: Operator;
80
+ customer?: Party;
81
+ /** Set when the flow needs the customer redirected to a hosted page. */
82
+ checkoutUrl?: string;
83
+ /** Set only for a `failed` transaction. */
84
+ failureReason?: FailureReason;
85
+ /** Raw, unmodified provider payload. Always populated so callers never get stuck. */
86
+ providerRaw: unknown;
87
+ /** ISO 8601 timestamp. */
88
+ createdAt: string;
89
+ /** ISO 8601 timestamp. */
90
+ updatedAt: string;
91
+ }
92
+ interface ProviderCapabilities {
93
+ collection: boolean;
94
+ disbursement: boolean;
95
+ /** Status can be queried by caller reference, not only by provider id. */
96
+ lookupByReference: boolean;
97
+ /** Collection requires redirecting the customer to a hosted checkout page. */
98
+ hostedCheckout: boolean;
99
+ operators: readonly Operator[];
100
+ currencies: readonly Currency[];
101
+ }
102
+ interface RawWebhook {
103
+ headers: Record<string, string | string[] | undefined>;
104
+ /** Raw request body as received; keep it a string when a signature covers it. */
105
+ body: string | Uint8Array | Record<string, unknown>;
106
+ /** Full request URL or path+query, when the provider signs it. */
107
+ url?: string;
108
+ }
109
+ type WebhookEventType = 'transaction.succeeded' | 'transaction.failed' | 'transaction.pending' | 'transaction.cancelled' | 'transaction.expired' | 'unknown';
110
+ interface WebhookEvent {
111
+ type: WebhookEventType;
112
+ transaction: Transaction;
113
+ providerRaw: unknown;
114
+ }
115
+ /** Map a normalised {@link TransactionStatus} onto the webhook event type adapters emit. */
116
+ declare function webhookEventTypeForStatus(status: TransactionStatus): WebhookEventType;
117
+ interface RetryPolicy {
118
+ /** Number of retries after the first attempt. */
119
+ retries: number;
120
+ /** Base delay in ms; grows exponentially with full jitter. */
121
+ baseDelayMs: number;
122
+ /** Upper bound on a single delay in ms. */
123
+ maxDelayMs: number;
124
+ }
125
+ interface RequestOptions {
126
+ /** Route this call to a specific configured provider by id (skips operator routing). */
127
+ provider?: string;
128
+ /** Abort signal forwarded to the underlying transport and retry waits. */
129
+ signal?: AbortSignal;
130
+ /** Per-call override of the client retry policy. */
131
+ retry?: Partial<RetryPolicy>;
132
+ /** Per-call override of the client `failover` setting (`collection` only). */
133
+ failover?: boolean;
134
+ }
135
+
136
+ interface MomorailErrorOptions {
137
+ /** Id of the adapter that produced the error, when known. */
138
+ provider?: string;
139
+ /** Whether a retry with the same input could plausibly succeed. */
140
+ retryable?: boolean;
141
+ /** Raw provider payload that triggered the error. */
142
+ providerRaw?: unknown;
143
+ cause?: unknown;
144
+ }
145
+ /** Base class for every error Momorail throws. Adapters map provider errors onto a subclass. */
146
+ declare class MomorailError extends Error {
147
+ readonly provider: string | undefined;
148
+ readonly code: string;
149
+ readonly retryable: boolean;
150
+ readonly providerRaw: unknown;
151
+ constructor(message: string, code: string, options?: MomorailErrorOptions);
152
+ }
153
+ /** Credentials were rejected by the provider. */
154
+ declare class AuthError extends MomorailError {
155
+ constructor(message?: string, options?: MomorailErrorOptions);
156
+ }
157
+ /** The request was malformed before it ever reached the provider. */
158
+ declare class ValidationError extends MomorailError {
159
+ constructor(message: string, options?: MomorailErrorOptions);
160
+ }
161
+ /** The provider is reachable but temporarily refusing work; safe to retry. */
162
+ declare class ProviderUnavailableError extends MomorailError {
163
+ constructor(message?: string, options?: MomorailErrorOptions);
164
+ }
165
+ /** The provider throttled the request. Carries a hint of how long to wait. */
166
+ declare class RateLimitError extends MomorailError {
167
+ readonly retryAfterMs: number | undefined;
168
+ constructor(message?: string, options?: MomorailErrorOptions & {
169
+ retryAfterMs?: number;
170
+ });
171
+ }
172
+ /** No transaction matched the given id or reference. */
173
+ declare class TransactionNotFoundError extends MomorailError {
174
+ constructor(message?: string, options?: MomorailErrorOptions);
175
+ }
176
+ /** A webhook payload failed signature verification and must not be trusted. */
177
+ declare class WebhookVerificationError extends MomorailError {
178
+ constructor(message?: string, options?: MomorailErrorOptions);
179
+ }
180
+
181
+ /**
182
+ * The contract every aggregator adapter implements. An adapter translates
183
+ * between this shape and exactly one provider's API; it never talks to more
184
+ * than one provider, and it does not implement retry — the client owns that.
185
+ */
186
+ interface PaymentProvider {
187
+ /** Stable lowercase identifier, e.g. `'cinetpay'`. Matches `/^[a-z0-9_-]+$/`. */
188
+ readonly id: string;
189
+ collection(input: CollectionInput, options?: ProviderCallOptions): Promise<Transaction>;
190
+ disbursement(input: DisbursementInput, options?: ProviderCallOptions): Promise<Transaction>;
191
+ getTransaction(ref: TransactionRef, options?: ProviderCallOptions): Promise<Transaction>;
192
+ /** Verify and normalise an inbound webhook. Throws {@link WebhookVerificationError} on a bad signature. */
193
+ parseWebhook(raw: RawWebhook): Promise<WebhookEvent>;
194
+ capabilities(): ProviderCapabilities;
195
+ }
196
+ /** The slice of {@link RequestOptions} an adapter receives (retry stays with the client). */
197
+ type ProviderCallOptions = Pick<RequestOptions, 'signal'>;
198
+
199
+ declare const DEFAULT_RETRY_POLICY: RetryPolicy;
200
+ interface RetryContext {
201
+ policy: RetryPolicy;
202
+ signal?: AbortSignal;
203
+ onRetry?: (info: {
204
+ attempt: number;
205
+ delayMs: number;
206
+ error: unknown;
207
+ }) => void;
208
+ }
209
+ /**
210
+ * Run `fn`, retrying only while it throws a {@link MomorailError} whose `retryable`
211
+ * flag is set. `attempt` is zero-based. A provider-supplied `retryAfterMs`
212
+ * (e.g. on {@link RateLimitError}) lengthens the wait but never shortens it.
213
+ */
214
+ declare function runWithRetry<T>(fn: (attempt: number) => Promise<T>, ctx: RetryContext): Promise<T>;
215
+
216
+ /**
217
+ * Where an adapter remembers the transactions it has created, so a retried
218
+ * `collection` / `disbursement` with the same `reference` returns the same
219
+ * `Transaction` rather than starting a second one, and so a sparse
220
+ * `getTransaction` / webhook payload can be enriched with what the adapter
221
+ * already knows.
222
+ *
223
+ * The default is {@link MemoryIdempotencyStore} (per process). Pass a shared
224
+ * implementation (Redis, a database) for idempotency that survives restarts and
225
+ * spans instances. Keys are opaque strings; values are plain JSON-serialisable
226
+ * `Transaction` objects.
227
+ */
228
+ interface IdempotencyStore {
229
+ get(key: string): Transaction | undefined | Promise<Transaction | undefined>;
230
+ set(key: string, value: Transaction): void | Promise<void>;
231
+ }
232
+ /** In-memory {@link IdempotencyStore} with a bounded, FIFO-evicting map. */
233
+ declare class MemoryIdempotencyStore implements IdempotencyStore {
234
+ #private;
235
+ constructor(options?: {
236
+ maxEntries?: number;
237
+ });
238
+ get(key: string): Transaction | undefined;
239
+ set(key: string, value: Transaction): void;
240
+ }
241
+ /**
242
+ * A namespaced view over an {@link IdempotencyStore} that an adapter uses to
243
+ * store and recall a `Transaction` by either the caller `reference` or the
244
+ * provider-side `id`. The namespace (the adapter id) keeps entries from
245
+ * different adapters apart when they share one backing store.
246
+ */
247
+ declare class IdempotencyCache {
248
+ #private;
249
+ constructor(store: IdempotencyStore, namespace: string);
250
+ byReference(reference: string): Promise<Transaction | undefined>;
251
+ byId(id: string): Promise<Transaction | undefined>;
252
+ remember(txn: Transaction): Promise<void>;
253
+ }
254
+
255
+ interface MomorailClientOptions {
256
+ /** One or more configured adapters. Must contain at least one, with unique ids. */
257
+ providers: PaymentProvider[];
258
+ /** Id used when a call doesn't name a provider and no adapter matches the operator. Defaults to the first entry. */
259
+ defaultProvider?: string;
260
+ /** Overrides merged onto {@link DEFAULT_RETRY_POLICY}. */
261
+ retry?: Partial<RetryPolicy>;
262
+ /**
263
+ * When true, `collection` retries the *next* adapter covering the same
264
+ * operator if the chosen one raises {@link ProviderUnavailableError} (nothing
265
+ * was created, so it is safe to move on). Off by default. Never applies to
266
+ * `disbursement`. Can be overridden per call with `options.failover`.
267
+ */
268
+ failover?: boolean;
269
+ onRetry?: (info: {
270
+ provider: string;
271
+ attempt: number;
272
+ delayMs: number;
273
+ error: unknown;
274
+ }) => void;
275
+ onFailover?: (info: {
276
+ from: string;
277
+ to: string;
278
+ error: unknown;
279
+ }) => void;
280
+ }
281
+ /**
282
+ * Fans a single unified surface out to one of several {@link PaymentProvider}
283
+ * adapters. Validates input, routes by provider id, and applies the retry policy
284
+ * so adapters stay small.
285
+ */
286
+ declare class MomorailClient {
287
+ #private;
288
+ constructor(options: MomorailClientOptions);
289
+ get providerIds(): string[];
290
+ /** Resolve a configured adapter by id, or the default when `id` is omitted. */
291
+ provider(id?: string): PaymentProvider;
292
+ /** Id of the adapter a call for `operator` would be routed to, if any. */
293
+ route(operator: Operator): string | undefined;
294
+ /** `operator -> [adapter ids that advertise it]`, default-first. */
295
+ coverage(): Record<string, string[]>;
296
+ collection(input: CollectionInput, options?: RequestOptions): Promise<Transaction>;
297
+ disbursement(input: DisbursementInput, options?: RequestOptions): Promise<Transaction>;
298
+ getTransaction(ref: TransactionRef, options?: RequestOptions): Promise<Transaction>;
299
+ parseWebhook(providerId: string, raw: RawWebhook): Promise<WebhookEvent>;
300
+ /**
301
+ * Re-fetch every non-terminal transaction and return the refreshed list,
302
+ * preserving order. Terminal transactions are passed through untouched.
303
+ */
304
+ reconcile(transactions: readonly Transaction[], options?: RequestOptions): Promise<Transaction[]>;
305
+ }
306
+
307
+ export { AuthError, CURRENCIES, type CollectionInput, type Currency, DEFAULT_RETRY_POLICY, type DisbursementInput, type FailureReason, IdempotencyCache, type IdempotencyStore, MemoryIdempotencyStore, MomorailClient, type MomorailClientOptions, MomorailError, type MomorailErrorOptions, type Money, OPERATORS, type Operator, type Party, type PaymentProvider, type ProviderCallOptions, type ProviderCapabilities, ProviderUnavailableError, RateLimitError, type RawWebhook, type RequestOptions, type RetryContext, type RetryPolicy, TERMINAL_STATUSES, type Transaction, TransactionNotFoundError, type TransactionRef, type TransactionStatus, type TransactionType, ValidationError, type WebhookEvent, type WebhookEventType, WebhookVerificationError, ZERO_DECIMAL_CURRENCIES, isOperator, isTerminal, runWithRetry, webhookEventTypeForStatus };
package/dist/index.js ADDED
@@ -0,0 +1,370 @@
1
+ // src/operators.ts
2
+ var OPERATORS = [
3
+ "orange_ml",
4
+ "moov_ml",
5
+ "wave_ml",
6
+ "orange_ci",
7
+ "mtn_ci",
8
+ "moov_ci",
9
+ "wave_ci",
10
+ "orange_sn",
11
+ "free_sn",
12
+ "wave_sn",
13
+ "expresso_sn",
14
+ "orange_bf",
15
+ "moov_bf",
16
+ "mtn_bj",
17
+ "moov_bj",
18
+ "orange_ne",
19
+ "moov_ne",
20
+ "airtel_ne",
21
+ "moov_tg",
22
+ "togocom_tg",
23
+ "orange_cd",
24
+ "airtel_cd",
25
+ "vodacom_cd"
26
+ ];
27
+ function isOperator(value) {
28
+ return OPERATORS.includes(value);
29
+ }
30
+ var CURRENCIES = ["XOF", "XAF", "GHS", "NGN"];
31
+ var ZERO_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set(["XOF", "XAF"]);
32
+
33
+ // src/types.ts
34
+ var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
35
+ "succeeded",
36
+ "failed",
37
+ "cancelled",
38
+ "expired"
39
+ ]);
40
+ function isTerminal(status) {
41
+ return TERMINAL_STATUSES.has(status);
42
+ }
43
+ function webhookEventTypeForStatus(status) {
44
+ switch (status) {
45
+ case "succeeded":
46
+ return "transaction.succeeded";
47
+ case "failed":
48
+ return "transaction.failed";
49
+ case "cancelled":
50
+ return "transaction.cancelled";
51
+ case "expired":
52
+ return "transaction.expired";
53
+ case "pending":
54
+ case "processing":
55
+ return "transaction.pending";
56
+ default:
57
+ return "unknown";
58
+ }
59
+ }
60
+
61
+ // src/errors.ts
62
+ var MomorailError = class extends Error {
63
+ provider;
64
+ code;
65
+ retryable;
66
+ providerRaw;
67
+ constructor(message, code, options = {}) {
68
+ super(message, options.cause !== void 0 ? { cause: options.cause } : void 0);
69
+ this.name = new.target.name;
70
+ this.code = code;
71
+ this.provider = options.provider;
72
+ this.retryable = options.retryable ?? false;
73
+ this.providerRaw = options.providerRaw;
74
+ }
75
+ };
76
+ var AuthError = class extends MomorailError {
77
+ constructor(message = "Authentication with the provider failed", options = {}) {
78
+ super(message, "auth_error", { retryable: false, ...options });
79
+ }
80
+ };
81
+ var ValidationError = class extends MomorailError {
82
+ constructor(message, options = {}) {
83
+ super(message, "validation_error", { retryable: false, ...options });
84
+ }
85
+ };
86
+ var ProviderUnavailableError = class extends MomorailError {
87
+ constructor(message = "The provider is temporarily unavailable", options = {}) {
88
+ super(message, "provider_unavailable", { retryable: true, ...options });
89
+ }
90
+ };
91
+ var RateLimitError = class extends MomorailError {
92
+ retryAfterMs;
93
+ constructor(message = "Rate limit exceeded", options = {}) {
94
+ super(message, "rate_limit", { retryable: true, ...options });
95
+ this.retryAfterMs = options.retryAfterMs;
96
+ }
97
+ };
98
+ var TransactionNotFoundError = class extends MomorailError {
99
+ constructor(message = "Transaction not found", options = {}) {
100
+ super(message, "transaction_not_found", { retryable: false, ...options });
101
+ }
102
+ };
103
+ var WebhookVerificationError = class extends MomorailError {
104
+ constructor(message = "Webhook signature verification failed", options = {}) {
105
+ super(message, "webhook_verification", { retryable: false, ...options });
106
+ }
107
+ };
108
+
109
+ // src/retry.ts
110
+ var DEFAULT_RETRY_POLICY = {
111
+ retries: 2,
112
+ baseDelayMs: 200,
113
+ maxDelayMs: 2e3
114
+ };
115
+ function isRetryable(error) {
116
+ return error instanceof MomorailError && error.retryable;
117
+ }
118
+ function jitteredDelay(attempt, policy) {
119
+ const cap = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt);
120
+ return Math.random() * cap;
121
+ }
122
+ function sleep(ms, signal) {
123
+ return new Promise((resolve, reject) => {
124
+ if (signal?.aborted) {
125
+ reject(signal.reason ?? new Error("Aborted"));
126
+ return;
127
+ }
128
+ const onAbort = () => {
129
+ clearTimeout(timer);
130
+ reject(signal?.reason ?? new Error("Aborted"));
131
+ };
132
+ const timer = setTimeout(() => {
133
+ signal?.removeEventListener("abort", onAbort);
134
+ resolve();
135
+ }, ms);
136
+ signal?.addEventListener("abort", onAbort, { once: true });
137
+ });
138
+ }
139
+ async function runWithRetry(fn, ctx) {
140
+ const { policy, signal } = ctx;
141
+ let lastError;
142
+ for (let attempt = 0; attempt <= policy.retries; attempt++) {
143
+ try {
144
+ return await fn(attempt);
145
+ } catch (error) {
146
+ lastError = error;
147
+ if (attempt === policy.retries || !isRetryable(error)) throw error;
148
+ let delayMs = jitteredDelay(attempt, policy);
149
+ const retryAfter = error.retryAfterMs;
150
+ if (typeof retryAfter === "number" && retryAfter > delayMs) delayMs = retryAfter;
151
+ ctx.onRetry?.({ attempt, delayMs, error });
152
+ await sleep(delayMs, signal);
153
+ }
154
+ }
155
+ throw lastError;
156
+ }
157
+
158
+ // src/idempotency.ts
159
+ var MemoryIdempotencyStore = class {
160
+ #max;
161
+ #map = /* @__PURE__ */ new Map();
162
+ constructor(options = {}) {
163
+ this.#max = options.maxEntries ?? 5e3;
164
+ }
165
+ get(key) {
166
+ return this.#map.get(key);
167
+ }
168
+ set(key, value) {
169
+ if (this.#map.size >= this.#max && !this.#map.has(key)) {
170
+ const oldest = this.#map.keys().next().value;
171
+ if (oldest !== void 0) this.#map.delete(oldest);
172
+ }
173
+ this.#map.set(key, value);
174
+ }
175
+ };
176
+ var IdempotencyCache = class {
177
+ #store;
178
+ #ns;
179
+ constructor(store, namespace) {
180
+ this.#store = store;
181
+ this.#ns = namespace;
182
+ }
183
+ byReference(reference) {
184
+ return Promise.resolve(this.#store.get(`${this.#ns}:ref:${reference}`));
185
+ }
186
+ byId(id) {
187
+ return Promise.resolve(this.#store.get(`${this.#ns}:pid:${id}`));
188
+ }
189
+ async remember(txn) {
190
+ await this.#store.set(`${this.#ns}:ref:${txn.reference}`, txn);
191
+ await this.#store.set(`${this.#ns}:pid:${txn.id}`, txn);
192
+ }
193
+ };
194
+
195
+ // src/client.ts
196
+ var MomorailClient = class {
197
+ #providers;
198
+ #defaultProvider;
199
+ #retry;
200
+ #failover;
201
+ #onRetry;
202
+ #onFailover;
203
+ constructor(options) {
204
+ const [first] = options.providers;
205
+ if (!first) {
206
+ throw new ValidationError("MomorailClient requires at least one provider");
207
+ }
208
+ this.#providers = /* @__PURE__ */ new Map();
209
+ for (const provider of options.providers) {
210
+ if (this.#providers.has(provider.id)) {
211
+ throw new ValidationError(`Duplicate provider id: ${provider.id}`);
212
+ }
213
+ this.#providers.set(provider.id, provider);
214
+ }
215
+ this.#defaultProvider = options.defaultProvider ?? first.id;
216
+ if (!this.#providers.has(this.#defaultProvider)) {
217
+ throw new ValidationError(`Unknown defaultProvider: ${this.#defaultProvider}`);
218
+ }
219
+ this.#retry = { ...DEFAULT_RETRY_POLICY, ...options.retry };
220
+ this.#failover = options.failover ?? false;
221
+ this.#onRetry = options.onRetry;
222
+ this.#onFailover = options.onFailover;
223
+ }
224
+ get providerIds() {
225
+ return [...this.#providers.keys()];
226
+ }
227
+ /** Resolve a configured adapter by id, or the default when `id` is omitted. */
228
+ provider(id) {
229
+ const key = id ?? this.#defaultProvider;
230
+ const provider = this.#providers.get(key);
231
+ if (!provider) throw new ValidationError(`Unknown provider: ${key}`);
232
+ return provider;
233
+ }
234
+ /** Id of the adapter a call for `operator` would be routed to, if any. */
235
+ route(operator) {
236
+ return this.#candidatesFor(operator)[0]?.id;
237
+ }
238
+ /** `operator -> [adapter ids that advertise it]`, default-first. */
239
+ coverage() {
240
+ const out = {};
241
+ for (const provider of this.#providers.values()) {
242
+ for (const op of provider.capabilities().operators) {
243
+ const list = out[op] ?? [];
244
+ list.push(provider.id);
245
+ out[op] = list;
246
+ }
247
+ }
248
+ return out;
249
+ }
250
+ async collection(input, options = {}) {
251
+ assertReference(input.reference);
252
+ assertAmount(input.amount);
253
+ const candidates = this.#candidatesFor(input.customer.operator, options.provider);
254
+ const failover = options.failover ?? this.#failover;
255
+ return this.#runAcross(
256
+ candidates,
257
+ failover,
258
+ options,
259
+ (p, signal) => p.collection(input, { signal })
260
+ );
261
+ }
262
+ async disbursement(input, options = {}) {
263
+ assertReference(input.reference);
264
+ assertAmount(input.amount);
265
+ const provider = this.#candidatesFor(
266
+ input.recipient.operator,
267
+ options.provider
268
+ )[0];
269
+ return this.#callProvider(provider, options, (p, signal) => p.disbursement(input, { signal }));
270
+ }
271
+ async getTransaction(ref, options = {}) {
272
+ if (!ref.id && !ref.reference) {
273
+ throw new ValidationError("getTransaction requires an id or a reference");
274
+ }
275
+ return this.#callProvider(
276
+ this.provider(options.provider),
277
+ options,
278
+ (p, signal) => p.getTransaction(ref, { signal })
279
+ );
280
+ }
281
+ async parseWebhook(providerId, raw) {
282
+ return this.provider(providerId).parseWebhook(raw);
283
+ }
284
+ /**
285
+ * Re-fetch every non-terminal transaction and return the refreshed list,
286
+ * preserving order. Terminal transactions are passed through untouched.
287
+ */
288
+ reconcile(transactions, options = {}) {
289
+ return Promise.all(
290
+ transactions.map(
291
+ (txn) => isTerminal(txn.status) ? Promise.resolve(txn) : this.getTransaction(
292
+ { id: txn.id, reference: txn.reference },
293
+ { ...options, provider: txn.provider }
294
+ )
295
+ )
296
+ );
297
+ }
298
+ /** Ordered adapters for a call: explicit id wins; else operator match (default first); else default. */
299
+ #candidatesFor(operator, explicit) {
300
+ if (explicit) return [this.provider(explicit)];
301
+ const all = [...this.#providers.values()];
302
+ if (!operator) return [this.provider()];
303
+ const matching = all.filter((p) => p.capabilities().operators.includes(operator));
304
+ if (matching.length === 0) return [this.provider()];
305
+ const def = this.#providers.get(this.#defaultProvider);
306
+ return def && matching.includes(def) ? [def, ...matching.filter((p) => p !== def)] : matching;
307
+ }
308
+ #callProvider(provider, options, run) {
309
+ const policy = { ...this.#retry, ...options.retry };
310
+ return runWithRetry((_attempt) => run(provider, options.signal), {
311
+ policy,
312
+ signal: options.signal,
313
+ onRetry: (info) => this.#onRetry?.({ provider: provider.id, ...info })
314
+ });
315
+ }
316
+ async #runAcross(candidates, failover, options, run) {
317
+ const list = failover ? candidates : candidates.slice(0, 1);
318
+ let lastError;
319
+ for (let i = 0; i < list.length; i++) {
320
+ const provider = list[i];
321
+ try {
322
+ return await this.#callProvider(provider, options, run);
323
+ } catch (err) {
324
+ lastError = err;
325
+ const next = list[i + 1];
326
+ if (!(err instanceof ProviderUnavailableError) || !next) throw err;
327
+ this.#onFailover?.({ from: provider.id, to: next.id, error: err });
328
+ }
329
+ }
330
+ throw lastError;
331
+ }
332
+ };
333
+ function assertReference(reference) {
334
+ if (typeof reference !== "string" || reference.trim().length === 0) {
335
+ throw new ValidationError("reference must be a non-empty string");
336
+ }
337
+ if (reference.length > 128) {
338
+ throw new ValidationError("reference must be at most 128 characters");
339
+ }
340
+ }
341
+ function assertAmount(amount) {
342
+ if (!Number.isFinite(amount.amount) || amount.amount <= 0) {
343
+ throw new ValidationError("amount.amount must be a positive number");
344
+ }
345
+ if (!Number.isInteger(amount.amount)) {
346
+ throw new ValidationError("amount.amount must be an integer");
347
+ }
348
+ }
349
+ export {
350
+ AuthError,
351
+ CURRENCIES,
352
+ DEFAULT_RETRY_POLICY,
353
+ IdempotencyCache,
354
+ MemoryIdempotencyStore,
355
+ MomorailClient,
356
+ MomorailError,
357
+ OPERATORS,
358
+ ProviderUnavailableError,
359
+ RateLimitError,
360
+ TERMINAL_STATUSES,
361
+ TransactionNotFoundError,
362
+ ValidationError,
363
+ WebhookVerificationError,
364
+ ZERO_DECIMAL_CURRENCIES,
365
+ isOperator,
366
+ isTerminal,
367
+ runWithRetry,
368
+ webhookEventTypeForStatus
369
+ };
370
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/operators.ts","../src/types.ts","../src/errors.ts","../src/retry.ts","../src/idempotency.ts","../src/client.ts"],"sourcesContent":["/**\n * Mobile money operators, each scoped by its ISO 3166-1 alpha-2 country code so\n * that the same brand in two countries (e.g. Orange in Mali vs Senegal) stays\n * distinct. Adapters advertise the subset they support via {@link ProviderCapabilities}.\n */\nexport const OPERATORS = [\n 'orange_ml',\n 'moov_ml',\n 'wave_ml',\n 'orange_ci',\n 'mtn_ci',\n 'moov_ci',\n 'wave_ci',\n 'orange_sn',\n 'free_sn',\n 'wave_sn',\n 'expresso_sn',\n 'orange_bf',\n 'moov_bf',\n 'mtn_bj',\n 'moov_bj',\n 'orange_ne',\n 'moov_ne',\n 'airtel_ne',\n 'moov_tg',\n 'togocom_tg',\n 'orange_cd',\n 'airtel_cd',\n 'vodacom_cd',\n] as const;\n\nexport type Operator = (typeof OPERATORS)[number];\n\nexport function isOperator(value: string): value is Operator {\n return (OPERATORS as readonly string[]).includes(value);\n}\n\nexport const CURRENCIES = ['XOF', 'XAF', 'GHS', 'NGN'] as const;\n\nexport type Currency = (typeof CURRENCIES)[number];\n\n/**\n * Currencies with no minor unit. Amounts are always integers in Momorail, but for\n * these the integer is a whole franc rather than a hundredth. Adapters use this\n * to format amounts for providers that still expect a decimal string.\n */\nexport const ZERO_DECIMAL_CURRENCIES: ReadonlySet<Currency> = new Set(['XOF', 'XAF']);\n","import type { Currency, Operator } from './operators.js';\n\nexport interface Money {\n /**\n * Amount as an integer. For zero-decimal currencies (XOF, XAF) this is a whole\n * unit; for the others it is the minor unit (e.g. pesewa, kobo).\n */\n amount: number;\n currency: Currency;\n}\n\nexport type TransactionType = 'collection' | 'disbursement';\n\nexport type TransactionStatus =\n | 'pending'\n | 'processing'\n | 'succeeded'\n | 'failed'\n | 'cancelled'\n | 'expired'\n | 'unknown';\n\n/** Statuses that never change again once reached. */\nexport const TERMINAL_STATUSES: ReadonlySet<TransactionStatus> = new Set<TransactionStatus>([\n 'succeeded',\n 'failed',\n 'cancelled',\n 'expired',\n]);\n\nexport function isTerminal(status: TransactionStatus): boolean {\n return TERMINAL_STATUSES.has(status);\n}\n\nexport type FailureReason =\n | 'insufficient_funds'\n | 'invalid_number'\n | 'user_declined'\n | 'timeout'\n | 'provider_error'\n | 'limit_exceeded'\n | 'duplicate_reference'\n | 'other';\n\nexport interface Party {\n /** E.164 phone number, e.g. \"+22370000000\". */\n phone: string;\n operator?: Operator;\n firstName?: string;\n lastName?: string;\n email?: string;\n}\n\nexport interface CollectionInput {\n amount: Money;\n customer: Party;\n /** Caller-supplied unique reference. Doubles as the idempotency key. */\n reference: string;\n description?: string;\n /** Server-to-server webhook URL the provider should call with status updates. */\n callbackUrl?: string;\n /** Browser redirect target after a hosted-checkout flow succeeds. */\n returnUrl?: string;\n /** Browser redirect target after a hosted-checkout flow is cancelled. */\n cancelUrl?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface DisbursementInput {\n amount: Money;\n recipient: Party;\n /** Caller-supplied unique reference. Doubles as the idempotency key. */\n reference: string;\n description?: string;\n callbackUrl?: string;\n metadata?: Record<string, string>;\n}\n\nexport interface TransactionRef {\n /** Provider-side id, when known. */\n id?: string;\n /** Caller-supplied reference. */\n reference?: string;\n}\n\nexport interface Transaction {\n provider: string;\n /** Provider-side id. */\n id: string;\n /** Caller-supplied reference echoed back. */\n reference: string;\n type: TransactionType;\n status: TransactionStatus;\n amount: Money;\n fees?: Money;\n operator?: Operator;\n customer?: Party;\n /** Set when the flow needs the customer redirected to a hosted page. */\n checkoutUrl?: string;\n /** Set only for a `failed` transaction. */\n failureReason?: FailureReason;\n /** Raw, unmodified provider payload. Always populated so callers never get stuck. */\n providerRaw: unknown;\n /** ISO 8601 timestamp. */\n createdAt: string;\n /** ISO 8601 timestamp. */\n updatedAt: string;\n}\n\nexport interface ProviderCapabilities {\n collection: boolean;\n disbursement: boolean;\n /** Status can be queried by caller reference, not only by provider id. */\n lookupByReference: boolean;\n /** Collection requires redirecting the customer to a hosted checkout page. */\n hostedCheckout: boolean;\n operators: readonly Operator[];\n currencies: readonly Currency[];\n}\n\nexport interface RawWebhook {\n headers: Record<string, string | string[] | undefined>;\n /** Raw request body as received; keep it a string when a signature covers it. */\n body: string | Uint8Array | Record<string, unknown>;\n /** Full request URL or path+query, when the provider signs it. */\n url?: string;\n}\n\nexport type WebhookEventType =\n | 'transaction.succeeded'\n | 'transaction.failed'\n | 'transaction.pending'\n | 'transaction.cancelled'\n | 'transaction.expired'\n | 'unknown';\n\nexport interface WebhookEvent {\n type: WebhookEventType;\n transaction: Transaction;\n providerRaw: unknown;\n}\n\n/** Map a normalised {@link TransactionStatus} onto the webhook event type adapters emit. */\nexport function webhookEventTypeForStatus(status: TransactionStatus): WebhookEventType {\n switch (status) {\n case 'succeeded':\n return 'transaction.succeeded';\n case 'failed':\n return 'transaction.failed';\n case 'cancelled':\n return 'transaction.cancelled';\n case 'expired':\n return 'transaction.expired';\n case 'pending':\n case 'processing':\n return 'transaction.pending';\n default:\n return 'unknown';\n }\n}\n\nexport interface RetryPolicy {\n /** Number of retries after the first attempt. */\n retries: number;\n /** Base delay in ms; grows exponentially with full jitter. */\n baseDelayMs: number;\n /** Upper bound on a single delay in ms. */\n maxDelayMs: number;\n}\n\nexport interface RequestOptions {\n /** Route this call to a specific configured provider by id (skips operator routing). */\n provider?: string;\n /** Abort signal forwarded to the underlying transport and retry waits. */\n signal?: AbortSignal;\n /** Per-call override of the client retry policy. */\n retry?: Partial<RetryPolicy>;\n /** Per-call override of the client `failover` setting (`collection` only). */\n failover?: boolean;\n}\n","export interface MomorailErrorOptions {\n /** Id of the adapter that produced the error, when known. */\n provider?: string;\n /** Whether a retry with the same input could plausibly succeed. */\n retryable?: boolean;\n /** Raw provider payload that triggered the error. */\n providerRaw?: unknown;\n cause?: unknown;\n}\n\n/** Base class for every error Momorail throws. Adapters map provider errors onto a subclass. */\nexport class MomorailError extends Error {\n readonly provider: string | undefined;\n readonly code: string;\n readonly retryable: boolean;\n readonly providerRaw: unknown;\n\n constructor(message: string, code: string, options: MomorailErrorOptions = {}) {\n super(message, options.cause !== undefined ? { cause: options.cause } : undefined);\n this.name = new.target.name;\n this.code = code;\n this.provider = options.provider;\n this.retryable = options.retryable ?? false;\n this.providerRaw = options.providerRaw;\n }\n}\n\n/** Credentials were rejected by the provider. */\nexport class AuthError extends MomorailError {\n constructor(\n message = 'Authentication with the provider failed',\n options: MomorailErrorOptions = {},\n ) {\n super(message, 'auth_error', { retryable: false, ...options });\n }\n}\n\n/** The request was malformed before it ever reached the provider. */\nexport class ValidationError extends MomorailError {\n constructor(message: string, options: MomorailErrorOptions = {}) {\n super(message, 'validation_error', { retryable: false, ...options });\n }\n}\n\n/** The provider is reachable but temporarily refusing work; safe to retry. */\nexport class ProviderUnavailableError extends MomorailError {\n constructor(\n message = 'The provider is temporarily unavailable',\n options: MomorailErrorOptions = {},\n ) {\n super(message, 'provider_unavailable', { retryable: true, ...options });\n }\n}\n\n/** The provider throttled the request. Carries a hint of how long to wait. */\nexport class RateLimitError extends MomorailError {\n readonly retryAfterMs: number | undefined;\n\n constructor(\n message = 'Rate limit exceeded',\n options: MomorailErrorOptions & { retryAfterMs?: number } = {},\n ) {\n super(message, 'rate_limit', { retryable: true, ...options });\n this.retryAfterMs = options.retryAfterMs;\n }\n}\n\n/** No transaction matched the given id or reference. */\nexport class TransactionNotFoundError extends MomorailError {\n constructor(message = 'Transaction not found', options: MomorailErrorOptions = {}) {\n super(message, 'transaction_not_found', { retryable: false, ...options });\n }\n}\n\n/** A webhook payload failed signature verification and must not be trusted. */\nexport class WebhookVerificationError extends MomorailError {\n constructor(\n message = 'Webhook signature verification failed',\n options: MomorailErrorOptions = {},\n ) {\n super(message, 'webhook_verification', { retryable: false, ...options });\n }\n}\n","import { MomorailError } from './errors.js';\nimport type { RetryPolicy } from './types.js';\n\nexport const DEFAULT_RETRY_POLICY: RetryPolicy = {\n retries: 2,\n baseDelayMs: 200,\n maxDelayMs: 2_000,\n};\n\nfunction isRetryable(error: unknown): boolean {\n return error instanceof MomorailError && error.retryable;\n}\n\n/** Exponential backoff capped at maxDelayMs, then full jitter in [0, cap]. */\nfunction jitteredDelay(attempt: number, policy: RetryPolicy): number {\n const cap = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt);\n return Math.random() * cap;\n}\n\nfunction sleep(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise((resolve, reject) => {\n if (signal?.aborted) {\n reject(signal.reason ?? new Error('Aborted'));\n return;\n }\n const onAbort = () => {\n clearTimeout(timer);\n reject(signal?.reason ?? new Error('Aborted'));\n };\n const timer = setTimeout(() => {\n signal?.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n signal?.addEventListener('abort', onAbort, { once: true });\n });\n}\n\nexport interface RetryContext {\n policy: RetryPolicy;\n signal?: AbortSignal;\n onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void;\n}\n\n/**\n * Run `fn`, retrying only while it throws a {@link MomorailError} whose `retryable`\n * flag is set. `attempt` is zero-based. A provider-supplied `retryAfterMs`\n * (e.g. on {@link RateLimitError}) lengthens the wait but never shortens it.\n */\nexport async function runWithRetry<T>(\n fn: (attempt: number) => Promise<T>,\n ctx: RetryContext,\n): Promise<T> {\n const { policy, signal } = ctx;\n let lastError: unknown;\n\n for (let attempt = 0; attempt <= policy.retries; attempt++) {\n try {\n return await fn(attempt);\n } catch (error) {\n lastError = error;\n if (attempt === policy.retries || !isRetryable(error)) throw error;\n\n let delayMs = jitteredDelay(attempt, policy);\n const retryAfter = (error as { retryAfterMs?: unknown }).retryAfterMs;\n if (typeof retryAfter === 'number' && retryAfter > delayMs) delayMs = retryAfter;\n\n ctx.onRetry?.({ attempt, delayMs, error });\n await sleep(delayMs, signal);\n }\n }\n\n throw lastError;\n}\n","import type { Transaction } from './types.js';\n\n/**\n * Where an adapter remembers the transactions it has created, so a retried\n * `collection` / `disbursement` with the same `reference` returns the same\n * `Transaction` rather than starting a second one, and so a sparse\n * `getTransaction` / webhook payload can be enriched with what the adapter\n * already knows.\n *\n * The default is {@link MemoryIdempotencyStore} (per process). Pass a shared\n * implementation (Redis, a database) for idempotency that survives restarts and\n * spans instances. Keys are opaque strings; values are plain JSON-serialisable\n * `Transaction` objects.\n */\nexport interface IdempotencyStore {\n get(key: string): Transaction | undefined | Promise<Transaction | undefined>;\n set(key: string, value: Transaction): void | Promise<void>;\n}\n\n/** In-memory {@link IdempotencyStore} with a bounded, FIFO-evicting map. */\nexport class MemoryIdempotencyStore implements IdempotencyStore {\n readonly #max: number;\n readonly #map = new Map<string, Transaction>();\n\n constructor(options: { maxEntries?: number } = {}) {\n this.#max = options.maxEntries ?? 5_000;\n }\n\n get(key: string): Transaction | undefined {\n return this.#map.get(key);\n }\n\n set(key: string, value: Transaction): void {\n if (this.#map.size >= this.#max && !this.#map.has(key)) {\n const oldest = this.#map.keys().next().value;\n if (oldest !== undefined) this.#map.delete(oldest);\n }\n this.#map.set(key, value);\n }\n}\n\n/**\n * A namespaced view over an {@link IdempotencyStore} that an adapter uses to\n * store and recall a `Transaction` by either the caller `reference` or the\n * provider-side `id`. The namespace (the adapter id) keeps entries from\n * different adapters apart when they share one backing store.\n */\nexport class IdempotencyCache {\n readonly #store: IdempotencyStore;\n readonly #ns: string;\n\n constructor(store: IdempotencyStore, namespace: string) {\n this.#store = store;\n this.#ns = namespace;\n }\n\n byReference(reference: string): Promise<Transaction | undefined> {\n return Promise.resolve(this.#store.get(`${this.#ns}:ref:${reference}`));\n }\n\n byId(id: string): Promise<Transaction | undefined> {\n return Promise.resolve(this.#store.get(`${this.#ns}:pid:${id}`));\n }\n\n async remember(txn: Transaction): Promise<void> {\n await this.#store.set(`${this.#ns}:ref:${txn.reference}`, txn);\n await this.#store.set(`${this.#ns}:pid:${txn.id}`, txn);\n }\n}\n","import { ProviderUnavailableError, ValidationError } from './errors.js';\nimport type { Operator } from './operators.js';\nimport type { PaymentProvider } from './provider.js';\nimport { DEFAULT_RETRY_POLICY, runWithRetry } from './retry.js';\nimport {\n type CollectionInput,\n type DisbursementInput,\n type Money,\n type RawWebhook,\n type RequestOptions,\n type RetryPolicy,\n type Transaction,\n type TransactionRef,\n type WebhookEvent,\n isTerminal,\n} from './types.js';\n\nexport interface MomorailClientOptions {\n /** One or more configured adapters. Must contain at least one, with unique ids. */\n providers: PaymentProvider[];\n /** Id used when a call doesn't name a provider and no adapter matches the operator. Defaults to the first entry. */\n defaultProvider?: string;\n /** Overrides merged onto {@link DEFAULT_RETRY_POLICY}. */\n retry?: Partial<RetryPolicy>;\n /**\n * When true, `collection` retries the *next* adapter covering the same\n * operator if the chosen one raises {@link ProviderUnavailableError} (nothing\n * was created, so it is safe to move on). Off by default. Never applies to\n * `disbursement`. Can be overridden per call with `options.failover`.\n */\n failover?: boolean;\n onRetry?: (info: {\n provider: string;\n attempt: number;\n delayMs: number;\n error: unknown;\n }) => void;\n onFailover?: (info: { from: string; to: string; error: unknown }) => void;\n}\n\n/**\n * Fans a single unified surface out to one of several {@link PaymentProvider}\n * adapters. Validates input, routes by provider id, and applies the retry policy\n * so adapters stay small.\n */\nexport class MomorailClient {\n readonly #providers: Map<string, PaymentProvider>;\n readonly #defaultProvider: string;\n readonly #retry: RetryPolicy;\n readonly #failover: boolean;\n readonly #onRetry: MomorailClientOptions['onRetry'];\n readonly #onFailover: MomorailClientOptions['onFailover'];\n\n constructor(options: MomorailClientOptions) {\n const [first] = options.providers;\n if (!first) {\n throw new ValidationError('MomorailClient requires at least one provider');\n }\n\n this.#providers = new Map();\n for (const provider of options.providers) {\n if (this.#providers.has(provider.id)) {\n throw new ValidationError(`Duplicate provider id: ${provider.id}`);\n }\n this.#providers.set(provider.id, provider);\n }\n\n this.#defaultProvider = options.defaultProvider ?? first.id;\n if (!this.#providers.has(this.#defaultProvider)) {\n throw new ValidationError(`Unknown defaultProvider: ${this.#defaultProvider}`);\n }\n\n this.#retry = { ...DEFAULT_RETRY_POLICY, ...options.retry };\n this.#failover = options.failover ?? false;\n this.#onRetry = options.onRetry;\n this.#onFailover = options.onFailover;\n }\n\n get providerIds(): string[] {\n return [...this.#providers.keys()];\n }\n\n /** Resolve a configured adapter by id, or the default when `id` is omitted. */\n provider(id?: string): PaymentProvider {\n const key = id ?? this.#defaultProvider;\n const provider = this.#providers.get(key);\n if (!provider) throw new ValidationError(`Unknown provider: ${key}`);\n return provider;\n }\n\n /** Id of the adapter a call for `operator` would be routed to, if any. */\n route(operator: Operator): string | undefined {\n return this.#candidatesFor(operator)[0]?.id;\n }\n\n /** `operator -> [adapter ids that advertise it]`, default-first. */\n coverage(): Record<string, string[]> {\n const out: Record<string, string[]> = {};\n for (const provider of this.#providers.values()) {\n for (const op of provider.capabilities().operators) {\n const list = out[op] ?? [];\n list.push(provider.id);\n out[op] = list;\n }\n }\n return out;\n }\n\n async collection(input: CollectionInput, options: RequestOptions = {}): Promise<Transaction> {\n assertReference(input.reference);\n assertAmount(input.amount);\n const candidates = this.#candidatesFor(input.customer.operator, options.provider);\n const failover = options.failover ?? this.#failover;\n return this.#runAcross(candidates, failover, options, (p, signal) =>\n p.collection(input, { signal }),\n );\n }\n\n async disbursement(input: DisbursementInput, options: RequestOptions = {}): Promise<Transaction> {\n assertReference(input.reference);\n assertAmount(input.amount);\n // Route by operator, but never fail a payout over to another rail.\n // #candidatesFor always returns a non-empty list.\n const provider = this.#candidatesFor(\n input.recipient.operator,\n options.provider,\n )[0] as PaymentProvider;\n return this.#callProvider(provider, options, (p, signal) => p.disbursement(input, { signal }));\n }\n\n async getTransaction(ref: TransactionRef, options: RequestOptions = {}): Promise<Transaction> {\n if (!ref.id && !ref.reference) {\n throw new ValidationError('getTransaction requires an id or a reference');\n }\n return this.#callProvider(this.provider(options.provider), options, (p, signal) =>\n p.getTransaction(ref, { signal }),\n );\n }\n\n async parseWebhook(providerId: string, raw: RawWebhook): Promise<WebhookEvent> {\n return this.provider(providerId).parseWebhook(raw);\n }\n\n /**\n * Re-fetch every non-terminal transaction and return the refreshed list,\n * preserving order. Terminal transactions are passed through untouched.\n */\n reconcile(\n transactions: readonly Transaction[],\n options: RequestOptions = {},\n ): Promise<Transaction[]> {\n return Promise.all(\n transactions.map((txn) =>\n isTerminal(txn.status)\n ? Promise.resolve(txn)\n : this.getTransaction(\n { id: txn.id, reference: txn.reference },\n { ...options, provider: txn.provider },\n ),\n ),\n );\n }\n\n /** Ordered adapters for a call: explicit id wins; else operator match (default first); else default. */\n #candidatesFor(operator: Operator | undefined, explicit?: string): PaymentProvider[] {\n if (explicit) return [this.provider(explicit)];\n const all = [...this.#providers.values()];\n if (!operator) return [this.provider()];\n\n const matching = all.filter((p) => p.capabilities().operators.includes(operator));\n if (matching.length === 0) return [this.provider()];\n\n const def = this.#providers.get(this.#defaultProvider);\n return def && matching.includes(def) ? [def, ...matching.filter((p) => p !== def)] : matching;\n }\n\n #callProvider<T>(\n provider: PaymentProvider,\n options: RequestOptions,\n run: (provider: PaymentProvider, signal?: AbortSignal) => Promise<T>,\n ): Promise<T> {\n const policy = { ...this.#retry, ...options.retry };\n return runWithRetry((_attempt) => run(provider, options.signal), {\n policy,\n signal: options.signal,\n onRetry: (info) => this.#onRetry?.({ provider: provider.id, ...info }),\n });\n }\n\n async #runAcross<T>(\n candidates: PaymentProvider[],\n failover: boolean,\n options: RequestOptions,\n run: (provider: PaymentProvider, signal?: AbortSignal) => Promise<T>,\n ): Promise<T> {\n const list = failover ? candidates : candidates.slice(0, 1);\n let lastError: unknown;\n for (let i = 0; i < list.length; i++) {\n const provider = list[i]!;\n try {\n return await this.#callProvider(provider, options, run);\n } catch (err) {\n lastError = err;\n const next = list[i + 1];\n if (!(err instanceof ProviderUnavailableError) || !next) throw err;\n this.#onFailover?.({ from: provider.id, to: next.id, error: err });\n }\n }\n throw lastError;\n }\n}\n\nfunction assertReference(reference: string): void {\n if (typeof reference !== 'string' || reference.trim().length === 0) {\n throw new ValidationError('reference must be a non-empty string');\n }\n if (reference.length > 128) {\n throw new ValidationError('reference must be at most 128 characters');\n }\n}\n\nfunction assertAmount(amount: Money): void {\n if (!Number.isFinite(amount.amount) || amount.amount <= 0) {\n throw new ValidationError('amount.amount must be a positive number');\n }\n if (!Number.isInteger(amount.amount)) {\n throw new ValidationError('amount.amount must be an integer');\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@momorail/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Unified domain model, client and error taxonomy for Momorail payment adapters",
6
+ "keywords": [
7
+ "mobile-money",
8
+ "payments",
9
+ "west-africa",
10
+ "africa",
11
+ "orange-money",
12
+ "mtn-momo",
13
+ "wave",
14
+ "xof",
15
+ "byo-keys",
16
+ "typescript",
17
+ "core"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "Boukymen <boukymen@gmail.com>",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/Boukymen/momorail.git",
24
+ "directory": "packages/core"
25
+ },
26
+ "homepage": "https://github.com/Boukymen/momorail/tree/main/packages/core#readme",
27
+ "bugs": "https://github.com/Boukymen/momorail/issues",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "devDependencies": {
43
+ "tsup": "^8.3.5",
44
+ "typescript": "^5.7.2",
45
+ "vitest": "^2.1.8"
46
+ },
47
+ "scripts": {
48
+ "build": "tsup",
49
+ "typecheck": "tsc --noEmit",
50
+ "test": "vitest run"
51
+ }
52
+ }