@myzonerocks/pact 0.1.4 → 0.1.7

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.
@@ -20,9 +20,11 @@ export function intentHash(i) {
20
20
  // The bridge that moves value unchanged when the payer and recipient already
21
21
  // share a currency. Its identifier is a stable wire value carried in a quote.
22
22
  export const BRIDGE_PASSTHROUGH = "passthrough";
23
- export function quotePreimage(q) {
24
- return new CanonicalWriter()
25
- .str(domain("quote"))
23
+ // writeQuoteFields appends a quote's fields to a writer in the one canonical order
24
+ // shared by the signing preimage and the wire codec. The two callers differ only in
25
+ // what frames the fields: the preimage prepends a domain tag, the wire form does not.
26
+ export function writeQuoteFields(w, q) {
27
+ return w
26
28
  .str(q.id)
27
29
  .str(q.intentId)
28
30
  .str(q.payInAdapterId)
@@ -36,8 +38,11 @@ export function quotePreimage(q) {
36
38
  .str(q.fxRate)
37
39
  .u64(q.expiresAt)
38
40
  .str(q.providerQuoteRef)
39
- .u64(q.latencyEstimateMs)
40
- .preimage();
41
+ .u64(q.latencyEstimateMs);
42
+ }
43
+ export function quotePreimage(q) {
44
+ const w = new CanonicalWriter().str(domain("quote"));
45
+ return writeQuoteFields(w, q).preimage();
41
46
  }
42
47
  export function quoteHash(q) {
43
48
  return hashPreimage(quotePreimage(q));
package/dist/src/state.js CHANGED
@@ -51,7 +51,9 @@ const transitions = {
51
51
  [State.Held]: new Set([State.Disbursing, State.Refunding, State.Expired]),
52
52
  [State.Disbursing]: new Set([State.Settled, State.Failed, State.Refunding]),
53
53
  [State.Refunding]: new Set([State.Refunded, State.Failed]),
54
- [State.Settled]: new Set([State.Refunded]),
54
+ // A settled intent refunds through the same refunding step a bridged unwind uses,
55
+ // so the refund is claimed before any money moves and cannot fire twice.
56
+ [State.Settled]: new Set([State.Refunding, State.Refunded]),
55
57
  [State.Failed]: new Set(),
56
58
  [State.Expired]: new Set(),
57
59
  [State.Refunded]: new Set(),
package/dist/src/wire.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Money } from "./money.js";
2
2
  import { stateName } from "./state.js";
3
3
  import { CanonicalWriter } from "./canonical.js";
4
+ import { writeQuoteFields } from "./message.js";
4
5
  // The wire codec serializes messages for transport. It is distinct from the
5
6
  // canonical signing preimage: signing binds a fixed subset of fields under a
6
7
  // domain tag, while the wire form carries every field so a peer can reconstruct
@@ -27,22 +28,7 @@ export function encodeIntent(i) {
27
28
  .preimage();
28
29
  }
29
30
  export function encodeQuote(q) {
30
- return new CanonicalWriter()
31
- .str(q.id)
32
- .str(q.intentId)
33
- .str(q.payInAdapterId)
34
- .str(q.payInRail)
35
- .str(q.payOutAdapterId)
36
- .str(q.payOutRail)
37
- .str(q.bridgeId)
38
- .money(q.srcAmount)
39
- .money(q.dstAmount)
40
- .money(q.fees)
41
- .str(q.fxRate)
42
- .u64(q.expiresAt)
43
- .str(q.providerQuoteRef)
44
- .u64(q.latencyEstimateMs)
45
- .preimage();
31
+ return writeQuoteFields(new CanonicalWriter(), q).preimage();
46
32
  }
47
33
  export function encodeAuthorization(a) {
48
34
  return new CanonicalWriter()
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Client } from "../src/client.js";
3
+ import { Money } from "../src/money.js";
4
+ import { State } from "../src/state.js";
5
+ import { FakeLeg } from "./fake.js";
6
+ import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
7
+ import { fromHex } from "../src/crypto.js";
8
+ // Initiate may only collect from an authorized intent. This guards against a
9
+ // replayed or out-of-order call charging the payer a second time, matching the
10
+ // same guard in the Go, Dart, Swift, and Kotlin SDKs.
11
+ describe("initiate state guard", () => {
12
+ it("refuses to initiate from any state but authorized", async () => {
13
+ const now = 1_700_000_000_000;
14
+ const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
15
+ const signer = Ed25519Signer.fromSeed("alice", seed);
16
+ const verifier = new Ed25519Verifier(new Map([["alice", signer.publicKey]]));
17
+ let n = 0;
18
+ const ids = () => `id-${String(++n).padStart(3, "0")}`;
19
+ const wallet = new FakeLeg("wallet", "wallet", "USD", ids);
20
+ const client = new Client({ payIn: [wallet], payOut: [wallet], verifier, clock: () => now, idGen: ids });
21
+ const intent = client.createIntent({
22
+ senderRef: "alice",
23
+ recipientRef: "bob",
24
+ amount: Money.parse("USD", 2, "1500"),
25
+ expiresAt: now + 600_000,
26
+ allowedRails: ["wallet"],
27
+ });
28
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "wallet", currency: "USD", exponent: 2 }]);
29
+ const quote = await client.select(quotes, signer.identity());
30
+ // Before authorizing, the intent is only quoted, so initiate is refused.
31
+ await expect(client.initiate(intent, quote, { intentId: intent.id })).rejects.toThrow();
32
+ const auth = await client.authorize(intent, quote, signer);
33
+ await client.initiate(intent, quote, auth);
34
+ await client.advance(intent.id, "wallet", { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
35
+ // Once settled, a replayed initiate must not collect again.
36
+ await expect(client.initiate(intent, quote, auth)).rejects.toThrow();
37
+ });
38
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myzonerocks/pact",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "TypeScript SDK for the PACT payment abstraction protocol",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -74,6 +74,37 @@ export interface Erc20Config {
74
74
  // The confirmation depth a transfer must reach before it settles. Omitted uses
75
75
  // defaultMinConfirmations.
76
76
  minConfirmations?: number;
77
+ // Store persists broadcast transfers; defaults to an in-memory store. A deployment
78
+ // that must survive a restart with transfers in flight supplies a durable one.
79
+ store?: SentStore;
80
+ }
81
+
82
+ // SentRecord is the broadcast transfer a later settlement reads back: the intent, the
83
+ // transaction hash, and the recipient and amount the on-chain receipt must match.
84
+ export interface SentRecord {
85
+ intentId: string;
86
+ txHash: string;
87
+ to: string;
88
+ amount: bigint;
89
+ }
90
+
91
+ // SentStore records a broadcast transfer so its settlement can be polled after a
92
+ // restart and a repeat for the same intent returns the first transfer. The default
93
+ // keeps records in memory; a durable one survives a restart.
94
+ export interface SentStore {
95
+ save(rec: SentRecord): Promise<void>;
96
+ byIntent(intentId: string): Promise<SentRecord | undefined>;
97
+ }
98
+
99
+ // MemorySentStore is the default in-process store.
100
+ export class MemorySentStore implements SentStore {
101
+ private readonly byIntentMap = new Map<string, SentRecord>();
102
+ async save(rec: SentRecord): Promise<void> {
103
+ this.byIntentMap.set(rec.intentId, rec);
104
+ }
105
+ async byIntent(intentId: string): Promise<SentRecord | undefined> {
106
+ return this.byIntentMap.get(intentId);
107
+ }
77
108
  }
78
109
 
79
110
  // Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
@@ -87,9 +118,9 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
87
118
  private readonly ids: () => string;
88
119
  private readonly decimals: number;
89
120
  private readonly minConf: number;
90
- // Each broadcast transfer and what it was meant to move, so a settlement can
91
- // confirm the mined transaction really matches the payment.
92
- private readonly sent = new Map<string, { txHash: string; to: string; amount: bigint }>();
121
+ // Broadcast transfers, so a settlement can confirm the mined transaction really
122
+ // matches the payment, and a repeat returns the first transfer.
123
+ private readonly store: SentStore;
93
124
 
94
125
  constructor(cfg: Erc20Config) {
95
126
  if (!cfg.token || !cfg.currency) {
@@ -108,6 +139,7 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
108
139
  this.ids = cfg.ids;
109
140
  this.decimals = cfg.decimals;
110
141
  this.minConf = cfg.minConfirmations ?? defaultMinConfirmations;
142
+ this.store = cfg.store ?? new MemorySentStore();
111
143
  }
112
144
 
113
145
  // checkScale refuses an amount whose exponent does not match the token's
@@ -138,12 +170,12 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
138
170
  const net = quote.srcAmount.sub(quote.fees);
139
171
  // A repeat for the same intent returns the transfer already broadcast rather
140
172
  // than sending the payer's tokens twice.
141
- const prev = this.sent.get(intentId);
173
+ const prev = await this.store.byIntent(intentId);
142
174
  if (prev) {
143
175
  return { providerRef: prev.txHash, received: net };
144
176
  }
145
177
  const txHash = await this.transfer(deliverTo, net.value());
146
- this.sent.set(intentId, { txHash, to: normalizeAddress(deliverTo), amount: net.value() });
178
+ await this.store.save({ intentId, txHash, to: normalizeAddress(deliverTo), amount: net.value() });
147
179
  return { providerRef: txHash, received: net };
148
180
  }
149
181
 
@@ -152,13 +184,13 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
152
184
  this.checkScale(quote.dstAmount);
153
185
  // A repeat for the same intent returns the transfer already broadcast rather
154
186
  // than delivering the recipient's tokens twice.
155
- const prev = this.sent.get(intentId);
187
+ const prev = await this.store.byIntent(intentId);
156
188
  if (prev) {
157
189
  return { providerRef: prev.txHash };
158
190
  }
159
191
  const amount = quote.dstAmount.value();
160
192
  const txHash = await this.transfer(recipientRef, amount);
161
- this.sent.set(intentId, { txHash, to: normalizeAddress(recipientRef), amount });
193
+ await this.store.save({ intentId, txHash, to: normalizeAddress(recipientRef), amount });
162
194
  return { providerRef: txHash };
163
195
  }
164
196
 
@@ -184,7 +216,7 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
184
216
  // shallow success stays submitted so the host keeps polling; a revert or a
185
217
  // mismatch fails, so a dropped, reorged, or spoofed transfer never settles.
186
218
  async settlementEvent(intentId: string): Promise<AdapterEvent> {
187
- const rec = this.sent.get(intentId);
219
+ const rec = await this.store.byIntent(intentId);
188
220
  if (!rec) {
189
221
  throw new Error("erc20: no broadcast transaction for intent");
190
222
  }
@@ -53,6 +53,10 @@ export interface B2CParams {
53
53
  phone: string;
54
54
  reference: string;
55
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;
56
60
  }
57
61
 
58
62
  // B2CResult is what Daraja returns when a payout is accepted for delivery.
@@ -82,14 +86,40 @@ export interface DarajaApi {
82
86
  query(checkoutRequestId: string): Promise<StkQueryResult>;
83
87
  }
84
88
 
85
- // push remembers what settling an STK collection needs between initiating it and
86
- // the unsigned callback that reports its outcome: the intent, the payer to
87
- // refund, the amount, and the last state the callback recorded.
88
- 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 {
89
93
  intentId: string;
94
+ checkoutId: string;
90
95
  payerPhone: string;
91
96
  amount: number;
92
- 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
+ }
93
123
  }
94
124
 
95
125
  export interface MpesaConfig {
@@ -97,6 +127,9 @@ export interface MpesaConfig {
97
127
  api: DarajaApi;
98
128
  callbackURL: string;
99
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;
100
133
  }
101
134
 
102
135
  // MpesaLeg moves mobile money over M-Pesa.
@@ -106,8 +139,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
106
139
  private readonly callbackURL: string;
107
140
  private readonly ids: () => string;
108
141
 
109
- private readonly byIntent = new Map<string, PushRecord>();
110
- private readonly byCheckout = new Map<string, PushRecord>();
142
+ private readonly store: PushStore;
111
143
 
112
144
  constructor(cfg: MpesaConfig) {
113
145
  if (!cfg.callbackURL) {
@@ -117,6 +149,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
117
149
  this.api = cfg.api;
118
150
  this.callbackURL = cfg.callbackURL;
119
151
  this.ids = cfg.ids;
152
+ this.store = cfg.store ?? new MemoryPushStore();
120
153
  }
121
154
 
122
155
  payInCapabilities(): PayInCapabilities {
@@ -140,7 +173,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
140
173
  if (!phone) {
141
174
  throw new Error("mpesa: collect requires the payer's phone");
142
175
  }
143
- const rec: PushRecord = { intentId, payerPhone: phone, amount, state: State.Unspecified };
144
176
  const result = await this.api.stkPush({
145
177
  amount,
146
178
  payerPhone: phone,
@@ -148,8 +180,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
148
180
  description: "payment",
149
181
  callbackURL: this.callbackURL,
150
182
  });
151
- this.byIntent.set(intentId, rec);
152
- this.byCheckout.set(result.checkoutRequestId, rec);
183
+ await this.store.save({ intentId, checkoutId: result.checkoutRequestId, payerPhone: phone, amount });
153
184
  return { providerRef: result.checkoutRequestId, received: net };
154
185
  }
155
186
 
@@ -161,7 +192,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
161
192
  if (!phone) {
162
193
  throw new Error("mpesa: disburse requires the recipient's phone");
163
194
  }
164
- 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}` });
165
196
  return { providerRef: result.conversationId };
166
197
  }
167
198
 
@@ -173,7 +204,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
173
204
  throw new Error("mpesa: a collection can only be refunded by counter-transfer");
174
205
  }
175
206
  const shillings = wholeShillings(amount);
176
- const rec = this.byIntent.get(intentId);
207
+ const rec = await this.store.byIntent(intentId);
177
208
  if (!rec) {
178
209
  throw new Error(`mpesa: no push for intent ${intentId}`);
179
210
  }
@@ -186,6 +217,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
186
217
  phone: rec.payerPhone,
187
218
  reference: intentId,
188
219
  remarks: reason,
220
+ idempotencyKey: `pact:refund:${intentId}`,
189
221
  });
190
222
  return {
191
223
  intentId,
@@ -215,7 +247,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
215
247
  async parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]> {
216
248
  const envelope = JSON.parse(new TextDecoder().decode(raw)) as StkCallbackEnvelope;
217
249
  const cb = envelope.Body?.stkCallback;
218
- const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
250
+ const rec = cb ? await this.store.byCheckout(cb.CheckoutRequestID) : undefined;
219
251
  if (!cb || !rec) {
220
252
  throw new Error(ErrUnknownCheckout);
221
253
  }
@@ -227,7 +259,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
227
259
  return [];
228
260
  }
229
261
  if (confirmed.resultCode !== 0) {
230
- rec.state = State.Failed;
231
262
  return [
232
263
  {
233
264
  intentId: rec.intentId,
@@ -247,7 +278,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
247
278
  }
248
279
 
249
280
  const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
250
- rec.state = State.Settled;
251
281
  return [
252
282
  {
253
283
  intentId: rec.intentId,
@@ -447,6 +477,7 @@ class HttpDarajaApi implements DarajaApi {
447
477
  async b2cPayment(params: B2CParams): Promise<B2CResult> {
448
478
  const token = await this.token();
449
479
  const body = {
480
+ OriginatorConversationID: params.idempotencyKey,
450
481
  InitiatorName: this.creds.shortCode,
451
482
  CommandID: "BusinessPayment",
452
483
  Amount: params.amount,
@@ -113,6 +113,37 @@ export interface PaypalConfig {
113
113
  currencies: string[];
114
114
  api: PaypalApi;
115
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
+ }
116
147
  }
117
148
 
118
149
  // PaypalLeg moves money over PayPal, serving both sides of a corridor.
@@ -121,16 +152,14 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
121
152
  private readonly currencies: string[];
122
153
  private readonly api: PaypalApi;
123
154
  private readonly ids: () => string;
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 }>();
155
+ private readonly store: CaptureStore;
128
156
 
129
157
  constructor(cfg: PaypalConfig) {
130
158
  this.id = cfg.id ?? "paypal";
131
159
  this.currencies = cfg.currencies;
132
160
  this.api = cfg.api;
133
161
  this.ids = cfg.ids;
162
+ this.store = cfg.store ?? new MemoryCaptureStore();
134
163
  }
135
164
 
136
165
  payInCapabilities(): PayInCapabilities {
@@ -156,10 +185,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
156
185
  payee: deliverTo,
157
186
  referenceId: referencePrefix + intentId,
158
187
  });
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,
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 },
163
192
  });
164
193
  return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
165
194
  }
@@ -181,9 +210,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
181
210
  params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
182
211
  }
183
212
  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,
213
+ await this.store.save({
214
+ intentId,
215
+ captureId: "",
216
+ expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
187
217
  });
188
218
  return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
189
219
  }
@@ -207,10 +237,11 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
207
237
  if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
208
238
  throw new Error(`paypal: cannot perform refund kind ${kind}`);
209
239
  }
210
- const captureId = this.captures.get(intentId);
211
- if (!captureId) {
240
+ const rec = await this.store.byIntent(intentId);
241
+ if (!rec || !rec.captureId) {
212
242
  throw new Error(`paypal: no capture for intent ${intentId}`);
213
243
  }
244
+ const captureId = rec.captureId;
214
245
  // A partial refund names the amount in major units; a full refund leaves it
215
246
  // absent so PayPal returns the whole capture.
216
247
  const params: RefundParams =
@@ -255,10 +286,11 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
255
286
  // created the order, so a genuine, signed capture for a different order can
256
287
  // carry a target intent's id. Settle only when the captured amount and
257
288
  // currency match what the intent was quoted to collect.
258
- const want = this.expected.get(intentId);
259
- if (!want) {
289
+ const rec = await this.store.byIntent(intentId);
290
+ if (!rec) {
260
291
  throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
261
292
  }
293
+ const want = rec.expected;
262
294
  if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
263
295
  throw new Error(
264
296
  `paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`,
@@ -353,8 +353,15 @@ function parseEvent(
353
353
  const event = JSON.parse(new TextDecoder().decode(payload)) as StripeEvent;
354
354
 
355
355
  switch (event.type) {
356
- case "payment_intent.succeeded":
357
- return oneEvent(decodePaymentIntent(event.data.object), State.Settled, "");
356
+ case "payment_intent.succeeded": {
357
+ // The webhook is signed by Stripe, so its amounts are authentic. A capture for
358
+ // less than the authorized amount must not settle the corridor as fully paid.
359
+ const pi = decodePaymentIntent(event.data.object);
360
+ if (pi.amountReceived < pi.amount) {
361
+ return oneEvent(pi, State.Failed, "captured amount is less than the authorized amount");
362
+ }
363
+ return oneEvent(pi, State.Settled, "");
364
+ }
358
365
  case "payment_intent.payment_failed":
359
366
  return oneEvent(decodePaymentIntent(event.data.object), State.Failed, "payment failed");
360
367
  case "charge.refunded":
@@ -368,18 +375,26 @@ interface WebhookIntent {
368
375
  id: string;
369
376
  metadata: Record<string, string>;
370
377
  created: number;
378
+ amount: number;
379
+ amountReceived: number;
371
380
  }
372
381
 
373
382
  function decodePaymentIntent(raw: unknown): WebhookIntent {
374
- const obj = raw as { id?: string; metadata?: Record<string, string>; created?: number };
375
- return { id: obj.id ?? "", metadata: obj.metadata ?? {}, created: obj.created ?? 0 };
383
+ const obj = raw as { id?: string; metadata?: Record<string, string>; created?: number; amount?: number; amount_received?: number };
384
+ return {
385
+ id: obj.id ?? "",
386
+ metadata: obj.metadata ?? {},
387
+ created: obj.created ?? 0,
388
+ amount: obj.amount ?? 0,
389
+ amountReceived: obj.amount_received ?? 0,
390
+ };
376
391
  }
377
392
 
378
393
  // decodeRefundedIntent reads the pact intent id off a refunded charge. A charge
379
394
  // carries the originating PaymentIntent id and copies its metadata.
380
395
  function decodeRefundedIntent(raw: unknown): WebhookIntent {
381
396
  const charge = raw as { payment_intent?: string; metadata?: Record<string, string>; created?: number };
382
- return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0 };
397
+ return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0, amount: 0, amountReceived: 0 };
383
398
  }
384
399
 
385
400
  function oneEvent(pi: WebhookIntent, state: State, reason: string): AdapterEvent[] {
@@ -483,7 +498,7 @@ class HttpStripeApi implements StripeApi {
483
498
 
484
499
  private async send<T>(path: string, init: RequestInit): Promise<T> {
485
500
  const headers: Record<string, string> = {
486
- ...((init.headers as Record<string, string>) ?? {}),
501
+ ...(init.headers as Record<string, string> | undefined),
487
502
  Authorization: `Bearer ${this.secretKey}`,
488
503
  "Stripe-Version": apiVersion,
489
504
  };
package/src/canonical.ts CHANGED
@@ -61,6 +61,8 @@ export class CanonicalWriter {
61
61
  // byte value so the encoding never depends on insertion order. The sort is on
62
62
  // encoded bytes, not on UTF-16 code units, to match the other SDKs exactly.
63
63
  stringMap(kv: Readonly<Record<string, string>>): this {
64
+ // Object.keys returns a fresh array, so sorting it in place mutates nothing shared.
65
+ // oxlint-disable-next-line unicorn/no-array-sort
64
66
  const keys = Object.keys(kv).sort(compareUtf8);
65
67
  this.u64(keys.length);
66
68
  for (const k of keys) {