@myzonerocks/pact 0.1.2 → 0.1.3
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.
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
2
2
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { PayOutLeg, InteractivePayInLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult, PayInPreparation } from "../leg.js";
|
|
4
4
|
export declare const ErrSignatureMismatch = "paypal: webhook signature does not verify";
|
|
5
5
|
export interface CreateOrderParams {
|
|
6
6
|
value: string;
|
|
7
7
|
currencyCode: string;
|
|
8
8
|
payee: string;
|
|
9
9
|
referenceId: string;
|
|
10
|
+
platformFee?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Order {
|
|
13
|
+
id: string;
|
|
14
|
+
status: string;
|
|
15
|
+
approveUrl: string;
|
|
10
16
|
}
|
|
11
17
|
export interface Capture {
|
|
12
18
|
orderId: string;
|
|
@@ -33,6 +39,7 @@ export interface Refund {
|
|
|
33
39
|
}
|
|
34
40
|
export interface PaypalApi {
|
|
35
41
|
createAndCaptureOrder(params: CreateOrderParams): Promise<Capture>;
|
|
42
|
+
createOrder(params: CreateOrderParams): Promise<Order>;
|
|
36
43
|
sendPayout(params: PayoutParams): Promise<Payout>;
|
|
37
44
|
refundCapture(params: RefundParams): Promise<Refund>;
|
|
38
45
|
verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean>;
|
|
@@ -43,7 +50,7 @@ export interface PaypalConfig {
|
|
|
43
50
|
api: PaypalApi;
|
|
44
51
|
ids: () => string;
|
|
45
52
|
}
|
|
46
|
-
export declare class PaypalLeg implements
|
|
53
|
+
export declare class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
47
54
|
readonly id: string;
|
|
48
55
|
private readonly currencies;
|
|
49
56
|
private readonly api;
|
|
@@ -53,6 +60,7 @@ export declare class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
|
53
60
|
payInCapabilities(): PayInCapabilities;
|
|
54
61
|
payOutCapabilities(): PayOutCapabilities;
|
|
55
62
|
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
63
|
+
prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
|
|
56
64
|
disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult>;
|
|
57
65
|
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
58
66
|
reverseOut(_intentId: string, _reason: string): Promise<Settlement>;
|
|
@@ -52,6 +52,25 @@ export class PaypalLeg {
|
|
|
52
52
|
this.captures.set(intentId, capture.captureId);
|
|
53
53
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
54
54
|
}
|
|
55
|
+
// prepare creates an order the payer approves and captures on their own device
|
|
56
|
+
// through the PayPal or Venmo flow — the interactive pay-in path — so nothing is
|
|
57
|
+
// captured here. The order names the recipient as payee and our cut as a platform
|
|
58
|
+
// fee; the buyer's capture then fires PAYMENT.CAPTURE.COMPLETED, which advances
|
|
59
|
+
// the corridor through the same pay-in path as a server-side collection. The
|
|
60
|
+
// order id is the token the payer's PayPal buttons need.
|
|
61
|
+
async prepare(intentId, quote, _auth, deliverTo) {
|
|
62
|
+
const params = {
|
|
63
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
64
|
+
currencyCode: quote.srcAmount.currency,
|
|
65
|
+
payee: deliverTo,
|
|
66
|
+
referenceId: referencePrefix + intentId,
|
|
67
|
+
};
|
|
68
|
+
if (!quote.fees.isZero()) {
|
|
69
|
+
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
70
|
+
}
|
|
71
|
+
const order = await this.api.createOrder(params);
|
|
72
|
+
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
73
|
+
}
|
|
55
74
|
// disburse sends a payout of the recipient's amount to recipientRef and returns
|
|
56
75
|
// the batch id.
|
|
57
76
|
async disburse(intentId, quote, recipientRef) {
|
|
@@ -199,6 +218,36 @@ class HttpPaypalApi {
|
|
|
199
218
|
}
|
|
200
219
|
return { orderId: captured.id ?? "", captureId: capture.id ?? "", status: capture.status ?? "" };
|
|
201
220
|
}
|
|
221
|
+
// createOrder creates an order the buyer approves and captures on their own
|
|
222
|
+
// device. It carries custom_id so the capture webhook ties back to the intent,
|
|
223
|
+
// and a platform fee via payment_instruction when one is set.
|
|
224
|
+
async createOrder(params) {
|
|
225
|
+
const unit = {
|
|
226
|
+
reference_id: params.referenceId,
|
|
227
|
+
custom_id: params.referenceId,
|
|
228
|
+
amount: { currency_code: params.currencyCode, value: params.value },
|
|
229
|
+
};
|
|
230
|
+
if (params.payee) {
|
|
231
|
+
unit.payee = { email_address: params.payee };
|
|
232
|
+
}
|
|
233
|
+
if (params.platformFee) {
|
|
234
|
+
unit.payment_instruction = {
|
|
235
|
+
platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const created = await this.postJSON("/v2/checkout/orders", {
|
|
239
|
+
intent: "CAPTURE",
|
|
240
|
+
purchase_units: [unit],
|
|
241
|
+
});
|
|
242
|
+
let approve = "";
|
|
243
|
+
for (const link of created.links ?? []) {
|
|
244
|
+
if (link.rel === "approve" || link.rel === "payer-action") {
|
|
245
|
+
approve = link.href ?? "";
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { id: created.id ?? "", status: created.status ?? "", approveUrl: approve };
|
|
250
|
+
}
|
|
202
251
|
async sendPayout(params) {
|
|
203
252
|
const out = await this.postJSON("/v1/payments/payouts", {
|
|
204
253
|
sender_batch_header: { sender_batch_id: params.referenceId },
|
|
@@ -26,6 +26,7 @@ export interface CreateIntentParams {
|
|
|
26
26
|
destination: string;
|
|
27
27
|
applicationFee?: number;
|
|
28
28
|
capture?: Capture;
|
|
29
|
+
methods?: string[];
|
|
29
30
|
metadata: Record<string, string>;
|
|
30
31
|
}
|
|
31
32
|
export interface CreateRefundParams {
|
|
@@ -47,6 +48,7 @@ export interface StripeConfig {
|
|
|
47
48
|
clock?: () => number;
|
|
48
49
|
tolerance?: number;
|
|
49
50
|
ids: () => string;
|
|
51
|
+
methods?: string[];
|
|
50
52
|
}
|
|
51
53
|
export declare class StripeLeg implements InteractivePayInLeg {
|
|
52
54
|
readonly id: string;
|
|
@@ -56,6 +58,7 @@ export declare class StripeLeg implements InteractivePayInLeg {
|
|
|
56
58
|
private readonly clock;
|
|
57
59
|
private readonly tolerance;
|
|
58
60
|
private readonly ids;
|
|
61
|
+
private readonly methods;
|
|
59
62
|
constructor(cfg: StripeConfig);
|
|
60
63
|
payInCapabilities(): PayInCapabilities;
|
|
61
64
|
collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
@@ -43,6 +43,7 @@ export class StripeLeg {
|
|
|
43
43
|
clock;
|
|
44
44
|
tolerance;
|
|
45
45
|
ids;
|
|
46
|
+
methods;
|
|
46
47
|
constructor(cfg) {
|
|
47
48
|
if (!cfg.webhookKey) {
|
|
48
49
|
throw new Error("stripe: config requires a webhook signing key");
|
|
@@ -54,6 +55,7 @@ export class StripeLeg {
|
|
|
54
55
|
this.clock = cfg.clock ?? (() => 0);
|
|
55
56
|
this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
|
|
56
57
|
this.ids = cfg.ids;
|
|
58
|
+
this.methods = cfg.methods;
|
|
57
59
|
}
|
|
58
60
|
// payInCapabilities advertises the card rail and the wallets that fund through
|
|
59
61
|
// it. The methods are informational; the corridor routes on the rail.
|
|
@@ -96,6 +98,7 @@ export class StripeLeg {
|
|
|
96
98
|
destination: deliverTo,
|
|
97
99
|
applicationFee: minorToInteger(quote.fees),
|
|
98
100
|
capture: Capture.OnConfirmation,
|
|
101
|
+
...(this.methods ? { methods: this.methods } : {}),
|
|
99
102
|
metadata: { [metadataIntentKey]: intentId },
|
|
100
103
|
}, idempotencyKey(intentId, "prepare"));
|
|
101
104
|
return { providerRef: pi.id, clientSecret: pi.clientSecret, method: "card" };
|
|
@@ -277,7 +280,14 @@ class HttpStripeApi {
|
|
|
277
280
|
if ((params.capture ?? Capture.Manual) === Capture.Manual) {
|
|
278
281
|
form.set("capture_method", "manual");
|
|
279
282
|
}
|
|
280
|
-
|
|
283
|
+
// Pinning the method types offers exactly those and nothing else; without a
|
|
284
|
+
// pin, dynamic payment methods surface whatever the dashboard has enabled.
|
|
285
|
+
if (params.methods && params.methods.length > 0) {
|
|
286
|
+
params.methods.forEach((m, i) => form.set(`payment_method_types[${i}]`, m));
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
form.set("automatic_payment_methods[enabled]", "true");
|
|
290
|
+
}
|
|
281
291
|
if (params.destination) {
|
|
282
292
|
form.set("transfer_data[destination]", params.destination);
|
|
283
293
|
}
|
package/dist/test/paypal.test.js
CHANGED
|
@@ -33,6 +33,10 @@ class FakePaypal {
|
|
|
33
33
|
const id = this.ids();
|
|
34
34
|
return { orderId: `order_${id}`, captureId: `capture_${id}`, status: "COMPLETED" };
|
|
35
35
|
}
|
|
36
|
+
async createOrder(params) {
|
|
37
|
+
this.lastOrder = params;
|
|
38
|
+
return { id: `order_${this.ids()}`, status: "CREATED", approveUrl: "https://paypal.test/approve" };
|
|
39
|
+
}
|
|
36
40
|
async sendPayout(params) {
|
|
37
41
|
this.lastPayout = params;
|
|
38
42
|
return { batchId: `batch_${this.ids()}`, status: "PENDING" };
|
|
@@ -127,6 +131,23 @@ describe("paypal direct corridor", () => {
|
|
|
127
131
|
await expect(leg.refundIn(intent.id, RefundKind.Partial, "half")).rejects.toThrow();
|
|
128
132
|
});
|
|
129
133
|
});
|
|
134
|
+
describe("paypal interactive pay-in", () => {
|
|
135
|
+
it("prepares an order to the recipient payee with our cut as a platform fee", async () => {
|
|
136
|
+
const api = new FakePaypal(counter("tx"));
|
|
137
|
+
const leg = buildLeg(api);
|
|
138
|
+
const prep = await leg.prepare("intent-1", quote({ srcAmount: Money.parse("USD", 2, "1500"), fees: Money.parse("USD", 2, "45") }), { intentId: "intent-1", quoteId: "q", signerIdentity: "alice", signature: new Uint8Array(0), signedAt: 0 }, "merchant@example.com");
|
|
139
|
+
expect(prep.providerRef).not.toBe("");
|
|
140
|
+
expect(prep.clientSecret).toBe(prep.providerRef);
|
|
141
|
+
expect(prep.method).toBe("paypal");
|
|
142
|
+
expect(api.lastOrder.payee).toBe("merchant@example.com");
|
|
143
|
+
expect(api.lastOrder.value).toBe("15.00");
|
|
144
|
+
expect(api.lastOrder.platformFee).toBe("0.45");
|
|
145
|
+
expect(api.lastOrder.referenceId).toBe("pact:intent-1");
|
|
146
|
+
// A zero fee omits the platform fee rather than sending "0.00".
|
|
147
|
+
await leg.prepare("intent-2", quote({ srcAmount: Money.parse("USD", 2, "1500"), fees: Money.parse("USD", 2, "0") }), { intentId: "intent-2", quoteId: "q", signerIdentity: "alice", signature: new Uint8Array(0), signedAt: 0 }, "merchant@example.com");
|
|
148
|
+
expect(api.lastOrder.platformFee).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
130
151
|
describe("paypal pay-out", () => {
|
|
131
152
|
it("sends the recipient's amount and refuses a reversal", async () => {
|
|
132
153
|
const api = new FakePaypal(counter("tx"));
|
package/package.json
CHANGED
package/src/adapters/paypal.ts
CHANGED
|
@@ -8,12 +8,13 @@ import { State } from "../state.js";
|
|
|
8
8
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
9
9
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
10
10
|
import type {
|
|
11
|
-
PayInLeg,
|
|
12
11
|
PayOutLeg,
|
|
12
|
+
InteractivePayInLeg,
|
|
13
13
|
PayInCapabilities,
|
|
14
14
|
PayOutCapabilities,
|
|
15
15
|
CollectResult,
|
|
16
16
|
DisburseResult,
|
|
17
|
+
PayInPreparation,
|
|
17
18
|
} from "../leg.js";
|
|
18
19
|
|
|
19
20
|
// The public base of the PayPal REST API. It is the same for every live
|
|
@@ -36,6 +37,18 @@ export interface CreateOrderParams {
|
|
|
36
37
|
currencyCode: string;
|
|
37
38
|
payee: string;
|
|
38
39
|
referenceId: string;
|
|
40
|
+
// platformFee, when set, is the decimal-major cut the platform takes from the
|
|
41
|
+
// payee's proceeds.
|
|
42
|
+
platformFee?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Order is a created-but-not-yet-captured order: the buyer approves it on their own
|
|
46
|
+
// device, then it is captured. approveUrl is PayPal's hosted approval link, offered
|
|
47
|
+
// for hosts that redirect rather than drive the JS SDK.
|
|
48
|
+
export interface Order {
|
|
49
|
+
id: string;
|
|
50
|
+
status: string;
|
|
51
|
+
approveUrl: string;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
// Capture is the result of capturing an order: the capture id is the reference a
|
|
@@ -80,6 +93,9 @@ export interface Refund {
|
|
|
80
93
|
// endpoint, so the verification secret never lives in this package.
|
|
81
94
|
export interface PaypalApi {
|
|
82
95
|
createAndCaptureOrder(params: CreateOrderParams): Promise<Capture>;
|
|
96
|
+
// createOrder creates an order the buyer approves and captures on their own
|
|
97
|
+
// device — the interactive pay-in path — rather than capturing it server-side.
|
|
98
|
+
createOrder(params: CreateOrderParams): Promise<Order>;
|
|
83
99
|
sendPayout(params: PayoutParams): Promise<Payout>;
|
|
84
100
|
refundCapture(params: RefundParams): Promise<Refund>;
|
|
85
101
|
verifyWebhook(headers: Record<string, string[]>, body: Uint8Array): Promise<boolean>;
|
|
@@ -93,7 +109,7 @@ export interface PaypalConfig {
|
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
// PaypalLeg moves money over PayPal, serving both sides of a corridor.
|
|
96
|
-
export class PaypalLeg implements
|
|
112
|
+
export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
97
113
|
readonly id: string;
|
|
98
114
|
private readonly currencies: string[];
|
|
99
115
|
private readonly api: PaypalApi;
|
|
@@ -134,6 +150,26 @@ export class PaypalLeg implements PayInLeg, PayOutLeg {
|
|
|
134
150
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
// prepare creates an order the payer approves and captures on their own device
|
|
154
|
+
// through the PayPal or Venmo flow — the interactive pay-in path — so nothing is
|
|
155
|
+
// captured here. The order names the recipient as payee and our cut as a platform
|
|
156
|
+
// fee; the buyer's capture then fires PAYMENT.CAPTURE.COMPLETED, which advances
|
|
157
|
+
// the corridor through the same pay-in path as a server-side collection. The
|
|
158
|
+
// order id is the token the payer's PayPal buttons need.
|
|
159
|
+
async prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation> {
|
|
160
|
+
const params: CreateOrderParams = {
|
|
161
|
+
value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent),
|
|
162
|
+
currencyCode: quote.srcAmount.currency,
|
|
163
|
+
payee: deliverTo,
|
|
164
|
+
referenceId: referencePrefix + intentId,
|
|
165
|
+
};
|
|
166
|
+
if (!quote.fees.isZero()) {
|
|
167
|
+
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
168
|
+
}
|
|
169
|
+
const order = await this.api.createOrder(params);
|
|
170
|
+
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
171
|
+
}
|
|
172
|
+
|
|
137
173
|
// disburse sends a payout of the recipient's amount to recipientRef and returns
|
|
138
174
|
// the batch id.
|
|
139
175
|
async disburse(intentId: string, quote: Quote, recipientRef: string): Promise<DisburseResult> {
|
|
@@ -310,6 +346,37 @@ class HttpPaypalApi implements PaypalApi {
|
|
|
310
346
|
return { orderId: captured.id ?? "", captureId: capture.id ?? "", status: capture.status ?? "" };
|
|
311
347
|
}
|
|
312
348
|
|
|
349
|
+
// createOrder creates an order the buyer approves and captures on their own
|
|
350
|
+
// device. It carries custom_id so the capture webhook ties back to the intent,
|
|
351
|
+
// and a platform fee via payment_instruction when one is set.
|
|
352
|
+
async createOrder(params: CreateOrderParams): Promise<Order> {
|
|
353
|
+
const unit: Record<string, unknown> = {
|
|
354
|
+
reference_id: params.referenceId,
|
|
355
|
+
custom_id: params.referenceId,
|
|
356
|
+
amount: { currency_code: params.currencyCode, value: params.value },
|
|
357
|
+
};
|
|
358
|
+
if (params.payee) {
|
|
359
|
+
unit.payee = { email_address: params.payee };
|
|
360
|
+
}
|
|
361
|
+
if (params.platformFee) {
|
|
362
|
+
unit.payment_instruction = {
|
|
363
|
+
platform_fees: [{ amount: { currency_code: params.currencyCode, value: params.platformFee } }],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const created = await this.postJSON<PaypalOrder>("/v2/checkout/orders", {
|
|
367
|
+
intent: "CAPTURE",
|
|
368
|
+
purchase_units: [unit],
|
|
369
|
+
});
|
|
370
|
+
let approve = "";
|
|
371
|
+
for (const link of created.links ?? []) {
|
|
372
|
+
if (link.rel === "approve" || link.rel === "payer-action") {
|
|
373
|
+
approve = link.href ?? "";
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { id: created.id ?? "", status: created.status ?? "", approveUrl: approve };
|
|
378
|
+
}
|
|
379
|
+
|
|
313
380
|
async sendPayout(params: PayoutParams): Promise<Payout> {
|
|
314
381
|
const out = await this.postJSON<{ batch_header?: { payout_batch_id?: string; batch_status?: string } }>(
|
|
315
382
|
"/v1/payments/payouts",
|
|
@@ -377,6 +444,7 @@ interface PaypalOrder {
|
|
|
377
444
|
id?: string;
|
|
378
445
|
status?: string;
|
|
379
446
|
purchase_units?: Array<{ payments?: { captures?: Array<{ id?: string; status?: string }> } }>;
|
|
447
|
+
links?: Array<{ href?: string; rel?: string }>;
|
|
380
448
|
}
|
|
381
449
|
|
|
382
450
|
function firstCapture(order: PaypalOrder): { id?: string; status?: string } | undefined {
|