@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
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,15 +5,19 @@
|
|
|
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
|
-
PayInLeg,
|
|
12
14
|
PayOutLeg,
|
|
15
|
+
InteractivePayInLeg,
|
|
13
16
|
PayInCapabilities,
|
|
14
17
|
PayOutCapabilities,
|
|
15
18
|
CollectResult,
|
|
16
19
|
DisburseResult,
|
|
20
|
+
PayInPreparation,
|
|
17
21
|
} from "../leg.js";
|
|
18
22
|
|
|
19
23
|
// The public base of the PayPal REST API. It is the same for every live
|
|
@@ -36,6 +40,18 @@ export interface CreateOrderParams {
|
|
|
36
40
|
currencyCode: string;
|
|
37
41
|
payee: string;
|
|
38
42
|
referenceId: string;
|
|
43
|
+
// platformFee, when set, is the decimal-major cut the platform takes from the
|
|
44
|
+
// payee's proceeds.
|
|
45
|
+
platformFee?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Order is a created-but-not-yet-captured order: the buyer approves it on their own
|
|
49
|
+
// device, then it is captured. approveUrl is PayPal's hosted approval link, offered
|
|
50
|
+
// for hosts that redirect rather than drive the JS SDK.
|
|
51
|
+
export interface Order {
|
|
52
|
+
id: string;
|
|
53
|
+
status: string;
|
|
54
|
+
approveUrl: string;
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
// Capture is the result of capturing an order: the capture id is the reference a
|
|
@@ -65,6 +81,10 @@ export interface Payout {
|
|
|
65
81
|
export interface RefundParams {
|
|
66
82
|
captureId: string;
|
|
67
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;
|
|
68
88
|
}
|
|
69
89
|
|
|
70
90
|
// Refund is the result of refunding a capture.
|
|
@@ -80,6 +100,9 @@ export interface Refund {
|
|
|
80
100
|
// endpoint, so the verification secret never lives in this package.
|
|
81
101
|
export interface PaypalApi {
|
|
82
102
|
createAndCaptureOrder(params: CreateOrderParams): Promise<Capture>;
|
|
103
|
+
// createOrder creates an order the buyer approves and captures on their own
|
|
104
|
+
// device — the interactive pay-in path — rather than capturing it server-side.
|
|
105
|
+
createOrder(params: CreateOrderParams): Promise<Order>;
|
|
83
106
|
sendPayout(params: PayoutParams): Promise<Payout>;
|
|
84
107
|
refundCapture(params: RefundParams): Promise<Refund>;
|
|
85
108
|
verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean>;
|
|
@@ -93,12 +116,15 @@ export interface PaypalConfig {
|
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
// PaypalLeg moves money over PayPal, serving both sides of a corridor.
|
|
96
|
-
export class PaypalLeg implements
|
|
119
|
+
export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
97
120
|
readonly id: string;
|
|
98
121
|
private readonly currencies: string[];
|
|
99
122
|
private readonly api: PaypalApi;
|
|
100
123
|
private readonly ids: () => string;
|
|
101
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 }>();
|
|
102
128
|
|
|
103
129
|
constructor(cfg: PaypalConfig) {
|
|
104
130
|
this.id = cfg.id ?? "paypal";
|
|
@@ -112,7 +138,7 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
|
112
138
|
rails: ["paypal"],
|
|
113
139
|
currencies: this.currencies,
|
|
114
140
|
methods: ["paypal", "venmo", "card"],
|
|
115
|
-
refunds: RefundKind.
|
|
141
|
+
refunds: RefundKind.Partial,
|
|
116
142
|
};
|
|
117
143
|
}
|
|
118
144
|
|
|
@@ -131,9 +157,37 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
|
131
157
|
referenceId: referencePrefix + intentId,
|
|
132
158
|
});
|
|
133
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
|
+
});
|
|
134
164
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
135
165
|
}
|
|
136
166
|
|
|
167
|
+
// prepare creates an order the payer approves and captures on their own device
|
|
168
|
+
// through the PayPal or Venmo flow — the interactive pay-in path — so nothing is
|
|
169
|
+
// captured here. The order names the recipient as payee and our cut as a platform
|
|
170
|
+
// fee; the buyer's capture then fires PAYMENT.CAPTURE.COMPLETED, which advances
|
|
171
|
+
// the corridor through the same pay-in path as a server-side collection. The
|
|
172
|
+
// order id is the token the payer's PayPal buttons need.
|
|
173
|
+
async prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation> {
|
|
174
|
+
const params: CreateOrderParams = {
|
|
175
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
176
|
+
currencyCode: quote.srcAmount.currency,
|
|
177
|
+
payee: deliverTo,
|
|
178
|
+
referenceId: referencePrefix + intentId,
|
|
179
|
+
};
|
|
180
|
+
if (!quote.fees.isZero()) {
|
|
181
|
+
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
182
|
+
}
|
|
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
|
+
});
|
|
188
|
+
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
189
|
+
}
|
|
190
|
+
|
|
137
191
|
// disburse sends a payout of the recipient's amount to recipientRef and returns
|
|
138
192
|
// the batch id.
|
|
139
193
|
async disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult> {
|
|
@@ -146,18 +200,24 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
|
146
200
|
return { providerRef: payout.batchId };
|
|
147
201
|
}
|
|
148
202
|
|
|
149
|
-
// refundIn reverses a captured payment in full. PayPal captures the
|
|
150
|
-
// the capture id it returned from collect, so the refund binds to
|
|
151
|
-
// reference.
|
|
152
|
-
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
153
|
-
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) {
|
|
154
208
|
throw new Error(`paypal: cannot perform refund kind ${kind}`);
|
|
155
209
|
}
|
|
156
210
|
const captureId = this.captures.get(intentId);
|
|
157
211
|
if (!captureId) {
|
|
158
212
|
throw new Error(`paypal: no capture for intent ${intentId}`);
|
|
159
213
|
}
|
|
160
|
-
|
|
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);
|
|
161
221
|
return {
|
|
162
222
|
intentId,
|
|
163
223
|
state: State.Refunded,
|
|
@@ -190,8 +250,22 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
|
190
250
|
const providerRef = resource.id ?? "";
|
|
191
251
|
|
|
192
252
|
switch (event.event_type) {
|
|
193
|
-
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
|
+
}
|
|
194
267
|
return [oneEvent(intentId, State.Settled, providerRef, "")];
|
|
268
|
+
}
|
|
195
269
|
case "PAYMENT.CAPTURE.DENIED":
|
|
196
270
|
return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
|
|
197
271
|
case "PAYMENT.CAPTURE.REFUNDED":
|
|
@@ -204,7 +278,12 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
|
204
278
|
|
|
205
279
|
interface PaypalWebhook {
|
|
206
280
|
event_type?: string;
|
|
207
|
-
resource?: {
|
|
281
|
+
resource?: {
|
|
282
|
+
id?: string;
|
|
283
|
+
custom_id?: string;
|
|
284
|
+
invoice_id?: string;
|
|
285
|
+
amount?: { currency_code?: string; value?: string };
|
|
286
|
+
};
|
|
208
287
|
}
|
|
209
288
|
|
|
210
289
|
function oneEvent(intentId: string, state: State, providerRef: string, reason: string): AdapterEvent {
|
|
@@ -269,7 +348,7 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
269
348
|
return this.token;
|
|
270
349
|
}
|
|
271
350
|
const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
|
|
272
|
-
const resp = await
|
|
351
|
+
const resp = await fetchWithTimeout(`${this.baseURL}/v1/oauth2/token`, {
|
|
273
352
|
method: "POST",
|
|
274
353
|
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
275
354
|
body: "grant_type=client_credentials",
|
|
@@ -291,10 +370,11 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
291
370
|
if (params.payee) {
|
|
292
371
|
unit.payee = { email_address: params.payee };
|
|
293
372
|
}
|
|
294
|
-
const created = await this.postJSON<PaypalOrder>(
|
|
295
|
-
|
|
296
|
-
purchase_units: [unit],
|
|
297
|
-
|
|
373
|
+
const created = await this.postJSON<PaypalOrder>(
|
|
374
|
+
"/v2/checkout/orders",
|
|
375
|
+
{ intent: "CAPTURE", purchase_units: [unit] },
|
|
376
|
+
idempotencyKey(params.referenceId, "paypal-order"),
|
|
377
|
+
);
|
|
298
378
|
const existing = firstCapture(created);
|
|
299
379
|
if (existing) {
|
|
300
380
|
return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
|
|
@@ -302,6 +382,7 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
302
382
|
const captured = await this.postJSON<PaypalOrder>(
|
|
303
383
|
`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`,
|
|
304
384
|
{},
|
|
385
|
+
idempotencyKey(params.referenceId, "paypal-capture"),
|
|
305
386
|
);
|
|
306
387
|
const capture = firstCapture(captured);
|
|
307
388
|
if (!capture) {
|
|
@@ -310,6 +391,38 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
310
391
|
return { orderId: captured.id ?? "", captureId: capture.id ?? "", status: capture.status ?? "" };
|
|
311
392
|
}
|
|
312
393
|
|
|
394
|
+
// createOrder creates an order the buyer approves and captures on their own
|
|
395
|
+
// device. It carries custom_id so the capture webhook ties back to the intent,
|
|
396
|
+
// and a platform fee via payment_instruction when one is set.
|
|
397
|
+
async createOrder(params: CreateOrderParams): Promise<Order> {
|
|
398
|
+
const unit: Record<string, unknown> = {
|
|
399
|
+
reference_id: params.referenceId,
|
|
400
|
+
custom_id: params.referenceId,
|
|
401
|
+
amount: { currency_code: params.currencyCode, value: params.value },
|
|
402
|
+
};
|
|
403
|
+
if (params.payee) {
|
|
404
|
+
unit.payee = { email_address: params.payee };
|
|
405
|
+
}
|
|
406
|
+
if (params.platformFee) {
|
|
407
|
+
unit.payment_instruction = {
|
|
408
|
+
platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const created = await this.postJSON<PaypalOrder>(
|
|
412
|
+
"/v2/checkout/orders",
|
|
413
|
+
{ intent: "CAPTURE", purchase_units: [unit] },
|
|
414
|
+
idempotencyKey(params.referenceId, "paypal-order"),
|
|
415
|
+
);
|
|
416
|
+
let approve = "";
|
|
417
|
+
for (const link of created.links ?? []) {
|
|
418
|
+
if (link.rel === "approve" || link.rel === "payer-action") {
|
|
419
|
+
approve = link.href ?? "";
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { id: created.id ?? "", status: created.status ?? "", approveUrl: approve };
|
|
424
|
+
}
|
|
425
|
+
|
|
313
426
|
async sendPayout(params: PayoutParams): Promise<Payout> {
|
|
314
427
|
const out = await this.postJSON<{ batch_header?: { payout_batch_id?: string; batch_status?: string } }>(
|
|
315
428
|
"/v1/payments/payouts",
|
|
@@ -333,9 +446,14 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
333
446
|
if (params.reason) {
|
|
334
447
|
body.note_to_payer = params.reason;
|
|
335
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
|
+
}
|
|
336
453
|
const out = await this.postJSON<{ id?: string; status?: string }>(
|
|
337
454
|
`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`,
|
|
338
455
|
body,
|
|
456
|
+
idempotencyKey(params.captureId, "paypal-refund"),
|
|
339
457
|
);
|
|
340
458
|
return { id: out.id ?? "", status: out.status ?? "" };
|
|
341
459
|
}
|
|
@@ -345,24 +463,51 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
345
463
|
// binds a payload to this integration; PayPal reports whether the signature is
|
|
346
464
|
// authentic.
|
|
347
465
|
async verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean> {
|
|
348
|
-
|
|
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({
|
|
349
472
|
webhook_id: this.creds.webhookId,
|
|
350
473
|
transmission_id: header(headers, "PayPal-Transmission-Id"),
|
|
351
474
|
transmission_time: header(headers, "PayPal-Transmission-Time"),
|
|
352
475
|
transmission_sig: header(headers, "PayPal-Transmission-Sig"),
|
|
353
476
|
cert_url: header(headers, "PayPal-Cert-Url"),
|
|
354
477
|
auth_algo: header(headers, "PayPal-Auth-Algo"),
|
|
355
|
-
webhook_event:
|
|
478
|
+
webhook_event: sentinel,
|
|
356
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
|
+
);
|
|
357
485
|
return out.verification_status === "SUCCESS";
|
|
358
486
|
}
|
|
359
487
|
|
|
360
|
-
|
|
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> {
|
|
361
499
|
const token = await this.accessToken();
|
|
362
|
-
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, {
|
|
363
508
|
method: "POST",
|
|
364
|
-
headers
|
|
365
|
-
body:
|
|
509
|
+
headers,
|
|
510
|
+
body: payload,
|
|
366
511
|
});
|
|
367
512
|
if (resp.status >= 300) {
|
|
368
513
|
throw new Error(`paypal: ${path} returned ${resp.status}`);
|
|
@@ -377,6 +522,7 @@ interface PaypalOrder {
|
|
|
377
522
|
id?: string;
|
|
378
523
|
status?: string;
|
|
379
524
|
purchase_units?: Array<{ payments?: { captures?: Array<{ id?: string; status?: string }> } }>;
|
|
525
|
+
links?: Array<{ href?: string; rel?: string }>;
|
|
380
526
|
}
|
|
381
527
|
|
|
382
528
|
function firstCapture(order: PaypalOrder): { id?: string; status?: string } | undefined {
|
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
|
}
|