@myzonerocks/pact 0.1.7 → 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.
- package/dist/src/adapters/mpesa.d.ts +7 -0
- package/dist/src/adapters/mpesa.js +73 -28
- package/dist/test/mpesa.test.js +45 -4
- package/package.json +1 -1
- package/src/adapters/mpesa.ts +94 -32
|
@@ -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 amount Daraja collected must equal the amount we authorized; a partial
|
|
157
|
-
// or tampered collection settles nothing.
|
|
158
|
-
const paid = metadataInt(cb.CallbackMetadata?.Item ?? [], "Amount");
|
|
159
|
-
if (paid !== rec.amount) {
|
|
160
|
-
throw new Error(`mpesa: confirmed amount ${paid} does not match the authorized ${rec.amount}`);
|
|
161
|
-
}
|
|
162
|
-
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
163
221
|
return [
|
|
164
222
|
{
|
|
165
223
|
intentId: rec.intentId,
|
|
@@ -185,19 +243,6 @@ function wholeShillings(m) {
|
|
|
185
243
|
}
|
|
186
244
|
return Number(amount);
|
|
187
245
|
}
|
|
188
|
-
// metadataInt pulls a named numeric value out of the callback metadata items,
|
|
189
|
-
// used to read the paid Amount. M-Pesa amounts are whole shillings but may arrive
|
|
190
|
-
// as a number with a fractional part, so it is floored to the shilling.
|
|
191
|
-
function metadataInt(items, name) {
|
|
192
|
-
for (const item of items) {
|
|
193
|
-
if (item.Name !== name) {
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
const n = Number(item.Value);
|
|
197
|
-
return Number.isFinite(n) ? Math.floor(n) : 0;
|
|
198
|
-
}
|
|
199
|
-
return 0;
|
|
200
|
-
}
|
|
201
246
|
// metadataString pulls a named string value out of the callback metadata items.
|
|
202
247
|
function metadataString(items, name) {
|
|
203
248
|
for (const item of items) {
|
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")}`;
|
|
@@ -201,13 +201,54 @@ describe("mpesa pay-in", () => {
|
|
|
201
201
|
const events = await leg.parseWebhook(successCallback(collected.providerRef, "FORGEDRCPT"), {});
|
|
202
202
|
expect(events).toHaveLength(0);
|
|
203
203
|
});
|
|
204
|
-
it("
|
|
204
|
+
it("a forged callback amount cannot block a confirmed collection", async () => {
|
|
205
205
|
const api = new FakeDaraja(counter("tx"));
|
|
206
206
|
const leg = buildLeg(api);
|
|
207
207
|
const q = quote({ srcAmount: kes("5000"), fees: kes("0") });
|
|
208
208
|
const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
|
|
209
|
-
// Daraja confirms success
|
|
209
|
+
// Daraja's authenticated query confirms success; the callback body claims a
|
|
210
|
+
// different (forged) amount, which must be ignored — the collection still settles.
|
|
210
211
|
api.queryResults.set(collected.providerRef, { resultCode: 0, resultDesc: "", pending: false });
|
|
211
|
-
await
|
|
212
|
+
const events = await leg.parseWebhook(successCallback(collected.providerRef, "QGR7XYZ123"), {});
|
|
213
|
+
expect(events).toHaveLength(1);
|
|
214
|
+
expect(events[0].state).toBe(State.Settled);
|
|
215
|
+
expect(events[0].providerTxRef).toBe("QGR7XYZ123");
|
|
216
|
+
});
|
|
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);
|
|
212
253
|
});
|
|
213
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,33 +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 amount Daraja collected must equal the amount we authorized; a partial
|
|
274
|
-
// or tampered collection settles nothing.
|
|
275
|
-
const paid = metadataInt(cb.CallbackMetadata?.Item ?? [], "Amount");
|
|
276
|
-
if (paid !== rec.amount) {
|
|
277
|
-
throw new Error(`mpesa: confirmed amount ${paid} does not match the authorized ${rec.amount}`);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
281
357
|
return [
|
|
282
358
|
{
|
|
283
359
|
intentId: rec.intentId,
|
|
@@ -321,20 +397,6 @@ function wholeShillings(m: Money): number {
|
|
|
321
397
|
return Number(amount);
|
|
322
398
|
}
|
|
323
399
|
|
|
324
|
-
// metadataInt pulls a named numeric value out of the callback metadata items,
|
|
325
|
-
// used to read the paid Amount. M-Pesa amounts are whole shillings but may arrive
|
|
326
|
-
// as a number with a fractional part, so it is floored to the shilling.
|
|
327
|
-
function metadataInt(items: StkCallbackItem[], name: string): number {
|
|
328
|
-
for (const item of items) {
|
|
329
|
-
if (item.Name !== name) {
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
const n = Number(item.Value);
|
|
333
|
-
return Number.isFinite(n) ? Math.floor(n) : 0;
|
|
334
|
-
}
|
|
335
|
-
return 0;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
400
|
// metadataString pulls a named string value out of the callback metadata items.
|
|
339
401
|
function metadataString(items: StkCallbackItem[], name: string): string {
|
|
340
402
|
for (const item of items) {
|