@myzonerocks/pact 0.1.1 → 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,9 +1,13 @@
1
1
  {
2
2
  "name": "@myzonerocks/pact",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "TypeScript SDK for the PACT payment abstraction protocol",
5
5
  "license": "Apache-2.0",
6
- "repository": { "type": "git", "url": "git+https://github.com/myzonerocks/pact.git", "directory": "sdk/ts" },
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/myzonerocks/pact.git",
9
+ "directory": "sdk/ts"
10
+ },
7
11
  "type": "module",
8
12
  "main": "./dist/src/index.js",
9
13
  "types": "./dist/src/index.d.ts",
@@ -33,7 +37,10 @@
33
37
  "import": "./dist/src/policy.js"
34
38
  }
35
39
  },
36
- "files": ["dist", "src"],
40
+ "files": [
41
+ "dist",
42
+ "src"
43
+ ],
37
44
  "scripts": {
38
45
  "build": "tsc -p tsconfig.json",
39
46
  "test": "vitest run",
@@ -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 {
@@ -75,6 +75,10 @@ export interface CreateIntentParams {
75
75
  destination: string;
76
76
  applicationFee?: number;
77
77
  capture?: Capture;
78
+ // methods pins the exact payment method types offered (e.g. card, cashapp, link);
79
+ // wallets like Apple Pay and Google Pay ride on "card". Empty falls back to the
80
+ // account's dynamic payment methods.
81
+ methods?: string[];
78
82
  metadata: Record<string, string>;
79
83
  }
80
84
 
@@ -110,6 +114,9 @@ export interface StripeConfig {
110
114
  // tolerance is the webhook timestamp tolerance in seconds; defaults to 300.
111
115
  tolerance?: number;
112
116
  ids: () => string;
117
+ // methods pins the payment method types offered on the card rail (e.g. card,
118
+ // cashapp, link). Empty offers the account's dynamic payment methods.
119
+ methods?: string[];
113
120
  }
114
121
 
115
122
  // StripeLeg collects card payments through Stripe. It exposes the wallets that
@@ -123,6 +130,7 @@ export class StripeLeg implements InteractivePayInLeg {
123
130
  private readonly clock: () => number;
124
131
  private readonly tolerance: number;
125
132
  private readonly ids: () => string;
133
+ private readonly methods: string[] | undefined;
126
134
 
127
135
  constructor(cfg: StripeConfig) {
128
136
  if (!cfg.webhookKey) {
@@ -135,6 +143,7 @@ export class StripeLeg implements InteractivePayInLeg {
135
143
  this.clock = cfg.clock ?? (() => 0);
136
144
  this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
137
145
  this.ids = cfg.ids;
146
+ this.methods = cfg.methods;
138
147
  }
139
148
 
140
149
  // payInCapabilities advertises the card rail and the wallets that fund through
@@ -184,6 +193,7 @@ export class StripeLeg implements InteractivePayInLeg {
184
193
  destination: deliverTo,
185
194
  applicationFee: minorToInteger(quote.fees),
186
195
  capture: Capture.OnConfirmation,
196
+ ...(this.methods ? { methods: this.methods } : {}),
187
197
  metadata: { [metadataIntentKey]: intentId },
188
198
  },
189
199
  idempotencyKey(intentId, "prepare"),
@@ -403,7 +413,13 @@ class HttpStripeApi implements StripeApi {
403
413
  if ((params.capture ?? Capture.Manual) === Capture.Manual) {
404
414
  form.set("capture_method", "manual");
405
415
  }
406
- form.set("automatic_payment_methods[enabled]", "true");
416
+ // Pinning the method types offers exactly those and nothing else; without a
417
+ // pin, dynamic payment methods surface whatever the dashboard has enabled.
418
+ if (params.methods && params.methods.length > 0) {
419
+ params.methods.forEach((m, i) => form.set(`payment_method_types[${i}]`, m));
420
+ } else {
421
+ form.set("automatic_payment_methods[enabled]", "true");
422
+ }
407
423
  if (params.destination) {
408
424
  form.set("transfer_data[destination]", params.destination);
409
425
  }