@myzonerocks/pact 0.1.2 → 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 +15 -3
  9. package/dist/src/adapters/paypal.js +121 -21
  10. package/dist/src/adapters/stripe.d.ts +6 -2
  11. package/dist/src/adapters/stripe.js +25 -12
  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 +54 -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 +168 -22
  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,15 +5,19 @@
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
- PayInLeg,
12
14
  PayOutLeg,
15
+ InteractivePayInLeg,
13
16
  PayInCapabilities,
14
17
  PayOutCapabilities,
15
18
  CollectResult,
16
19
  DisburseResult,
20
+ PayInPreparation,
17
21
  } from "../leg.js";
18
22
 
19
23
  // The public base of the PayPal REST API. It is the same for every live
@@ -36,6 +40,18 @@ export interface CreateOrderParams {
36
40
  currencyCode: string;
37
41
  payee: string;
38
42
  referenceId: string;
43
+ // platformFee, when set, is the decimal-major cut the platform takes from the
44
+ // payee's proceeds.
45
+ platformFee?: string;
46
+ }
47
+
48
+ // Order is a created-but-not-yet-captured order: the buyer approves it on their own
49
+ // device, then it is captured. approveUrl is PayPal's hosted approval link, offered
50
+ // for hosts that redirect rather than drive the JS SDK.
51
+ export interface Order {
52
+ id: string;
53
+ status: string;
54
+ approveUrl: string;
39
55
  }
40
56
 
41
57
  // Capture is the result of capturing an order: the capture id is the reference a
@@ -65,6 +81,10 @@ export interface Payout {
65
81
  export interface RefundParams {
66
82
  captureId: string;
67
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;
68
88
  }
69
89
 
70
90
  // Refund is the result of refunding a capture.
@@ -80,6 +100,9 @@ export interface Refund {
80
100
  // endpoint, so the verification secret never lives in this package.
81
101
  export interface PaypalApi {
82
102
  createAndCaptureOrder(params: CreateOrderParams): Promise<Capture>;
103
+ // createOrder creates an order the buyer approves and captures on their own
104
+ // device — the interactive pay-in path — rather than capturing it server-side.
105
+ createOrder(params: CreateOrderParams): Promise<Order>;
83
106
  sendPayout(params: PayoutParams): Promise<Payout>;
84
107
  refundCapture(params: RefundParams): Promise<Refund>;
85
108
  verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean>;
@@ -93,12 +116,15 @@ export interface PaypalConfig {
93
116
  }
94
117
 
95
118
  // PaypalLeg moves money over PayPal, serving both sides of a corridor.
96
- export class PaypalLeg implements PayInLeg, PayOutLeg {
119
+ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
97
120
  readonly id: string;
98
121
  private readonly currencies: string[];
99
122
  private readonly api: PaypalApi;
100
123
  private readonly ids: () => string;
101
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 }>();
102
128
 
103
129
  constructor(cfg: PaypalConfig) {
104
130
  this.id = cfg.id ?? "paypal";
@@ -112,7 +138,7 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
112
138
  rails: ["paypal"],
113
139
  currencies: this.currencies,
114
140
  methods: ["paypal", "venmo", "card"],
115
- refunds: RefundKind.Full,
141
+ refunds: RefundKind.Partial,
116
142
  };
117
143
  }
118
144
 
@@ -131,9 +157,37 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
131
157
  referenceId: referencePrefix + intentId,
132
158
  });
133
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
+ });
134
164
  return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
135
165
  }
136
166
 
167
+ // prepare creates an order the payer approves and captures on their own device
168
+ // through the PayPal or Venmo flow — the interactive pay-in path — so nothing is
169
+ // captured here. The order names the recipient as payee and our cut as a platform
170
+ // fee; the buyer's capture then fires PAYMENT.CAPTURE.COMPLETED, which advances
171
+ // the corridor through the same pay-in path as a server-side collection. The
172
+ // order id is the token the payer's PayPal buttons need.
173
+ async prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation> {
174
+ const params: CreateOrderParams = {
175
+ value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
176
+ currencyCode: quote.srcAmount.currency,
177
+ payee: deliverTo,
178
+ referenceId: referencePrefix + intentId,
179
+ };
180
+ if (!quote.fees.isZero()) {
181
+ params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
182
+ }
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
+ });
188
+ return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
189
+ }
190
+
137
191
  // disburse sends a payout of the recipient's amount to recipientRef and returns
138
192
  // the batch id.
139
193
  async disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult> {
@@ -146,18 +200,24 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
146
200
  return { providerRef: payout.batchId };
147
201
  }
148
202
 
149
- // refundIn reverses a captured payment in full. PayPal captures the order id in
150
- // the capture id it returned from collect, so the refund binds to that
151
- // reference.
152
- async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
153
- 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) {
154
208
  throw new Error(`paypal: cannot perform refund kind ${kind}`);
155
209
  }
156
210
  const captureId = this.captures.get(intentId);
157
211
  if (!captureId) {
158
212
  throw new Error(`paypal: no capture for intent ${intentId}`);
159
213
  }
160
- 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);
161
221
  return {
162
222
  intentId,
163
223
  state: State.Refunded,
@@ -190,8 +250,22 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
190
250
  const providerRef = resource.id ?? "";
191
251
 
192
252
  switch (event.event_type) {
193
- 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
+ }
194
267
  return [oneEvent(intentId, State.Settled, providerRef, "")];
268
+ }
195
269
  case "PAYMENT.CAPTURE.DENIED":
196
270
  return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
197
271
  case "PAYMENT.CAPTURE.REFUNDED":
@@ -204,7 +278,12 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
204
278
 
205
279
  interface PaypalWebhook {
206
280
  event_type?: string;
207
- 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
+ };
208
287
  }
209
288
 
210
289
  function oneEvent(intentId: string, state: State, providerRef: string, reason: string): AdapterEvent {
@@ -269,7 +348,7 @@ class HttpPaypalApi implements PaypalApi {
269
348
  return this.token;
270
349
  }
271
350
  const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
272
- const resp = await fetch(`${this.baseURL}/v1/oauth2/token`, {
351
+ const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
273
352
  method: "POST",
274
353
  headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
275
354
  body: "grant_type=client_credentials",
@@ -291,10 +370,11 @@ class HttpPaypalApi implements PaypalApi {
291
370
  if (params.payee) {
292
371
  unit.payee = { email_address: params.payee };
293
372
  }
294
- const created = await this.postJSON<PaypalOrder>("/v2/checkout/orders", {
295
- intent: "CAPTURE",
296
- purchase_units: [unit],
297
- });
373
+ const created = await this.postJSON<PaypalOrder>(
374
+ "/v2/checkout/orders",
375
+ { intent: "CAPTURE", purchase_units: [unit] },
376
+ idempotencyKey(params.referenceId, "paypal-order"),
377
+ );
298
378
  const existing = firstCapture(created);
299
379
  if (existing) {
300
380
  return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
@@ -302,6 +382,7 @@ class HttpPaypalApi implements PaypalApi {
302
382
  const captured = await this.postJSON<PaypalOrder>(
303
383
  `/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`,
304
384
  {},
385
+ idempotencyKey(params.referenceId, "paypal-capture"),
305
386
  );
306
387
  const capture = firstCapture(captured);
307
388
  if (!capture) {
@@ -310,6 +391,38 @@ class HttpPaypalApi implements PaypalApi {
310
391
  return { orderId: captured.id ?? "", captureId: capture.id ?? "", status: capture.status ?? "" };
311
392
  }
312
393
 
394
+ // createOrder creates an order the buyer approves and captures on their own
395
+ // device. It carries custom_id so the capture webhook ties back to the intent,
396
+ // and a platform fee via payment_instruction when one is set.
397
+ async createOrder(params: CreateOrderParams): Promise<Order> {
398
+ const unit: Record<string, unknown> = {
399
+ reference_id: params.referenceId,
400
+ custom_id: params.referenceId,
401
+ amount: { currency_code: params.currencyCode, value: params.value },
402
+ };
403
+ if (params.payee) {
404
+ unit.payee = { email_address: params.payee };
405
+ }
406
+ if (params.platformFee) {
407
+ unit.payment_instruction = {
408
+ platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
409
+ };
410
+ }
411
+ const created = await this.postJSON<PaypalOrder>(
412
+ "/v2/checkout/orders",
413
+ { intent: "CAPTURE", purchase_units: [unit] },
414
+ idempotencyKey(params.referenceId, "paypal-order"),
415
+ );
416
+ let approve = "";
417
+ for (const link of created.links ?? []) {
418
+ if (link.rel === "approve" || link.rel === "payer-action") {
419
+ approve = link.href ?? "";
420
+ break;
421
+ }
422
+ }
423
+ return { id: created.id ?? "", status: created.status ?? "", approveUrl: approve };
424
+ }
425
+
313
426
  async sendPayout(params: PayoutParams): Promise<Payout> {
314
427
  const out = await this.postJSON<{ batch_header?: { payout_batch_id?: string; batch_status?: string } }>(
315
428
  "/v1/payments/payouts",
@@ -333,9 +446,14 @@ class HttpPaypalApi implements PaypalApi {
333
446
  if (params.reason) {
334
447
  body.note_to_payer = params.reason;
335
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
+ }
336
453
  const out = await this.postJSON<{ id?: string; status?: string }>(
337
454
  `/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`,
338
455
  body,
456
+ idempotencyKey(params.captureId, "paypal-refund"),
339
457
  );
340
458
  return { id: out.id ?? "", status: out.status ?? "" };
341
459
  }
@@ -345,24 +463,51 @@ class HttpPaypalApi implements PaypalApi {
345
463
  // binds a payload to this integration; PayPal reports whether the signature is
346
464
  // authentic.
347
465
  async verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean> {
348
- 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({
349
472
  webhook_id: this.creds.webhookId,
350
473
  transmission_id: header(headers, "PayPal-Transmission-Id"),
351
474
  transmission_time: header(headers, "PayPal-Transmission-Time"),
352
475
  transmission_sig: header(headers, "PayPal-Transmission-Sig"),
353
476
  cert_url: header(headers, "PayPal-Cert-Url"),
354
477
  auth_algo: header(headers, "PayPal-Auth-Algo"),
355
- webhook_event: JSON.parse(new TextDecoder().decode(body)),
478
+ webhook_event: sentinel,
356
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
+ );
357
485
  return out.verification_status === "SUCCESS";
358
486
  }
359
487
 
360
- 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> {
361
499
  const token = await this.accessToken();
362
- 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, {
363
508
  method: "POST",
364
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
365
- body: JSON.stringify(body),
509
+ headers,
510
+ body: payload,
366
511
  });
367
512
  if (resp.status >= 300) {
368
513
  throw new Error(`paypal: ${path} returned ${resp.status}`);
@@ -377,6 +522,7 @@ interface PaypalOrder {
377
522
  id?: string;
378
523
  status?: string;
379
524
  purchase_units?: Array<{ payments?: { captures?: Array<{ id?: string; status?: string }> } }>;
525
+ links?: Array<{ href?: string; rel?: string }>;
380
526
  }
381
527
 
382
528
  function firstCapture(order: PaypalOrder): { id?: string; status?: string } | undefined {
@@ -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
  }