@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 { PayInLeg, PayOutLeg, PayInCapabilities, PayOutCapabilities, CollectResult, DisburseResult } from "../leg.js";
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 PayInLeg, PayOutLeg {
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
- form.set("automatic_payment_methods[enabled]", "true");
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myzonerocks/pact",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "TypeScript SDK for the PACT payment abstraction protocol",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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 PayInLeg, PayOutLeg {
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 {