@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.
- package/dist/src/adapter.d.ts +18 -0
- package/dist/src/adapter.js +11 -0
- package/dist/src/adapters/erc20.d.ts +43 -0
- package/dist/src/adapters/erc20.js +126 -0
- package/dist/src/adapters/mpesa.d.ts +61 -0
- package/dist/src/adapters/mpesa.js +272 -0
- package/dist/src/adapters/paypal.d.ts +68 -0
- package/dist/src/adapters/paypal.js +279 -0
- package/dist/src/adapters/stripe.d.ts +61 -0
- package/dist/src/adapters/stripe.js +336 -0
- package/dist/src/bridge.d.ts +40 -0
- package/dist/src/bridge.js +99 -0
- package/dist/src/canonical.d.ts +14 -0
- package/dist/src/canonical.js +82 -0
- package/dist/src/client.d.ts +84 -0
- package/dist/src/client.js +510 -0
- package/dist/src/compliance.d.ts +23 -0
- package/dist/src/compliance.js +26 -0
- package/dist/src/crypto.d.ts +12 -0
- package/dist/src/crypto.js +50 -0
- package/dist/src/fake.d.ts +29 -0
- package/dist/src/fake.js +79 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +16 -0
- package/dist/src/ledger.d.ts +47 -0
- package/dist/src/ledger.js +84 -0
- package/dist/src/leg.d.ts +34 -0
- package/dist/src/leg.js +4 -0
- package/dist/src/message.d.ts +55 -0
- package/dist/src/message.js +88 -0
- package/dist/src/money.d.ts +16 -0
- package/dist/src/money.js +81 -0
- package/dist/src/payload.d.ts +11 -0
- package/dist/src/payload.js +52 -0
- package/dist/src/policy.d.ts +26 -0
- package/dist/src/policy.js +83 -0
- package/dist/src/protocol.d.ts +4 -0
- package/dist/src/protocol.js +15 -0
- package/dist/src/router.d.ts +16 -0
- package/dist/src/router.js +87 -0
- package/dist/src/signing.d.ts +27 -0
- package/dist/src/signing.js +72 -0
- package/dist/src/state.d.ts +23 -0
- package/dist/src/state.js +76 -0
- package/dist/src/wire.d.ts +30 -0
- package/dist/src/wire.js +221 -0
- package/dist/test/erc20.test.d.ts +1 -0
- package/dist/test/erc20.test.js +131 -0
- package/dist/test/lifecycle.test.d.ts +1 -0
- package/dist/test/lifecycle.test.js +212 -0
- package/dist/test/mpesa.test.d.ts +1 -0
- package/dist/test/mpesa.test.js +180 -0
- package/dist/test/payload.test.d.ts +1 -0
- package/dist/test/payload.test.js +32 -0
- package/dist/test/paypal.test.d.ts +1 -0
- package/dist/test/paypal.test.js +140 -0
- package/dist/test/policy.test.d.ts +1 -0
- package/dist/test/policy.test.js +131 -0
- package/dist/test/stripe.test.d.ts +1 -0
- package/dist/test/stripe.test.js +176 -0
- package/dist/test/vectors.test.d.ts +1 -0
- package/dist/test/vectors.test.js +91 -0
- package/dist/test/wire.test.d.ts +1 -0
- package/dist/test/wire.test.js +104 -0
- package/package.json +50 -0
- package/src/adapter.ts +32 -0
- package/src/adapters/erc20.ts +181 -0
- package/src/adapters/mpesa.ts +408 -0
- package/src/adapters/paypal.ts +409 -0
- package/src/adapters/stripe.ts +478 -0
- package/src/bridge.ts +148 -0
- package/src/canonical.ts +94 -0
- package/src/client.ts +605 -0
- package/src/compliance.ts +65 -0
- package/src/crypto.ts +68 -0
- package/src/fake.ts +96 -0
- package/src/index.ts +106 -0
- package/src/ledger.ts +145 -0
- package/src/leg.ts +65 -0
- package/src/message.ts +178 -0
- package/src/money.ts +87 -0
- package/src/payload.ts +58 -0
- package/src/policy.ts +110 -0
- package/src/protocol.ts +19 -0
- package/src/router.ts +97 -0
- package/src/signing.ts +92 -0
- package/src/state.ts +76 -0
- package/src/wire.ts +248 -0
|
@@ -0,0 +1,409 @@
|
|
|
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 type { Quote, Authorization, Settlement } from "../message.js";
|
|
9
|
+
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
10
|
+
import type {
|
|
11
|
+
PayInLeg,
|
|
12
|
+
PayOutLeg,
|
|
13
|
+
PayInCapabilities,
|
|
14
|
+
PayOutCapabilities,
|
|
15
|
+
CollectResult,
|
|
16
|
+
DisburseResult,
|
|
17
|
+
} from "../leg.js";
|
|
18
|
+
|
|
19
|
+
// The public base of the PayPal REST API. It is the same for every live
|
|
20
|
+
// integration and carries no secret; the sandbox host or a local test server is
|
|
21
|
+
// injected instead.
|
|
22
|
+
const defaultBaseURL = "https://api-m.paypal.com";
|
|
23
|
+
|
|
24
|
+
// The pact intent id travels on the order and payout reference so a webhook can
|
|
25
|
+
// be tied back to the intent it settles.
|
|
26
|
+
const referencePrefix = "pact:";
|
|
27
|
+
|
|
28
|
+
// ErrSignatureMismatch reports a webhook PayPal could not authenticate.
|
|
29
|
+
export const ErrSignatureMismatch = "paypal: webhook signature does not verify";
|
|
30
|
+
|
|
31
|
+
// CreateOrderParams describes an order to create and capture. value is the
|
|
32
|
+
// decimal-major amount PayPal expects, e.g. "15.00"; payee is the merchant the
|
|
33
|
+
// captured funds settle to.
|
|
34
|
+
export interface CreateOrderParams {
|
|
35
|
+
value: string;
|
|
36
|
+
currencyCode: string;
|
|
37
|
+
payee: string;
|
|
38
|
+
referenceId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Capture is the result of capturing an order: the capture id is the reference a
|
|
42
|
+
// refund and a settlement receipt bind to.
|
|
43
|
+
export interface Capture {
|
|
44
|
+
orderId: string;
|
|
45
|
+
captureId: string;
|
|
46
|
+
status: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// PayoutParams describes a single-item payout to a recipient.
|
|
50
|
+
export interface PayoutParams {
|
|
51
|
+
value: string;
|
|
52
|
+
currencyCode: string;
|
|
53
|
+
receiver: string;
|
|
54
|
+
referenceId: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Payout is the result of sending a payout: the batch id is its provider
|
|
58
|
+
// reference.
|
|
59
|
+
export interface Payout {
|
|
60
|
+
batchId: string;
|
|
61
|
+
status: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// RefundParams describes a refund against a capture.
|
|
65
|
+
export interface RefundParams {
|
|
66
|
+
captureId: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Refund is the result of refunding a capture.
|
|
71
|
+
export interface Refund {
|
|
72
|
+
id: string;
|
|
73
|
+
status: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// PaypalApi is the surface of PayPal this leg depends on. Depending on an
|
|
77
|
+
// interface keeps the leg unit-testable without a network and without
|
|
78
|
+
// credentials. verifyWebhook authenticates an inbound webhook: the live client
|
|
79
|
+
// forwards the signature headers and body to PayPal's verify-webhook-signature
|
|
80
|
+
// endpoint, so the verification secret never lives in this package.
|
|
81
|
+
export interface PaypalApi {
|
|
82
|
+
createAndCaptureOrder(params: CreateOrderParams): Promise<Capture>;
|
|
83
|
+
sendPayout(params: PayoutParams): Promise<Payout>;
|
|
84
|
+
refundCapture(params: RefundParams): Promise<Refund>;
|
|
85
|
+
verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface PaypalConfig {
|
|
89
|
+
id?: string;
|
|
90
|
+
currencies: string[];
|
|
91
|
+
api: PaypalApi;
|
|
92
|
+
ids: () => string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// PaypalLeg moves money over PayPal, serving both sides of a corridor.
|
|
96
|
+
export class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
private readonly currencies: string[];
|
|
99
|
+
private readonly api: PaypalApi;
|
|
100
|
+
private readonly ids: () => string;
|
|
101
|
+
private readonly captures = new Map<string, string>();
|
|
102
|
+
|
|
103
|
+
constructor(cfg: PaypalConfig) {
|
|
104
|
+
this.id = cfg.id ?? "paypal";
|
|
105
|
+
this.currencies = cfg.currencies;
|
|
106
|
+
this.api = cfg.api;
|
|
107
|
+
this.ids = cfg.ids;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
payInCapabilities(): PayInCapabilities {
|
|
111
|
+
return {
|
|
112
|
+
rails: ["paypal"],
|
|
113
|
+
currencies: this.currencies,
|
|
114
|
+
methods: ["paypal", "venmo", "card"],
|
|
115
|
+
refunds: RefundKind.Full,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
payOutCapabilities(): PayOutCapabilities {
|
|
120
|
+
return { rails: ["paypal"], currencies: this.currencies, reversible: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// collect creates and captures an order to deliverTo — the recipient's payee for
|
|
124
|
+
// a direct corridor, the escrow payee for a bridged one — and returns the
|
|
125
|
+
// capture id. received is the net a bridge would convert.
|
|
126
|
+
async collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult> {
|
|
127
|
+
const capture = await this.api.createAndCaptureOrder({
|
|
128
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
129
|
+
currencyCode: quote.srcAmount.currency,
|
|
130
|
+
payee: deliverTo,
|
|
131
|
+
referenceId: referencePrefix + intentId,
|
|
132
|
+
});
|
|
133
|
+
this.captures.set(intentId, capture.captureId);
|
|
134
|
+
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// disburse sends a payout of the recipient's amount to recipientRef and returns
|
|
138
|
+
// the batch id.
|
|
139
|
+
async disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult> {
|
|
140
|
+
const payout = await this.api.sendPayout({
|
|
141
|
+
value: majorAmount(quote.dstAmount.minor(), quote.dstAmount.exponent),
|
|
142
|
+
currencyCode: quote.dstAmount.currency,
|
|
143
|
+
receiver: recipientRef,
|
|
144
|
+
referenceId: referencePrefix + intentId,
|
|
145
|
+
});
|
|
146
|
+
return { providerRef: payout.batchId };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// refundIn reverses a captured payment in full. PayPal captures the order id in
|
|
150
|
+
// the capture id it returned from collect, so the refund binds to that
|
|
151
|
+
// reference.
|
|
152
|
+
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
153
|
+
if (kind !== RefundKind.Full) {
|
|
154
|
+
throw new Error(`paypal: cannot perform refund kind ${kind}`);
|
|
155
|
+
}
|
|
156
|
+
const captureId = this.captures.get(intentId);
|
|
157
|
+
if (!captureId) {
|
|
158
|
+
throw new Error(`paypal: no capture for intent ${intentId}`);
|
|
159
|
+
}
|
|
160
|
+
const refund = await this.api.refundCapture({ captureId, reason });
|
|
161
|
+
return {
|
|
162
|
+
intentId,
|
|
163
|
+
state: State.Refunded,
|
|
164
|
+
adapterId: this.id,
|
|
165
|
+
providerTxRef: refund.id,
|
|
166
|
+
onchainTxHash: "",
|
|
167
|
+
receiptHash: new Uint8Array(0),
|
|
168
|
+
reason,
|
|
169
|
+
settledAt: 0,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// reverseOut reports the truth of the rail: a delivered payout cannot be pulled
|
|
174
|
+
// back, so a pay-out reversal is refused rather than faked.
|
|
175
|
+
async reverseOut(_intentId: string, _reason: string): Promise<Settlement> {
|
|
176
|
+
throw new Error("paypal: a delivered payout cannot be reversed");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// parseWebhook authenticates an inbound webhook through PayPal's own
|
|
180
|
+
// verification call, then maps the event onto the protocol. A payload that does
|
|
181
|
+
// not verify is rejected; an event the leg does not act on yields no protocol
|
|
182
|
+
// event.
|
|
183
|
+
async parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): Promise<AdapterEvent[]> {
|
|
184
|
+
if (!(await this.api.verifyWebhook(headers, raw))) {
|
|
185
|
+
throw new Error(ErrSignatureMismatch);
|
|
186
|
+
}
|
|
187
|
+
const event = JSON.parse(new TextDecoder().decode(raw)) as PaypalWebhook;
|
|
188
|
+
const resource = event.resource ?? {};
|
|
189
|
+
const intentId = intentFromReference(resource.custom_id ?? "", resource.invoice_id ?? "");
|
|
190
|
+
const providerRef = resource.id ?? "";
|
|
191
|
+
|
|
192
|
+
switch (event.event_type) {
|
|
193
|
+
case "PAYMENT.CAPTURE.COMPLETED":
|
|
194
|
+
return [oneEvent(intentId, State.Settled, providerRef, "")];
|
|
195
|
+
case "PAYMENT.CAPTURE.DENIED":
|
|
196
|
+
return [oneEvent(intentId, State.Failed, providerRef, "capture denied")];
|
|
197
|
+
case "PAYMENT.CAPTURE.REFUNDED":
|
|
198
|
+
return [oneEvent(intentId, State.Refunded, providerRef, "refunded")];
|
|
199
|
+
default:
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface PaypalWebhook {
|
|
206
|
+
event_type?: string;
|
|
207
|
+
resource?: { id?: string; custom_id?: string; invoice_id?: string };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function oneEvent(intentId: string, state: State, providerRef: string, reason: string): AdapterEvent {
|
|
211
|
+
return { intentId, state, providerTxRef: providerRef, onchainTxHash: "", reason, settledAt: 0 };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// intentFromReference strips the reference prefix off whichever field carried it.
|
|
215
|
+
function intentFromReference(...refs: string[]): string {
|
|
216
|
+
for (const ref of refs) {
|
|
217
|
+
if (ref.length > referencePrefix.length && ref.startsWith(referencePrefix)) {
|
|
218
|
+
return ref.slice(referencePrefix.length);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// majorAmount renders whole-and-fraction minor units as the decimal-major string
|
|
225
|
+
// PayPal expects, e.g. 1500 minor at exponent 2 becomes "15.00".
|
|
226
|
+
export function majorAmount(minor: string, exponent: number): string {
|
|
227
|
+
if (exponent === 0) {
|
|
228
|
+
return minor;
|
|
229
|
+
}
|
|
230
|
+
const neg = minor.startsWith("-");
|
|
231
|
+
let digits = neg ? minor.slice(1) : minor;
|
|
232
|
+
while (digits.length <= exponent) {
|
|
233
|
+
digits = "0" + digits;
|
|
234
|
+
}
|
|
235
|
+
const split = digits.length - exponent;
|
|
236
|
+
const value = digits.slice(0, split) + "." + digits.slice(split);
|
|
237
|
+
return neg ? "-" + value : value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Credentials are the PayPal secrets a host injects from its own store. This
|
|
241
|
+
// package holds no defaults for any of them.
|
|
242
|
+
export interface Credentials {
|
|
243
|
+
clientId: string;
|
|
244
|
+
clientSecret: string;
|
|
245
|
+
webhookId: string;
|
|
246
|
+
baseURL?: string; // optional, defaults to the public PayPal host
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// httpPaypalApi is the live PayPal REST client. It fetches a client-credentials
|
|
250
|
+
// access token, reuses it until it nears expiry, and presents it as a Bearer on
|
|
251
|
+
// each call.
|
|
252
|
+
class HttpPaypalApi implements PaypalApi {
|
|
253
|
+
private readonly creds: Credentials;
|
|
254
|
+
private readonly baseURL: string;
|
|
255
|
+
private readonly now: () => number;
|
|
256
|
+
private token = "";
|
|
257
|
+
private tokenExpiryMs = 0;
|
|
258
|
+
|
|
259
|
+
constructor(creds: Credentials, now: () => number) {
|
|
260
|
+
this.creds = creds;
|
|
261
|
+
this.baseURL = creds.baseURL || defaultBaseURL;
|
|
262
|
+
this.now = now;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// accessToken returns a cached client-credentials token, refreshing it when it
|
|
266
|
+
// is missing or within a minute of expiry.
|
|
267
|
+
private async accessToken(): Promise<string> {
|
|
268
|
+
if (this.token && this.now() < this.tokenExpiryMs - 60_000) {
|
|
269
|
+
return this.token;
|
|
270
|
+
}
|
|
271
|
+
const basic = Buffer.from(`${this.creds.clientId}:${this.creds.clientSecret}`).toString("base64");
|
|
272
|
+
const resp = await fetch(`${this.baseURL}/v1/oauth2/token`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
275
|
+
body: "grant_type=client_credentials",
|
|
276
|
+
});
|
|
277
|
+
if (resp.status >= 300) {
|
|
278
|
+
throw new Error(`paypal: /v1/oauth2/token returned ${resp.status}`);
|
|
279
|
+
}
|
|
280
|
+
const out = (await resp.json()) as { access_token?: string; expires_in?: number };
|
|
281
|
+
this.token = out.access_token ?? "";
|
|
282
|
+
this.tokenExpiryMs = this.now() + (out.expires_in ?? 0) * 1000;
|
|
283
|
+
return this.token;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async createAndCaptureOrder(params: CreateOrderParams): Promise<Capture> {
|
|
287
|
+
const unit: Record<string, unknown> = {
|
|
288
|
+
reference_id: params.referenceId,
|
|
289
|
+
amount: { currency_code: params.currencyCode, value: params.value },
|
|
290
|
+
};
|
|
291
|
+
if (params.payee) {
|
|
292
|
+
unit.payee = { email_address: params.payee };
|
|
293
|
+
}
|
|
294
|
+
const created = await this.postJSON<PaypalOrder>("/v2/checkout/orders", {
|
|
295
|
+
intent: "CAPTURE",
|
|
296
|
+
purchase_units: [unit],
|
|
297
|
+
});
|
|
298
|
+
const existing = firstCapture(created);
|
|
299
|
+
if (existing) {
|
|
300
|
+
return { orderId: created.id ?? "", captureId: existing.id ?? "", status: existing.status ?? "" };
|
|
301
|
+
}
|
|
302
|
+
const captured = await this.postJSON<PaypalOrder>(
|
|
303
|
+
`/v2/checkout/orders/${encodeURIComponent(created.id ?? "")}/capture`,
|
|
304
|
+
{},
|
|
305
|
+
);
|
|
306
|
+
const capture = firstCapture(captured);
|
|
307
|
+
if (!capture) {
|
|
308
|
+
throw new Error(`paypal: order ${created.id} captured without a capture id`);
|
|
309
|
+
}
|
|
310
|
+
return { orderId: captured.id ?? "", captureId: capture.id ?? "", status: capture.status ?? "" };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async sendPayout(params: PayoutParams): Promise<Payout> {
|
|
314
|
+
const out = await this.postJSON<{ batch_header?: { payout_batch_id?: string; batch_status?: string } }>(
|
|
315
|
+
"/v1/payments/payouts",
|
|
316
|
+
{
|
|
317
|
+
sender_batch_header: { sender_batch_id: params.referenceId },
|
|
318
|
+
items: [
|
|
319
|
+
{
|
|
320
|
+
recipient_type: "EMAIL",
|
|
321
|
+
amount: { currency: params.currencyCode, value: params.value },
|
|
322
|
+
receiver: params.receiver,
|
|
323
|
+
sender_item_id: params.referenceId,
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
return { batchId: out.batch_header?.payout_batch_id ?? "", status: out.batch_header?.batch_status ?? "" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async refundCapture(params: RefundParams): Promise<Refund> {
|
|
332
|
+
const body: Record<string, unknown> = {};
|
|
333
|
+
if (params.reason) {
|
|
334
|
+
body.note_to_payer = params.reason;
|
|
335
|
+
}
|
|
336
|
+
const out = await this.postJSON<{ id?: string; status?: string }>(
|
|
337
|
+
`/v2/payments/captures/${encodeURIComponent(params.captureId)}/refund`,
|
|
338
|
+
body,
|
|
339
|
+
);
|
|
340
|
+
return { id: out.id ?? "", status: out.status ?? "" };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// verifyWebhook forwards the signature headers and the raw body to PayPal's
|
|
344
|
+
// verify-webhook-signature endpoint. The webhook id is the shared reference that
|
|
345
|
+
// binds a payload to this integration; PayPal reports whether the signature is
|
|
346
|
+
// authentic.
|
|
347
|
+
async verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean> {
|
|
348
|
+
const out = await this.postJSON<{ verification_status?: string }>("/v1/notifications/verify-webhook-signature", {
|
|
349
|
+
webhook_id: this.creds.webhookId,
|
|
350
|
+
transmission_id: header(headers, "PayPal-Transmission-Id"),
|
|
351
|
+
transmission_time: header(headers, "PayPal-Transmission-Time"),
|
|
352
|
+
transmission_sig: header(headers, "PayPal-Transmission-Sig"),
|
|
353
|
+
cert_url: header(headers, "PayPal-Cert-Url"),
|
|
354
|
+
auth_algo: header(headers, "PayPal-Auth-Algo"),
|
|
355
|
+
webhook_event: JSON.parse(new TextDecoder().decode(body)),
|
|
356
|
+
});
|
|
357
|
+
return out.verification_status === "SUCCESS";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async postJSON<T>(path: string, body: unknown): Promise<T> {
|
|
361
|
+
const token = await this.accessToken();
|
|
362
|
+
const resp = await fetch(this.baseURL + path, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
365
|
+
body: JSON.stringify(body),
|
|
366
|
+
});
|
|
367
|
+
if (resp.status >= 300) {
|
|
368
|
+
throw new Error(`paypal: ${path} returned ${resp.status}`);
|
|
369
|
+
}
|
|
370
|
+
const text = await resp.text();
|
|
371
|
+
return (text ? JSON.parse(text) : {}) as T;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// paypalOrder is the subset of an Orders v2 response the leg reads.
|
|
376
|
+
interface PaypalOrder {
|
|
377
|
+
id?: string;
|
|
378
|
+
status?: string;
|
|
379
|
+
purchase_units?: Array<{ payments?: { captures?: Array<{ id?: string; status?: string }> } }>;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function firstCapture(order: PaypalOrder): { id?: string; status?: string } | undefined {
|
|
383
|
+
for (const unit of order.purchase_units ?? []) {
|
|
384
|
+
const captures = unit.payments?.captures ?? [];
|
|
385
|
+
if (captures.length > 0) {
|
|
386
|
+
return captures[0];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// header reads a single request header case-insensitively.
|
|
393
|
+
function header(headers: Record<string, string[]>, name: string): string {
|
|
394
|
+
for (const key of Object.keys(headers)) {
|
|
395
|
+
if (key.toLowerCase() === name.toLowerCase()) {
|
|
396
|
+
const values = headers[key];
|
|
397
|
+
if (values && values.length > 0) {
|
|
398
|
+
return values[0]!;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return "";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// newHttpPaypalApi builds a live PayPal client. now is injectable so token expiry
|
|
406
|
+
// is deterministic in tests.
|
|
407
|
+
export function newHttpPaypalApi(creds: Credentials, now: () => number = () => Date.now()): PaypalApi {
|
|
408
|
+
return new HttpPaypalApi(creds, now);
|
|
409
|
+
}
|