@myzonerocks/pact 0.1.8 → 0.2.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/dist/src/adapters/mpesa.d.ts +7 -0
- package/dist/src/adapters/mpesa.js +73 -15
- package/dist/test/mpesa.test.js +38 -1
- package/package.json +1 -1
- package/src/adapters/mpesa.ts +94 -17
|
@@ -46,13 +46,18 @@ export interface PushStore {
|
|
|
46
46
|
save(rec: PushRecord): Promise<void>;
|
|
47
47
|
byIntent(intentId: string): Promise<PushRecord | undefined>;
|
|
48
48
|
byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
|
|
49
|
+
pending(olderThanMs: number): Promise<PushRecord[]>;
|
|
50
|
+
resolve(checkoutId: string): Promise<void>;
|
|
49
51
|
}
|
|
50
52
|
export declare class MemoryPushStore implements PushStore {
|
|
51
53
|
private readonly byIntentMap;
|
|
52
54
|
private readonly byCheckoutMap;
|
|
55
|
+
now: () => number;
|
|
53
56
|
save(rec: PushRecord): Promise<void>;
|
|
54
57
|
byIntent(intentId: string): Promise<PushRecord | undefined>;
|
|
55
58
|
byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
|
|
59
|
+
pending(olderThanMs: number): Promise<PushRecord[]>;
|
|
60
|
+
resolve(checkoutId: string): Promise<void>;
|
|
56
61
|
}
|
|
57
62
|
export interface MpesaConfig {
|
|
58
63
|
id?: string;
|
|
@@ -75,6 +80,8 @@ export declare class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
75
80
|
refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
|
|
76
81
|
reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
|
|
77
82
|
parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]>;
|
|
83
|
+
reconcile(olderThanMs: number): Promise<AdapterEvent[]>;
|
|
84
|
+
private resolve;
|
|
78
85
|
}
|
|
79
86
|
export declare function normalizePhone(phone: string): string;
|
|
80
87
|
export interface Credentials {
|
|
@@ -13,16 +13,41 @@ export const ErrUnknownCheckout = "mpesa: callback for an unknown checkout reque
|
|
|
13
13
|
export class MemoryPushStore {
|
|
14
14
|
byIntentMap = new Map();
|
|
15
15
|
byCheckoutMap = new Map();
|
|
16
|
+
// now is the clock the push age is measured against; it is a field so a test can make
|
|
17
|
+
// "old enough to reconcile" deterministic.
|
|
18
|
+
now = () => Date.now();
|
|
16
19
|
async save(rec) {
|
|
17
|
-
|
|
20
|
+
// A re-save for the same intent keeps the original send time and resolved state,
|
|
21
|
+
// mirroring the durable store, so re-pushing never resets the reconcile clock.
|
|
22
|
+
let entry = this.byIntentMap.get(rec.intentId);
|
|
23
|
+
if (!entry) {
|
|
24
|
+
entry = { rec, createdAt: this.now(), resolved: false };
|
|
25
|
+
this.byIntentMap.set(rec.intentId, entry);
|
|
26
|
+
}
|
|
27
|
+
entry.rec = rec;
|
|
18
28
|
if (rec.checkoutId)
|
|
19
|
-
this.byCheckoutMap.set(rec.checkoutId,
|
|
29
|
+
this.byCheckoutMap.set(rec.checkoutId, entry);
|
|
20
30
|
}
|
|
21
31
|
async byIntent(intentId) {
|
|
22
|
-
return this.byIntentMap.get(intentId);
|
|
32
|
+
return this.byIntentMap.get(intentId)?.rec;
|
|
23
33
|
}
|
|
24
34
|
async byCheckout(checkoutId) {
|
|
25
|
-
return this.byCheckoutMap.get(checkoutId);
|
|
35
|
+
return this.byCheckoutMap.get(checkoutId)?.rec;
|
|
36
|
+
}
|
|
37
|
+
async pending(olderThanMs) {
|
|
38
|
+
const cutoff = this.now() - olderThanMs;
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const entry of this.byIntentMap.values()) {
|
|
41
|
+
if (entry.resolved || entry.createdAt > cutoff)
|
|
42
|
+
continue;
|
|
43
|
+
out.push(entry.rec);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
async resolve(checkoutId) {
|
|
48
|
+
const entry = this.byCheckoutMap.get(checkoutId);
|
|
49
|
+
if (entry)
|
|
50
|
+
entry.resolved = true;
|
|
26
51
|
}
|
|
27
52
|
}
|
|
28
53
|
// MpesaLeg moves mobile money over M-Pesa.
|
|
@@ -135,31 +160,64 @@ export class MpesaLeg {
|
|
|
135
160
|
if (!cb || !rec) {
|
|
136
161
|
throw new Error(ErrUnknownCheckout);
|
|
137
162
|
}
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
//
|
|
163
|
+
// The receipt is taken from the callback only as an audit reference; the settled
|
|
164
|
+
// amount is never read from the unsigned body. The authoritative outcome comes from
|
|
165
|
+
// the query in resolve.
|
|
166
|
+
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
167
|
+
return this.resolve(rec, receipt);
|
|
168
|
+
}
|
|
169
|
+
// reconcile settles or fails the pushes whose callback has not arrived by reading
|
|
170
|
+
// their outcome back from Daraja. It is the recovery path for a dropped or delayed STK
|
|
171
|
+
// callback: a host runs it on a timer so a lost callback is not terminal. Only pushes
|
|
172
|
+
// older than olderThanMs are queried, so a healthy collection still settles from its
|
|
173
|
+
// callback and only an overdue one is polled; a push that fails to query is left for
|
|
174
|
+
// the next run rather than stalling the rest. The events it returns are the same
|
|
175
|
+
// settle and fail events a callback would have produced, applied through the same path.
|
|
176
|
+
async reconcile(olderThanMs) {
|
|
177
|
+
const pending = await this.store.pending(olderThanMs);
|
|
178
|
+
const events = [];
|
|
179
|
+
for (const rec of pending) {
|
|
180
|
+
try {
|
|
181
|
+
// Queried one at a time on purpose: this is a background sweep and Daraja
|
|
182
|
+
// rate-limits its query endpoint, so a burst of parallel reads would be
|
|
183
|
+
// throttled rather than faster. The sweep runs off any request path, so
|
|
184
|
+
// serializing it costs no user-facing latency.
|
|
185
|
+
// eslint-disable-next-line no-await-in-loop
|
|
186
|
+
events.push(...(await this.resolve(rec, "")));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// One push failing to query must not stall the batch; the next run retries it.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return events;
|
|
193
|
+
}
|
|
194
|
+
// resolve reads a push's authoritative outcome from Daraja and maps it to a protocol
|
|
195
|
+
// event, the single mapping both the callback and the reconcile timer go through. A
|
|
196
|
+
// push still processing yields no event and stays pending for a later look; a settled
|
|
197
|
+
// or failed one is marked resolved so it is not queried again. The settled amount is
|
|
198
|
+
// the one we recorded when the STK push fixed it, never read from the unsigned
|
|
199
|
+
// callback: a payer approves that exact amount or cancels, and reading it from a body
|
|
200
|
+
// whose checkout id is not a secret would let a forged callback carry a wrong amount
|
|
201
|
+
// and block a collection the payer completed. receipt is the callback's audit
|
|
202
|
+
// reference; the reconcile path, which has no callback body, passes none.
|
|
203
|
+
async resolve(rec, receipt) {
|
|
204
|
+
const confirmed = await this.api.query(rec.checkoutId);
|
|
141
205
|
if (confirmed.pending) {
|
|
142
206
|
return [];
|
|
143
207
|
}
|
|
208
|
+
await this.store.resolve(rec.checkoutId);
|
|
144
209
|
if (confirmed.resultCode !== 0) {
|
|
145
210
|
return [
|
|
146
211
|
{
|
|
147
212
|
intentId: rec.intentId,
|
|
148
213
|
state: State.Failed,
|
|
149
|
-
providerTxRef:
|
|
214
|
+
providerTxRef: rec.checkoutId,
|
|
150
215
|
onchainTxHash: "",
|
|
151
216
|
reason: confirmed.resultDesc,
|
|
152
217
|
settledAt: 0,
|
|
153
218
|
},
|
|
154
219
|
];
|
|
155
220
|
}
|
|
156
|
-
// The STK push fixed the amount we authorized; a payer approves that exact amount
|
|
157
|
-
// or cancels, so an approved collection is always the full authorized amount. The
|
|
158
|
-
// authenticated query above confirms success, so the settled amount is the one we
|
|
159
|
-
// recorded — it is never read from the unsigned callback, since a forged callback
|
|
160
|
-
// (the checkout id is not a secret) could otherwise carry a wrong amount and block
|
|
161
|
-
// a collection the payer completed. The receipt is an audit reference only.
|
|
162
|
-
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
163
221
|
return [
|
|
164
222
|
{
|
|
165
223
|
intentId: rec.intentId,
|
package/dist/test/mpesa.test.js
CHANGED
|
@@ -8,7 +8,7 @@ import { UsdcBridge } from "../src/bridge.js";
|
|
|
8
8
|
import { FakeLeg, FakeRates, FakeVault } from "./fake.js";
|
|
9
9
|
import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
|
|
10
10
|
import { fromHex } from "../src/crypto.js";
|
|
11
|
-
import { MpesaLeg, ErrUnknownCheckout, normalizePhone, } from "../src/adapters/mpesa.js";
|
|
11
|
+
import { MpesaLeg, MemoryPushStore, ErrUnknownCheckout, normalizePhone, } from "../src/adapters/mpesa.js";
|
|
12
12
|
function counter(prefix) {
|
|
13
13
|
let n = 0;
|
|
14
14
|
return () => `${prefix}_${String(++n).padStart(3, "0")}`;
|
|
@@ -215,3 +215,40 @@ describe("mpesa pay-in", () => {
|
|
|
215
215
|
expect(events[0].providerTxRef).toBe("QGR7XYZ123");
|
|
216
216
|
});
|
|
217
217
|
});
|
|
218
|
+
describe("mpesa reconcile", () => {
|
|
219
|
+
// A leg over a store whose clock the test drives, so "old enough to reconcile" is
|
|
220
|
+
// deterministic, together with the fake Daraja and the stock quote the cases share.
|
|
221
|
+
function reconcileHarness() {
|
|
222
|
+
const clock = { now: 1_700_000_000_000 };
|
|
223
|
+
const store = new MemoryPushStore();
|
|
224
|
+
store.now = () => clock.now;
|
|
225
|
+
const api = new FakeDaraja(counter("tx"));
|
|
226
|
+
const leg = new MpesaLeg({ api, callbackURL: "https://host.example/webhook/mpesa", ids: counter("mp"), store });
|
|
227
|
+
const q = quote({ srcAmount: kes("1500"), fees: kes("0") });
|
|
228
|
+
return { clock, store, api, leg, q };
|
|
229
|
+
}
|
|
230
|
+
it("settles a dropped callback once it is overdue, and never twice", async () => {
|
|
231
|
+
const { clock, leg, q } = reconcileHarness();
|
|
232
|
+
await leg.collect("intent-1", q, emptyAuth, "0711000111");
|
|
233
|
+
// The callback never arrives and the push is still young, so reconcile leaves it be.
|
|
234
|
+
expect(await leg.reconcile(30_000)).toHaveLength(0);
|
|
235
|
+
// A minute passes with no callback: the overdue push settles from Daraja's query.
|
|
236
|
+
clock.now += 60_000;
|
|
237
|
+
const events = await leg.reconcile(30_000);
|
|
238
|
+
expect(events).toHaveLength(1);
|
|
239
|
+
expect(events[0].state).toBe(State.Settled);
|
|
240
|
+
expect(events[0].intentId).toBe("intent-1");
|
|
241
|
+
// The settled push is marked resolved, so a later run does not re-emit it.
|
|
242
|
+
expect(await leg.reconcile(30_000)).toHaveLength(0);
|
|
243
|
+
});
|
|
244
|
+
it("leaves a push the payer has not acted on pending", async () => {
|
|
245
|
+
const { clock, store, api, leg, q } = reconcileHarness();
|
|
246
|
+
const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
|
|
247
|
+
// Daraja is still processing this checkout: the payer has not approved.
|
|
248
|
+
api.queryResults.set(collected.providerRef, { resultCode: 0, resultDesc: "", pending: true });
|
|
249
|
+
clock.now += 60_000;
|
|
250
|
+
expect(await leg.reconcile(30_000)).toHaveLength(0);
|
|
251
|
+
// Still unresolved, so a later run will pick it up once it resolves.
|
|
252
|
+
expect(await store.pending(30_000)).toHaveLength(1);
|
|
253
|
+
});
|
|
254
|
+
});
|
package/package.json
CHANGED
package/src/adapters/mpesa.ts
CHANGED
|
@@ -100,25 +100,69 @@ export interface PushRecord {
|
|
|
100
100
|
// resolves it. The default store keeps them in memory; a deployment that runs more than
|
|
101
101
|
// one instance, or must survive a restart with collections in flight, supplies a
|
|
102
102
|
// durable one so a callback never arrives to find its checkout forgotten.
|
|
103
|
+
//
|
|
104
|
+
// pending and resolve are the recovery path for a callback that never lands: pending
|
|
105
|
+
// lists the pushes still awaiting an outcome so their result can be read back from
|
|
106
|
+
// Daraja, and resolve marks one done so it drops out of that list. A push is queried
|
|
107
|
+
// only while it is both unresolved and old enough that its callback is overdue, so the
|
|
108
|
+
// set pending returns shrinks as collections settle rather than growing without bound.
|
|
103
109
|
export interface PushStore {
|
|
104
110
|
save(rec: PushRecord): Promise<void>;
|
|
105
111
|
byIntent(intentId: string): Promise<PushRecord | undefined>;
|
|
106
112
|
byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
|
|
113
|
+
// pending lists unresolved pushes whose STK push went out at least olderThanMs ago,
|
|
114
|
+
// so a young push is left to its callback and only an overdue one is queried.
|
|
115
|
+
pending(olderThanMs: number): Promise<PushRecord[]>;
|
|
116
|
+
// resolve marks the push under checkoutId settled or failed so it is no longer
|
|
117
|
+
// returned by pending. It is safe to call more than once for the same checkout.
|
|
118
|
+
resolve(checkoutId: string): Promise<void>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// pushEntry is a stored push with the metadata reconciliation needs: when it went out,
|
|
122
|
+
// so an overdue push can be told from a young one, and whether it has resolved, so a
|
|
123
|
+
// settled push is not queried again.
|
|
124
|
+
interface pushEntry {
|
|
125
|
+
rec: PushRecord;
|
|
126
|
+
createdAt: number;
|
|
127
|
+
resolved: boolean;
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
// MemoryPushStore is the default in-process store.
|
|
110
131
|
export class MemoryPushStore implements PushStore {
|
|
111
|
-
private readonly byIntentMap = new Map<string,
|
|
112
|
-
private readonly byCheckoutMap = new Map<string,
|
|
132
|
+
private readonly byIntentMap = new Map<string, pushEntry>();
|
|
133
|
+
private readonly byCheckoutMap = new Map<string, pushEntry>();
|
|
134
|
+
// now is the clock the push age is measured against; it is a field so a test can make
|
|
135
|
+
// "old enough to reconcile" deterministic.
|
|
136
|
+
now: () => number = () => Date.now();
|
|
113
137
|
async save(rec: PushRecord): Promise<void> {
|
|
114
|
-
|
|
115
|
-
|
|
138
|
+
// A re-save for the same intent keeps the original send time and resolved state,
|
|
139
|
+
// mirroring the durable store, so re-pushing never resets the reconcile clock.
|
|
140
|
+
let entry = this.byIntentMap.get(rec.intentId);
|
|
141
|
+
if (!entry) {
|
|
142
|
+
entry = { rec, createdAt: this.now(), resolved: false };
|
|
143
|
+
this.byIntentMap.set(rec.intentId, entry);
|
|
144
|
+
}
|
|
145
|
+
entry.rec = rec;
|
|
146
|
+
if (rec.checkoutId) this.byCheckoutMap.set(rec.checkoutId, entry);
|
|
116
147
|
}
|
|
117
148
|
async byIntent(intentId: string): Promise<PushRecord | undefined> {
|
|
118
|
-
return this.byIntentMap.get(intentId);
|
|
149
|
+
return this.byIntentMap.get(intentId)?.rec;
|
|
119
150
|
}
|
|
120
151
|
async byCheckout(checkoutId: string): Promise<PushRecord | undefined> {
|
|
121
|
-
return this.byCheckoutMap.get(checkoutId);
|
|
152
|
+
return this.byCheckoutMap.get(checkoutId)?.rec;
|
|
153
|
+
}
|
|
154
|
+
async pending(olderThanMs: number): Promise<PushRecord[]> {
|
|
155
|
+
const cutoff = this.now() - olderThanMs;
|
|
156
|
+
const out: PushRecord[] = [];
|
|
157
|
+
for (const entry of this.byIntentMap.values()) {
|
|
158
|
+
if (entry.resolved || entry.createdAt > cutoff) continue;
|
|
159
|
+
out.push(entry.rec);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
async resolve(checkoutId: string): Promise<void> {
|
|
164
|
+
const entry = this.byCheckoutMap.get(checkoutId);
|
|
165
|
+
if (entry) entry.resolved = true;
|
|
122
166
|
}
|
|
123
167
|
}
|
|
124
168
|
|
|
@@ -251,32 +295,65 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
251
295
|
if (!cb || !rec) {
|
|
252
296
|
throw new Error(ErrUnknownCheckout);
|
|
253
297
|
}
|
|
298
|
+
// The receipt is taken from the callback only as an audit reference; the settled
|
|
299
|
+
// amount is never read from the unsigned body. The authoritative outcome comes from
|
|
300
|
+
// the query in resolve.
|
|
301
|
+
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
302
|
+
return this.resolve(rec, receipt);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// reconcile settles or fails the pushes whose callback has not arrived by reading
|
|
306
|
+
// their outcome back from Daraja. It is the recovery path for a dropped or delayed STK
|
|
307
|
+
// callback: a host runs it on a timer so a lost callback is not terminal. Only pushes
|
|
308
|
+
// older than olderThanMs are queried, so a healthy collection still settles from its
|
|
309
|
+
// callback and only an overdue one is polled; a push that fails to query is left for
|
|
310
|
+
// the next run rather than stalling the rest. The events it returns are the same
|
|
311
|
+
// settle and fail events a callback would have produced, applied through the same path.
|
|
312
|
+
async reconcile(olderThanMs: number): Promise<AdapterEvent[]> {
|
|
313
|
+
const pending = await this.store.pending(olderThanMs);
|
|
314
|
+
const events: AdapterEvent[] = [];
|
|
315
|
+
for (const rec of pending) {
|
|
316
|
+
try {
|
|
317
|
+
// Queried one at a time on purpose: this is a background sweep and Daraja
|
|
318
|
+
// rate-limits its query endpoint, so a burst of parallel reads would be
|
|
319
|
+
// throttled rather than faster. The sweep runs off any request path, so
|
|
320
|
+
// serializing it costs no user-facing latency.
|
|
321
|
+
// eslint-disable-next-line no-await-in-loop
|
|
322
|
+
events.push(...(await this.resolve(rec, "")));
|
|
323
|
+
} catch {
|
|
324
|
+
// One push failing to query must not stall the batch; the next run retries it.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return events;
|
|
328
|
+
}
|
|
254
329
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
330
|
+
// resolve reads a push's authoritative outcome from Daraja and maps it to a protocol
|
|
331
|
+
// event, the single mapping both the callback and the reconcile timer go through. A
|
|
332
|
+
// push still processing yields no event and stays pending for a later look; a settled
|
|
333
|
+
// or failed one is marked resolved so it is not queried again. The settled amount is
|
|
334
|
+
// the one we recorded when the STK push fixed it, never read from the unsigned
|
|
335
|
+
// callback: a payer approves that exact amount or cancels, and reading it from a body
|
|
336
|
+
// whose checkout id is not a secret would let a forged callback carry a wrong amount
|
|
337
|
+
// and block a collection the payer completed. receipt is the callback's audit
|
|
338
|
+
// reference; the reconcile path, which has no callback body, passes none.
|
|
339
|
+
private async resolve(rec: PushRecord, receipt: string): Promise<AdapterEvent[]> {
|
|
340
|
+
const confirmed = await this.api.query(rec.checkoutId);
|
|
258
341
|
if (confirmed.pending) {
|
|
259
342
|
return [];
|
|
260
343
|
}
|
|
344
|
+
await this.store.resolve(rec.checkoutId);
|
|
261
345
|
if (confirmed.resultCode !== 0) {
|
|
262
346
|
return [
|
|
263
347
|
{
|
|
264
348
|
intentId: rec.intentId,
|
|
265
349
|
state: State.Failed,
|
|
266
|
-
providerTxRef:
|
|
350
|
+
providerTxRef: rec.checkoutId,
|
|
267
351
|
onchainTxHash: "",
|
|
268
352
|
reason: confirmed.resultDesc,
|
|
269
353
|
settledAt: 0,
|
|
270
354
|
},
|
|
271
355
|
];
|
|
272
356
|
}
|
|
273
|
-
// The STK push fixed the amount we authorized; a payer approves that exact amount
|
|
274
|
-
// or cancels, so an approved collection is always the full authorized amount. The
|
|
275
|
-
// authenticated query above confirms success, so the settled amount is the one we
|
|
276
|
-
// recorded — it is never read from the unsigned callback, since a forged callback
|
|
277
|
-
// (the checkout id is not a secret) could otherwise carry a wrong amount and block
|
|
278
|
-
// a collection the payer completed. The receipt is an audit reference only.
|
|
279
|
-
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
280
357
|
return [
|
|
281
358
|
{
|
|
282
359
|
intentId: rec.intentId,
|