@myzonerocks/pact 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/src/adapter.d.ts +1 -1
- package/dist/src/adapters/erc20.d.ts +30 -4
- package/dist/src/adapters/erc20.js +105 -50
- package/dist/src/adapters/http.d.ts +2 -0
- package/dist/src/adapters/http.js +12 -0
- package/dist/src/adapters/mpesa.d.ts +30 -4
- package/dist/src/adapters/mpesa.js +106 -22
- package/dist/src/adapters/paypal.d.ts +23 -2
- package/dist/src/adapters/paypal.js +91 -29
- package/dist/src/adapters/stripe.d.ts +3 -2
- package/dist/src/adapters/stripe.js +14 -11
- package/dist/src/bridge.js +12 -5
- package/dist/src/canonical.js +5 -0
- package/dist/src/client.d.ts +10 -2
- package/dist/src/client.js +215 -30
- package/dist/src/compliance.d.ts +4 -0
- package/dist/src/compliance.js +10 -3
- package/dist/src/crypto.js +4 -1
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/dist/src/ledger.d.ts +6 -2
- package/dist/src/ledger.js +2 -2
- package/dist/src/leg.d.ts +1 -1
- package/dist/src/message.d.ts +1 -1
- package/dist/src/message.js +8 -5
- package/dist/src/money.d.ts +1 -0
- package/dist/src/money.js +18 -3
- package/dist/src/protocol.d.ts +1 -0
- package/dist/src/protocol.js +8 -0
- package/dist/src/router.d.ts +2 -0
- package/dist/src/router.js +45 -7
- package/dist/src/state.js +3 -1
- package/dist/src/wire.js +19 -2
- package/dist/test/erc20.test.js +95 -31
- package/dist/test/fake.d.ts +29 -0
- package/dist/test/fake.js +79 -0
- package/dist/test/lifecycle.test.js +31 -3
- package/dist/test/money.test.d.ts +1 -0
- package/dist/test/money.test.js +27 -0
- package/dist/test/mpesa.test.js +41 -8
- package/dist/test/paypal.test.js +33 -8
- package/dist/test/policy.test.js +6 -2
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +52 -0
- package/dist/test/stripe.test.js +5 -4
- package/dist/test/vectors.test.js +48 -2
- package/dist/test/wire.test.js +15 -0
- package/package.json +1 -1
- package/src/adapter.ts +7 -2
- package/src/adapters/erc20.ts +150 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +148 -28
- package/src/adapters/paypal.ts +138 -28
- package/src/adapters/stripe.ts +16 -13
- package/src/bridge.ts +12 -5
- package/src/canonical.ts +5 -0
- package/src/client.ts +228 -33
- package/src/compliance.ts +20 -3
- package/src/crypto.ts +4 -1
- package/src/index.ts +0 -1
- package/src/ledger.ts +12 -4
- package/src/leg.ts +4 -1
- package/src/message.ts +8 -5
- package/src/money.ts +19 -3
- package/src/protocol.ts +9 -0
- package/src/router.ts +44 -4
- package/src/state.ts +3 -1
- package/src/wire.ts +20 -3
- package/src/fake.ts +0 -96
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Money } from "../money.js";
|
|
1
2
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
3
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
4
|
import type { PayOutLeg, InteractivePayInLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult, PayInPreparation } from "../leg.js";
|
|
@@ -32,6 +33,8 @@ export interface Payout {
|
|
|
32
33
|
export interface RefundParams {
|
|
33
34
|
captureId: string;
|
|
34
35
|
reason: string;
|
|
36
|
+
value?: string;
|
|
37
|
+
currencyCode?: string;
|
|
35
38
|
}
|
|
36
39
|
export interface Refund {
|
|
37
40
|
id: string;
|
|
@@ -49,20 +52,38 @@ export interface PaypalConfig {
|
|
|
49
52
|
currencies: string[];
|
|
50
53
|
api: PaypalApi;
|
|
51
54
|
ids: () => string;
|
|
55
|
+
store?: CaptureStore;
|
|
56
|
+
}
|
|
57
|
+
export interface CaptureRecord {
|
|
58
|
+
intentId: string;
|
|
59
|
+
captureId: string;
|
|
60
|
+
expected: {
|
|
61
|
+
value: string;
|
|
62
|
+
currency: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export interface CaptureStore {
|
|
66
|
+
save(rec: CaptureRecord): Promise<void>;
|
|
67
|
+
byIntent(intentId: string): Promise<CaptureRecord | undefined>;
|
|
68
|
+
}
|
|
69
|
+
export declare class MemoryCaptureStore implements CaptureStore {
|
|
70
|
+
private readonly byIntentMap;
|
|
71
|
+
save(rec: CaptureRecord): Promise<void>;
|
|
72
|
+
byIntent(intentId: string): Promise<CaptureRecord | undefined>;
|
|
52
73
|
}
|
|
53
74
|
export declare class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
54
75
|
readonly id: string;
|
|
55
76
|
private readonly currencies;
|
|
56
77
|
private readonly api;
|
|
57
78
|
private readonly ids;
|
|
58
|
-
private readonly
|
|
79
|
+
private readonly store;
|
|
59
80
|
constructor(cfg: PaypalConfig);
|
|
60
81
|
payInCapabilities(): PayInCapabilities;
|
|
61
82
|
payOutCapabilities(): PayOutCapabilities;
|
|
62
83
|
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
63
84
|
prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
|
|
64
85
|
disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
|
|
65
|
-
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
86
|
+
refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
|
|
66
87
|
reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
|
|
67
88
|
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): Promise<AdapterEvent[]>;
|
|
68
89
|
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// can be refunded in full, but a delivered payout cannot be pulled back.
|
|
7
7
|
import { State } from "../state.js";
|
|
8
8
|
import { RefundKind } from "../adapter.js";
|
|
9
|
+
import { idempotencyKey } from "../protocol.js";
|
|
10
|
+
import { fetchWithTimeout } from "./http.js";
|
|
9
11
|
// The public base of the PayPal REST API. It is the same for every live
|
|
10
12
|
// integration and carries no secret; the sandbox host or a local test server is
|
|
11
13
|
// injected instead.
|
|
@@ -15,25 +17,36 @@ const defaultBaseURL = "https://api-m.paypal.com";
|
|
|
15
17
|
const referencePrefix = "pact:";
|
|
16
18
|
// ErrSignatureMismatch reports a webhook PayPal could not authenticate.
|
|
17
19
|
export const ErrSignatureMismatch = "paypal: webhook signature does not verify";
|
|
20
|
+
// MemoryCaptureStore is the default in-process store.
|
|
21
|
+
export class MemoryCaptureStore {
|
|
22
|
+
byIntentMap = new Map();
|
|
23
|
+
async save(rec) {
|
|
24
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
25
|
+
}
|
|
26
|
+
async byIntent(intentId) {
|
|
27
|
+
return this.byIntentMap.get(intentId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
18
30
|
// PaypalLeg moves money over PayPal, serving both sides of a corridor.
|
|
19
31
|
export class PaypalLeg {
|
|
20
32
|
id;
|
|
21
33
|
currencies;
|
|
22
34
|
api;
|
|
23
35
|
ids;
|
|
24
|
-
|
|
36
|
+
store;
|
|
25
37
|
constructor(cfg) {
|
|
26
38
|
this.id = cfg.id ?? "paypal";
|
|
27
39
|
this.currencies = cfg.currencies;
|
|
28
40
|
this.api = cfg.api;
|
|
29
41
|
this.ids = cfg.ids;
|
|
42
|
+
this.store = cfg.store ?? new MemoryCaptureStore();
|
|
30
43
|
}
|
|
31
44
|
payInCapabilities() {
|
|
32
45
|
return {
|
|
33
46
|
rails: ["paypal"],
|
|
34
47
|
currencies: this.currencies,
|
|
35
48
|
methods: ["paypal", "venmo", "card"],
|
|
36
|
-
refunds: RefundKind.
|
|
49
|
+
refunds: RefundKind.Partial,
|
|
37
50
|
};
|
|
38
51
|
}
|
|
39
52
|
payOutCapabilities() {
|
|
@@ -49,7 +62,11 @@ export class PaypalLeg {
|
|
|
49
62
|
payee: deliverTo,
|
|
50
63
|
referenceId: referencePrefix + intentId,
|
|
51
64
|
});
|
|
52
|
-
this.
|
|
65
|
+
await this.store.save({
|
|
66
|
+
intentId,
|
|
67
|
+
captureId: capture.captureId,
|
|
68
|
+
expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
|
|
69
|
+
});
|
|
53
70
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
54
71
|
}
|
|
55
72
|
// prepare creates an order the payer approves and captures on their own device
|
|
@@ -69,6 +86,11 @@ export class PaypalLeg {
|
|
|
69
86
|
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
70
87
|
}
|
|
71
88
|
const order = await this.api.createOrder(params);
|
|
89
|
+
await this.store.save({
|
|
90
|
+
intentId,
|
|
91
|
+
captureId: "",
|
|
92
|
+
expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
|
|
93
|
+
});
|
|
72
94
|
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
73
95
|
}
|
|
74
96
|
// disburse sends a payout of the recipient's amount to recipientRef and returns
|
|
@@ -82,18 +104,24 @@ export class PaypalLeg {
|
|
|
82
104
|
});
|
|
83
105
|
return { providerRef: payout.batchId };
|
|
84
106
|
}
|
|
85
|
-
// refundIn reverses a captured payment in full. PayPal captures the
|
|
86
|
-
// the capture id it returned from collect, so the refund binds to
|
|
87
|
-
// reference.
|
|
88
|
-
async refundIn(intentId, kind, reason) {
|
|
89
|
-
if (kind !== RefundKind.Full) {
|
|
107
|
+
// refundIn reverses a captured payment, in full or in part. PayPal captures the
|
|
108
|
+
// order id in the capture id it returned from collect, so the refund binds to
|
|
109
|
+
// that reference; a partial refund names the amount to return.
|
|
110
|
+
async refundIn(intentId, kind, amount, reason) {
|
|
111
|
+
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
90
112
|
throw new Error(`paypal: cannot perform refund kind ${kind}`);
|
|
91
113
|
}
|
|
92
|
-
const
|
|
93
|
-
if (!captureId) {
|
|
114
|
+
const rec = await this.store.byIntent(intentId);
|
|
115
|
+
if (!rec || !rec.captureId) {
|
|
94
116
|
throw new Error(`paypal: no capture for intent ${intentId}`);
|
|
95
117
|
}
|
|
96
|
-
const
|
|
118
|
+
const captureId = rec.captureId;
|
|
119
|
+
// A partial refund names the amount in major units; a full refund leaves it
|
|
120
|
+
// absent so PayPal returns the whole capture.
|
|
121
|
+
const params = kind === RefundKind.Partial
|
|
122
|
+
? { captureId, reason, value: majorAmount(amount.minor(), amount.exponent), currencyCode: amount.currency }
|
|
123
|
+
: { captureId, reason };
|
|
124
|
+
const refund = await this.api.refundCapture(params);
|
|
97
125
|
return {
|
|
98
126
|
intentId,
|
|
99
127
|
state: State.Refunded,
|
|
@@ -123,8 +151,21 @@ export class PaypalLeg {
|
|
|
123
151
|
const intentId = intentFromReference(resource.custom_id ?? "", resource.invoice_id ?? "");
|
|
124
152
|
const providerRef = resource.id ?? "";
|
|
125
153
|
switch (event.event_type) {
|
|
126
|
-
case "PAYMENT.CAPTURE.COMPLETED":
|
|
154
|
+
case "PAYMENT.CAPTURE.COMPLETED": {
|
|
155
|
+
// The custom_id that ties a capture to an intent is chosen by whoever
|
|
156
|
+
// created the order, so a genuine, signed capture for a different order can
|
|
157
|
+
// carry a target intent's id. Settle only when the captured amount and
|
|
158
|
+
// currency match what the intent was quoted to collect.
|
|
159
|
+
const rec = await this.store.byIntent(intentId);
|
|
160
|
+
if (!rec) {
|
|
161
|
+
throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
|
|
162
|
+
}
|
|
163
|
+
const want = rec.expected;
|
|
164
|
+
if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
|
|
165
|
+
throw new Error(`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`);
|
|
166
|
+
}
|
|
127
167
|
return [oneEvent(intentId, State.Settled, providerRef, "")];
|
|
168
|
+
}
|
|
128
169
|
case "PAYMENT.CAPTURE.DENIED":
|
|
129
170
|
return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
|
|
130
171
|
case "PAYMENT.CAPTURE.REFUNDED":
|
|
@@ -182,7 +223,7 @@ class HttpPaypalApi {
|
|
|
182
223
|
return this.token;
|
|
183
224
|
}
|
|
184
225
|
const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
|
|
185
|
-
const resp = await
|
|
226
|
+
const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
|
|
186
227
|
method: "POST",
|
|
187
228
|
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
188
229
|
body: "grant_type=client_credentials",
|
|
@@ -203,15 +244,12 @@ class HttpPaypalApi {
|
|
|
203
244
|
if (params.payee) {
|
|
204
245
|
unit.payee = { email_address: params.payee };
|
|
205
246
|
}
|
|
206
|
-
const created = await this.postJSON("/v2/checkout/orders", {
|
|
207
|
-
intent: "CAPTURE",
|
|
208
|
-
purchase_units: [unit],
|
|
209
|
-
});
|
|
247
|
+
const created = await this.postJSON("/v2/checkout/orders", { intent: "CAPTURE", purchase_units: [unit] }, idempotencyKey(params.referenceId, "paypal-order"));
|
|
210
248
|
const existing = firstCapture(created);
|
|
211
249
|
if (existing) {
|
|
212
250
|
return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
|
|
213
251
|
}
|
|
214
|
-
const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {});
|
|
252
|
+
const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {}, idempotencyKey(params.referenceId, "paypal-capture"));
|
|
215
253
|
const capture = firstCapture(captured);
|
|
216
254
|
if (!capture) {
|
|
217
255
|
throw new Error(`paypal: order ${created.id} captured without a capture id`);
|
|
@@ -235,10 +273,7 @@ class HttpPaypalApi {
|
|
|
235
273
|
platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
|
|
236
274
|
};
|
|
237
275
|
}
|
|
238
|
-
const created = await this.postJSON("/v2/checkout/orders", {
|
|
239
|
-
intent: "CAPTURE",
|
|
240
|
-
purchase_units: [unit],
|
|
241
|
-
});
|
|
276
|
+
const created = await this.postJSON("/v2/checkout/orders", { intent: "CAPTURE", purchase_units: [unit] }, idempotencyKey(params.referenceId, "paypal-order"));
|
|
242
277
|
let approve = "";
|
|
243
278
|
for (const link of created.links ?? []) {
|
|
244
279
|
if (link.rel === "approve" || link.rel === "payer-action") {
|
|
@@ -267,7 +302,11 @@ class HttpPaypalApi {
|
|
|
267
302
|
if (params.reason) {
|
|
268
303
|
body.note_to_payer = params.reason;
|
|
269
304
|
}
|
|
270
|
-
|
|
305
|
+
// A partial refund names the amount; an absent value refunds the full capture.
|
|
306
|
+
if (params.value) {
|
|
307
|
+
body.amount = { value: params.value, currency_code: params.currencyCode };
|
|
308
|
+
}
|
|
309
|
+
const out = await this.postJSON(`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`, body, idempotencyKey(params.captureId, "paypal-refund"));
|
|
271
310
|
return { id: out.id ?? "", status: out.status ?? "" };
|
|
272
311
|
}
|
|
273
312
|
// verifyWebhook forwards the signature headers and the raw body to PayPal's
|
|
@@ -275,23 +314,46 @@ class HttpPaypalApi {
|
|
|
275
314
|
// binds a payload to this integration; PayPal reports whether the signature is
|
|
276
315
|
// authentic.
|
|
277
316
|
async verifyWebhook(headers, body) {
|
|
278
|
-
|
|
317
|
+
// PayPal's signature covers the exact bytes of the event it delivered, so the
|
|
318
|
+
// event is forwarded verbatim. Parsing and re-serializing it would reorder
|
|
319
|
+
// keys or restyle numbers and make an authentic webhook fail verification.
|
|
320
|
+
const rawEvent = new TextDecoder().decode(body);
|
|
321
|
+
const sentinel = "__pact_raw_webhook_event__";
|
|
322
|
+
const envelope = JSON.stringify({
|
|
279
323
|
webhook_id: this.creds.webhookId,
|
|
280
324
|
transmission_id: header(headers, "PayPal-Transmission-Id"),
|
|
281
325
|
transmission_time: header(headers, "PayPal-Transmission-Time"),
|
|
282
326
|
transmission_sig: header(headers, "PayPal-Transmission-Sig"),
|
|
283
327
|
cert_url: header(headers, "PayPal-Cert-Url"),
|
|
284
328
|
auth_algo: header(headers, "PayPal-Auth-Algo"),
|
|
285
|
-
webhook_event:
|
|
329
|
+
webhook_event: sentinel,
|
|
286
330
|
});
|
|
331
|
+
const payload = envelope.replace(`"${sentinel}"`, rawEvent);
|
|
332
|
+
const out = await this.postSerialized("/v1/notifications/verify-webhook-signature", payload);
|
|
287
333
|
return out.verification_status === "SUCCESS";
|
|
288
334
|
}
|
|
289
|
-
|
|
335
|
+
// requestId, when set, is sent as PayPal-Request-Id. PayPal deduplicates a
|
|
336
|
+
// mutation that carries a request id it has already seen, so a retry after a
|
|
337
|
+
// lost response reuses the first order, capture, or refund rather than creating
|
|
338
|
+
// a second.
|
|
339
|
+
async postJSON(path, body, requestId) {
|
|
340
|
+
return this.postSerialized(path, JSON.stringify(body), requestId);
|
|
341
|
+
}
|
|
342
|
+
// postSerialized posts an already-serialized JSON payload, so a caller that must
|
|
343
|
+
// control the exact bytes on the wire — a webhook forwarded verbatim — can do so.
|
|
344
|
+
async postSerialized(path, payload, requestId) {
|
|
290
345
|
const token = await this.accessToken();
|
|
291
|
-
const
|
|
346
|
+
const headers = {
|
|
347
|
+
"Content-Type": "application/json",
|
|
348
|
+
Authorization: `Bearer ${token}`,
|
|
349
|
+
};
|
|
350
|
+
if (requestId) {
|
|
351
|
+
headers["PayPal-Request-Id"] = requestId;
|
|
352
|
+
}
|
|
353
|
+
const resp = await fetchWithTimeout(this.baseURL + path, {
|
|
292
354
|
method: "POST",
|
|
293
|
-
headers
|
|
294
|
-
body:
|
|
355
|
+
headers,
|
|
356
|
+
body: payload,
|
|
295
357
|
});
|
|
296
358
|
if (resp.status >= 300) {
|
|
297
359
|
throw new Error(`paypal: ${path} returned ${resp.status}`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Money } from "../money.js";
|
|
1
2
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
3
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
4
|
import type { InteractivePayInLeg, PayInCapabilities, CollectResult, PayInPreparation } from "../leg.js";
|
|
@@ -45,7 +46,7 @@ export interface StripeConfig {
|
|
|
45
46
|
currencies: string[];
|
|
46
47
|
api: StripeApi;
|
|
47
48
|
webhookKey: string;
|
|
48
|
-
clock
|
|
49
|
+
clock: () => number;
|
|
49
50
|
tolerance?: number;
|
|
50
51
|
ids: () => string;
|
|
51
52
|
methods?: string[];
|
|
@@ -63,7 +64,7 @@ export declare class StripeLeg implements InteractivePayInLeg {
|
|
|
63
64
|
payInCapabilities(): PayInCapabilities;
|
|
64
65
|
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
65
66
|
prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
|
|
66
|
-
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
67
|
+
refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
|
|
67
68
|
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
|
|
68
69
|
}
|
|
69
70
|
export declare const ErrNoSignature = "stripe: missing signature header";
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
9
9
|
import { State } from "../state.js";
|
|
10
10
|
import { RefundKind } from "../adapter.js";
|
|
11
|
+
import { idempotencyKey } from "../protocol.js";
|
|
12
|
+
import { fetchWithTimeout } from "./http.js";
|
|
11
13
|
// The public base of the Stripe REST API. It is the same for every integration
|
|
12
14
|
// and carries no secret; tests point the leg at a local server instead.
|
|
13
15
|
const defaultBaseURL = "https://api.stripe.com";
|
|
@@ -20,8 +22,6 @@ const metadataIntentKey = "pact_intent_id";
|
|
|
20
22
|
// defaultToleranceSeconds is how far a webhook timestamp may drift from now
|
|
21
23
|
// before it is rejected as a possible replay.
|
|
22
24
|
const defaultToleranceSeconds = 300;
|
|
23
|
-
// The protocol identifier used to bind an idempotency key to one protocol step.
|
|
24
|
-
const idPrefix = "pact";
|
|
25
25
|
// Capture selects when the charge is captured. Manual creates the intent already
|
|
26
26
|
// carrying a payment method for the server to capture immediately — the flow an
|
|
27
27
|
// agent or a saved-card charge uses. OnConfirmation defers capture to the moment
|
|
@@ -48,11 +48,16 @@ export class StripeLeg {
|
|
|
48
48
|
if (!cfg.webhookKey) {
|
|
49
49
|
throw new Error("stripe: config requires a webhook signing key");
|
|
50
50
|
}
|
|
51
|
+
// Without a clock every webhook timestamp reads as far in the past and
|
|
52
|
+
// silently fails the tolerance check, so a real webhook never verifies.
|
|
53
|
+
if (!cfg.clock) {
|
|
54
|
+
throw new Error("stripe: config requires a clock for webhook timestamp checks");
|
|
55
|
+
}
|
|
51
56
|
this.id = cfg.id ?? "stripe";
|
|
52
57
|
this.currencies = cfg.currencies;
|
|
53
58
|
this.api = cfg.api;
|
|
54
59
|
this.webhookKey = cfg.webhookKey;
|
|
55
|
-
this.clock = cfg.clock
|
|
60
|
+
this.clock = cfg.clock;
|
|
56
61
|
this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
|
|
57
62
|
this.ids = cfg.ids;
|
|
58
63
|
this.methods = cfg.methods;
|
|
@@ -106,12 +111,15 @@ export class StripeLeg {
|
|
|
106
111
|
// refundIn reverses a captured payment, in full or in part. Stripe supports
|
|
107
112
|
// both, so the leg accepts the full and partial refund kinds and rejects a
|
|
108
113
|
// counter-transfer it cannot express.
|
|
109
|
-
async refundIn(intentId, kind, reason) {
|
|
114
|
+
async refundIn(intentId, kind, amount, reason) {
|
|
110
115
|
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
111
116
|
throw new Error(`stripe: cannot perform refund kind ${kind}`);
|
|
112
117
|
}
|
|
113
118
|
const pi = await this.api.findPaymentIntent(intentId);
|
|
114
|
-
|
|
119
|
+
// Stripe refunds the full capture when no amount is set, so a full refund
|
|
120
|
+
// leaves amount zero and a partial one carries the exact minor units to return.
|
|
121
|
+
const minor = kind === RefundKind.Partial ? minorToInteger(amount) : 0;
|
|
122
|
+
const refund = await this.api.createRefund({ paymentIntentId: pi.id, amount: minor, reason }, idempotencyKey(intentId, "refund"));
|
|
115
123
|
return {
|
|
116
124
|
intentId,
|
|
117
125
|
state: State.Refunded,
|
|
@@ -145,11 +153,6 @@ function signatureHeader(headers) {
|
|
|
145
153
|
}
|
|
146
154
|
return "";
|
|
147
155
|
}
|
|
148
|
-
// idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
|
|
149
|
-
// the original result instead of acting twice.
|
|
150
|
-
function idempotencyKey(ref, step) {
|
|
151
|
-
return `${idPrefix}:${ref}:${step}`;
|
|
152
|
-
}
|
|
153
156
|
// minorToInteger narrows a Money amount to the safe integer Stripe expects,
|
|
154
157
|
// refusing anything that would overflow. Fiat amounts fit comfortably; the guard
|
|
155
158
|
// exists so a token amount can never be sent to a card rail by mistake.
|
|
@@ -342,7 +345,7 @@ class HttpStripeApi {
|
|
|
342
345
|
Authorization: `Bearer ${this.secretKey}`,
|
|
343
346
|
"Stripe-Version": apiVersion,
|
|
344
347
|
};
|
|
345
|
-
const resp = await
|
|
348
|
+
const resp = await fetchWithTimeout(this.baseURL + path, { ...init, headers });
|
|
346
349
|
const body = await resp.text();
|
|
347
350
|
if (resp.status >= 300) {
|
|
348
351
|
throw new Error(`stripe: ${path} returned ${resp.status}: ${stripeErrorMessage(body)}`);
|
package/dist/src/bridge.js
CHANGED
|
@@ -83,14 +83,21 @@ export function applyInverseRate(dst, rate, srcCurrency, srcExponent) {
|
|
|
83
83
|
// parseRate reads a decimal rate like "129.45" into a numerator and denominator,
|
|
84
84
|
// so the conversion stays exact integer arithmetic with no floating point.
|
|
85
85
|
export function parseRate(rate) {
|
|
86
|
+
// Bound the length so an untrusted rate can't force a huge bigint parse and
|
|
87
|
+
// exponentiation in the FX math.
|
|
88
|
+
if (rate.length > 80) {
|
|
89
|
+
throw new Error("pact: rate has more than 80 characters");
|
|
90
|
+
}
|
|
91
|
+
// A non-negative integer part with no leading zeros and an optional fractional
|
|
92
|
+
// part; no sign, no radix prefix, no whitespace, matching the amount grammar so
|
|
93
|
+
// the SDKs never disagree on a rate's validity or value.
|
|
94
|
+
if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(rate)) {
|
|
95
|
+
throw new Error(`pact: ${JSON.stringify(rate)} is not a canonical decimal rate`);
|
|
96
|
+
}
|
|
86
97
|
const dot = rate.indexOf(".");
|
|
87
98
|
const whole = dot < 0 ? rate : rate.slice(0, dot);
|
|
88
99
|
const frac = dot < 0 ? "" : rate.slice(dot + 1);
|
|
89
|
-
const
|
|
90
|
-
if (!/^\d+$/.test(digits)) {
|
|
91
|
-
throw new Error(`pact: ${JSON.stringify(rate)} is not a decimal rate`);
|
|
92
|
-
}
|
|
93
|
-
const num = BigInt(digits);
|
|
100
|
+
const num = BigInt(whole + frac);
|
|
94
101
|
const den = frac.length > 0 ? pow10(frac.length) : 1n;
|
|
95
102
|
return [num, den];
|
|
96
103
|
}
|
package/dist/src/canonical.js
CHANGED
|
@@ -20,6 +20,11 @@ export class CanonicalWriter {
|
|
|
20
20
|
this.push(b);
|
|
21
21
|
return this;
|
|
22
22
|
}
|
|
23
|
+
// str writes a string as its raw UTF-8 bytes. No Unicode normalization is
|
|
24
|
+
// applied: the bytes are hashed as given, so two participants that compose the
|
|
25
|
+
// same text in different Unicode forms produce different hashes. Callers that
|
|
26
|
+
// need them to agree must normalize before building a message; string fields are
|
|
27
|
+
// otherwise treated as opaque bytes.
|
|
23
28
|
str(s) {
|
|
24
29
|
return this.bytes(encoder.encode(s));
|
|
25
30
|
}
|
package/dist/src/client.d.ts
CHANGED
|
@@ -4,9 +4,9 @@ import { type Intent, type Quote, type Authorization, type Settlement } from "./
|
|
|
4
4
|
import { type Signer, type Verifier } from "./signing.js";
|
|
5
5
|
import { type Bridge } from "./bridge.js";
|
|
6
6
|
import { type PayInLeg, type PayOutLeg, type PayInPreparation } from "./leg.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type AdapterEvent } from "./adapter.js";
|
|
8
8
|
import { type Policy } from "./router.js";
|
|
9
|
-
import { type KycProvider, type RiskHook } from "./compliance.js";
|
|
9
|
+
import { type KycProvider, type RiskHook, type SignerAuthorizer } from "./compliance.js";
|
|
10
10
|
import { type Ledger, type LedgerEvent } from "./ledger.js";
|
|
11
11
|
export type Clock = () => number;
|
|
12
12
|
export type IdGen = () => string;
|
|
@@ -21,6 +21,7 @@ export interface ClientConfig {
|
|
|
21
21
|
verifier: Verifier;
|
|
22
22
|
kyc?: KycProvider;
|
|
23
23
|
risk?: RiskHook;
|
|
24
|
+
signerAuth?: SignerAuthorizer;
|
|
24
25
|
clock?: Clock;
|
|
25
26
|
idGen?: IdGen;
|
|
26
27
|
skew?: number;
|
|
@@ -49,6 +50,7 @@ export declare class Client {
|
|
|
49
50
|
private readonly verifier;
|
|
50
51
|
private readonly kyc;
|
|
51
52
|
private readonly risk;
|
|
53
|
+
private readonly signerAuth;
|
|
52
54
|
private readonly clock;
|
|
53
55
|
private readonly idGen;
|
|
54
56
|
private readonly skew;
|
|
@@ -63,6 +65,7 @@ export declare class Client {
|
|
|
63
65
|
private pickBridge;
|
|
64
66
|
select(quotes: Quote[], identity: string): Promise<Quote>;
|
|
65
67
|
authorize(intent: Intent, quote: Quote, signer: Signer): Promise<Authorization>;
|
|
68
|
+
private resolvePayIn;
|
|
66
69
|
initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State>;
|
|
67
70
|
interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{
|
|
68
71
|
preparation: PayInPreparation;
|
|
@@ -74,7 +77,12 @@ export declare class Client {
|
|
|
74
77
|
}>;
|
|
75
78
|
private holdAndDisburse;
|
|
76
79
|
private settleBridged;
|
|
80
|
+
private claimRefund;
|
|
77
81
|
private unwind;
|
|
82
|
+
refund(intentId: string, amount: Money, reason: string): Promise<{
|
|
83
|
+
settlement: Settlement;
|
|
84
|
+
state: State;
|
|
85
|
+
}>;
|
|
78
86
|
private recordFailure;
|
|
79
87
|
private finishAdvance;
|
|
80
88
|
private finish;
|