@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.
Files changed (70) hide show
  1. package/README.md +3 -3
  2. package/dist/src/adapter.d.ts +1 -1
  3. package/dist/src/adapters/erc20.d.ts +30 -4
  4. package/dist/src/adapters/erc20.js +105 -50
  5. package/dist/src/adapters/http.d.ts +2 -0
  6. package/dist/src/adapters/http.js +12 -0
  7. package/dist/src/adapters/mpesa.d.ts +30 -4
  8. package/dist/src/adapters/mpesa.js +106 -22
  9. package/dist/src/adapters/paypal.d.ts +23 -2
  10. package/dist/src/adapters/paypal.js +91 -29
  11. package/dist/src/adapters/stripe.d.ts +3 -2
  12. package/dist/src/adapters/stripe.js +14 -11
  13. package/dist/src/bridge.js +12 -5
  14. package/dist/src/canonical.js +5 -0
  15. package/dist/src/client.d.ts +10 -2
  16. package/dist/src/client.js +215 -30
  17. package/dist/src/compliance.d.ts +4 -0
  18. package/dist/src/compliance.js +10 -3
  19. package/dist/src/crypto.js +4 -1
  20. package/dist/src/index.d.ts +0 -1
  21. package/dist/src/index.js +0 -1
  22. package/dist/src/ledger.d.ts +6 -2
  23. package/dist/src/ledger.js +2 -2
  24. package/dist/src/leg.d.ts +1 -1
  25. package/dist/src/message.d.ts +1 -1
  26. package/dist/src/message.js +8 -5
  27. package/dist/src/money.d.ts +1 -0
  28. package/dist/src/money.js +18 -3
  29. package/dist/src/protocol.d.ts +1 -0
  30. package/dist/src/protocol.js +8 -0
  31. package/dist/src/router.d.ts +2 -0
  32. package/dist/src/router.js +45 -7
  33. package/dist/src/state.js +3 -1
  34. package/dist/src/wire.js +19 -2
  35. package/dist/test/erc20.test.js +95 -31
  36. package/dist/test/fake.d.ts +29 -0
  37. package/dist/test/fake.js +79 -0
  38. package/dist/test/lifecycle.test.js +31 -3
  39. package/dist/test/money.test.d.ts +1 -0
  40. package/dist/test/money.test.js +27 -0
  41. package/dist/test/mpesa.test.js +41 -8
  42. package/dist/test/paypal.test.js +33 -8
  43. package/dist/test/policy.test.js +6 -2
  44. package/dist/test/router.test.d.ts +1 -0
  45. package/dist/test/router.test.js +52 -0
  46. package/dist/test/stripe.test.js +5 -4
  47. package/dist/test/vectors.test.js +48 -2
  48. package/dist/test/wire.test.js +15 -0
  49. package/package.json +1 -1
  50. package/src/adapter.ts +7 -2
  51. package/src/adapters/erc20.ts +150 -51
  52. package/src/adapters/http.ts +14 -0
  53. package/src/adapters/mpesa.ts +148 -28
  54. package/src/adapters/paypal.ts +138 -28
  55. package/src/adapters/stripe.ts +16 -13
  56. package/src/bridge.ts +12 -5
  57. package/src/canonical.ts +5 -0
  58. package/src/client.ts +228 -33
  59. package/src/compliance.ts +20 -3
  60. package/src/crypto.ts +4 -1
  61. package/src/index.ts +0 -1
  62. package/src/ledger.ts +12 -4
  63. package/src/leg.ts +4 -1
  64. package/src/message.ts +8 -5
  65. package/src/money.ts +19 -3
  66. package/src/protocol.ts +9 -0
  67. package/src/router.ts +44 -4
  68. package/src/state.ts +3 -1
  69. package/src/wire.ts +20 -3
  70. package/src/fake.ts +0 -96
@@ -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
- // push remembers what settling an STK collection needs between initiating it and
72
- // the unsigned callback that reports its outcome: the intent, the payer to
73
- // refund, the amount, and the last state the callback recorded.
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
- state: State;
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 byIntent = new Map<string, PushRecord>();
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.byIntent.set(intentId, rec);
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 rec = this.byIntent.get(intentId);
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: rec.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, so it is authenticated by matching its checkout id
191
- // to a push this leg started; an unrecognized id is refused. The headers are
192
- // accepted for interface symmetry and for a host that adds its own IP or
193
- // shared-secret gate on top.
194
- parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): AdapterEvent[] {
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.get(cb.CheckoutRequestID) : undefined;
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
- if (cb.ResultCode !== 0) {
203
- rec.state = State.Failed;
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: cb.ResultDesc ?? "",
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 fetch(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
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
- return out.access_token ?? "";
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 fetch(this.baseURL + path, {
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),
@@ -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 captures = new Map<string, string>();
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.Full,
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.captures.set(intentId, capture.captureId);
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 order id in
186
- // the capture id it returned from collect, so the refund binds to that
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 captureId = this.captures.get(intentId);
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 refund = await this.api.refundCapture({ captureId, reason });
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?: { id?: string; custom_id?: string; invoice_id?: string };
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 fetch(`${this.baseURL}/v1/oauth2/token`, {
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>("/v2/checkout/orders", {
331
- intent: "CAPTURE",
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>("/v2/checkout/orders", {
367
- intent: "CAPTURE",
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
- const out = await this.postJSON<{ verification_status?: string }>("/v1/notifications/verify-webhook-signature", {
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: JSON.parse(new TextDecoder().decode(body)),
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
- private async postJSON<T>(path: string, body: unknown): Promise<T> {
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 resp = await fetch(this.baseURL + path, {
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: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
432
- body: JSON.stringify(body),
541
+ headers,
542
+ body: payload,
433
543
  });
434
544
  if (resp.status >= 300) {
435
545
  throw new Error(`paypal: ${path} returned ${resp.status}`);