@myzonerocks/pact 0.1.3 → 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 +5 -1
- package/dist/src/adapters/paypal.js +76 -25
- 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 +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 +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 +118 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +102 -13
- package/src/adapters/paypal.ts +102 -24
- 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
package/src/adapters/mpesa.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { Money } from "../money.js";
|
|
|
9
9
|
import { State } from "../state.js";
|
|
10
10
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
11
11
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
12
|
+
import { fetchWithTimeout } from "./http.js";
|
|
12
13
|
import type {
|
|
13
14
|
PayInLeg,
|
|
14
15
|
PayOutLeg,
|
|
@@ -60,12 +61,25 @@ export interface B2CResult {
|
|
|
60
61
|
responseCode: string;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// StkQueryResult is the authoritative outcome of a push, read back from Daraja by
|
|
65
|
+
// its checkout id. pending means Daraja is still processing and the outcome isn't
|
|
66
|
+
// known yet; otherwise resultCode 0 is success and any other code is a failure.
|
|
67
|
+
export interface StkQueryResult {
|
|
68
|
+
resultCode: number;
|
|
69
|
+
resultDesc: string;
|
|
70
|
+
pending: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
// DarajaApi is the surface of Daraja this leg depends on. Depending on an
|
|
64
74
|
// interface keeps the leg unit-testable without a network and without
|
|
65
75
|
// credentials.
|
|
66
76
|
export interface DarajaApi {
|
|
67
77
|
stkPush(params: StkPushParams): Promise<StkPushResult>;
|
|
68
78
|
b2cPayment(params: B2CParams): Promise<B2CResult>;
|
|
79
|
+
// query reads a push's real outcome back from Daraja by its checkout id. The
|
|
80
|
+
// STK callback is unsigned, so this authenticated read — not the callback body
|
|
81
|
+
// — is what a settlement is trusted to.
|
|
82
|
+
query(checkoutRequestId: string): Promise<StkQueryResult>;
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
// push remembers what settling an STK collection needs between initiating it and
|
|
@@ -154,16 +168,21 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
154
168
|
// refundIn answers a collected payment with a business-to-customer payout back
|
|
155
169
|
// to the payer. An STK collection cannot be reversed in place, so a
|
|
156
170
|
// counter-transfer is the only refund this rail supports.
|
|
157
|
-
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
171
|
+
async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
|
|
158
172
|
if (kind !== RefundKind.CounterTransfer) {
|
|
159
173
|
throw new Error("mpesa: a collection can only be refunded by counter-transfer");
|
|
160
174
|
}
|
|
175
|
+
const shillings = wholeShillings(amount);
|
|
161
176
|
const rec = this.byIntent.get(intentId);
|
|
162
177
|
if (!rec) {
|
|
163
178
|
throw new Error(`mpesa: no push for intent ${intentId}`);
|
|
164
179
|
}
|
|
180
|
+
// A refund cannot return more shillings than the push collected.
|
|
181
|
+
if (shillings > rec.amount) {
|
|
182
|
+
throw new Error(`mpesa: refund of ${shillings} exceeds the ${rec.amount} collected`);
|
|
183
|
+
}
|
|
165
184
|
const result = await this.api.b2cPayment({
|
|
166
|
-
amount:
|
|
185
|
+
amount: shillings,
|
|
167
186
|
phone: rec.payerPhone,
|
|
168
187
|
reference: intentId,
|
|
169
188
|
remarks: reason,
|
|
@@ -187,11 +206,13 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
187
206
|
}
|
|
188
207
|
|
|
189
208
|
// parseWebhook reads an STK callback and normalizes it into a protocol event.
|
|
190
|
-
// The callback is unsigned
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
209
|
+
// The callback is unsigned and its CheckoutRequestID is a value we hand back to
|
|
210
|
+
// the caller — not a secret — so the callback body is treated only as a nudge.
|
|
211
|
+
// The real outcome is read back from Daraja with our own credentials, and a
|
|
212
|
+
// settlement is emitted only when that authenticated query confirms success and
|
|
213
|
+
// the amount Daraja paid equals the amount we authorized. The headers are
|
|
214
|
+
// accepted for interface symmetry and for a host that adds its own gate on top.
|
|
215
|
+
async parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]> {
|
|
195
216
|
const envelope = JSON.parse(new TextDecoder().decode(raw)) as StkCallbackEnvelope;
|
|
196
217
|
const cb = envelope.Body?.stkCallback;
|
|
197
218
|
const rec = cb ? this.byCheckout.get(cb.CheckoutRequestID) : undefined;
|
|
@@ -199,7 +220,13 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
199
220
|
throw new Error(ErrUnknownCheckout);
|
|
200
221
|
}
|
|
201
222
|
|
|
202
|
-
|
|
223
|
+
const confirmed = await this.api.query(cb.CheckoutRequestID);
|
|
224
|
+
// No outcome yet — wait for a later callback rather than settling or failing
|
|
225
|
+
// on an unconfirmed body.
|
|
226
|
+
if (confirmed.pending) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
if (confirmed.resultCode !== 0) {
|
|
203
230
|
rec.state = State.Failed;
|
|
204
231
|
return [
|
|
205
232
|
{
|
|
@@ -207,11 +234,17 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
207
234
|
state: State.Failed,
|
|
208
235
|
providerTxRef: cb.CheckoutRequestID,
|
|
209
236
|
onchainTxHash: "",
|
|
210
|
-
reason:
|
|
237
|
+
reason: confirmed.resultDesc,
|
|
211
238
|
settledAt: 0,
|
|
212
239
|
},
|
|
213
240
|
];
|
|
214
241
|
}
|
|
242
|
+
// The amount Daraja collected must equal the amount we authorized; a partial
|
|
243
|
+
// or tampered collection settles nothing.
|
|
244
|
+
const paid = metadataInt(cb.CallbackMetadata?.Item ?? [], "Amount");
|
|
245
|
+
if (paid !== rec.amount) {
|
|
246
|
+
throw new Error(`mpesa: confirmed amount ${paid} does not match the authorized ${rec.amount}`);
|
|
247
|
+
}
|
|
215
248
|
|
|
216
249
|
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
217
250
|
rec.state = State.Settled;
|
|
@@ -258,6 +291,20 @@ function wholeShillings(m: Money): number {
|
|
|
258
291
|
return Number(amount);
|
|
259
292
|
}
|
|
260
293
|
|
|
294
|
+
// metadataInt pulls a named numeric value out of the callback metadata items,
|
|
295
|
+
// used to read the paid Amount. M-Pesa amounts are whole shillings but may arrive
|
|
296
|
+
// as a number with a fractional part, so it is floored to the shilling.
|
|
297
|
+
function metadataInt(items: StkCallbackItem[], name: string): number {
|
|
298
|
+
for (const item of items) {
|
|
299
|
+
if (item.Name !== name) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const n = Number(item.Value);
|
|
303
|
+
return Number.isFinite(n) ? Math.floor(n) : 0;
|
|
304
|
+
}
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
261
308
|
// metadataString pulls a named string value out of the callback metadata items.
|
|
262
309
|
function metadataString(items: StkCallbackItem[], name: string): string {
|
|
263
310
|
for (const item of items) {
|
|
@@ -301,6 +348,8 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
301
348
|
private readonly creds: Credentials;
|
|
302
349
|
private readonly baseURL: string;
|
|
303
350
|
private readonly now: () => Date;
|
|
351
|
+
private cachedToken = "";
|
|
352
|
+
private tokenExpiryMs = 0;
|
|
304
353
|
|
|
305
354
|
constructor(creds: Credentials, now: () => Date) {
|
|
306
355
|
this.creds = creds;
|
|
@@ -309,16 +358,25 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
309
358
|
}
|
|
310
359
|
|
|
311
360
|
private async token(): Promise<string> {
|
|
361
|
+
// Reuse the cached token until it is within a minute of expiry, so a burst of
|
|
362
|
+
// pushes does not re-authenticate against Daraja on every call.
|
|
363
|
+
if (this.cachedToken && this.now().getTime() < this.tokenExpiryMs - 60_000) {
|
|
364
|
+
return this.cachedToken;
|
|
365
|
+
}
|
|
312
366
|
const basic = Buffer.from(`${this.creds.consumerKey}:${this.creds.consumerSecret}`).toString("base64");
|
|
313
|
-
const resp = await
|
|
367
|
+
const resp = await fetchWithTimeout(`${this.baseURL}/oauth/v1/generate?grant_type=client_credentials`, {
|
|
314
368
|
method: "GET",
|
|
315
369
|
headers: { Authorization: `Basic ${basic}` },
|
|
316
370
|
});
|
|
317
371
|
if (resp.status >= 300) {
|
|
318
372
|
throw new Error(`mpesa: /oauth/v1/generate returned ${resp.status}`);
|
|
319
373
|
}
|
|
320
|
-
const out = (await resp.json()) as { access_token?: string };
|
|
321
|
-
|
|
374
|
+
const out = (await resp.json()) as { access_token?: string; expires_in?: string | number };
|
|
375
|
+
// Daraja tokens live an hour; fall back to that if the field is absent.
|
|
376
|
+
const ttlSeconds = Number(out.expires_in) > 0 ? Number(out.expires_in) : 3599;
|
|
377
|
+
this.cachedToken = out.access_token ?? "";
|
|
378
|
+
this.tokenExpiryMs = this.now().getTime() + ttlSeconds * 1000;
|
|
379
|
+
return this.cachedToken;
|
|
322
380
|
}
|
|
323
381
|
|
|
324
382
|
// password is the base64 of shortcode+passkey+timestamp Daraja requires on each
|
|
@@ -355,6 +413,37 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
355
413
|
};
|
|
356
414
|
}
|
|
357
415
|
|
|
416
|
+
async query(checkoutRequestId: string): Promise<StkQueryResult> {
|
|
417
|
+
const token = await this.token();
|
|
418
|
+
const timestamp = formatTimestamp(this.now());
|
|
419
|
+
const body = {
|
|
420
|
+
BusinessShortCode: this.creds.shortCode,
|
|
421
|
+
Password: this.password(timestamp),
|
|
422
|
+
Timestamp: timestamp,
|
|
423
|
+
CheckoutRequestID: checkoutRequestId,
|
|
424
|
+
};
|
|
425
|
+
const resp = await fetchWithTimeout(this.baseURL + "/mpesa/stkpushquery/v1/query", {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
428
|
+
body: JSON.stringify(body),
|
|
429
|
+
});
|
|
430
|
+
const out = (await resp.json()) as {
|
|
431
|
+
ResultCode?: string;
|
|
432
|
+
ResultDesc?: string;
|
|
433
|
+
errorCode?: string;
|
|
434
|
+
};
|
|
435
|
+
// Daraja answers a query for a push it is still processing with an error code
|
|
436
|
+
// rather than a result; treat that as pending so the outcome is confirmed on a
|
|
437
|
+
// later query rather than mistaken for a failure.
|
|
438
|
+
if (out.errorCode === "500.001.1001") {
|
|
439
|
+
return { resultCode: 0, resultDesc: "", pending: true };
|
|
440
|
+
}
|
|
441
|
+
if (resp.status >= 300) {
|
|
442
|
+
throw new Error(`mpesa: stk query returned ${resp.status}`);
|
|
443
|
+
}
|
|
444
|
+
return { resultCode: Number(out.ResultCode ?? -1), resultDesc: out.ResultDesc ?? "", pending: false };
|
|
445
|
+
}
|
|
446
|
+
|
|
358
447
|
async b2cPayment(params: B2CParams): Promise<B2CResult> {
|
|
359
448
|
const token = await this.token();
|
|
360
449
|
const body = {
|
|
@@ -375,7 +464,7 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
375
464
|
}
|
|
376
465
|
|
|
377
466
|
private async postJSON<T>(token: string, path: string, body: unknown): Promise<T> {
|
|
378
|
-
const resp = await
|
|
467
|
+
const resp = await fetchWithTimeout(this.baseURL + path, {
|
|
379
468
|
method: "POST",
|
|
380
469
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
381
470
|
body: JSON.stringify(body),
|
package/src/adapters/paypal.ts
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
// Venmo flow client-side; this package never sees card data. A captured payment
|
|
6
6
|
// can be refunded in full, but a delivered payout cannot be pulled back.
|
|
7
7
|
import { State } from "../state.js";
|
|
8
|
+
import type { Money } from "../money.js";
|
|
8
9
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
9
10
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
11
|
+
import { idempotencyKey } from "../protocol.js";
|
|
12
|
+
import { fetchWithTimeout } from "./http.js";
|
|
10
13
|
import type {
|
|
11
14
|
PayOutLeg,
|
|
12
15
|
InteractivePayInLeg,
|
|
@@ -78,6 +81,10 @@ export interface Payout {
|
|
|
78
81
|
export interface RefundParams {
|
|
79
82
|
captureId: string;
|
|
80
83
|
reason: string;
|
|
84
|
+
// value and currencyCode name a partial refund in major units; both absent
|
|
85
|
+
// refunds the full capture.
|
|
86
|
+
value?: string;
|
|
87
|
+
currencyCode?: string;
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
// Refund is the result of refunding a capture.
|
|
@@ -115,6 +122,9 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
115
122
|
private readonly api: PaypalApi;
|
|
116
123
|
private readonly ids: () => string;
|
|
117
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 }>();
|
|
118
128
|
|
|
119
129
|
constructor(cfg: PaypalConfig) {
|
|
120
130
|
this.id = cfg.id ?? "paypal";
|
|
@@ -128,7 +138,7 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
128
138
|
rails: ["paypal"],
|
|
129
139
|
currencies: this.currencies,
|
|
130
140
|
methods: ["paypal", "venmo", "card"],
|
|
131
|
-
refunds: RefundKind.
|
|
141
|
+
refunds: RefundKind.Partial,
|
|
132
142
|
};
|
|
133
143
|
}
|
|
134
144
|
|
|
@@ -147,6 +157,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
147
157
|
referenceId: referencePrefix + intentId,
|
|
148
158
|
});
|
|
149
159
|
this.captures.set(intentId, capture.captureId);
|
|
160
|
+
this.expected.set(intentId, {
|
|
161
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
162
|
+
currency: quote.srcAmount.currency,
|
|
163
|
+
});
|
|
150
164
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
151
165
|
}
|
|
152
166
|
|
|
@@ -167,6 +181,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
167
181
|
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
168
182
|
}
|
|
169
183
|
const order = await this.api.createOrder(params);
|
|
184
|
+
this.expected.set(intentId, {
|
|
185
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
186
|
+
currency: quote.srcAmount.currency,
|
|
187
|
+
});
|
|
170
188
|
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
171
189
|
}
|
|
172
190
|
|
|
@@ -182,18 +200,24 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
182
200
|
return { providerRef: payout.batchId };
|
|
183
201
|
}
|
|
184
202
|
|
|
185
|
-
// refundIn reverses a captured payment in full. PayPal captures the
|
|
186
|
-
// the capture id it returned from collect, so the refund binds to
|
|
187
|
-
// reference.
|
|
188
|
-
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
189
|
-
if (kind !== RefundKind.Full) {
|
|
203
|
+
// refundIn reverses a captured payment, in full or in part. PayPal captures the
|
|
204
|
+
// order id in the capture id it returned from collect, so the refund binds to
|
|
205
|
+
// that reference; a partial refund names the amount to return.
|
|
206
|
+
async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
|
|
207
|
+
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
190
208
|
throw new Error(`paypal: cannot perform refund kind ${kind}`);
|
|
191
209
|
}
|
|
192
210
|
const captureId = this.captures.get(intentId);
|
|
193
211
|
if (!captureId) {
|
|
194
212
|
throw new Error(`paypal: no capture for intent ${intentId}`);
|
|
195
213
|
}
|
|
196
|
-
|
|
214
|
+
// A partial refund names the amount in major units; a full refund leaves it
|
|
215
|
+
// absent so PayPal returns the whole capture.
|
|
216
|
+
const params: RefundParams =
|
|
217
|
+
kind === RefundKind.Partial
|
|
218
|
+
? { captureId, reason, value: majorAmount(amount.minor(), amount.exponent), currencyCode: amount.currency }
|
|
219
|
+
: { captureId, reason };
|
|
220
|
+
const refund = await this.api.refundCapture(params);
|
|
197
221
|
return {
|
|
198
222
|
intentId,
|
|
199
223
|
state: State.Refunded,
|
|
@@ -226,8 +250,22 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
226
250
|
const providerRef = resource.id ?? "";
|
|
227
251
|
|
|
228
252
|
switch (event.event_type) {
|
|
229
|
-
case "PAYMENT.CAPTURE.COMPLETED":
|
|
253
|
+
case "PAYMENT.CAPTURE.COMPLETED": {
|
|
254
|
+
// The custom_id that ties a capture to an intent is chosen by whoever
|
|
255
|
+
// created the order, so a genuine, signed capture for a different order can
|
|
256
|
+
// carry a target intent's id. Settle only when the captured amount and
|
|
257
|
+
// currency match what the intent was quoted to collect.
|
|
258
|
+
const want = this.expected.get(intentId);
|
|
259
|
+
if (!want) {
|
|
260
|
+
throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
|
|
261
|
+
}
|
|
262
|
+
if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
230
267
|
return [oneEvent(intentId, State.Settled, providerRef, "")];
|
|
268
|
+
}
|
|
231
269
|
case "PAYMENT.CAPTURE.DENIED":
|
|
232
270
|
return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
|
|
233
271
|
case "PAYMENT.CAPTURE.REFUNDED":
|
|
@@ -240,7 +278,12 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
240
278
|
|
|
241
279
|
interface PaypalWebhook {
|
|
242
280
|
event_type?: string;
|
|
243
|
-
resource?: {
|
|
281
|
+
resource?: {
|
|
282
|
+
id?: string;
|
|
283
|
+
custom_id?: string;
|
|
284
|
+
invoice_id?: string;
|
|
285
|
+
amount?: { currency_code?: string; value?: string };
|
|
286
|
+
};
|
|
244
287
|
}
|
|
245
288
|
|
|
246
289
|
function oneEvent(intentId: string, state: State, providerRef: string, reason: string): AdapterEvent {
|
|
@@ -305,7 +348,7 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
305
348
|
return this.token;
|
|
306
349
|
}
|
|
307
350
|
const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
|
|
308
|
-
const resp = await
|
|
351
|
+
const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
|
|
309
352
|
method: "POST",
|
|
310
353
|
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
311
354
|
body: "grant_type=client_credentials",
|
|
@@ -327,10 +370,11 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
327
370
|
if (params.payee) {
|
|
328
371
|
unit.payee = { email_address: params.payee };
|
|
329
372
|
}
|
|
330
|
-
const created = await this.postJSON<PaypalOrder>(
|
|
331
|
-
|
|
332
|
-
purchase_units: [unit],
|
|
333
|
-
|
|
373
|
+
const created = await this.postJSON<PaypalOrder>(
|
|
374
|
+
"/v2/checkout/orders",
|
|
375
|
+
{ intent: "CAPTURE", purchase_units: [unit] },
|
|
376
|
+
idempotencyKey(params.referenceId, "paypal-order"),
|
|
377
|
+
);
|
|
334
378
|
const existing = firstCapture(created);
|
|
335
379
|
if (existing) {
|
|
336
380
|
return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
|
|
@@ -338,6 +382,7 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
338
382
|
const captured = await this.postJSON<PaypalOrder>(
|
|
339
383
|
`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`,
|
|
340
384
|
{},
|
|
385
|
+
idempotencyKey(params.referenceId, "paypal-capture"),
|
|
341
386
|
);
|
|
342
387
|
const capture = firstCapture(captured);
|
|
343
388
|
if (!capture) {
|
|
@@ -363,10 +408,11 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
363
408
|
platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
|
|
364
409
|
};
|
|
365
410
|
}
|
|
366
|
-
const created = await this.postJSON<PaypalOrder>(
|
|
367
|
-
|
|
368
|
-
purchase_units: [unit],
|
|
369
|
-
|
|
411
|
+
const created = await this.postJSON<PaypalOrder>(
|
|
412
|
+
"/v2/checkout/orders",
|
|
413
|
+
{ intent: "CAPTURE", purchase_units: [unit] },
|
|
414
|
+
idempotencyKey(params.referenceId, "paypal-order"),
|
|
415
|
+
);
|
|
370
416
|
let approve = "";
|
|
371
417
|
for (const link of created.links ?? []) {
|
|
372
418
|
if (link.rel === "approve" || link.rel === "payer-action") {
|
|
@@ -400,9 +446,14 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
400
446
|
if (params.reason) {
|
|
401
447
|
body.note_to_payer = params.reason;
|
|
402
448
|
}
|
|
449
|
+
// A partial refund names the amount; an absent value refunds the full capture.
|
|
450
|
+
if (params.value) {
|
|
451
|
+
body.amount = { value: params.value, currency_code: params.currencyCode };
|
|
452
|
+
}
|
|
403
453
|
const out = await this.postJSON<{ id?: string; status?: string }>(
|
|
404
454
|
`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`,
|
|
405
455
|
body,
|
|
456
|
+
idempotencyKey(params.captureId, "paypal-refund"),
|
|
406
457
|
);
|
|
407
458
|
return { id: out.id ?? "", status: out.status ?? "" };
|
|
408
459
|
}
|
|
@@ -412,24 +463,51 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
412
463
|
// binds a payload to this integration; PayPal reports whether the signature is
|
|
413
464
|
// authentic.
|
|
414
465
|
async verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean> {
|
|
415
|
-
|
|
466
|
+
// PayPal's signature covers the exact bytes of the event it delivered, so the
|
|
467
|
+
// event is forwarded verbatim. Parsing and re-serializing it would reorder
|
|
468
|
+
// keys or restyle numbers and make an authentic webhook fail verification.
|
|
469
|
+
const rawEvent = new TextDecoder().decode(body);
|
|
470
|
+
const sentinel = "__pact_raw_webhook_event__";
|
|
471
|
+
const envelope = JSON.stringify({
|
|
416
472
|
webhook_id: this.creds.webhookId,
|
|
417
473
|
transmission_id: header(headers, "PayPal-Transmission-Id"),
|
|
418
474
|
transmission_time: header(headers, "PayPal-Transmission-Time"),
|
|
419
475
|
transmission_sig: header(headers, "PayPal-Transmission-Sig"),
|
|
420
476
|
cert_url: header(headers, "PayPal-Cert-Url"),
|
|
421
477
|
auth_algo: header(headers, "PayPal-Auth-Algo"),
|
|
422
|
-
webhook_event:
|
|
478
|
+
webhook_event: sentinel,
|
|
423
479
|
});
|
|
480
|
+
const payload = envelope.replace(`"${sentinel}"`, rawEvent);
|
|
481
|
+
const out = await this.postSerialized<{ verification_status?: string }>(
|
|
482
|
+
"/v1/notifications/verify-webhook-signature",
|
|
483
|
+
payload,
|
|
484
|
+
);
|
|
424
485
|
return out.verification_status === "SUCCESS";
|
|
425
486
|
}
|
|
426
487
|
|
|
427
|
-
|
|
488
|
+
// requestId, when set, is sent as PayPal-Request-Id. PayPal deduplicates a
|
|
489
|
+
// mutation that carries a request id it has already seen, so a retry after a
|
|
490
|
+
// lost response reuses the first order, capture, or refund rather than creating
|
|
491
|
+
// a second.
|
|
492
|
+
private async postJSON<T>(path: string, body: unknown, requestId?: string): Promise<T> {
|
|
493
|
+
return this.postSerialized<T>(path, JSON.stringify(body), requestId);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// postSerialized posts an already-serialized JSON payload, so a caller that must
|
|
497
|
+
// control the exact bytes on the wire — a webhook forwarded verbatim — can do so.
|
|
498
|
+
private async postSerialized<T>(path: string, payload: string, requestId?: string): Promise<T> {
|
|
428
499
|
const token = await this.accessToken();
|
|
429
|
-
const
|
|
500
|
+
const headers: Record<string, string> = {
|
|
501
|
+
"Content-Type": "application/json",
|
|
502
|
+
Authorization: `Bearer ${token}`,
|
|
503
|
+
};
|
|
504
|
+
if (requestId) {
|
|
505
|
+
headers["PayPal-Request-Id"] = requestId;
|
|
506
|
+
}
|
|
507
|
+
const resp = await fetchWithTimeout(this.baseURL + path, {
|
|
430
508
|
method: "POST",
|
|
431
|
-
headers
|
|
432
|
-
body:
|
|
509
|
+
headers,
|
|
510
|
+
body: payload,
|
|
433
511
|
});
|
|
434
512
|
if (resp.status >= 300) {
|
|
435
513
|
throw new Error(`paypal: ${path} returned ${resp.status}`);
|
package/src/adapters/stripe.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { Money } from "../money.js";
|
|
|
10
10
|
import { State } from "../state.js";
|
|
11
11
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
12
12
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
13
|
+
import { idempotencyKey } from "../protocol.js";
|
|
14
|
+
import { fetchWithTimeout } from "./http.js";
|
|
13
15
|
import type { InteractivePayInLeg, PayInCapabilities, CollectResult, PayInPreparation } from "../leg.js";
|
|
14
16
|
|
|
15
17
|
// The public base of the Stripe REST API. It is the same for every integration
|
|
@@ -29,7 +31,6 @@ const metadataIntentKey = "pact_intent_id";
|
|
|
29
31
|
const defaultToleranceSeconds = 300;
|
|
30
32
|
|
|
31
33
|
// The protocol identifier used to bind an idempotency key to one protocol step.
|
|
32
|
-
const idPrefix = "pact";
|
|
33
34
|
|
|
34
35
|
// PaymentIntent is the subset of Stripe's PaymentIntent this leg reads.
|
|
35
36
|
// clientSecret is present only on a freshly created intent and is the single
|
|
@@ -108,9 +109,8 @@ export interface StripeConfig {
|
|
|
108
109
|
currencies: string[];
|
|
109
110
|
api: StripeApi;
|
|
110
111
|
webhookKey: string;
|
|
111
|
-
// clock returns the current time in Unix seconds, for webhook timestamp
|
|
112
|
-
|
|
113
|
-
clock?: () => number;
|
|
112
|
+
// clock returns the current time in Unix seconds, for webhook timestamp checks.
|
|
113
|
+
clock: () => number;
|
|
114
114
|
// tolerance is the webhook timestamp tolerance in seconds; defaults to 300.
|
|
115
115
|
tolerance?: number;
|
|
116
116
|
ids: () => string;
|
|
@@ -136,11 +136,16 @@ export class StripeLeg implements InteractivePayInLeg {
|
|
|
136
136
|
if (!cfg.webhookKey) {
|
|
137
137
|
throw new Error("stripe: config requires a webhook signing key");
|
|
138
138
|
}
|
|
139
|
+
// Without a clock every webhook timestamp reads as far in the past and
|
|
140
|
+
// silently fails the tolerance check, so a real webhook never verifies.
|
|
141
|
+
if (!cfg.clock) {
|
|
142
|
+
throw new Error("stripe: config requires a clock for webhook timestamp checks");
|
|
143
|
+
}
|
|
139
144
|
this.id = cfg.id ?? "stripe";
|
|
140
145
|
this.currencies = cfg.currencies;
|
|
141
146
|
this.api = cfg.api;
|
|
142
147
|
this.webhookKey = cfg.webhookKey;
|
|
143
|
-
this.clock = cfg.clock
|
|
148
|
+
this.clock = cfg.clock;
|
|
144
149
|
this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
|
|
145
150
|
this.ids = cfg.ids;
|
|
146
151
|
this.methods = cfg.methods;
|
|
@@ -204,13 +209,16 @@ export class StripeLeg implements InteractivePayInLeg {
|
|
|
204
209
|
// refundIn reverses a captured payment, in full or in part. Stripe supports
|
|
205
210
|
// both, so the leg accepts the full and partial refund kinds and rejects a
|
|
206
211
|
// counter-transfer it cannot express.
|
|
207
|
-
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
212
|
+
async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
|
|
208
213
|
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
209
214
|
throw new Error(`stripe: cannot perform refund kind ${kind}`);
|
|
210
215
|
}
|
|
211
216
|
const pi = await this.api.findPaymentIntent(intentId);
|
|
217
|
+
// Stripe refunds the full capture when no amount is set, so a full refund
|
|
218
|
+
// leaves amount zero and a partial one carries the exact minor units to return.
|
|
219
|
+
const minor = kind === RefundKind.Partial ? minorToInteger(amount) : 0;
|
|
212
220
|
const refund = await this.api.createRefund(
|
|
213
|
-
{ paymentIntentId: pi.id, amount:
|
|
221
|
+
{ paymentIntentId: pi.id, amount: minor, reason },
|
|
214
222
|
idempotencyKey(intentId, "refund"),
|
|
215
223
|
);
|
|
216
224
|
return {
|
|
@@ -249,11 +257,6 @@ function signatureHeader(headers: Record<string, string[]>): string {
|
|
|
249
257
|
return "";
|
|
250
258
|
}
|
|
251
259
|
|
|
252
|
-
// idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
|
|
253
|
-
// the original result instead of acting twice.
|
|
254
|
-
function idempotencyKey(ref: string, step: string): string {
|
|
255
|
-
return `${idPrefix}:${ref}:${step}`;
|
|
256
|
-
}
|
|
257
260
|
|
|
258
261
|
// minorToInteger narrows a Money amount to the safe integer Stripe expects,
|
|
259
262
|
// refusing anything that would overflow. Fiat amounts fit comfortably; the guard
|
|
@@ -484,7 +487,7 @@ class HttpStripeApi implements StripeApi {
|
|
|
484
487
|
Authorization: `Bearer ${this.secretKey}`,
|
|
485
488
|
"Stripe-Version": apiVersion,
|
|
486
489
|
};
|
|
487
|
-
const resp = await
|
|
490
|
+
const resp = await fetchWithTimeout(this.baseURL + path, { ...init, headers });
|
|
488
491
|
const body = await resp.text();
|
|
489
492
|
if (resp.status >= 300) {
|
|
490
493
|
throw new Error(`stripe: ${path} returned ${resp.status}: ${stripeErrorMessage(body)}`);
|
package/src/bridge.ts
CHANGED
|
@@ -131,14 +131,21 @@ export function applyInverseRate(dst: Money, rate: string, srcCurrency: string,
|
|
|
131
131
|
// parseRate reads a decimal rate like "129.45" into a numerator and denominator,
|
|
132
132
|
// so the conversion stays exact integer arithmetic with no floating point.
|
|
133
133
|
export function parseRate(rate: string): [bigint, bigint] {
|
|
134
|
+
// Bound the length so an untrusted rate can't force a huge bigint parse and
|
|
135
|
+
// exponentiation in the FX math.
|
|
136
|
+
if (rate.length > 80) {
|
|
137
|
+
throw new Error("pact: rate has more than 80 characters");
|
|
138
|
+
}
|
|
139
|
+
// A non-negative integer part with no leading zeros and an optional fractional
|
|
140
|
+
// part; no sign, no radix prefix, no whitespace, matching the amount grammar so
|
|
141
|
+
// the SDKs never disagree on a rate's validity or value.
|
|
142
|
+
if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(rate)) {
|
|
143
|
+
throw new Error(`pact: ${JSON.stringify(rate)} is not a canonical decimal rate`);
|
|
144
|
+
}
|
|
134
145
|
const dot = rate.indexOf(".");
|
|
135
146
|
const whole = dot < 0 ? rate : rate.slice(0, dot);
|
|
136
147
|
const frac = dot < 0 ? "" : rate.slice(dot + 1);
|
|
137
|
-
const
|
|
138
|
-
if (!/^\d+$/.test(digits)) {
|
|
139
|
-
throw new Error(`pact: ${JSON.stringify(rate)} is not a decimal rate`);
|
|
140
|
-
}
|
|
141
|
-
const num = BigInt(digits);
|
|
148
|
+
const num = BigInt(whole + frac);
|
|
142
149
|
const den = frac.length > 0 ? pow10(frac.length) : 1n;
|
|
143
150
|
return [num, den];
|
|
144
151
|
}
|
package/src/canonical.ts
CHANGED
|
@@ -26,6 +26,11 @@ export class CanonicalWriter {
|
|
|
26
26
|
return this;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// str writes a string as its raw UTF-8 bytes. No Unicode normalization is
|
|
30
|
+
// applied: the bytes are hashed as given, so two participants that compose the
|
|
31
|
+
// same text in different Unicode forms produce different hashes. Callers that
|
|
32
|
+
// need them to agree must normalize before building a message; string fields are
|
|
33
|
+
// otherwise treated as opaque bytes.
|
|
29
34
|
str(s: string): this {
|
|
30
35
|
return this.bytes(encoder.encode(s));
|
|
31
36
|
}
|