@myzonerocks/pact 0.1.0

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 (88) hide show
  1. package/dist/src/adapter.d.ts +18 -0
  2. package/dist/src/adapter.js +11 -0
  3. package/dist/src/adapters/erc20.d.ts +43 -0
  4. package/dist/src/adapters/erc20.js +126 -0
  5. package/dist/src/adapters/mpesa.d.ts +61 -0
  6. package/dist/src/adapters/mpesa.js +272 -0
  7. package/dist/src/adapters/paypal.d.ts +68 -0
  8. package/dist/src/adapters/paypal.js +279 -0
  9. package/dist/src/adapters/stripe.d.ts +61 -0
  10. package/dist/src/adapters/stripe.js +336 -0
  11. package/dist/src/bridge.d.ts +40 -0
  12. package/dist/src/bridge.js +99 -0
  13. package/dist/src/canonical.d.ts +14 -0
  14. package/dist/src/canonical.js +82 -0
  15. package/dist/src/client.d.ts +84 -0
  16. package/dist/src/client.js +510 -0
  17. package/dist/src/compliance.d.ts +23 -0
  18. package/dist/src/compliance.js +26 -0
  19. package/dist/src/crypto.d.ts +12 -0
  20. package/dist/src/crypto.js +50 -0
  21. package/dist/src/fake.d.ts +29 -0
  22. package/dist/src/fake.js +79 -0
  23. package/dist/src/index.d.ts +16 -0
  24. package/dist/src/index.js +16 -0
  25. package/dist/src/ledger.d.ts +47 -0
  26. package/dist/src/ledger.js +84 -0
  27. package/dist/src/leg.d.ts +34 -0
  28. package/dist/src/leg.js +4 -0
  29. package/dist/src/message.d.ts +55 -0
  30. package/dist/src/message.js +88 -0
  31. package/dist/src/money.d.ts +16 -0
  32. package/dist/src/money.js +81 -0
  33. package/dist/src/payload.d.ts +11 -0
  34. package/dist/src/payload.js +52 -0
  35. package/dist/src/policy.d.ts +26 -0
  36. package/dist/src/policy.js +83 -0
  37. package/dist/src/protocol.d.ts +4 -0
  38. package/dist/src/protocol.js +15 -0
  39. package/dist/src/router.d.ts +16 -0
  40. package/dist/src/router.js +87 -0
  41. package/dist/src/signing.d.ts +27 -0
  42. package/dist/src/signing.js +72 -0
  43. package/dist/src/state.d.ts +23 -0
  44. package/dist/src/state.js +76 -0
  45. package/dist/src/wire.d.ts +30 -0
  46. package/dist/src/wire.js +221 -0
  47. package/dist/test/erc20.test.d.ts +1 -0
  48. package/dist/test/erc20.test.js +131 -0
  49. package/dist/test/lifecycle.test.d.ts +1 -0
  50. package/dist/test/lifecycle.test.js +212 -0
  51. package/dist/test/mpesa.test.d.ts +1 -0
  52. package/dist/test/mpesa.test.js +180 -0
  53. package/dist/test/payload.test.d.ts +1 -0
  54. package/dist/test/payload.test.js +32 -0
  55. package/dist/test/paypal.test.d.ts +1 -0
  56. package/dist/test/paypal.test.js +140 -0
  57. package/dist/test/policy.test.d.ts +1 -0
  58. package/dist/test/policy.test.js +131 -0
  59. package/dist/test/stripe.test.d.ts +1 -0
  60. package/dist/test/stripe.test.js +176 -0
  61. package/dist/test/vectors.test.d.ts +1 -0
  62. package/dist/test/vectors.test.js +91 -0
  63. package/dist/test/wire.test.d.ts +1 -0
  64. package/dist/test/wire.test.js +104 -0
  65. package/package.json +50 -0
  66. package/src/adapter.ts +32 -0
  67. package/src/adapters/erc20.ts +181 -0
  68. package/src/adapters/mpesa.ts +408 -0
  69. package/src/adapters/paypal.ts +409 -0
  70. package/src/adapters/stripe.ts +478 -0
  71. package/src/bridge.ts +148 -0
  72. package/src/canonical.ts +94 -0
  73. package/src/client.ts +605 -0
  74. package/src/compliance.ts +65 -0
  75. package/src/crypto.ts +68 -0
  76. package/src/fake.ts +96 -0
  77. package/src/index.ts +106 -0
  78. package/src/ledger.ts +145 -0
  79. package/src/leg.ts +65 -0
  80. package/src/message.ts +178 -0
  81. package/src/money.ts +87 -0
  82. package/src/payload.ts +58 -0
  83. package/src/policy.ts +110 -0
  84. package/src/protocol.ts +19 -0
  85. package/src/router.ts +97 -0
  86. package/src/signing.ts +92 -0
  87. package/src/state.ts +76 -0
  88. package/src/wire.ts +248 -0
@@ -0,0 +1,279 @@
1
+ // PayPal leg for the PACT protocol. It runs server-side: it holds the PayPal
2
+ // client credentials and webhook id, creates and captures Orders on the pay-in
3
+ // side, sends Payouts on the pay-out side, and verifies inbound webhooks through
4
+ // PayPal's own verification call. The payer approves the order in the PayPal or
5
+ // Venmo flow client-side; this package never sees card data. A captured payment
6
+ // can be refunded in full, but a delivered payout cannot be pulled back.
7
+ import { State } from "../state.js";
8
+ import { RefundKind } from "../adapter.js";
9
+ // The public base of the PayPal REST API. It is the same for every live
10
+ // integration and carries no secret; the sandbox host or a local test server is
11
+ // injected instead.
12
+ const defaultBaseURL = "https://api-m.paypal.com";
13
+ // The pact intent id travels on the order and payout reference so a webhook can
14
+ // be tied back to the intent it settles.
15
+ const referencePrefix = "pact:";
16
+ // ErrSignatureMismatch reports a webhook PayPal could not authenticate.
17
+ export const ErrSignatureMismatch = "paypal: webhook signature does not verify";
18
+ // PaypalLeg moves money over PayPal, serving both sides of a corridor.
19
+ export class PaypalLeg {
20
+ id;
21
+ currencies;
22
+ api;
23
+ ids;
24
+ captures = new Map();
25
+ constructor(cfg) {
26
+ this.id = cfg.id ?? "paypal";
27
+ this.currencies = cfg.currencies;
28
+ this.api = cfg.api;
29
+ this.ids = cfg.ids;
30
+ }
31
+ payInCapabilities() {
32
+ return {
33
+ rails: ["paypal"],
34
+ currencies: this.currencies,
35
+ methods: ["paypal", "venmo", "card"],
36
+ refunds: RefundKind.Full,
37
+ };
38
+ }
39
+ payOutCapabilities() {
40
+ return { rails: ["paypal"], currencies: this.currencies, reversible: false };
41
+ }
42
+ // collect creates and captures an order to deliverTo — the recipient's payee for
43
+ // a direct corridor, the escrow payee for a bridged one — and returns the
44
+ // capture id. received is the net a bridge would convert.
45
+ async collect(intentId, quote, _auth, deliverTo) {
46
+ const capture = await this.api.createAndCaptureOrder({
47
+ value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
48
+ currencyCode: quote.srcAmount.currency,
49
+ payee: deliverTo,
50
+ referenceId: referencePrefix + intentId,
51
+ });
52
+ this.captures.set(intentId, capture.captureId);
53
+ return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
54
+ }
55
+ // disburse sends a payout of the recipient's amount to recipientRef and returns
56
+ // the batch id.
57
+ async disburse(intentId, quote, recipientRef) {
58
+ const payout = await this.api.sendPayout({
59
+ value: majorAmount(quote.dstAmount.minor(), quote.dstAmount.exponent),
60
+ currencyCode: quote.dstAmount.currency,
61
+ receiver: recipientRef,
62
+ referenceId: referencePrefix + intentId,
63
+ });
64
+ return { providerRef: payout.batchId };
65
+ }
66
+ // refundIn reverses a captured payment in full. PayPal captures the order id in
67
+ // the capture id it returned from collect, so the refund binds to that
68
+ // reference.
69
+ async refundIn(intentId, kind, reason) {
70
+ if (kind !== RefundKind.Full) {
71
+ throw new Error(`paypal: cannot perform refund kind ${kind}`);
72
+ }
73
+ const captureId = this.captures.get(intentId);
74
+ if (!captureId) {
75
+ throw new Error(`paypal: no capture for intent ${intentId}`);
76
+ }
77
+ const refund = await this.api.refundCapture({ captureId, reason });
78
+ return {
79
+ intentId,
80
+ state: State.Refunded,
81
+ adapterId: this.id,
82
+ providerTxRef: refund.id,
83
+ onchainTxHash: "",
84
+ receiptHash: new Uint8Array(0),
85
+ reason,
86
+ settledAt: 0,
87
+ };
88
+ }
89
+ // reverseOut reports the truth of the rail: a delivered payout cannot be pulled
90
+ // back, so a pay-out reversal is refused rather than faked.
91
+ async reverseOut(_intentId, _reason) {
92
+ throw new Error("paypal: a delivered payout cannot be reversed");
93
+ }
94
+ // parseWebhook authenticates an inbound webhook through PayPal's own
95
+ // verification call, then maps the event onto the protocol. A payload that does
96
+ // not verify is rejected; an event the leg does not act on yields no protocol
97
+ // event.
98
+ async parseWebhook(raw, headers) {
99
+ if (!(await this.api.verifyWebhook(headers, raw))) {
100
+ throw new Error(ErrSignatureMismatch);
101
+ }
102
+ const event = JSON.parse(new TextDecoder().decode(raw));
103
+ const resource = event.resource ?? {};
104
+ const intentId = intentFromReference(resource.custom_id ?? "", resource.invoice_id ?? "");
105
+ const providerRef = resource.id ?? "";
106
+ switch (event.event_type) {
107
+ case "PAYMENT.CAPTURE.COMPLETED":
108
+ return [oneEvent(intentId, State.Settled, providerRef, "")];
109
+ case "PAYMENT.CAPTURE.DENIED":
110
+ return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
111
+ case "PAYMENT.CAPTURE.REFUNDED":
112
+ return [oneEvent(intentId, State.Refunded, providerRef, "refunded")];
113
+ default:
114
+ return [];
115
+ }
116
+ }
117
+ }
118
+ function oneEvent(intentId, state, providerRef, reason) {
119
+ return { intentId, state, providerTxRef: providerRef, onchainTxHash: "", reason, settledAt: 0 };
120
+ }
121
+ // intentFromReference strips the reference prefix off whichever field carried it.
122
+ function intentFromReference(...refs) {
123
+ for (const ref of refs) {
124
+ if (ref.length > referencePrefix.length && ref.startsWith(referencePrefix)) {
125
+ return ref.slice(referencePrefix.length);
126
+ }
127
+ }
128
+ return "";
129
+ }
130
+ // majorAmount renders whole-and-fraction minor units as the decimal-major string
131
+ // PayPal expects, e.g. 1500 minor at exponent 2 becomes "15.00".
132
+ export function majorAmount(minor, exponent) {
133
+ if (exponent === 0) {
134
+ return minor;
135
+ }
136
+ const neg = minor.startsWith("-");
137
+ let digits = neg ? minor.slice(1) : minor;
138
+ while (digits.length <= exponent) {
139
+ digits = "0" + digits;
140
+ }
141
+ const split = digits.length - exponent;
142
+ const value = digits.slice(0, split) + "." + digits.slice(split);
143
+ return neg ? "-" + value : value;
144
+ }
145
+ // httpPaypalApi is the live PayPal REST client. It fetches a client-credentials
146
+ // access token, reuses it until it nears expiry, and presents it as a Bearer on
147
+ // each call.
148
+ class HttpPaypalApi {
149
+ creds;
150
+ baseURL;
151
+ now;
152
+ token = "";
153
+ tokenExpiryMs = 0;
154
+ constructor(creds, now) {
155
+ this.creds = creds;
156
+ this.baseURL = creds.baseURL || defaultBaseURL;
157
+ this.now = now;
158
+ }
159
+ // accessToken returns a cached client-credentials token, refreshing it when it
160
+ // is missing or within a minute of expiry.
161
+ async accessToken() {
162
+ if (this.token && this.now() < this.tokenExpiryMs - 60_000) {
163
+ return this.token;
164
+ }
165
+ const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
166
+ const resp = await fetch(`${this.baseURL}/v1/oauth2/token`, {
167
+ method: "POST",
168
+ headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
169
+ body: "grant_type=client_credentials",
170
+ });
171
+ if (resp.status >= 300) {
172
+ throw new Error(`paypal: /v1/oauth2/token returned ${resp.status}`);
173
+ }
174
+ const out = (await resp.json());
175
+ this.token = out.access_token ?? "";
176
+ this.tokenExpiryMs = this.now() + (out.expires_in ?? 0) * 1000;
177
+ return this.token;
178
+ }
179
+ async createAndCaptureOrder(params) {
180
+ const unit = {
181
+ reference_id: params.referenceId,
182
+ amount: { currency_code: params.currencyCode, value: params.value },
183
+ };
184
+ if (params.payee) {
185
+ unit.payee = { email_address: params.payee };
186
+ }
187
+ const created = await this.postJSON("/v2/checkout/orders", {
188
+ intent: "CAPTURE",
189
+ purchase_units: [unit],
190
+ });
191
+ const existing = firstCapture(created);
192
+ if (existing) {
193
+ return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
194
+ }
195
+ const captured = await this.postJSON(`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`, {});
196
+ const capture = firstCapture(captured);
197
+ if (!capture) {
198
+ throw new Error(`paypal: order ${created.id} captured without a capture id`);
199
+ }
200
+ return { orderId: captured.id ?? "", captureId: capture.id ?? "", status: capture.status ?? "" };
201
+ }
202
+ async sendPayout(params) {
203
+ const out = await this.postJSON("/v1/payments/payouts", {
204
+ sender_batch_header: { sender_batch_id: params.referenceId },
205
+ items: [
206
+ {
207
+ recipient_type: "EMAIL",
208
+ amount: { currency: params.currencyCode, value: params.value },
209
+ receiver: params.receiver,
210
+ sender_item_id: params.referenceId,
211
+ },
212
+ ],
213
+ });
214
+ return { batchId: out.batch_header?.payout_batch_id ?? "", status: out.batch_header?.batch_status ?? "" };
215
+ }
216
+ async refundCapture(params) {
217
+ const body = {};
218
+ if (params.reason) {
219
+ body.note_to_payer = params.reason;
220
+ }
221
+ const out = await this.postJSON(`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`, body);
222
+ return { id: out.id ?? "", status: out.status ?? "" };
223
+ }
224
+ // verifyWebhook forwards the signature headers and the raw body to PayPal's
225
+ // verify-webhook-signature endpoint. The webhook id is the shared reference that
226
+ // binds a payload to this integration; PayPal reports whether the signature is
227
+ // authentic.
228
+ async verifyWebhook(headers, body) {
229
+ const out = await this.postJSON("/v1/notifications/verify-webhook-signature", {
230
+ webhook_id: this.creds.webhookId,
231
+ transmission_id: header(headers, "PayPal-Transmission-Id"),
232
+ transmission_time: header(headers, "PayPal-Transmission-Time"),
233
+ transmission_sig: header(headers, "PayPal-Transmission-Sig"),
234
+ cert_url: header(headers, "PayPal-Cert-Url"),
235
+ auth_algo: header(headers, "PayPal-Auth-Algo"),
236
+ webhook_event: JSON.parse(new TextDecoder().decode(body)),
237
+ });
238
+ return out.verification_status === "SUCCESS";
239
+ }
240
+ async postJSON(path, body) {
241
+ const token = await this.accessToken();
242
+ const resp = await fetch(this.baseURL + path, {
243
+ method: "POST",
244
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
245
+ body: JSON.stringify(body),
246
+ });
247
+ if (resp.status >= 300) {
248
+ throw new Error(`paypal: ${path} returned ${resp.status}`);
249
+ }
250
+ const text = await resp.text();
251
+ return (text ? JSON.parse(text) : {});
252
+ }
253
+ }
254
+ function firstCapture(order) {
255
+ for (const unit of order.purchase_units ?? []) {
256
+ const captures = unit.payments?.captures ?? [];
257
+ if (captures.length > 0) {
258
+ return captures[0];
259
+ }
260
+ }
261
+ return undefined;
262
+ }
263
+ // header reads a single request header case-insensitively.
264
+ function header(headers, name) {
265
+ for (const key of Object.keys(headers)) {
266
+ if (key.toLowerCase() === name.toLowerCase()) {
267
+ const values = headers[key];
268
+ if (values && values.length > 0) {
269
+ return values[0];
270
+ }
271
+ }
272
+ }
273
+ return "";
274
+ }
275
+ // newHttpPaypalApi builds a live PayPal client. now is injectable so token expiry
276
+ // is deterministic in tests.
277
+ export function newHttpPaypalApi(creds, now = () => Date.now()) {
278
+ return new HttpPaypalApi(creds, now);
279
+ }
@@ -0,0 +1,61 @@
1
+ import type { Quote, Authorization, Settlement } from "../message.js";
2
+ import { RefundKind, type AdapterEvent } from "../adapter.js";
3
+ import type { PayInLeg, PayInCapabilities, CollectResult } from "../leg.js";
4
+ export interface PaymentIntent {
5
+ id: string;
6
+ status: string;
7
+ amount: number;
8
+ currency: string;
9
+ latestCharge: string;
10
+ metadata: Record<string, string>;
11
+ created: number;
12
+ }
13
+ export interface Refund {
14
+ id: string;
15
+ status: string;
16
+ amount: number;
17
+ }
18
+ export interface CreateIntentParams {
19
+ amount: number;
20
+ currency: string;
21
+ destination: string;
22
+ metadata: Record<string, string>;
23
+ }
24
+ export interface CreateRefundParams {
25
+ paymentIntentId: string;
26
+ amount: number;
27
+ reason: string;
28
+ }
29
+ export interface StripeApi {
30
+ createPaymentIntent(params: CreateIntentParams, idempotencyKey: string): Promise<PaymentIntent>;
31
+ capturePaymentIntent(id: string, idempotencyKey: string): Promise<PaymentIntent>;
32
+ findPaymentIntent(pactIntentId: string): Promise<PaymentIntent>;
33
+ createRefund(params: CreateRefundParams, idempotencyKey: string): Promise<Refund>;
34
+ }
35
+ export interface StripeConfig {
36
+ id?: string;
37
+ currencies: string[];
38
+ api: StripeApi;
39
+ webhookKey: string;
40
+ clock?: () => number;
41
+ tolerance?: number;
42
+ ids: () => string;
43
+ }
44
+ export declare class StripeLeg implements PayInLeg {
45
+ readonly id: string;
46
+ private readonly currencies;
47
+ private readonly api;
48
+ private readonly webhookKey;
49
+ private readonly clock;
50
+ private readonly tolerance;
51
+ private readonly ids;
52
+ constructor(cfg: StripeConfig);
53
+ payInCapabilities(): PayInCapabilities;
54
+ collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
55
+ refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
56
+ parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
57
+ }
58
+ export declare const ErrNoSignature = "stripe: missing signature header";
59
+ export declare const ErrSignatureMismatch = "stripe: signature does not verify";
60
+ export declare const ErrTimestampOutOfTolerance = "stripe: webhook timestamp outside tolerance";
61
+ export declare function newHttpStripeApi(secretKey: string, baseURL?: string): StripeApi;
@@ -0,0 +1,336 @@
1
+ // Stripe leg for the PACT protocol. It runs server-side only: it holds the Stripe
2
+ // secret and webhook signing keys, creates and captures PaymentIntents, and
3
+ // normalizes Stripe webhooks into protocol events. Stripe serves the pay-in side
4
+ // of a corridor: it charges the payer's card and captures the funds to the
5
+ // account the corridor delivers to, be that the recipient directly or a bridge
6
+ // escrow. Card and wallet collection happens client-side against the
7
+ // PaymentIntent this leg creates; the leg never sees raw card data.
8
+ import { createHmac, timingSafeEqual } from "node:crypto";
9
+ import { State } from "../state.js";
10
+ import { RefundKind } from "../adapter.js";
11
+ // The public base of the Stripe REST API. It is the same for every integration
12
+ // and carries no secret; tests point the leg at a local server instead.
13
+ const defaultBaseURL = "https://api.stripe.com";
14
+ // The Stripe API version this leg is written against. Pinning it keeps response
15
+ // shapes stable across Stripe's own releases.
16
+ const apiVersion = "2026-04-22.dahlia";
17
+ // The pact intent id travels on the PaymentIntent metadata under this key so a
18
+ // webhook can be tied back to the intent it settles.
19
+ const metadataIntentKey = "pact_intent_id";
20
+ // defaultToleranceSeconds is how far a webhook timestamp may drift from now
21
+ // before it is rejected as a possible replay.
22
+ const defaultToleranceSeconds = 300;
23
+ // The protocol identifier used to bind an idempotency key to one protocol step.
24
+ const idPrefix = "pact";
25
+ // StripeLeg collects card payments through Stripe. It exposes the wallets that
26
+ // ride on the card rail as funding methods, and answers a refund with a full or
27
+ // partial reversal.
28
+ export class StripeLeg {
29
+ id;
30
+ currencies;
31
+ api;
32
+ webhookKey;
33
+ clock;
34
+ tolerance;
35
+ ids;
36
+ constructor(cfg) {
37
+ if (!cfg.webhookKey) {
38
+ throw new Error("stripe: config requires a webhook signing key");
39
+ }
40
+ this.id = cfg.id ?? "stripe";
41
+ this.currencies = cfg.currencies;
42
+ this.api = cfg.api;
43
+ this.webhookKey = cfg.webhookKey;
44
+ this.clock = cfg.clock ?? (() => 0);
45
+ this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
46
+ this.ids = cfg.ids;
47
+ }
48
+ // payInCapabilities advertises the card rail and the wallets that fund through
49
+ // it. The methods are informational; the corridor routes on the rail.
50
+ payInCapabilities() {
51
+ return {
52
+ rails: ["card"],
53
+ currencies: this.currencies,
54
+ methods: ["card", "cashapp", "apple_pay", "google_pay", "amazon_pay", "link"],
55
+ refunds: RefundKind.Partial,
56
+ };
57
+ }
58
+ // collect charges the payer and captures the funds to deliverTo — the recipient
59
+ // for a direct corridor, the bridge escrow for a bridged one. It creates the
60
+ // PaymentIntent and captures it in one step, each call keyed to this intent so a
61
+ // retry reuses the original charge rather than opening a second. received is the
62
+ // net a bridge would convert: what the payer paid less the corridor fees.
63
+ async collect(intentId, quote, _auth, deliverTo) {
64
+ const amount = minorToInteger(quote.srcAmount);
65
+ const pi = await this.api.createPaymentIntent({
66
+ amount,
67
+ currency: quote.srcAmount.currency,
68
+ destination: deliverTo,
69
+ metadata: { [metadataIntentKey]: intentId },
70
+ }, idempotencyKey(intentId, "collect"));
71
+ const captured = await this.api.capturePaymentIntent(pi.id, idempotencyKey(intentId, "capture"));
72
+ return { providerRef: captured.id, received: quote.srcAmount.sub(quote.fees) };
73
+ }
74
+ // refundIn reverses a captured payment, in full or in part. Stripe supports
75
+ // both, so the leg accepts the full and partial refund kinds and rejects a
76
+ // counter-transfer it cannot express.
77
+ async refundIn(intentId, kind, reason) {
78
+ if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
79
+ throw new Error(`stripe: cannot perform refund kind ${kind}`);
80
+ }
81
+ const pi = await this.api.findPaymentIntent(intentId);
82
+ const refund = await this.api.createRefund({ paymentIntentId: pi.id, amount: 0, reason }, idempotencyKey(intentId, "refund"));
83
+ return {
84
+ intentId,
85
+ state: State.Refunded,
86
+ adapterId: this.id,
87
+ providerTxRef: refund.id,
88
+ onchainTxHash: "",
89
+ receiptHash: new Uint8Array(0),
90
+ reason,
91
+ settledAt: 0,
92
+ };
93
+ }
94
+ // parseWebhook verifies a Stripe webhook and normalizes it into protocol
95
+ // events. It reads the signature from the Stripe-Signature header and rejects
96
+ // any payload whose signature or timestamp does not check out. The timestamp is
97
+ // checked against the injected clock so the result is deterministic.
98
+ parseWebhook(raw, headers) {
99
+ return parseEvent(raw, signatureHeader(headers), this.webhookKey, this.clock(), this.tolerance);
100
+ }
101
+ }
102
+ // signatureHeader pulls the Stripe signature out of the request headers, matching
103
+ // the header name case-insensitively so it works regardless of how the host's
104
+ // HTTP layer canonicalizes keys.
105
+ function signatureHeader(headers) {
106
+ for (const key of Object.keys(headers)) {
107
+ if (key.toLowerCase() === "stripe-signature") {
108
+ const values = headers[key];
109
+ if (values && values.length > 0) {
110
+ return values[0];
111
+ }
112
+ }
113
+ }
114
+ return "";
115
+ }
116
+ // idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
117
+ // the original result instead of acting twice.
118
+ function idempotencyKey(ref, step) {
119
+ return `${idPrefix}:${ref}:${step}`;
120
+ }
121
+ // minorToInteger narrows a Money amount to the safe integer Stripe expects,
122
+ // refusing anything that would overflow. Fiat amounts fit comfortably; the guard
123
+ // exists so a token amount can never be sent to a card rail by mistake.
124
+ function minorToInteger(m) {
125
+ const amount = m.value();
126
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER) || amount < 0n) {
127
+ throw new Error(`stripe: amount ${m.minor()} exceeds the card-rail range`);
128
+ }
129
+ return Number(amount);
130
+ }
131
+ // ErrNoSignature reports a webhook that arrived without a usable Stripe signature
132
+ // header.
133
+ export const ErrNoSignature = "stripe: missing signature header";
134
+ // ErrSignatureMismatch reports a webhook whose signature does not verify.
135
+ export const ErrSignatureMismatch = "stripe: signature does not verify";
136
+ // ErrTimestampOutOfTolerance reports a webhook whose timestamp is too old or too
137
+ // far in the future to trust.
138
+ export const ErrTimestampOutOfTolerance = "stripe: webhook timestamp outside tolerance";
139
+ // verifySignature checks a Stripe-Signature header against the raw payload and
140
+ // the webhook signing secret: sign "{t}.{payload}" with HMAC-SHA256 and compare
141
+ // in constant time. now and tolerance are injected so the check is deterministic
142
+ // in tests.
143
+ function verifySignature(payload, header, secret, now, tolerance) {
144
+ if (!header) {
145
+ throw new Error(ErrNoSignature);
146
+ }
147
+ let timestamp = "";
148
+ const signatures = [];
149
+ for (const part of header.split(",")) {
150
+ const kv = part.trim();
151
+ const eq = kv.indexOf("=");
152
+ if (eq < 0) {
153
+ continue;
154
+ }
155
+ const name = kv.slice(0, eq);
156
+ const value = kv.slice(eq + 1);
157
+ if (name === "t") {
158
+ timestamp = value;
159
+ }
160
+ else if (name === "v1") {
161
+ signatures.push(value);
162
+ }
163
+ }
164
+ if (!timestamp || signatures.length === 0) {
165
+ throw new Error(ErrNoSignature);
166
+ }
167
+ const ts = Number(timestamp);
168
+ if (!Number.isInteger(ts)) {
169
+ throw new Error(ErrNoSignature);
170
+ }
171
+ const diff = now - ts;
172
+ if (diff > tolerance || diff < -tolerance) {
173
+ throw new Error(ErrTimestampOutOfTolerance);
174
+ }
175
+ const mac = createHmac("sha256", secret);
176
+ mac.update(timestamp);
177
+ mac.update(".");
178
+ mac.update(payload);
179
+ const expected = mac.digest();
180
+ for (const sig of signatures) {
181
+ let got;
182
+ try {
183
+ got = Buffer.from(sig, "hex");
184
+ }
185
+ catch {
186
+ continue;
187
+ }
188
+ if (got.length === expected.length && timingSafeEqual(got, expected)) {
189
+ return;
190
+ }
191
+ }
192
+ throw new Error(ErrSignatureMismatch);
193
+ }
194
+ // parseEvent verifies the signature and maps a Stripe event onto the protocol.
195
+ // Events this leg does not act on yield no protocol event rather than an error.
196
+ function parseEvent(payload, header, secret, now, tolerance) {
197
+ verifySignature(payload, header, secret, now, tolerance);
198
+ const event = JSON.parse(new TextDecoder().decode(payload));
199
+ switch (event.type) {
200
+ case "payment_intent.succeeded":
201
+ return oneEvent(decodePaymentIntent(event.data.object), State.Settled, "");
202
+ case "payment_intent.payment_failed":
203
+ return oneEvent(decodePaymentIntent(event.data.object), State.Failed, "payment failed");
204
+ case "charge.refunded":
205
+ return oneEvent(decodeRefundedIntent(event.data.object), State.Refunded, "refunded");
206
+ default:
207
+ return [];
208
+ }
209
+ }
210
+ function decodePaymentIntent(raw) {
211
+ const obj = raw;
212
+ return { id: obj.id ?? "", metadata: obj.metadata ?? {}, created: obj.created ?? 0 };
213
+ }
214
+ // decodeRefundedIntent reads the pact intent id off a refunded charge. A charge
215
+ // carries the originating PaymentIntent id and copies its metadata.
216
+ function decodeRefundedIntent(raw) {
217
+ const charge = raw;
218
+ return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0 };
219
+ }
220
+ function oneEvent(pi, state, reason) {
221
+ return [
222
+ {
223
+ intentId: pi.metadata[metadataIntentKey] ?? "",
224
+ state,
225
+ providerTxRef: pi.id,
226
+ onchainTxHash: "",
227
+ reason,
228
+ settledAt: pi.created * 1000,
229
+ },
230
+ ];
231
+ }
232
+ // httpStripeApi is the live Stripe REST client. The secret key is injected by the
233
+ // host from its own secrets store and never originates in this code.
234
+ class HttpStripeApi {
235
+ secretKey;
236
+ baseURL;
237
+ constructor(secretKey, baseURL) {
238
+ this.secretKey = secretKey;
239
+ this.baseURL = baseURL || defaultBaseURL;
240
+ }
241
+ async createPaymentIntent(params, idempotencyKey) {
242
+ const form = new URLSearchParams();
243
+ form.set("amount", String(params.amount));
244
+ form.set("currency", params.currency.toLowerCase());
245
+ form.set("capture_method", "manual");
246
+ form.set("automatic_payment_methods[enabled]", "true");
247
+ if (params.destination) {
248
+ form.set("transfer_data[destination]", params.destination);
249
+ }
250
+ for (const [k, v] of Object.entries(params.metadata)) {
251
+ form.set(`metadata[${k}]`, v);
252
+ }
253
+ const pi = await this.post("/v1/payment_intents", form, idempotencyKey);
254
+ return normalizeIntent(pi);
255
+ }
256
+ async capturePaymentIntent(id, idempotencyKey) {
257
+ const pi = await this.post(`/v1/payment_intents/${encodeURIComponent(id)}/capture`, new URLSearchParams(), idempotencyKey);
258
+ return normalizeIntent(pi);
259
+ }
260
+ async findPaymentIntent(pactIntentId) {
261
+ const query = new URLSearchParams();
262
+ query.set("query", `metadata["${metadataIntentKey}"]:"${pactIntentId}"`);
263
+ const result = await this.get(`/v1/payment_intents/search?${query.toString()}`);
264
+ const first = result.data[0];
265
+ if (!first) {
266
+ throw new Error(`stripe: no payment intent for ${pactIntentId}`);
267
+ }
268
+ return normalizeIntent(first);
269
+ }
270
+ async createRefund(params, idempotencyKey) {
271
+ const form = new URLSearchParams();
272
+ form.set("payment_intent", params.paymentIntentId);
273
+ if (params.amount > 0) {
274
+ form.set("amount", String(params.amount));
275
+ }
276
+ if (params.reason) {
277
+ form.set("metadata[reason]", params.reason);
278
+ }
279
+ const r = await this.post("/v1/refunds", form, idempotencyKey);
280
+ return { id: r.id ?? "", status: r.status ?? "", amount: r.amount ?? 0 };
281
+ }
282
+ async post(path, form, idempotencyKey) {
283
+ const headers = { "Content-Type": "application/x-www-form-urlencoded" };
284
+ if (idempotencyKey) {
285
+ headers["Idempotency-Key"] = idempotencyKey;
286
+ }
287
+ return this.send(path, { method: "POST", headers, body: form.toString() });
288
+ }
289
+ async get(path) {
290
+ return this.send(path, { method: "GET" });
291
+ }
292
+ async send(path, init) {
293
+ const headers = {
294
+ ...(init.headers ?? {}),
295
+ Authorization: `Bearer ${this.secretKey}`,
296
+ "Stripe-Version": apiVersion,
297
+ };
298
+ const resp = await fetch(this.baseURL + path, { ...init, headers });
299
+ const body = await resp.text();
300
+ if (resp.status >= 300) {
301
+ throw new Error(`stripe: ${path} returned ${resp.status}: ${stripeErrorMessage(body)}`);
302
+ }
303
+ return JSON.parse(body);
304
+ }
305
+ }
306
+ // stripeErrorMessage lifts Stripe's error message out of a response body without
307
+ // echoing anything that could carry a key.
308
+ function stripeErrorMessage(body) {
309
+ try {
310
+ const envelope = JSON.parse(body);
311
+ const message = envelope.error?.message;
312
+ if (!message) {
313
+ return "unknown error";
314
+ }
315
+ return envelope.error?.code ? `${message} (${envelope.error.code})` : message;
316
+ }
317
+ catch {
318
+ return "unknown error";
319
+ }
320
+ }
321
+ function normalizeIntent(p) {
322
+ return {
323
+ id: p.id ?? "",
324
+ status: p.status ?? "",
325
+ amount: p.amount ?? 0,
326
+ currency: (p.currency ?? "").toUpperCase(),
327
+ latestCharge: p.latest_charge ?? "",
328
+ metadata: p.metadata ?? {},
329
+ created: p.created ?? 0,
330
+ };
331
+ }
332
+ // newHttpStripeApi builds a live Stripe client. baseURL is optional and defaults
333
+ // to the public Stripe API; it exists so tests can substitute a local server.
334
+ export function newHttpStripeApi(secretKey, baseURL = "") {
335
+ return new HttpStripeApi(secretKey, baseURL);
336
+ }