@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.
- package/dist/src/adapter.d.ts +1 -1
- package/dist/src/adapters/erc20.d.ts +13 -3
- package/dist/src/adapters/erc20.js +93 -49
- package/dist/src/adapters/http.d.ts +2 -0
- package/dist/src/adapters/http.js +12 -0
- package/dist/src/adapters/mpesa.d.ts +9 -2
- package/dist/src/adapters/mpesa.js +82 -12
- package/dist/src/adapters/paypal.d.ts +15 -3
- package/dist/src/adapters/paypal.js +121 -21
- package/dist/src/adapters/stripe.d.ts +6 -2
- package/dist/src/adapters/stripe.js +25 -12
- package/dist/src/bridge.js +12 -5
- package/dist/src/canonical.js +5 -0
- package/dist/src/client.d.ts +8 -2
- package/dist/src/client.js +181 -20
- 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/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 +54 -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 +118 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +102 -13
- package/src/adapters/paypal.ts +168 -22
- package/src/adapters/stripe.ts +16 -13
- package/src/bridge.ts +12 -5
- package/src/canonical.ts +5 -0
- package/src/client.ts +194 -22
- 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/wire.ts +20 -3
- package/src/fake.ts +0 -96
|
@@ -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.
|
|
@@ -22,6 +24,9 @@ export class PaypalLeg {
|
|
|
22
24
|
api;
|
|
23
25
|
ids;
|
|
24
26
|
captures = new Map();
|
|
27
|
+
// What each intent was quoted to collect, so a capture webhook can be
|
|
28
|
+
// cross-checked: the paid amount and currency must match before it settles.
|
|
29
|
+
expected = new Map();
|
|
25
30
|
constructor(cfg) {
|
|
26
31
|
this.id = cfg.id ?? "paypal";
|
|
27
32
|
this.currencies = cfg.currencies;
|
|
@@ -33,7 +38,7 @@ export class PaypalLeg {
|
|
|
33
38
|
rails: ["paypal"],
|
|
34
39
|
currencies: this.currencies,
|
|
35
40
|
methods: ["paypal", "venmo", "card"],
|
|
36
|
-
refunds: RefundKind.
|
|
41
|
+
refunds: RefundKind.Partial,
|
|
37
42
|
};
|
|
38
43
|
}
|
|
39
44
|
payOutCapabilities() {
|
|
@@ -50,8 +55,35 @@ export class PaypalLeg {
|
|
|
50
55
|
referenceId: referencePrefix + intentId,
|
|
51
56
|
});
|
|
52
57
|
this.captures.set(intentId, capture.captureId);
|
|
58
|
+
this.expected.set(intentId, {
|
|
59
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
60
|
+
currency: quote.srcAmount.currency,
|
|
61
|
+
});
|
|
53
62
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
54
63
|
}
|
|
64
|
+
// prepare creates an order the payer approves and captures on their own device
|
|
65
|
+
// through the PayPal or Venmo flow — the interactive pay-in path — so nothing is
|
|
66
|
+
// captured here. The order names the recipient as payee and our cut as a platform
|
|
67
|
+
// fee; the buyer's capture then fires PAYMENT.CAPTURE.COMPLETED, which advances
|
|
68
|
+
// the corridor through the same pay-in path as a server-side collection. The
|
|
69
|
+
// order id is the token the payer's PayPal buttons need.
|
|
70
|
+
async prepare(intentId, quote, _auth, deliverTo) {
|
|
71
|
+
const params = {
|
|
72
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
73
|
+
currencyCode: quote.srcAmount.currency,
|
|
74
|
+
payee: deliverTo,
|
|
75
|
+
referenceId: referencePrefix + intentId,
|
|
76
|
+
};
|
|
77
|
+
if (!quote.fees.isZero()) {
|
|
78
|
+
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
79
|
+
}
|
|
80
|
+
const order = await this.api.createOrder(params);
|
|
81
|
+
this.expected.set(intentId, {
|
|
82
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
83
|
+
currency: quote.srcAmount.currency,
|
|
84
|
+
});
|
|
85
|
+
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
86
|
+
}
|
|
55
87
|
// disburse sends a payout of the recipient's amount to recipientRef and returns
|
|
56
88
|
// the batch id.
|
|
57
89
|
async disburse(intentId, quote, recipientRef) {
|
|
@@ -63,18 +95,23 @@ export class PaypalLeg {
|
|
|
63
95
|
});
|
|
64
96
|
return { providerRef: payout.batchId };
|
|
65
97
|
}
|
|
66
|
-
// refundIn reverses a captured payment in full. PayPal captures the
|
|
67
|
-
// the capture id it returned from collect, so the refund binds to
|
|
68
|
-
// reference.
|
|
69
|
-
async refundIn(intentId, kind, reason) {
|
|
70
|
-
if (kind !== RefundKind.Full) {
|
|
98
|
+
// refundIn reverses a captured payment, in full or in part. PayPal captures the
|
|
99
|
+
// order id in the capture id it returned from collect, so the refund binds to
|
|
100
|
+
// that reference; a partial refund names the amount to return.
|
|
101
|
+
async refundIn(intentId, kind, amount, reason) {
|
|
102
|
+
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
71
103
|
throw new Error(`paypal: cannot perform refund kind ${kind}`);
|
|
72
104
|
}
|
|
73
105
|
const captureId = this.captures.get(intentId);
|
|
74
106
|
if (!captureId) {
|
|
75
107
|
throw new Error(`paypal: no capture for intent ${intentId}`);
|
|
76
108
|
}
|
|
77
|
-
|
|
109
|
+
// A partial refund names the amount in major units; a full refund leaves it
|
|
110
|
+
// absent so PayPal returns the whole capture.
|
|
111
|
+
const params = kind === RefundKind.Partial
|
|
112
|
+
? { captureId, reason, value: majorAmount(amount.minor(), amount.exponent), currencyCode: amount.currency }
|
|
113
|
+
: { captureId, reason };
|
|
114
|
+
const refund = await this.api.refundCapture(params);
|
|
78
115
|
return {
|
|
79
116
|
intentId,
|
|
80
117
|
state: State.Refunded,
|
|
@@ -104,8 +141,20 @@ export class PaypalLeg {
|
|
|
104
141
|
const intentId = intentFromReference(resource.custom_id ?? "", resource.invoice_id ?? "");
|
|
105
142
|
const providerRef = resource.id ?? "";
|
|
106
143
|
switch (event.event_type) {
|
|
107
|
-
case "PAYMENT.CAPTURE.COMPLETED":
|
|
144
|
+
case "PAYMENT.CAPTURE.COMPLETED": {
|
|
145
|
+
// The custom_id that ties a capture to an intent is chosen by whoever
|
|
146
|
+
// created the order, so a genuine, signed capture for a different order can
|
|
147
|
+
// carry a target intent's id. Settle only when the captured amount and
|
|
148
|
+
// currency match what the intent was quoted to collect.
|
|
149
|
+
const want = this.expected.get(intentId);
|
|
150
|
+
if (!want) {
|
|
151
|
+
throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
|
|
152
|
+
}
|
|
153
|
+
if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
|
|
154
|
+
throw new Error(`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`);
|
|
155
|
+
}
|
|
108
156
|
return [oneEvent(intentId, State.Settled, providerRef, "")];
|
|
157
|
+
}
|
|
109
158
|
case "PAYMENT.CAPTURE.DENIED":
|
|
110
159
|
return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
|
|
111
160
|
case "PAYMENT.CAPTURE.REFUNDED":
|
|
@@ -163,7 +212,7 @@ class HttpPaypalApi {
|
|
|
163
212
|
return this.token;
|
|
164
213
|
}
|
|
165
214
|
const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
|
|
166
|
-
const resp = await
|
|
215
|
+
const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
|
|
167
216
|
method: "POST",
|
|
168
217
|
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
169
218
|
body: "grant_type=client_credentials",
|
|
@@ -184,21 +233,45 @@ class HttpPaypalApi {
|
|
|
184
233
|
if (params.payee) {
|
|
185
234
|
unit.payee = { email_address: params.payee };
|
|
186
235
|
}
|
|
187
|
-
const created = await this.postJSON("/v2/checkout/orders", {
|
|
188
|
-
intent: "CAPTURE",
|
|
189
|
-
purchase_units: [unit],
|
|
190
|
-
});
|
|
236
|
+
const created = await this.postJSON("/v2/checkout/orders", { intent: "CAPTURE", purchase_units: [unit] }, idempotencyKey(params.referenceId, "paypal-order"));
|
|
191
237
|
const existing = firstCapture(created);
|
|
192
238
|
if (existing) {
|
|
193
239
|
return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
|
|
194
240
|
}
|
|
195
|
-
const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {});
|
|
241
|
+
const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {}, idempotencyKey(params.referenceId, "paypal-capture"));
|
|
196
242
|
const capture = firstCapture(captured);
|
|
197
243
|
if (!capture) {
|
|
198
244
|
throw new Error(`paypal: order ${created.id} captured without a capture id`);
|
|
199
245
|
}
|
|
200
246
|
return { orderId: captured.id ?? "", captureId: capture.id ?? "", status: capture.status ?? "" };
|
|
201
247
|
}
|
|
248
|
+
// createOrder creates an order the buyer approves and captures on their own
|
|
249
|
+
// device. It carries custom_id so the capture webhook ties back to the intent,
|
|
250
|
+
// and a platform fee via payment_instruction when one is set.
|
|
251
|
+
async createOrder(params) {
|
|
252
|
+
const unit = {
|
|
253
|
+
reference_id: params.referenceId,
|
|
254
|
+
custom_id: params.referenceId,
|
|
255
|
+
amount: { currency_code: params.currencyCode, value: params.value },
|
|
256
|
+
};
|
|
257
|
+
if (params.payee) {
|
|
258
|
+
unit.payee = { email_address: params.payee };
|
|
259
|
+
}
|
|
260
|
+
if (params.platformFee) {
|
|
261
|
+
unit.payment_instruction = {
|
|
262
|
+
platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const created = await this.postJSON("/v2/checkout/orders", { intent: "CAPTURE", purchase_units: [unit] }, idempotencyKey(params.referenceId, "paypal-order"));
|
|
266
|
+
let approve = "";
|
|
267
|
+
for (const link of created.links ?? []) {
|
|
268
|
+
if (link.rel === "approve" || link.rel === "payer-action") {
|
|
269
|
+
approve = link.href ?? "";
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { id: created.id ?? "", status: created.status ?? "", approveUrl: approve };
|
|
274
|
+
}
|
|
202
275
|
async sendPayout(params) {
|
|
203
276
|
const out = await this.postJSON("/v1/payments/payouts", {
|
|
204
277
|
sender_batch_header: { sender_batch_id: params.referenceId },
|
|
@@ -218,7 +291,11 @@ class HttpPaypalApi {
|
|
|
218
291
|
if (params.reason) {
|
|
219
292
|
body.note_to_payer = params.reason;
|
|
220
293
|
}
|
|
221
|
-
|
|
294
|
+
// A partial refund names the amount; an absent value refunds the full capture.
|
|
295
|
+
if (params.value) {
|
|
296
|
+
body.amount = { value: params.value, currency_code: params.currencyCode };
|
|
297
|
+
}
|
|
298
|
+
const out = await this.postJSON(`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`, body, idempotencyKey(params.captureId, "paypal-refund"));
|
|
222
299
|
return { id: out.id ?? "", status: out.status ?? "" };
|
|
223
300
|
}
|
|
224
301
|
// verifyWebhook forwards the signature headers and the raw body to PayPal's
|
|
@@ -226,23 +303,46 @@ class HttpPaypalApi {
|
|
|
226
303
|
// binds a payload to this integration; PayPal reports whether the signature is
|
|
227
304
|
// authentic.
|
|
228
305
|
async verifyWebhook(headers, body) {
|
|
229
|
-
|
|
306
|
+
// PayPal's signature covers the exact bytes of the event it delivered, so the
|
|
307
|
+
// event is forwarded verbatim. Parsing and re-serializing it would reorder
|
|
308
|
+
// keys or restyle numbers and make an authentic webhook fail verification.
|
|
309
|
+
const rawEvent = new TextDecoder().decode(body);
|
|
310
|
+
const sentinel = "__pact_raw_webhook_event__";
|
|
311
|
+
const envelope = JSON.stringify({
|
|
230
312
|
webhook_id: this.creds.webhookId,
|
|
231
313
|
transmission_id: header(headers, "PayPal-Transmission-Id"),
|
|
232
314
|
transmission_time: header(headers, "PayPal-Transmission-Time"),
|
|
233
315
|
transmission_sig: header(headers, "PayPal-Transmission-Sig"),
|
|
234
316
|
cert_url: header(headers, "PayPal-Cert-Url"),
|
|
235
317
|
auth_algo: header(headers, "PayPal-Auth-Algo"),
|
|
236
|
-
webhook_event:
|
|
318
|
+
webhook_event: sentinel,
|
|
237
319
|
});
|
|
320
|
+
const payload = envelope.replace(`"${sentinel}"`, rawEvent);
|
|
321
|
+
const out = await this.postSerialized("/v1/notifications/verify-webhook-signature", payload);
|
|
238
322
|
return out.verification_status === "SUCCESS";
|
|
239
323
|
}
|
|
240
|
-
|
|
324
|
+
// requestId, when set, is sent as PayPal-Request-Id. PayPal deduplicates a
|
|
325
|
+
// mutation that carries a request id it has already seen, so a retry after a
|
|
326
|
+
// lost response reuses the first order, capture, or refund rather than creating
|
|
327
|
+
// a second.
|
|
328
|
+
async postJSON(path, body, requestId) {
|
|
329
|
+
return this.postSerialized(path, JSON.stringify(body), requestId);
|
|
330
|
+
}
|
|
331
|
+
// postSerialized posts an already-serialized JSON payload, so a caller that must
|
|
332
|
+
// control the exact bytes on the wire — a webhook forwarded verbatim — can do so.
|
|
333
|
+
async postSerialized(path, payload, requestId) {
|
|
241
334
|
const token = await this.accessToken();
|
|
242
|
-
const
|
|
335
|
+
const headers = {
|
|
336
|
+
"Content-Type": "application/json",
|
|
337
|
+
Authorization: `Bearer ${token}`,
|
|
338
|
+
};
|
|
339
|
+
if (requestId) {
|
|
340
|
+
headers["PayPal-Request-Id"] = requestId;
|
|
341
|
+
}
|
|
342
|
+
const resp = await fetchWithTimeout(this.baseURL + path, {
|
|
243
343
|
method: "POST",
|
|
244
|
-
headers
|
|
245
|
-
body:
|
|
344
|
+
headers,
|
|
345
|
+
body: payload,
|
|
246
346
|
});
|
|
247
347
|
if (resp.status >= 300) {
|
|
248
348
|
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";
|
|
@@ -26,6 +27,7 @@ export interface CreateIntentParams {
|
|
|
26
27
|
destination: string;
|
|
27
28
|
applicationFee?: number;
|
|
28
29
|
capture?: Capture;
|
|
30
|
+
methods?: string[];
|
|
29
31
|
metadata: Record<string, string>;
|
|
30
32
|
}
|
|
31
33
|
export interface CreateRefundParams {
|
|
@@ -44,9 +46,10 @@ export interface StripeConfig {
|
|
|
44
46
|
currencies: string[];
|
|
45
47
|
api: StripeApi;
|
|
46
48
|
webhookKey: string;
|
|
47
|
-
clock
|
|
49
|
+
clock: () => number;
|
|
48
50
|
tolerance?: number;
|
|
49
51
|
ids: () => string;
|
|
52
|
+
methods?: string[];
|
|
50
53
|
}
|
|
51
54
|
export declare class StripeLeg implements InteractivePayInLeg {
|
|
52
55
|
readonly id: string;
|
|
@@ -56,11 +59,12 @@ export declare class StripeLeg implements InteractivePayInLeg {
|
|
|
56
59
|
private readonly clock;
|
|
57
60
|
private readonly tolerance;
|
|
58
61
|
private readonly ids;
|
|
62
|
+
private readonly methods;
|
|
59
63
|
constructor(cfg: StripeConfig);
|
|
60
64
|
payInCapabilities(): PayInCapabilities;
|
|
61
65
|
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
62
66
|
prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
|
|
63
|
-
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
67
|
+
refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
|
|
64
68
|
parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
|
|
65
69
|
}
|
|
66
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
|
|
@@ -43,17 +43,24 @@ export class StripeLeg {
|
|
|
43
43
|
clock;
|
|
44
44
|
tolerance;
|
|
45
45
|
ids;
|
|
46
|
+
methods;
|
|
46
47
|
constructor(cfg) {
|
|
47
48
|
if (!cfg.webhookKey) {
|
|
48
49
|
throw new Error("stripe: config requires a webhook signing key");
|
|
49
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
|
+
}
|
|
50
56
|
this.id = cfg.id ?? "stripe";
|
|
51
57
|
this.currencies = cfg.currencies;
|
|
52
58
|
this.api = cfg.api;
|
|
53
59
|
this.webhookKey = cfg.webhookKey;
|
|
54
|
-
this.clock = cfg.clock
|
|
60
|
+
this.clock = cfg.clock;
|
|
55
61
|
this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
|
|
56
62
|
this.ids = cfg.ids;
|
|
63
|
+
this.methods = cfg.methods;
|
|
57
64
|
}
|
|
58
65
|
// payInCapabilities advertises the card rail and the wallets that fund through
|
|
59
66
|
// it. The methods are informational; the corridor routes on the rail.
|
|
@@ -96,6 +103,7 @@ export class StripeLeg {
|
|
|
96
103
|
destination: deliverTo,
|
|
97
104
|
applicationFee: minorToInteger(quote.fees),
|
|
98
105
|
capture: Capture.OnConfirmation,
|
|
106
|
+
...(this.methods ? { methods: this.methods } : {}),
|
|
99
107
|
metadata: { [metadataIntentKey]: intentId },
|
|
100
108
|
}, idempotencyKey(intentId, "prepare"));
|
|
101
109
|
return { providerRef: pi.id, clientSecret: pi.clientSecret, method: "card" };
|
|
@@ -103,12 +111,15 @@ export class StripeLeg {
|
|
|
103
111
|
// refundIn reverses a captured payment, in full or in part. Stripe supports
|
|
104
112
|
// both, so the leg accepts the full and partial refund kinds and rejects a
|
|
105
113
|
// counter-transfer it cannot express.
|
|
106
|
-
async refundIn(intentId, kind, reason) {
|
|
114
|
+
async refundIn(intentId, kind, amount, reason) {
|
|
107
115
|
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
108
116
|
throw new Error(`stripe: cannot perform refund kind ${kind}`);
|
|
109
117
|
}
|
|
110
118
|
const pi = await this.api.findPaymentIntent(intentId);
|
|
111
|
-
|
|
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"));
|
|
112
123
|
return {
|
|
113
124
|
intentId,
|
|
114
125
|
state: State.Refunded,
|
|
@@ -142,11 +153,6 @@ function signatureHeader(headers) {
|
|
|
142
153
|
}
|
|
143
154
|
return "";
|
|
144
155
|
}
|
|
145
|
-
// idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
|
|
146
|
-
// the original result instead of acting twice.
|
|
147
|
-
function idempotencyKey(ref, step) {
|
|
148
|
-
return `${idPrefix}:${ref}:${step}`;
|
|
149
|
-
}
|
|
150
156
|
// minorToInteger narrows a Money amount to the safe integer Stripe expects,
|
|
151
157
|
// refusing anything that would overflow. Fiat amounts fit comfortably; the guard
|
|
152
158
|
// exists so a token amount can never be sent to a card rail by mistake.
|
|
@@ -277,7 +283,14 @@ class HttpStripeApi {
|
|
|
277
283
|
if ((params.capture ?? Capture.Manual) === Capture.Manual) {
|
|
278
284
|
form.set("capture_method", "manual");
|
|
279
285
|
}
|
|
280
|
-
|
|
286
|
+
// Pinning the method types offers exactly those and nothing else; without a
|
|
287
|
+
// pin, dynamic payment methods surface whatever the dashboard has enabled.
|
|
288
|
+
if (params.methods && params.methods.length > 0) {
|
|
289
|
+
params.methods.forEach((m, i) => form.set(`payment_method_types[${i}]`, m));
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
form.set("automatic_payment_methods[enabled]", "true");
|
|
293
|
+
}
|
|
281
294
|
if (params.destination) {
|
|
282
295
|
form.set("transfer_data[destination]", params.destination);
|
|
283
296
|
}
|
|
@@ -332,7 +345,7 @@ class HttpStripeApi {
|
|
|
332
345
|
Authorization: `Bearer ${this.secretKey}`,
|
|
333
346
|
"Stripe-Version": apiVersion,
|
|
334
347
|
};
|
|
335
|
-
const resp = await
|
|
348
|
+
const resp = await fetchWithTimeout(this.baseURL + path, { ...init, headers });
|
|
336
349
|
const body = await resp.text();
|
|
337
350
|
if (resp.status >= 300) {
|
|
338
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;
|
|
@@ -75,6 +77,10 @@ export declare class Client {
|
|
|
75
77
|
private holdAndDisburse;
|
|
76
78
|
private settleBridged;
|
|
77
79
|
private unwind;
|
|
80
|
+
refund(intentId: string, amount: Money, reason: string): Promise<{
|
|
81
|
+
settlement: Settlement;
|
|
82
|
+
state: State;
|
|
83
|
+
}>;
|
|
78
84
|
private recordFailure;
|
|
79
85
|
private finishAdvance;
|
|
80
86
|
private finish;
|