@myzonerocks/pact 0.1.8 → 0.1.9

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.
@@ -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
- this.byIntentMap.set(rec.intentId, rec);
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, rec);
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
- const confirmed = await this.api.query(cb.CheckoutRequestID);
139
- // No outcome yet wait for a later callback rather than settling or failing
140
- // on an unconfirmed body.
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: cb.CheckoutRequestID,
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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myzonerocks/pact",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "TypeScript SDK for the PACT payment abstraction protocol",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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, PushRecord>();
112
- private readonly byCheckoutMap = new Map<string, PushRecord>();
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
- this.byIntentMap.set(rec.intentId, rec);
115
- if (rec.checkoutId) this.byCheckoutMap.set(rec.checkoutId, rec);
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
- const confirmed = await this.api.query(cb.CheckoutRequestID);
256
- // No outcome yet wait for a later callback rather than settling or failing
257
- // on an unconfirmed body.
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: cb.CheckoutRequestID,
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,