@myzonerocks/pact 0.1.3 → 0.1.6
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/README.md +3 -3
- package/dist/src/adapter.d.ts +1 -1
- package/dist/src/adapters/erc20.d.ts +30 -4
- package/dist/src/adapters/erc20.js +105 -50
- package/dist/src/adapters/http.d.ts +2 -0
- package/dist/src/adapters/http.js +12 -0
- package/dist/src/adapters/mpesa.d.ts +30 -4
- package/dist/src/adapters/mpesa.js +106 -22
- package/dist/src/adapters/paypal.d.ts +23 -2
- package/dist/src/adapters/paypal.js +91 -29
- package/dist/src/adapters/stripe.d.ts +3 -2
- package/dist/src/adapters/stripe.js +14 -11
- package/dist/src/bridge.js +12 -5
- package/dist/src/canonical.js +5 -0
- package/dist/src/client.d.ts +10 -2
- package/dist/src/client.js +215 -30
- package/dist/src/compliance.d.ts +4 -0
- package/dist/src/compliance.js +10 -3
- package/dist/src/crypto.js +4 -1
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/dist/src/ledger.d.ts +6 -2
- package/dist/src/ledger.js +2 -2
- package/dist/src/leg.d.ts +1 -1
- package/dist/src/message.d.ts +1 -1
- package/dist/src/message.js +8 -5
- package/dist/src/money.d.ts +1 -0
- package/dist/src/money.js +18 -3
- package/dist/src/protocol.d.ts +1 -0
- package/dist/src/protocol.js +8 -0
- package/dist/src/router.d.ts +2 -0
- package/dist/src/router.js +45 -7
- package/dist/src/state.js +3 -1
- package/dist/src/wire.js +19 -2
- package/dist/test/erc20.test.js +95 -31
- package/dist/test/fake.d.ts +29 -0
- package/dist/test/fake.js +79 -0
- package/dist/test/lifecycle.test.js +31 -3
- package/dist/test/money.test.d.ts +1 -0
- package/dist/test/money.test.js +27 -0
- package/dist/test/mpesa.test.js +41 -8
- package/dist/test/paypal.test.js +33 -8
- package/dist/test/policy.test.js +6 -2
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +52 -0
- package/dist/test/stripe.test.js +5 -4
- package/dist/test/vectors.test.js +48 -2
- package/dist/test/wire.test.js +15 -0
- package/package.json +1 -1
- package/src/adapter.ts +7 -2
- package/src/adapters/erc20.ts +150 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +148 -28
- package/src/adapters/paypal.ts +138 -28
- package/src/adapters/stripe.ts +16 -13
- package/src/bridge.ts +12 -5
- package/src/canonical.ts +5 -0
- package/src/client.ts +228 -33
- package/src/compliance.ts +20 -3
- package/src/crypto.ts +4 -1
- package/src/index.ts +0 -1
- package/src/ledger.ts +12 -4
- package/src/leg.ts +4 -1
- package/src/message.ts +8 -5
- package/src/money.ts +19 -3
- package/src/protocol.ts +9 -0
- package/src/router.ts +44 -4
- package/src/state.ts +3 -1
- package/src/wire.ts +20 -3
- package/src/fake.ts +0 -96
package/src/adapters/mpesa.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { Money } from "../money.js";
|
|
|
9
9
|
import { State } from "../state.js";
|
|
10
10
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
11
11
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
12
|
+
import { fetchWithTimeout } from "./http.js";
|
|
12
13
|
import type {
|
|
13
14
|
PayInLeg,
|
|
14
15
|
PayOutLeg,
|
|
@@ -52,6 +53,10 @@ export interface B2CParams {
|
|
|
52
53
|
phone: string;
|
|
53
54
|
reference: string;
|
|
54
55
|
remarks: string;
|
|
56
|
+
// Same across retries of one logical transfer and distinct between different
|
|
57
|
+
// transfers on the same intent (a payout versus a refund), so Daraja collapses a
|
|
58
|
+
// retry but never merges two separate movements.
|
|
59
|
+
idempotencyKey: string;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
// B2CResult is what Daraja returns when a payout is accepted for delivery.
|
|
@@ -60,22 +65,61 @@ export interface B2CResult {
|
|
|
60
65
|
responseCode: string;
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
// StkQueryResult is the authoritative outcome of a push, read back from Daraja by
|
|
69
|
+
// its checkout id. pending means Daraja is still processing and the outcome isn't
|
|
70
|
+
// known yet; otherwise resultCode 0 is success and any other code is a failure.
|
|
71
|
+
export interface StkQueryResult {
|
|
72
|
+
resultCode: number;
|
|
73
|
+
resultDesc: string;
|
|
74
|
+
pending: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
// DarajaApi is the surface of Daraja this leg depends on. Depending on an
|
|
64
78
|
// interface keeps the leg unit-testable without a network and without
|
|
65
79
|
// credentials.
|
|
66
80
|
export interface DarajaApi {
|
|
67
81
|
stkPush(params: StkPushParams): Promise<StkPushResult>;
|
|
68
82
|
b2cPayment(params: B2CParams): Promise<B2CResult>;
|
|
83
|
+
// query reads a push's real outcome back from Daraja by its checkout id. The
|
|
84
|
+
// STK callback is unsigned, so this authenticated read — not the callback body
|
|
85
|
+
// — is what a settlement is trusted to.
|
|
86
|
+
query(checkoutRequestId: string): Promise<StkQueryResult>;
|
|
69
87
|
}
|
|
70
88
|
|
|
71
|
-
//
|
|
72
|
-
// the
|
|
73
|
-
// refund,
|
|
74
|
-
interface PushRecord {
|
|
89
|
+
// PushRecord is what settling or refunding an M-Pesa collection needs after the STK
|
|
90
|
+
// push: the intent, the checkout id the unsigned callback arrives under, the payer to
|
|
91
|
+
// refund, and the amount authorized.
|
|
92
|
+
export interface PushRecord {
|
|
75
93
|
intentId: string;
|
|
94
|
+
checkoutId: string;
|
|
76
95
|
payerPhone: string;
|
|
77
96
|
amount: number;
|
|
78
|
-
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// PushStore holds push records between initiating a collection and the callback that
|
|
100
|
+
// resolves it. The default store keeps them in memory; a deployment that runs more than
|
|
101
|
+
// one instance, or must survive a restart with collections in flight, supplies a
|
|
102
|
+
// durable one so a callback never arrives to find its checkout forgotten.
|
|
103
|
+
export interface PushStore {
|
|
104
|
+
save(rec: PushRecord): Promise<void>;
|
|
105
|
+
byIntent(intentId: string): Promise<PushRecord | undefined>;
|
|
106
|
+
byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// MemoryPushStore is the default in-process store.
|
|
110
|
+
export class MemoryPushStore implements PushStore {
|
|
111
|
+
private readonly byIntentMap = new Map<string, PushRecord>();
|
|
112
|
+
private readonly byCheckoutMap = new Map<string, PushRecord>();
|
|
113
|
+
async save(rec: PushRecord): Promise<void> {
|
|
114
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
115
|
+
if (rec.checkoutId) this.byCheckoutMap.set(rec.checkoutId, rec);
|
|
116
|
+
}
|
|
117
|
+
async byIntent(intentId: string): Promise<PushRecord | undefined> {
|
|
118
|
+
return this.byIntentMap.get(intentId);
|
|
119
|
+
}
|
|
120
|
+
async byCheckout(checkoutId: string): Promise<PushRecord | undefined> {
|
|
121
|
+
return this.byCheckoutMap.get(checkoutId);
|
|
122
|
+
}
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
export interface MpesaConfig {
|
|
@@ -83,6 +127,9 @@ export interface MpesaConfig {
|
|
|
83
127
|
api: DarajaApi;
|
|
84
128
|
callbackURL: string;
|
|
85
129
|
ids: () => string;
|
|
130
|
+
// Store persists push records; defaults to an in-memory store. A deployment that
|
|
131
|
+
// scales beyond one instance or must survive a restart supplies a durable one.
|
|
132
|
+
store?: PushStore;
|
|
86
133
|
}
|
|
87
134
|
|
|
88
135
|
// MpesaLeg moves mobile money over M-Pesa.
|
|
@@ -92,8 +139,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
92
139
|
private readonly callbackURL: string;
|
|
93
140
|
private readonly ids: () => string;
|
|
94
141
|
|
|
95
|
-
private readonly
|
|
96
|
-
private readonly byCheckout = new Map<string, PushRecord>();
|
|
142
|
+
private readonly store: PushStore;
|
|
97
143
|
|
|
98
144
|
constructor(cfg: MpesaConfig) {
|
|
99
145
|
if (!cfg.callbackURL) {
|
|
@@ -103,6 +149,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
103
149
|
this.api = cfg.api;
|
|
104
150
|
this.callbackURL = cfg.callbackURL;
|
|
105
151
|
this.ids = cfg.ids;
|
|
152
|
+
this.store = cfg.store ?? new MemoryPushStore();
|
|
106
153
|
}
|
|
107
154
|
|
|
108
155
|
payInCapabilities(): PayInCapabilities {
|
|
@@ -126,7 +173,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
126
173
|
if (!phone) {
|
|
127
174
|
throw new Error("mpesa: collect requires the payer's phone");
|
|
128
175
|
}
|
|
129
|
-
const rec: PushRecord = { intentId, payerPhone: phone, amount, state: State.Unspecified };
|
|
130
176
|
const result = await this.api.stkPush({
|
|
131
177
|
amount,
|
|
132
178
|
payerPhone: phone,
|
|
@@ -134,8 +180,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
134
180
|
description: "payment",
|
|
135
181
|
callbackURL: this.callbackURL,
|
|
136
182
|
});
|
|
137
|
-
this.
|
|
138
|
-
this.byCheckout.set(result.checkoutRequestId, rec);
|
|
183
|
+
await this.store.save({ intentId, checkoutId: result.checkoutRequestId, payerPhone: phone, amount });
|
|
139
184
|
return { providerRef: result.checkoutRequestId, received: net };
|
|
140
185
|
}
|
|
141
186
|
|
|
@@ -147,26 +192,32 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
147
192
|
if (!phone) {
|
|
148
193
|
throw new Error("mpesa: disburse requires the recipient's phone");
|
|
149
194
|
}
|
|
150
|
-
const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout" });
|
|
195
|
+
const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout", idempotencyKey: `pact:payout:${intentId}` });
|
|
151
196
|
return { providerRef: result.conversationId };
|
|
152
197
|
}
|
|
153
198
|
|
|
154
199
|
// refundIn answers a collected payment with a business-to-customer payout back
|
|
155
200
|
// to the payer. An STK collection cannot be reversed in place, so a
|
|
156
201
|
// counter-transfer is the only refund this rail supports.
|
|
157
|
-
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
202
|
+
async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
|
|
158
203
|
if (kind !== RefundKind.CounterTransfer) {
|
|
159
204
|
throw new Error("mpesa: a collection can only be refunded by counter-transfer");
|
|
160
205
|
}
|
|
161
|
-
const
|
|
206
|
+
const shillings = wholeShillings(amount);
|
|
207
|
+
const rec = await this.store.byIntent(intentId);
|
|
162
208
|
if (!rec) {
|
|
163
209
|
throw new Error(`mpesa: no push for intent ${intentId}`);
|
|
164
210
|
}
|
|
211
|
+
// A refund cannot return more shillings than the push collected.
|
|
212
|
+
if (shillings > rec.amount) {
|
|
213
|
+
throw new Error(`mpesa: refund of ${shillings} exceeds the ${rec.amount} collected`);
|
|
214
|
+
}
|
|
165
215
|
const result = await this.api.b2cPayment({
|
|
166
|
-
amount:
|
|
216
|
+
amount: shillings,
|
|
167
217
|
phone: rec.payerPhone,
|
|
168
218
|
reference: intentId,
|
|
169
219
|
remarks: reason,
|
|
220
|
+
idempotencyKey: `pact:refund:${intentId}`,
|
|
170
221
|
});
|
|
171
222
|
return {
|
|
172
223
|
intentId,
|
|
@@ -187,34 +238,46 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
187
238
|
}
|
|
188
239
|
|
|
189
240
|
// parseWebhook reads an STK callback and normalizes it into a protocol event.
|
|
190
|
-
// The callback is unsigned
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
241
|
+
// The callback is unsigned and its CheckoutRequestID is a value we hand back to
|
|
242
|
+
// the caller — not a secret — so the callback body is treated only as a nudge.
|
|
243
|
+
// The real outcome is read back from Daraja with our own credentials, and a
|
|
244
|
+
// settlement is emitted only when that authenticated query confirms success and
|
|
245
|
+
// the amount Daraja paid equals the amount we authorized. The headers are
|
|
246
|
+
// accepted for interface symmetry and for a host that adds its own gate on top.
|
|
247
|
+
async parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]> {
|
|
195
248
|
const envelope = JSON.parse(new TextDecoder().decode(raw)) as StkCallbackEnvelope;
|
|
196
249
|
const cb = envelope.Body?.stkCallback;
|
|
197
|
-
const rec = cb ? this.byCheckout
|
|
250
|
+
const rec = cb ? await this.store.byCheckout(cb.CheckoutRequestID) : undefined;
|
|
198
251
|
if (!cb || !rec) {
|
|
199
252
|
throw new Error(ErrUnknownCheckout);
|
|
200
253
|
}
|
|
201
254
|
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
258
|
+
if (confirmed.pending) {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
if (confirmed.resultCode !== 0) {
|
|
204
262
|
return [
|
|
205
263
|
{
|
|
206
264
|
intentId: rec.intentId,
|
|
207
265
|
state: State.Failed,
|
|
208
266
|
providerTxRef: cb.CheckoutRequestID,
|
|
209
267
|
onchainTxHash: "",
|
|
210
|
-
reason:
|
|
268
|
+
reason: confirmed.resultDesc,
|
|
211
269
|
settledAt: 0,
|
|
212
270
|
},
|
|
213
271
|
];
|
|
214
272
|
}
|
|
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
|
+
}
|
|
215
279
|
|
|
216
280
|
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
217
|
-
rec.state = State.Settled;
|
|
218
281
|
return [
|
|
219
282
|
{
|
|
220
283
|
intentId: rec.intentId,
|
|
@@ -258,6 +321,20 @@ function wholeShillings(m: Money): number {
|
|
|
258
321
|
return Number(amount);
|
|
259
322
|
}
|
|
260
323
|
|
|
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
|
+
|
|
261
338
|
// metadataString pulls a named string value out of the callback metadata items.
|
|
262
339
|
function metadataString(items: StkCallbackItem[], name: string): string {
|
|
263
340
|
for (const item of items) {
|
|
@@ -301,6 +378,8 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
301
378
|
private readonly creds: Credentials;
|
|
302
379
|
private readonly baseURL: string;
|
|
303
380
|
private readonly now: () => Date;
|
|
381
|
+
private cachedToken = "";
|
|
382
|
+
private tokenExpiryMs = 0;
|
|
304
383
|
|
|
305
384
|
constructor(creds: Credentials, now: () => Date) {
|
|
306
385
|
this.creds = creds;
|
|
@@ -309,16 +388,25 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
309
388
|
}
|
|
310
389
|
|
|
311
390
|
private async token(): Promise<string> {
|
|
391
|
+
// Reuse the cached token until it is within a minute of expiry, so a burst of
|
|
392
|
+
// pushes does not re-authenticate against Daraja on every call.
|
|
393
|
+
if (this.cachedToken && this.now().getTime() < this.tokenExpiryMs - 60_000) {
|
|
394
|
+
return this.cachedToken;
|
|
395
|
+
}
|
|
312
396
|
const basic = Buffer.from(`${this.creds.consumerKey}:${this.creds.consumerSecret}`).toString("base64");
|
|
313
|
-
const resp = await
|
|
397
|
+
const resp = await fetchWithTimeout(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
|
|
314
398
|
method: "GET",
|
|
315
399
|
headers: { Authorization: `Basic ${basic}` },
|
|
316
400
|
});
|
|
317
401
|
if (resp.status >= 300) {
|
|
318
402
|
throw new Error(`mpesa: /oauth/v1/generate returned ${resp.status}`);
|
|
319
403
|
}
|
|
320
|
-
const out = (await resp.json()) as { access_token?: string };
|
|
321
|
-
|
|
404
|
+
const out = (await resp.json()) as { access_token?: string; expires_in?: string | number };
|
|
405
|
+
// Daraja tokens live an hour; fall back to that if the field is absent.
|
|
406
|
+
const ttlSeconds = Number(out.expires_in) > 0 ? Number(out.expires_in) : 3599;
|
|
407
|
+
this.cachedToken = out.access_token ?? "";
|
|
408
|
+
this.tokenExpiryMs = this.now().getTime() + ttlSeconds * 1000;
|
|
409
|
+
return this.cachedToken;
|
|
322
410
|
}
|
|
323
411
|
|
|
324
412
|
// password is the base64 of shortcode+passkey+timestamp Daraja requires on each
|
|
@@ -355,9 +443,41 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
355
443
|
};
|
|
356
444
|
}
|
|
357
445
|
|
|
446
|
+
async query(checkoutRequestId: string): Promise<StkQueryResult> {
|
|
447
|
+
const token = await this.token();
|
|
448
|
+
const timestamp = formatTimestamp(this.now());
|
|
449
|
+
const body = {
|
|
450
|
+
BusinessShortCode: this.creds.shortCode,
|
|
451
|
+
Password: this.password(timestamp),
|
|
452
|
+
Timestamp: timestamp,
|
|
453
|
+
CheckoutRequestID: checkoutRequestId,
|
|
454
|
+
};
|
|
455
|
+
const resp = await fetchWithTimeout(this.baseURL + "/mpesa/stkpushquery/v1/query", {
|
|
456
|
+
method: "POST",
|
|
457
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
458
|
+
body: JSON.stringify(body),
|
|
459
|
+
});
|
|
460
|
+
const out = (await resp.json()) as {
|
|
461
|
+
ResultCode?: string;
|
|
462
|
+
ResultDesc?: string;
|
|
463
|
+
errorCode?: string;
|
|
464
|
+
};
|
|
465
|
+
// Daraja answers a query for a push it is still processing with an error code
|
|
466
|
+
// rather than a result; treat that as pending so the outcome is confirmed on a
|
|
467
|
+
// later query rather than mistaken for a failure.
|
|
468
|
+
if (out.errorCode === "500.001.1001") {
|
|
469
|
+
return { resultCode: 0, resultDesc: "", pending: true };
|
|
470
|
+
}
|
|
471
|
+
if (resp.status >= 300) {
|
|
472
|
+
throw new Error(`mpesa: stk query returned ${resp.status}`);
|
|
473
|
+
}
|
|
474
|
+
return { resultCode: Number(out.ResultCode ?? -1), resultDesc: out.ResultDesc ?? "", pending: false };
|
|
475
|
+
}
|
|
476
|
+
|
|
358
477
|
async b2cPayment(params: B2CParams): Promise<B2CResult> {
|
|
359
478
|
const token = await this.token();
|
|
360
479
|
const body = {
|
|
480
|
+
OriginatorConversationID: params.idempotencyKey,
|
|
361
481
|
InitiatorName: this.creds.shortCode,
|
|
362
482
|
CommandID: "BusinessPayment",
|
|
363
483
|
Amount: params.amount,
|
|
@@ -375,7 +495,7 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
375
495
|
}
|
|
376
496
|
|
|
377
497
|
private async postJSON<T>(token: string, path: string, body: unknown): Promise<T> {
|
|
378
|
-
const resp = await
|
|
498
|
+
const resp = await fetchWithTimeout(this.baseURL + path, {
|
|
379
499
|
method: "POST",
|
|
380
500
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
381
501
|
body: JSON.stringify(body),
|
package/src/adapters/paypal.ts
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
// Venmo flow client-side; this package never sees card data. A captured payment
|
|
6
6
|
// can be refunded in full, but a delivered payout cannot be pulled back.
|
|
7
7
|
import { State } from "../state.js";
|
|
8
|
+
import type { Money } from "../money.js";
|
|
8
9
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
9
10
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
11
|
+
import { idempotencyKey } from "../protocol.js";
|
|
12
|
+
import { fetchWithTimeout } from "./http.js";
|
|
10
13
|
import type {
|
|
11
14
|
PayOutLeg,
|
|
12
15
|
InteractivePayInLeg,
|
|
@@ -78,6 +81,10 @@ export interface Payout {
|
|
|
78
81
|
export interface RefundParams {
|
|
79
82
|
captureId: string;
|
|
80
83
|
reason: string;
|
|
84
|
+
// value and currencyCode name a partial refund in major units; both absent
|
|
85
|
+
// refunds the full capture.
|
|
86
|
+
value?: string;
|
|
87
|
+
currencyCode?: string;
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
// Refund is the result of refunding a capture.
|
|
@@ -106,6 +113,37 @@ export interface PaypalConfig {
|
|
|
106
113
|
currencies: string[];
|
|
107
114
|
api: PaypalApi;
|
|
108
115
|
ids: () => string;
|
|
116
|
+
// Store persists capture records; defaults to an in-memory store. A deployment that
|
|
117
|
+
// scales beyond one instance or must survive a restart supplies a durable one.
|
|
118
|
+
store?: CaptureStore;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// CaptureRecord is what settling or refunding a PayPal collection needs: the intent,
|
|
122
|
+
// the capture id a refund binds to (empty until a server-side capture returns one),
|
|
123
|
+
// and the amount the intent was quoted to collect.
|
|
124
|
+
export interface CaptureRecord {
|
|
125
|
+
intentId: string;
|
|
126
|
+
captureId: string;
|
|
127
|
+
expected: { value: string; currency: string };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// CaptureStore holds capture records between creating an order and the webhook that
|
|
131
|
+
// resolves it. The default keeps them in memory; a durable one lets a capture webhook
|
|
132
|
+
// resolve across a restart or on a second instance.
|
|
133
|
+
export interface CaptureStore {
|
|
134
|
+
save(rec: CaptureRecord): Promise<void>;
|
|
135
|
+
byIntent(intentId: string): Promise<CaptureRecord | undefined>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// MemoryCaptureStore is the default in-process store.
|
|
139
|
+
export class MemoryCaptureStore implements CaptureStore {
|
|
140
|
+
private readonly byIntentMap = new Map<string, CaptureRecord>();
|
|
141
|
+
async save(rec: CaptureRecord): Promise<void> {
|
|
142
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
143
|
+
}
|
|
144
|
+
async byIntent(intentId: string): Promise<CaptureRecord | undefined> {
|
|
145
|
+
return this.byIntentMap.get(intentId);
|
|
146
|
+
}
|
|
109
147
|
}
|
|
110
148
|
|
|
111
149
|
// PaypalLeg moves money over PayPal, serving both sides of a corridor.
|
|
@@ -114,13 +152,14 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
114
152
|
private readonly currencies: string[];
|
|
115
153
|
private readonly api: PaypalApi;
|
|
116
154
|
private readonly ids: () => string;
|
|
117
|
-
private readonly
|
|
155
|
+
private readonly store: CaptureStore;
|
|
118
156
|
|
|
119
157
|
constructor(cfg: PaypalConfig) {
|
|
120
158
|
this.id = cfg.id ?? "paypal";
|
|
121
159
|
this.currencies = cfg.currencies;
|
|
122
160
|
this.api = cfg.api;
|
|
123
161
|
this.ids = cfg.ids;
|
|
162
|
+
this.store = cfg.store ?? new MemoryCaptureStore();
|
|
124
163
|
}
|
|
125
164
|
|
|
126
165
|
payInCapabilities(): PayInCapabilities {
|
|
@@ -128,7 +167,7 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
128
167
|
rails: ["paypal"],
|
|
129
168
|
currencies: this.currencies,
|
|
130
169
|
methods: ["paypal", "venmo", "card"],
|
|
131
|
-
refunds: RefundKind.
|
|
170
|
+
refunds: RefundKind.Partial,
|
|
132
171
|
};
|
|
133
172
|
}
|
|
134
173
|
|
|
@@ -146,7 +185,11 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
146
185
|
payee: deliverTo,
|
|
147
186
|
referenceId: referencePrefix + intentId,
|
|
148
187
|
});
|
|
149
|
-
this.
|
|
188
|
+
await this.store.save({
|
|
189
|
+
intentId,
|
|
190
|
+
captureId: capture.captureId,
|
|
191
|
+
expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
|
|
192
|
+
});
|
|
150
193
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
151
194
|
}
|
|
152
195
|
|
|
@@ -167,6 +210,11 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
167
210
|
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
168
211
|
}
|
|
169
212
|
const order = await this.api.createOrder(params);
|
|
213
|
+
await this.store.save({
|
|
214
|
+
intentId,
|
|
215
|
+
captureId: "",
|
|
216
|
+
expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
|
|
217
|
+
});
|
|
170
218
|
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
171
219
|
}
|
|
172
220
|
|
|
@@ -182,18 +230,25 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
182
230
|
return { providerRef: payout.batchId };
|
|
183
231
|
}
|
|
184
232
|
|
|
185
|
-
// refundIn reverses a captured payment in full. PayPal captures the
|
|
186
|
-
// the capture id it returned from collect, so the refund binds to
|
|
187
|
-
// reference.
|
|
188
|
-
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
189
|
-
if (kind !== RefundKind.Full) {
|
|
233
|
+
// refundIn reverses a captured payment, in full or in part. PayPal captures the
|
|
234
|
+
// order id in the capture id it returned from collect, so the refund binds to
|
|
235
|
+
// that reference; a partial refund names the amount to return.
|
|
236
|
+
async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
|
|
237
|
+
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
190
238
|
throw new Error(`paypal: cannot perform refund kind ${kind}`);
|
|
191
239
|
}
|
|
192
|
-
const
|
|
193
|
-
if (!captureId) {
|
|
240
|
+
const rec = await this.store.byIntent(intentId);
|
|
241
|
+
if (!rec || !rec.captureId) {
|
|
194
242
|
throw new Error(`paypal: no capture for intent ${intentId}`);
|
|
195
243
|
}
|
|
196
|
-
const
|
|
244
|
+
const captureId = rec.captureId;
|
|
245
|
+
// A partial refund names the amount in major units; a full refund leaves it
|
|
246
|
+
// absent so PayPal returns the whole capture.
|
|
247
|
+
const params: RefundParams =
|
|
248
|
+
kind === RefundKind.Partial
|
|
249
|
+
? { captureId, reason, value: majorAmount(amount.minor(), amount.exponent), currencyCode: amount.currency }
|
|
250
|
+
: { captureId, reason };
|
|
251
|
+
const refund = await this.api.refundCapture(params);
|
|
197
252
|
return {
|
|
198
253
|
intentId,
|
|
199
254
|
state: State.Refunded,
|
|
@@ -226,8 +281,23 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
226
281
|
const providerRef = resource.id ?? "";
|
|
227
282
|
|
|
228
283
|
switch (event.event_type) {
|
|
229
|
-
case "PAYMENT.CAPTURE.COMPLETED":
|
|
284
|
+
case "PAYMENT.CAPTURE.COMPLETED": {
|
|
285
|
+
// The custom_id that ties a capture to an intent is chosen by whoever
|
|
286
|
+
// created the order, so a genuine, signed capture for a different order can
|
|
287
|
+
// carry a target intent's id. Settle only when the captured amount and
|
|
288
|
+
// currency match what the intent was quoted to collect.
|
|
289
|
+
const rec = await this.store.byIntent(intentId);
|
|
290
|
+
if (!rec) {
|
|
291
|
+
throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
|
|
292
|
+
}
|
|
293
|
+
const want = rec.expected;
|
|
294
|
+
if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
230
299
|
return [oneEvent(intentId, State.Settled, providerRef, "")];
|
|
300
|
+
}
|
|
231
301
|
case "PAYMENT.CAPTURE.DENIED":
|
|
232
302
|
return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
|
|
233
303
|
case "PAYMENT.CAPTURE.REFUNDED":
|
|
@@ -240,7 +310,12 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
240
310
|
|
|
241
311
|
interface PaypalWebhook {
|
|
242
312
|
event_type?: string;
|
|
243
|
-
resource?: {
|
|
313
|
+
resource?: {
|
|
314
|
+
id?: string;
|
|
315
|
+
custom_id?: string;
|
|
316
|
+
invoice_id?: string;
|
|
317
|
+
amount?: { currency_code?: string; value?: string };
|
|
318
|
+
};
|
|
244
319
|
}
|
|
245
320
|
|
|
246
321
|
function oneEvent(intentId: string, state: State, providerRef: string, reason: string): AdapterEvent {
|
|
@@ -305,7 +380,7 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
305
380
|
return this.token;
|
|
306
381
|
}
|
|
307
382
|
const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
|
|
308
|
-
const resp = await
|
|
383
|
+
const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
|
|
309
384
|
method: "POST",
|
|
310
385
|
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
311
386
|
body: "grant_type=client_credentials",
|
|
@@ -327,10 +402,11 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
327
402
|
if (params.payee) {
|
|
328
403
|
unit.payee = { email_address: params.payee };
|
|
329
404
|
}
|
|
330
|
-
const created = await this.postJSON<PaypalOrder>(
|
|
331
|
-
|
|
332
|
-
purchase_units: [unit],
|
|
333
|
-
|
|
405
|
+
const created = await this.postJSON<PaypalOrder>(
|
|
406
|
+
"/v2/checkout/orders",
|
|
407
|
+
{ intent: "CAPTURE", purchase_units: [unit] },
|
|
408
|
+
idempotencyKey(params.referenceId, "paypal-order"),
|
|
409
|
+
);
|
|
334
410
|
const existing = firstCapture(created);
|
|
335
411
|
if (existing) {
|
|
336
412
|
return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
|
|
@@ -338,6 +414,7 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
338
414
|
const captured = await this.postJSON<PaypalOrder>(
|
|
339
415
|
`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`,
|
|
340
416
|
{},
|
|
417
|
+
idempotencyKey(params.referenceId, "paypal-capture"),
|
|
341
418
|
);
|
|
342
419
|
const capture = firstCapture(captured);
|
|
343
420
|
if (!capture) {
|
|
@@ -363,10 +440,11 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
363
440
|
platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
|
|
364
441
|
};
|
|
365
442
|
}
|
|
366
|
-
const created = await this.postJSON<PaypalOrder>(
|
|
367
|
-
|
|
368
|
-
purchase_units: [unit],
|
|
369
|
-
|
|
443
|
+
const created = await this.postJSON<PaypalOrder>(
|
|
444
|
+
"/v2/checkout/orders",
|
|
445
|
+
{ intent: "CAPTURE", purchase_units: [unit] },
|
|
446
|
+
idempotencyKey(params.referenceId, "paypal-order"),
|
|
447
|
+
);
|
|
370
448
|
let approve = "";
|
|
371
449
|
for (const link of created.links ?? []) {
|
|
372
450
|
if (link.rel === "approve" || link.rel === "payer-action") {
|
|
@@ -400,9 +478,14 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
400
478
|
if (params.reason) {
|
|
401
479
|
body.note_to_payer = params.reason;
|
|
402
480
|
}
|
|
481
|
+
// A partial refund names the amount; an absent value refunds the full capture.
|
|
482
|
+
if (params.value) {
|
|
483
|
+
body.amount = { value: params.value, currency_code: params.currencyCode };
|
|
484
|
+
}
|
|
403
485
|
const out = await this.postJSON<{ id?: string; status?: string }>(
|
|
404
486
|
`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`,
|
|
405
487
|
body,
|
|
488
|
+
idempotencyKey(params.captureId, "paypal-refund"),
|
|
406
489
|
);
|
|
407
490
|
return { id: out.id ?? "", status: out.status ?? "" };
|
|
408
491
|
}
|
|
@@ -412,24 +495,51 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
412
495
|
// binds a payload to this integration; PayPal reports whether the signature is
|
|
413
496
|
// authentic.
|
|
414
497
|
async verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean> {
|
|
415
|
-
|
|
498
|
+
// PayPal's signature covers the exact bytes of the event it delivered, so the
|
|
499
|
+
// event is forwarded verbatim. Parsing and re-serializing it would reorder
|
|
500
|
+
// keys or restyle numbers and make an authentic webhook fail verification.
|
|
501
|
+
const rawEvent = new TextDecoder().decode(body);
|
|
502
|
+
const sentinel = "__pact_raw_webhook_event__";
|
|
503
|
+
const envelope = JSON.stringify({
|
|
416
504
|
webhook_id: this.creds.webhookId,
|
|
417
505
|
transmission_id: header(headers, "PayPal-Transmission-Id"),
|
|
418
506
|
transmission_time: header(headers, "PayPal-Transmission-Time"),
|
|
419
507
|
transmission_sig: header(headers, "PayPal-Transmission-Sig"),
|
|
420
508
|
cert_url: header(headers, "PayPal-Cert-Url"),
|
|
421
509
|
auth_algo: header(headers, "PayPal-Auth-Algo"),
|
|
422
|
-
webhook_event:
|
|
510
|
+
webhook_event: sentinel,
|
|
423
511
|
});
|
|
512
|
+
const payload = envelope.replace(`"${sentinel}"`, rawEvent);
|
|
513
|
+
const out = await this.postSerialized<{ verification_status?: string }>(
|
|
514
|
+
"/v1/notifications/verify-webhook-signature",
|
|
515
|
+
payload,
|
|
516
|
+
);
|
|
424
517
|
return out.verification_status === "SUCCESS";
|
|
425
518
|
}
|
|
426
519
|
|
|
427
|
-
|
|
520
|
+
// requestId, when set, is sent as PayPal-Request-Id. PayPal deduplicates a
|
|
521
|
+
// mutation that carries a request id it has already seen, so a retry after a
|
|
522
|
+
// lost response reuses the first order, capture, or refund rather than creating
|
|
523
|
+
// a second.
|
|
524
|
+
private async postJSON<T>(path: string, body: unknown, requestId?: string): Promise<T> {
|
|
525
|
+
return this.postSerialized<T>(path, JSON.stringify(body), requestId);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// postSerialized posts an already-serialized JSON payload, so a caller that must
|
|
529
|
+
// control the exact bytes on the wire — a webhook forwarded verbatim — can do so.
|
|
530
|
+
private async postSerialized<T>(path: string, payload: string, requestId?: string): Promise<T> {
|
|
428
531
|
const token = await this.accessToken();
|
|
429
|
-
const
|
|
532
|
+
const headers: Record<string, string> = {
|
|
533
|
+
"Content-Type": "application/json",
|
|
534
|
+
Authorization: `Bearer ${token}`,
|
|
535
|
+
};
|
|
536
|
+
if (requestId) {
|
|
537
|
+
headers["PayPal-Request-Id"] = requestId;
|
|
538
|
+
}
|
|
539
|
+
const resp = await fetchWithTimeout(this.baseURL + path, {
|
|
430
540
|
method: "POST",
|
|
431
|
-
headers
|
|
432
|
-
body:
|
|
541
|
+
headers,
|
|
542
|
+
body: payload,
|
|
433
543
|
});
|
|
434
544
|
if (resp.status >= 300) {
|
|
435
545
|
throw new Error(`paypal: ${path} returned ${resp.status}`);
|