@myzonerocks/pact 0.1.3 → 0.1.4

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 (67) hide show
  1. package/dist/src/adapter.d.ts +1 -1
  2. package/dist/src/adapters/erc20.d.ts +13 -3
  3. package/dist/src/adapters/erc20.js +93 -49
  4. package/dist/src/adapters/http.d.ts +2 -0
  5. package/dist/src/adapters/http.js +12 -0
  6. package/dist/src/adapters/mpesa.d.ts +9 -2
  7. package/dist/src/adapters/mpesa.js +82 -12
  8. package/dist/src/adapters/paypal.d.ts +5 -1
  9. package/dist/src/adapters/paypal.js +76 -25
  10. package/dist/src/adapters/stripe.d.ts +3 -2
  11. package/dist/src/adapters/stripe.js +14 -11
  12. package/dist/src/bridge.js +12 -5
  13. package/dist/src/canonical.js +5 -0
  14. package/dist/src/client.d.ts +8 -2
  15. package/dist/src/client.js +181 -20
  16. package/dist/src/compliance.d.ts +4 -0
  17. package/dist/src/compliance.js +10 -3
  18. package/dist/src/crypto.js +4 -1
  19. package/dist/src/index.d.ts +0 -1
  20. package/dist/src/index.js +0 -1
  21. package/dist/src/ledger.d.ts +6 -2
  22. package/dist/src/ledger.js +2 -2
  23. package/dist/src/leg.d.ts +1 -1
  24. package/dist/src/message.d.ts +1 -1
  25. package/dist/src/message.js +8 -5
  26. package/dist/src/money.d.ts +1 -0
  27. package/dist/src/money.js +18 -3
  28. package/dist/src/protocol.d.ts +1 -0
  29. package/dist/src/protocol.js +8 -0
  30. package/dist/src/router.d.ts +2 -0
  31. package/dist/src/router.js +45 -7
  32. package/dist/src/wire.js +19 -2
  33. package/dist/test/erc20.test.js +95 -31
  34. package/dist/test/fake.d.ts +29 -0
  35. package/dist/test/fake.js +79 -0
  36. package/dist/test/lifecycle.test.js +31 -3
  37. package/dist/test/money.test.d.ts +1 -0
  38. package/dist/test/money.test.js +27 -0
  39. package/dist/test/mpesa.test.js +41 -8
  40. package/dist/test/paypal.test.js +33 -8
  41. package/dist/test/policy.test.js +6 -2
  42. package/dist/test/router.test.d.ts +1 -0
  43. package/dist/test/router.test.js +52 -0
  44. package/dist/test/stripe.test.js +5 -4
  45. package/dist/test/vectors.test.js +48 -2
  46. package/dist/test/wire.test.js +15 -0
  47. package/package.json +1 -1
  48. package/src/adapter.ts +7 -2
  49. package/src/adapters/erc20.ts +118 -51
  50. package/src/adapters/http.ts +14 -0
  51. package/src/adapters/mpesa.ts +102 -13
  52. package/src/adapters/paypal.ts +102 -24
  53. package/src/adapters/stripe.ts +16 -13
  54. package/src/bridge.ts +12 -5
  55. package/src/canonical.ts +5 -0
  56. package/src/client.ts +194 -22
  57. package/src/compliance.ts +20 -3
  58. package/src/crypto.ts +4 -1
  59. package/src/index.ts +0 -1
  60. package/src/ledger.ts +12 -4
  61. package/src/leg.ts +4 -1
  62. package/src/message.ts +8 -5
  63. package/src/money.ts +19 -3
  64. package/src/protocol.ts +9 -0
  65. package/src/router.ts +44 -4
  66. package/src/wire.ts +20 -3
  67. 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,
@@ -60,12 +61,25 @@ export interface B2CResult {
60
61
  responseCode: string;
61
62
  }
62
63
 
64
+ // StkQueryResult is the authoritative outcome of a push, read back from Daraja by
65
+ // its checkout id. pending means Daraja is still processing and the outcome isn't
66
+ // known yet; otherwise resultCode 0 is success and any other code is a failure.
67
+ export interface StkQueryResult {
68
+ resultCode: number;
69
+ resultDesc: string;
70
+ pending: boolean;
71
+ }
72
+
63
73
  // DarajaApi is the surface of Daraja this leg depends on. Depending on an
64
74
  // interface keeps the leg unit-testable without a network and without
65
75
  // credentials.
66
76
  export interface DarajaApi {
67
77
  stkPush(params: StkPushParams): Promise<StkPushResult>;
68
78
  b2cPayment(params: B2CParams): Promise<B2CResult>;
79
+ // query reads a push's real outcome back from Daraja by its checkout id. The
80
+ // STK callback is unsigned, so this authenticated read — not the callback body
81
+ // — is what a settlement is trusted to.
82
+ query(checkoutRequestId: string): Promise<StkQueryResult>;
69
83
  }
70
84
 
71
85
  // push remembers what settling an STK collection needs between initiating it and
@@ -154,16 +168,21 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
154
168
  // refundIn answers a collected payment with a business-to-customer payout back
155
169
  // to the payer. An STK collection cannot be reversed in place, so a
156
170
  // counter-transfer is the only refund this rail supports.
157
- async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
171
+ async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
158
172
  if (kind !== RefundKind.CounterTransfer) {
159
173
  throw new Error("mpesa: a collection can only be refunded by counter-transfer");
160
174
  }
175
+ const shillings = wholeShillings(amount);
161
176
  const rec = this.byIntent.get(intentId);
162
177
  if (!rec) {
163
178
  throw new Error(`mpesa: no push for intent ${intentId}`);
164
179
  }
180
+ // A refund cannot return more shillings than the push collected.
181
+ if (shillings > rec.amount) {
182
+ throw new Error(`mpesa: refund of ${shillings} exceeds the ${rec.amount} collected`);
183
+ }
165
184
  const result = await this.api.b2cPayment({
166
- amount: rec.amount,
185
+ amount: shillings,
167
186
  phone: rec.payerPhone,
168
187
  reference: intentId,
169
188
  remarks: reason,
@@ -187,11 +206,13 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
187
206
  }
188
207
 
189
208
  // 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[] {
209
+ // The callback is unsigned and its CheckoutRequestID is a value we hand back to
210
+ // the caller — not a secret so the callback body is treated only as a nudge.
211
+ // The real outcome is read back from Daraja with our own credentials, and a
212
+ // settlement is emitted only when that authenticated query confirms success and
213
+ // the amount Daraja paid equals the amount we authorized. The headers are
214
+ // accepted for interface symmetry and for a host that adds its own gate on top.
215
+ async parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]> {
195
216
  const envelope = JSON.parse(new TextDecoder().decode(raw)) as StkCallbackEnvelope;
196
217
  const cb = envelope.Body?.stkCallback;
197
218
  const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
@@ -199,7 +220,13 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
199
220
  throw new Error(ErrUnknownCheckout);
200
221
  }
201
222
 
202
- if (cb.ResultCode !== 0) {
223
+ const confirmed = await this.api.query(cb.CheckoutRequestID);
224
+ // No outcome yet — wait for a later callback rather than settling or failing
225
+ // on an unconfirmed body.
226
+ if (confirmed.pending) {
227
+ return [];
228
+ }
229
+ if (confirmed.resultCode !== 0) {
203
230
  rec.state = State.Failed;
204
231
  return [
205
232
  {
@@ -207,11 +234,17 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
207
234
  state: State.Failed,
208
235
  providerTxRef: cb.CheckoutRequestID,
209
236
  onchainTxHash: "",
210
- reason: cb.ResultDesc ?? "",
237
+ reason: confirmed.resultDesc,
211
238
  settledAt: 0,
212
239
  },
213
240
  ];
214
241
  }
242
+ // The amount Daraja collected must equal the amount we authorized; a partial
243
+ // or tampered collection settles nothing.
244
+ const paid = metadataInt(cb.CallbackMetadata?.Item ?? [], "Amount");
245
+ if (paid !== rec.amount) {
246
+ throw new Error(`mpesa: confirmed amount ${paid} does not match the authorized ${rec.amount}`);
247
+ }
215
248
 
216
249
  const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
217
250
  rec.state = State.Settled;
@@ -258,6 +291,20 @@ function wholeShillings(m: Money): number {
258
291
  return Number(amount);
259
292
  }
260
293
 
294
+ // metadataInt pulls a named numeric value out of the callback metadata items,
295
+ // used to read the paid Amount. M-Pesa amounts are whole shillings but may arrive
296
+ // as a number with a fractional part, so it is floored to the shilling.
297
+ function metadataInt(items: StkCallbackItem[], name: string): number {
298
+ for (const item of items) {
299
+ if (item.Name !== name) {
300
+ continue;
301
+ }
302
+ const n = Number(item.Value);
303
+ return Number.isFinite(n) ? Math.floor(n) : 0;
304
+ }
305
+ return 0;
306
+ }
307
+
261
308
  // metadataString pulls a named string value out of the callback metadata items.
262
309
  function metadataString(items: StkCallbackItem[], name: string): string {
263
310
  for (const item of items) {
@@ -301,6 +348,8 @@ class HttpDarajaApi implements DarajaApi {
301
348
  private readonly creds: Credentials;
302
349
  private readonly baseURL: string;
303
350
  private readonly now: () => Date;
351
+ private cachedToken = "";
352
+ private tokenExpiryMs = 0;
304
353
 
305
354
  constructor(creds: Credentials, now: () => Date) {
306
355
  this.creds = creds;
@@ -309,16 +358,25 @@ class HttpDarajaApi implements DarajaApi {
309
358
  }
310
359
 
311
360
  private async token(): Promise<string> {
361
+ // Reuse the cached token until it is within a minute of expiry, so a burst of
362
+ // pushes does not re-authenticate against Daraja on every call.
363
+ if (this.cachedToken && this.now().getTime() < this.tokenExpiryMs - 60_000) {
364
+ return this.cachedToken;
365
+ }
312
366
  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`, {
367
+ const resp = await fetchWithTimeout(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
314
368
  method: "GET",
315
369
  headers: { Authorization: `Basic ${basic}` },
316
370
  });
317
371
  if (resp.status >= 300) {
318
372
  throw new Error(`mpesa: /oauth/v1/generate returned ${resp.status}`);
319
373
  }
320
- const out = (await resp.json()) as { access_token?: string };
321
- return out.access_token ?? "";
374
+ const out = (await resp.json()) as { access_token?: string; expires_in?: string | number };
375
+ // Daraja tokens live an hour; fall back to that if the field is absent.
376
+ const ttlSeconds = Number(out.expires_in) > 0 ? Number(out.expires_in) : 3599;
377
+ this.cachedToken = out.access_token ?? "";
378
+ this.tokenExpiryMs = this.now().getTime() + ttlSeconds * 1000;
379
+ return this.cachedToken;
322
380
  }
323
381
 
324
382
  // password is the base64 of shortcode+passkey+timestamp Daraja requires on each
@@ -355,6 +413,37 @@ class HttpDarajaApi implements DarajaApi {
355
413
  };
356
414
  }
357
415
 
416
+ async query(checkoutRequestId: string): Promise<StkQueryResult> {
417
+ const token = await this.token();
418
+ const timestamp = formatTimestamp(this.now());
419
+ const body = {
420
+ BusinessShortCode: this.creds.shortCode,
421
+ Password: this.password(timestamp),
422
+ Timestamp: timestamp,
423
+ CheckoutRequestID: checkoutRequestId,
424
+ };
425
+ const resp = await fetchWithTimeout(this.baseURL + "/mpesa/stkpushquery/v1/query", {
426
+ method: "POST",
427
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
428
+ body: JSON.stringify(body),
429
+ });
430
+ const out = (await resp.json()) as {
431
+ ResultCode?: string;
432
+ ResultDesc?: string;
433
+ errorCode?: string;
434
+ };
435
+ // Daraja answers a query for a push it is still processing with an error code
436
+ // rather than a result; treat that as pending so the outcome is confirmed on a
437
+ // later query rather than mistaken for a failure.
438
+ if (out.errorCode === "500.001.1001") {
439
+ return { resultCode: 0, resultDesc: "", pending: true };
440
+ }
441
+ if (resp.status >= 300) {
442
+ throw new Error(`mpesa: stk query returned ${resp.status}`);
443
+ }
444
+ return { resultCode: Number(out.ResultCode ?? -1), resultDesc: out.ResultDesc ?? "", pending: false };
445
+ }
446
+
358
447
  async b2cPayment(params: B2CParams): Promise<B2CResult> {
359
448
  const token = await this.token();
360
449
  const body = {
@@ -375,7 +464,7 @@ class HttpDarajaApi implements DarajaApi {
375
464
  }
376
465
 
377
466
  private async postJSON<T>(token: string, path: string, body: unknown): Promise<T> {
378
- const resp = await fetch(this.baseURL + path, {
467
+ const resp = await fetchWithTimeout(this.baseURL + path, {
379
468
  method: "POST",
380
469
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
381
470
  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.
@@ -115,6 +122,9 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
115
122
  private readonly api: PaypalApi;
116
123
  private readonly ids: () => string;
117
124
  private readonly captures = new Map<string, string>();
125
+ // What each intent was quoted to collect, so a capture webhook can be
126
+ // cross-checked: the paid amount and currency must match before it settles.
127
+ private readonly expected = new Map<string, { value: string; currency: string }>();
118
128
 
119
129
  constructor(cfg: PaypalConfig) {
120
130
  this.id = cfg.id ?? "paypal";
@@ -128,7 +138,7 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
128
138
  rails: ["paypal"],
129
139
  currencies: this.currencies,
130
140
  methods: ["paypal", "venmo", "card"],
131
- refunds: RefundKind.Full,
141
+ refunds: RefundKind.Partial,
132
142
  };
133
143
  }
134
144
 
@@ -147,6 +157,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
147
157
  referenceId: referencePrefix + intentId,
148
158
  });
149
159
  this.captures.set(intentId, capture.captureId);
160
+ this.expected.set(intentId, {
161
+ value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
162
+ currency: quote.srcAmount.currency,
163
+ });
150
164
  return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
151
165
  }
152
166
 
@@ -167,6 +181,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
167
181
  params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
168
182
  }
169
183
  const order = await this.api.createOrder(params);
184
+ this.expected.set(intentId, {
185
+ value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
186
+ currency: quote.srcAmount.currency,
187
+ });
170
188
  return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
171
189
  }
172
190
 
@@ -182,18 +200,24 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
182
200
  return { providerRef: payout.batchId };
183
201
  }
184
202
 
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) {
203
+ // refundIn reverses a captured payment, in full or in part. PayPal captures the
204
+ // order id in the capture id it returned from collect, so the refund binds to
205
+ // that reference; a partial refund names the amount to return.
206
+ async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
207
+ if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
190
208
  throw new Error(`paypal: cannot perform refund kind ${kind}`);
191
209
  }
192
210
  const captureId = this.captures.get(intentId);
193
211
  if (!captureId) {
194
212
  throw new Error(`paypal: no capture for intent ${intentId}`);
195
213
  }
196
- const refund = await this.api.refundCapture({ captureId, reason });
214
+ // A partial refund names the amount in major units; a full refund leaves it
215
+ // absent so PayPal returns the whole capture.
216
+ const params: RefundParams =
217
+ kind === RefundKind.Partial
218
+ ? { captureId, reason, value: majorAmount(amount.minor(), amount.exponent), currencyCode: amount.currency }
219
+ : { captureId, reason };
220
+ const refund = await this.api.refundCapture(params);
197
221
  return {
198
222
  intentId,
199
223
  state: State.Refunded,
@@ -226,8 +250,22 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
226
250
  const providerRef = resource.id ?? "";
227
251
 
228
252
  switch (event.event_type) {
229
- case "PAYMENT.CAPTURE.COMPLETED":
253
+ case "PAYMENT.CAPTURE.COMPLETED": {
254
+ // The custom_id that ties a capture to an intent is chosen by whoever
255
+ // created the order, so a genuine, signed capture for a different order can
256
+ // carry a target intent's id. Settle only when the captured amount and
257
+ // currency match what the intent was quoted to collect.
258
+ const want = this.expected.get(intentId);
259
+ if (!want) {
260
+ throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
261
+ }
262
+ if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
263
+ throw new Error(
264
+ `paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`,
265
+ );
266
+ }
230
267
  return [oneEvent(intentId, State.Settled, providerRef, "")];
268
+ }
231
269
  case "PAYMENT.CAPTURE.DENIED":
232
270
  return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
233
271
  case "PAYMENT.CAPTURE.REFUNDED":
@@ -240,7 +278,12 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
240
278
 
241
279
  interface PaypalWebhook {
242
280
  event_type?: string;
243
- resource?: { id?: string; custom_id?: string; invoice_id?: string };
281
+ resource?: {
282
+ id?: string;
283
+ custom_id?: string;
284
+ invoice_id?: string;
285
+ amount?: { currency_code?: string; value?: string };
286
+ };
244
287
  }
245
288
 
246
289
  function oneEvent(intentId: string, state: State, providerRef: string, reason: string): AdapterEvent {
@@ -305,7 +348,7 @@ class HttpPaypalApi implements PaypalApi {
305
348
  return this.token;
306
349
  }
307
350
  const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
308
- const resp = await fetch(`${this.baseURL}/v1/oauth2/token`, {
351
+ const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
309
352
  method: "POST",
310
353
  headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
311
354
  body: "grant_type=client_credentials",
@@ -327,10 +370,11 @@ class HttpPaypalApi implements PaypalApi {
327
370
  if (params.payee) {
328
371
  unit.payee = { email_address: params.payee };
329
372
  }
330
- const created = await this.postJSON<PaypalOrder>("/v2/checkout/orders", {
331
- intent: "CAPTURE",
332
- purchase_units: [unit],
333
- });
373
+ const created = await this.postJSON<PaypalOrder>(
374
+ "/v2/checkout/orders",
375
+ { intent: "CAPTURE", purchase_units: [unit] },
376
+ idempotencyKey(params.referenceId, "paypal-order"),
377
+ );
334
378
  const existing = firstCapture(created);
335
379
  if (existing) {
336
380
  return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
@@ -338,6 +382,7 @@ class HttpPaypalApi implements PaypalApi {
338
382
  const captured = await this.postJSON<PaypalOrder>(
339
383
  `/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`,
340
384
  {},
385
+ idempotencyKey(params.referenceId, "paypal-capture"),
341
386
  );
342
387
  const capture = firstCapture(captured);
343
388
  if (!capture) {
@@ -363,10 +408,11 @@ class HttpPaypalApi implements PaypalApi {
363
408
  platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
364
409
  };
365
410
  }
366
- const created = await this.postJSON<PaypalOrder>("/v2/checkout/orders", {
367
- intent: "CAPTURE",
368
- purchase_units: [unit],
369
- });
411
+ const created = await this.postJSON<PaypalOrder>(
412
+ "/v2/checkout/orders",
413
+ { intent: "CAPTURE", purchase_units: [unit] },
414
+ idempotencyKey(params.referenceId, "paypal-order"),
415
+ );
370
416
  let approve = "";
371
417
  for (const link of created.links ?? []) {
372
418
  if (link.rel === "approve" || link.rel === "payer-action") {
@@ -400,9 +446,14 @@ class HttpPaypalApi implements PaypalApi {
400
446
  if (params.reason) {
401
447
  body.note_to_payer = params.reason;
402
448
  }
449
+ // A partial refund names the amount; an absent value refunds the full capture.
450
+ if (params.value) {
451
+ body.amount = { value: params.value, currency_code: params.currencyCode };
452
+ }
403
453
  const out = await this.postJSON<{ id?: string; status?: string }>(
404
454
  `/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`,
405
455
  body,
456
+ idempotencyKey(params.captureId, "paypal-refund"),
406
457
  );
407
458
  return { id: out.id ?? "", status: out.status ?? "" };
408
459
  }
@@ -412,24 +463,51 @@ class HttpPaypalApi implements PaypalApi {
412
463
  // binds a payload to this integration; PayPal reports whether the signature is
413
464
  // authentic.
414
465
  async verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean> {
415
- const out = await this.postJSON<{ verification_status?: string }>("/v1/notifications/verify-webhook-signature", {
466
+ // PayPal's signature covers the exact bytes of the event it delivered, so the
467
+ // event is forwarded verbatim. Parsing and re-serializing it would reorder
468
+ // keys or restyle numbers and make an authentic webhook fail verification.
469
+ const rawEvent = new TextDecoder().decode(body);
470
+ const sentinel = "__pact_raw_webhook_event__";
471
+ const envelope = JSON.stringify({
416
472
  webhook_id: this.creds.webhookId,
417
473
  transmission_id: header(headers, "PayPal-Transmission-Id"),
418
474
  transmission_time: header(headers, "PayPal-Transmission-Time"),
419
475
  transmission_sig: header(headers, "PayPal-Transmission-Sig"),
420
476
  cert_url: header(headers, "PayPal-Cert-Url"),
421
477
  auth_algo: header(headers, "PayPal-Auth-Algo"),
422
- webhook_event: JSON.parse(new TextDecoder().decode(body)),
478
+ webhook_event: sentinel,
423
479
  });
480
+ const payload = envelope.replace(`"${sentinel}"`, rawEvent);
481
+ const out = await this.postSerialized<{ verification_status?: string }>(
482
+ "/v1/notifications/verify-webhook-signature",
483
+ payload,
484
+ );
424
485
  return out.verification_status === "SUCCESS";
425
486
  }
426
487
 
427
- private async postJSON<T>(path: string, body: unknown): Promise<T> {
488
+ // requestId, when set, is sent as PayPal-Request-Id. PayPal deduplicates a
489
+ // mutation that carries a request id it has already seen, so a retry after a
490
+ // lost response reuses the first order, capture, or refund rather than creating
491
+ // a second.
492
+ private async postJSON<T>(path: string, body: unknown, requestId?: string): Promise<T> {
493
+ return this.postSerialized<T>(path, JSON.stringify(body), requestId);
494
+ }
495
+
496
+ // postSerialized posts an already-serialized JSON payload, so a caller that must
497
+ // control the exact bytes on the wire — a webhook forwarded verbatim — can do so.
498
+ private async postSerialized<T>(path: string, payload: string, requestId?: string): Promise<T> {
428
499
  const token = await this.accessToken();
429
- const resp = await fetch(this.baseURL + path, {
500
+ const headers: Record<string, string> = {
501
+ "Content-Type": "application/json",
502
+ Authorization: `Bearer ${token}`,
503
+ };
504
+ if (requestId) {
505
+ headers["PayPal-Request-Id"] = requestId;
506
+ }
507
+ const resp = await fetchWithTimeout(this.baseURL + path, {
430
508
  method: "POST",
431
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
432
- body: JSON.stringify(body),
509
+ headers,
510
+ body: payload,
433
511
  });
434
512
  if (resp.status >= 300) {
435
513
  throw new Error(`paypal: ${path} returned ${resp.status}`);
@@ -10,6 +10,8 @@ import { Money } from "../money.js";
10
10
  import { State } from "../state.js";
11
11
  import type { Quote, Authorization, Settlement } from "../message.js";
12
12
  import { RefundKind, type AdapterEvent } from "../adapter.js";
13
+ import { idempotencyKey } from "../protocol.js";
14
+ import { fetchWithTimeout } from "./http.js";
13
15
  import type { InteractivePayInLeg, PayInCapabilities, CollectResult, PayInPreparation } from "../leg.js";
14
16
 
15
17
  // The public base of the Stripe REST API. It is the same for every integration
@@ -29,7 +31,6 @@ const metadataIntentKey = "pact_intent_id";
29
31
  const defaultToleranceSeconds = 300;
30
32
 
31
33
  // The protocol identifier used to bind an idempotency key to one protocol step.
32
- const idPrefix = "pact";
33
34
 
34
35
  // PaymentIntent is the subset of Stripe's PaymentIntent this leg reads.
35
36
  // clientSecret is present only on a freshly created intent and is the single
@@ -108,9 +109,8 @@ export interface StripeConfig {
108
109
  currencies: string[];
109
110
  api: StripeApi;
110
111
  webhookKey: string;
111
- // clock returns the current time in Unix seconds, for webhook timestamp
112
- // checks. When absent, timestamps are checked against zero.
113
- clock?: () => number;
112
+ // clock returns the current time in Unix seconds, for webhook timestamp checks.
113
+ clock: () => number;
114
114
  // tolerance is the webhook timestamp tolerance in seconds; defaults to 300.
115
115
  tolerance?: number;
116
116
  ids: () => string;
@@ -136,11 +136,16 @@ export class StripeLeg implements InteractivePayInLeg {
136
136
  if (!cfg.webhookKey) {
137
137
  throw new Error("stripe: config requires a webhook signing key");
138
138
  }
139
+ // Without a clock every webhook timestamp reads as far in the past and
140
+ // silently fails the tolerance check, so a real webhook never verifies.
141
+ if (!cfg.clock) {
142
+ throw new Error("stripe: config requires a clock for webhook timestamp checks");
143
+ }
139
144
  this.id = cfg.id ?? "stripe";
140
145
  this.currencies = cfg.currencies;
141
146
  this.api = cfg.api;
142
147
  this.webhookKey = cfg.webhookKey;
143
- this.clock = cfg.clock ?? (() => 0);
148
+ this.clock = cfg.clock;
144
149
  this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
145
150
  this.ids = cfg.ids;
146
151
  this.methods = cfg.methods;
@@ -204,13 +209,16 @@ export class StripeLeg implements InteractivePayInLeg {
204
209
  // refundIn reverses a captured payment, in full or in part. Stripe supports
205
210
  // both, so the leg accepts the full and partial refund kinds and rejects a
206
211
  // counter-transfer it cannot express.
207
- async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
212
+ async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
208
213
  if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
209
214
  throw new Error(`stripe: cannot perform refund kind ${kind}`);
210
215
  }
211
216
  const pi = await this.api.findPaymentIntent(intentId);
217
+ // Stripe refunds the full capture when no amount is set, so a full refund
218
+ // leaves amount zero and a partial one carries the exact minor units to return.
219
+ const minor = kind === RefundKind.Partial ? minorToInteger(amount) : 0;
212
220
  const refund = await this.api.createRefund(
213
- { paymentIntentId: pi.id, amount: 0, reason },
221
+ { paymentIntentId: pi.id, amount: minor, reason },
214
222
  idempotencyKey(intentId, "refund"),
215
223
  );
216
224
  return {
@@ -249,11 +257,6 @@ function signatureHeader(headers: Record<string, string[]>): string {
249
257
  return "";
250
258
  }
251
259
 
252
- // idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
253
- // the original result instead of acting twice.
254
- function idempotencyKey(ref: string, step: string): string {
255
- return `${idPrefix}:${ref}:${step}`;
256
- }
257
260
 
258
261
  // minorToInteger narrows a Money amount to the safe integer Stripe expects,
259
262
  // refusing anything that would overflow. Fiat amounts fit comfortably; the guard
@@ -484,7 +487,7 @@ class HttpStripeApi implements StripeApi {
484
487
  Authorization: `Bearer ${this.secretKey}`,
485
488
  "Stripe-Version": apiVersion,
486
489
  };
487
- const resp = await fetch(this.baseURL + path, { ...init, headers });
490
+ const resp = await fetchWithTimeout(this.baseURL + path, { ...init, headers });
488
491
  const body = await resp.text();
489
492
  if (resp.status >= 300) {
490
493
  throw new Error(`stripe: ${path} returned ${resp.status}: ${stripeErrorMessage(body)}`);
package/src/bridge.ts CHANGED
@@ -131,14 +131,21 @@ export function applyInverseRate(dst: Money, rate: string, srcCurrency: string,
131
131
  // parseRate reads a decimal rate like "129.45" into a numerator and denominator,
132
132
  // so the conversion stays exact integer arithmetic with no floating point.
133
133
  export function parseRate(rate: string): [bigint, bigint] {
134
+ // Bound the length so an untrusted rate can't force a huge bigint parse and
135
+ // exponentiation in the FX math.
136
+ if (rate.length > 80) {
137
+ throw new Error("pact: rate has more than 80 characters");
138
+ }
139
+ // A non-negative integer part with no leading zeros and an optional fractional
140
+ // part; no sign, no radix prefix, no whitespace, matching the amount grammar so
141
+ // the SDKs never disagree on a rate's validity or value.
142
+ if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(rate)) {
143
+ throw new Error(`pact: ${JSON.stringify(rate)} is not a canonical decimal rate`);
144
+ }
134
145
  const dot = rate.indexOf(".");
135
146
  const whole = dot < 0 ? rate : rate.slice(0, dot);
136
147
  const frac = dot < 0 ? "" : rate.slice(dot + 1);
137
- const digits = whole + frac;
138
- if (!/^\d+$/.test(digits)) {
139
- throw new Error(`pact: ${JSON.stringify(rate)} is not a decimal rate`);
140
- }
141
- const num = BigInt(digits);
148
+ const num = BigInt(whole + frac);
142
149
  const den = frac.length > 0 ? pow10(frac.length) : 1n;
143
150
  return [num, den];
144
151
  }
package/src/canonical.ts CHANGED
@@ -26,6 +26,11 @@ export class CanonicalWriter {
26
26
  return this;
27
27
  }
28
28
 
29
+ // str writes a string as its raw UTF-8 bytes. No Unicode normalization is
30
+ // applied: the bytes are hashed as given, so two participants that compose the
31
+ // same text in different Unicode forms produce different hashes. Callers that
32
+ // need them to agree must normalize before building a message; string fields are
33
+ // otherwise treated as opaque bytes.
29
34
  str(s: string): this {
30
35
  return this.bytes(encoder.encode(s));
31
36
  }