@myzonerocks/pact 0.1.0 → 0.1.1

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/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @myzonerocks/pact
2
+
3
+ The TypeScript SDK for **PACT**, a payment abstraction protocol. PACT expresses a
4
+ payment as a small sequence of typed, signed messages that any settlement provider
5
+ can fulfil, carried over any transport. Every payment is a **corridor** — collect
6
+ from the payer, convert the money, deliver to the recipient — so any pay-in
7
+ provider composes with any pay-out provider across any currency, whether the money
8
+ moves over a card network, a mobile-money wallet, or a blockchain.
9
+
10
+ This package is the browser- and Node-ready kernel plus the provider adapters. It
11
+ owns the protocol messages, the state machine, the event-sourced ledger, the
12
+ corridor router, and the leg, bridge, and compliance seams. It does **not** own
13
+ transport, key custody, liquidity, the FX feed, or KYC policy — those are seams you
14
+ inject, so the same code serves a wallet app and a custodial backend alike.
15
+
16
+ ## Install
17
+
18
+ ```
19
+ bun add @myzonerocks/pact
20
+ # or: npm install @myzonerocks/pact
21
+ ```
22
+
23
+ The kernel is exported from the package root; each adapter is a subpath so a bundle
24
+ pulls in only the providers it uses.
25
+
26
+ ```ts
27
+ import { Client, Money, encodePayload, decodePayload } from "@myzonerocks/pact";
28
+ import { StripeLeg } from "@myzonerocks/pact/adapters/stripe";
29
+ import { Erc20Leg } from "@myzonerocks/pact/adapters/erc20";
30
+ ```
31
+
32
+ ## Drive a corridor
33
+
34
+ `intent.amount` is what the **recipient receives**; the corridor grosses it up into
35
+ what the payer pays, with exact-integer math that always rounds in the recipient's
36
+ favour. Settlement is route-and-forget: `initiate` kicks off the pay-in and returns,
37
+ and a normalized provider event feeds `advance`, which is idempotent so a payment
38
+ settles once.
39
+
40
+ ```ts
41
+ const client = new Client({ payIn: [stripe], payOut: [payout], verifier });
42
+
43
+ const intent = client.createIntent({
44
+ senderRef: payer,
45
+ recipientRef: merchantAccount,
46
+ amount: Money.parse("USD", 2, "1500"), // recipient receives $15.00
47
+ expiresAt: now + 600_000,
48
+ allowedRails: ["card"],
49
+ });
50
+
51
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "stripe", currency: "USD", exponent: 2 }]);
52
+ const quote = await client.select(quotes, payer);
53
+ const auth = await client.authorize(intent, quote, signer);
54
+
55
+ // The payer confirms the card on their own device, so create the intent awaiting
56
+ // their confirmation and hand the client secret to the card form.
57
+ const { preparation } = await client.interactiveInitiate(intent, quote, auth);
58
+ // ...payer confirms; the provider's webhook is normalized and fed to advance:
59
+ await client.advance(intent.id, "stripe", event); // -> settled, once
60
+ ```
61
+
62
+ For a server-side or saved-method charge, use `initiate` instead, which collects in
63
+ one step rather than waiting on the payer.
64
+
65
+ ## Carry a payment over your transport
66
+
67
+ The kernel emits and consumes opaque bytes; it never touches transport. Wrap a
68
+ message with `encodePayload` to get a self-identifying, versioned blob to place
69
+ inside your own envelope — a chat message, a queue entry — and `decodePayload` on
70
+ receipt. No plaintext ever needs to reach a relay.
71
+
72
+ ```ts
73
+ const blob = encodePayload(intent); // place inside your E2EE envelope
74
+ const message = decodePayload(received); // recover on the other side
75
+ ```
76
+
77
+ ## Adapters
78
+
79
+ An adapter translates between PACT messages and one provider, as a pay-in leg
80
+ (collect), a pay-out leg (disburse), or both. Be honest in `payInCapabilities` /
81
+ `payOutCapabilities` — the kernel refuses any operation you did not promise, so an
82
+ irreversible rail declares a counter-transfer refund rather than fake a reversal.
83
+
84
+ | Subpath | Provider | Legs |
85
+ |---|---|---|
86
+ | `@myzonerocks/pact/adapters/stripe` | Stripe | card pay-in (brings Apple Pay, Google Pay, Cash App Pay as methods) |
87
+ | `@myzonerocks/pact/adapters/erc20` | ERC-20 | crypto pay-in + pay-out |
88
+ | `@myzonerocks/pact/adapters/mpesa` | M-Pesa | STK pay-in + B2C pay-out |
89
+ | `@myzonerocks/pact/adapters/paypal` | PayPal | Orders pay-in (Venmo) + Payouts pay-out |
90
+
91
+ Server-side adapters (Stripe, M-Pesa, PayPal) hold provider secrets and verify
92
+ webhooks, so run them on a server, never in a browser bundle.
93
+
94
+ ## Design commitments
95
+
96
+ - **Money is exact integer minor units** at arbitrary precision, never a float —
97
+ an 18-decimal token overflows 64 bits at nine whole tokens.
98
+ - **Signing is over a canonical preimage, not JSON or protobuf bytes**, with a
99
+ domain-separation tag per message kind, so a signature verifies across every
100
+ SDK and can never be replayed across message kinds, intents, or quotes.
101
+ - **Every step is idempotent and replay-safe**; a duplicated webhook settles once.
102
+ - **The state machine is total**: every illegal transition is rejected.
103
+ - **Cross-provider settlement is never faked as atomic**: value rests in escrow
104
+ between the legs, so a failed pay-out unwinds to a refund rather than a loss.
105
+
106
+ ## Conformance
107
+
108
+ The Go, TypeScript, and Dart SDKs are held to one wire format by shared vectors: a
109
+ fixed intent produces a fixed canonical preimage, hash, and signature that all
110
+ three reproduce byte-for-byte.
111
+
112
+ ```
113
+ bun run test
114
+ ```
115
+
116
+ ## Learn more
117
+
118
+ The protocol is specified in the repository at
119
+ [github.com/myzonerocks/pact](https://github.com/myzonerocks/pact) — `spec/PACT.md`
120
+ for messages, canonical signing, the ledger, and conformance, and
121
+ `spec/cross-rail.md` for the corridor model.
122
+
123
+ Licensed under Apache-2.0.
@@ -1,12 +1,13 @@
1
1
  import type { Quote, Authorization, Settlement } from "../message.js";
2
2
  import { RefundKind, type AdapterEvent } from "../adapter.js";
3
- import type { PayInLeg, PayInCapabilities, CollectResult } from "../leg.js";
3
+ import type { InteractivePayInLeg, PayInCapabilities, CollectResult, PayInPreparation } from "../leg.js";
4
4
  export interface PaymentIntent {
5
5
  id: string;
6
6
  status: string;
7
7
  amount: number;
8
8
  currency: string;
9
9
  latestCharge: string;
10
+ clientSecret: string;
10
11
  metadata: Record<string, string>;
11
12
  created: number;
12
13
  }
@@ -15,10 +16,16 @@ export interface Refund {
15
16
  status: string;
16
17
  amount: number;
17
18
  }
19
+ export declare enum Capture {
20
+ Manual = "manual",
21
+ OnConfirmation = "on_confirmation"
22
+ }
18
23
  export interface CreateIntentParams {
19
24
  amount: number;
20
25
  currency: string;
21
26
  destination: string;
27
+ applicationFee?: number;
28
+ capture?: Capture;
22
29
  metadata: Record<string, string>;
23
30
  }
24
31
  export interface CreateRefundParams {
@@ -41,7 +48,7 @@ export interface StripeConfig {
41
48
  tolerance?: number;
42
49
  ids: () => string;
43
50
  }
44
- export declare class StripeLeg implements PayInLeg {
51
+ export declare class StripeLeg implements InteractivePayInLeg {
45
52
  readonly id: string;
46
53
  private readonly currencies;
47
54
  private readonly api;
@@ -52,6 +59,7 @@ export declare class StripeLeg implements PayInLeg {
52
59
  constructor(cfg: StripeConfig);
53
60
  payInCapabilities(): PayInCapabilities;
54
61
  collect(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<CollectResult>;
62
+ prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
55
63
  refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
56
64
  parseWebhook(raw: Uint8Array, headers: Record<string, string[]>): AdapterEvent[];
57
65
  }
@@ -22,6 +22,16 @@ const metadataIntentKey = "pact_intent_id";
22
22
  const defaultToleranceSeconds = 300;
23
23
  // The protocol identifier used to bind an idempotency key to one protocol step.
24
24
  const idPrefix = "pact";
25
+ // Capture selects when the charge is captured. Manual creates the intent already
26
+ // carrying a payment method for the server to capture immediately — the flow an
27
+ // agent or a saved-card charge uses. OnConfirmation defers capture to the moment
28
+ // the payer confirms on their own device, which is what an interactive card form
29
+ // needs: there is no payment method until the payer supplies one.
30
+ export var Capture;
31
+ (function (Capture) {
32
+ Capture["Manual"] = "manual";
33
+ Capture["OnConfirmation"] = "on_confirmation";
34
+ })(Capture || (Capture = {}));
25
35
  // StripeLeg collects card payments through Stripe. It exposes the wallets that
26
36
  // ride on the card rail as funding methods, and answers a refund with a full or
27
37
  // partial reversal.
@@ -66,11 +76,30 @@ export class StripeLeg {
66
76
  amount,
67
77
  currency: quote.srcAmount.currency,
68
78
  destination: deliverTo,
79
+ capture: Capture.Manual,
69
80
  metadata: { [metadataIntentKey]: intentId },
70
81
  }, idempotencyKey(intentId, "collect"));
71
82
  const captured = await this.api.capturePaymentIntent(pi.id, idempotencyKey(intentId, "capture"));
72
83
  return { providerRef: captured.id, received: quote.srcAmount.sub(quote.fees) };
73
84
  }
85
+ // prepare creates a PaymentIntent the payer confirms on their own device, which
86
+ // is how an interactive card form collects: there is no payment method until the
87
+ // payer supplies one, so nothing is captured here. It returns the client secret
88
+ // the card form needs and lets Stripe capture the instant the payer confirms, at
89
+ // which point payment_intent.succeeded advances the corridor. The platform fee is
90
+ // collected by Stripe onto our own account, so we earn on the movement without
91
+ // ever holding the funds.
92
+ async prepare(intentId, quote, _auth, deliverTo) {
93
+ const pi = await this.api.createPaymentIntent({
94
+ amount: minorToInteger(quote.srcAmount),
95
+ currency: quote.srcAmount.currency,
96
+ destination: deliverTo,
97
+ applicationFee: minorToInteger(quote.fees),
98
+ capture: Capture.OnConfirmation,
99
+ metadata: { [metadataIntentKey]: intentId },
100
+ }, idempotencyKey(intentId, "prepare"));
101
+ return { providerRef: pi.id, clientSecret: pi.clientSecret, method: "card" };
102
+ }
74
103
  // refundIn reverses a captured payment, in full or in part. Stripe supports
75
104
  // both, so the leg accepts the full and partial refund kinds and rejects a
76
105
  // counter-transfer it cannot express.
@@ -242,11 +271,19 @@ class HttpStripeApi {
242
271
  const form = new URLSearchParams();
243
272
  form.set("amount", String(params.amount));
244
273
  form.set("currency", params.currency.toLowerCase());
245
- form.set("capture_method", "manual");
274
+ // Manual capture waits for a server capture once a payment method is present;
275
+ // on-confirmation capture lets Stripe take the funds the instant the payer
276
+ // confirms in their own card form, firing payment_intent.succeeded straight away.
277
+ if ((params.capture ?? Capture.Manual) === Capture.Manual) {
278
+ form.set("capture_method", "manual");
279
+ }
246
280
  form.set("automatic_payment_methods[enabled]", "true");
247
281
  if (params.destination) {
248
282
  form.set("transfer_data[destination]", params.destination);
249
283
  }
284
+ if (params.applicationFee && params.applicationFee > 0) {
285
+ form.set("application_fee_amount", String(params.applicationFee));
286
+ }
250
287
  for (const [k, v] of Object.entries(params.metadata)) {
251
288
  form.set(`metadata[${k}]`, v);
252
289
  }
@@ -325,6 +362,7 @@ function normalizeIntent(p) {
325
362
  amount: p.amount ?? 0,
326
363
  currency: (p.currency ?? "").toUpperCase(),
327
364
  latestCharge: p.latest_charge ?? "",
365
+ clientSecret: p.client_secret ?? "",
328
366
  metadata: p.metadata ?? {},
329
367
  created: p.created ?? 0,
330
368
  };
@@ -3,7 +3,7 @@ import { Money } from "./money.js";
3
3
  import { type Intent, type Quote, type Authorization, type Settlement } from "./message.js";
4
4
  import { type Signer, type Verifier } from "./signing.js";
5
5
  import { type Bridge } from "./bridge.js";
6
- import { type PayInLeg, type PayOutLeg } from "./leg.js";
6
+ import { type PayInLeg, type PayOutLeg, type PayInPreparation } from "./leg.js";
7
7
  import type { AdapterEvent } from "./adapter.js";
8
8
  import { type Policy } from "./router.js";
9
9
  import { type KycProvider, type RiskHook } from "./compliance.js";
@@ -64,6 +64,10 @@ export declare class Client {
64
64
  select(quotes: Quote[], identity: string): Promise<Quote>;
65
65
  authorize(intent: Intent, quote: Quote, signer: Signer): Promise<Authorization>;
66
66
  initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State>;
67
+ interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{
68
+ preparation: PayInPreparation;
69
+ state: State;
70
+ }>;
67
71
  advance(intentId: string, adapterId: string, event: AdapterEvent): Promise<{
68
72
  settlement: Settlement;
69
73
  state: State;
@@ -6,7 +6,7 @@ import { CanonicalWriter, hashPreimage } from "./canonical.js";
6
6
  import { intentHash, quoteHash, authorizationHash, corridorReceipt, isDirect, BRIDGE_PASSTHROUGH, } from "./message.js";
7
7
  import { authorize as signAuthorization, verifyAuthorization } from "./signing.js";
8
8
  import { PassThroughBridge } from "./bridge.js";
9
- import { railInList } from "./leg.js";
9
+ import { railInList, isInteractivePayInLeg } from "./leg.js";
10
10
  import { route, PolicyKind, NoQuoteError, isExpired } from "./router.js";
11
11
  import { permissiveKyc, allowRisk, withinLimits } from "./compliance.js";
12
12
  import { MemoryLedger, eventReceipt } from "./ledger.js";
@@ -224,6 +224,32 @@ export class Client {
224
224
  this.ledger.apply({ intentId: intent.id, to, payloadHash: submitHash(collected.providerRef), collect: collected });
225
225
  return to;
226
226
  }
227
+ // interactiveInitiate kicks off a corridor whose pay-in the payer completes on
228
+ // their own device. It creates the provider intent awaiting the payer's
229
+ // confirmation, records the corridor as submitted, and returns the preparation
230
+ // the payer's device needs. The confirming provider event then advances the
231
+ // corridor to settled through the same pay-in path as an immediate collection.
232
+ // Only a direct corridor is interactive; a bridged pay-in still collects
233
+ // server-side into escrow, and the leg must offer interactive collection.
234
+ async interactiveInitiate(intent, quote, auth) {
235
+ verifyAuthorization(auth, intent, quote, this.verifier);
236
+ if (!isDirect(quote)) {
237
+ throw new Error("pact: interactive pay-in is only available on a direct corridor");
238
+ }
239
+ const payInLeg = this.payIn.get(quote.payInAdapterId);
240
+ if (!payInLeg) {
241
+ throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
242
+ }
243
+ if (!isInteractivePayInLeg(payInLeg)) {
244
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
245
+ }
246
+ const preparation = await payInLeg.prepare(intent.id, quote, auth, recipientDestination(intent));
247
+ // Record the same shape a direct collection would, so the confirming webhook
248
+ // advances the corridor to settled through the unchanged pay-in path.
249
+ const collected = { providerRef: preparation.providerRef, received: quote.srcAmount.sub(quote.fees) };
250
+ this.ledger.apply({ intentId: intent.id, to: State.Submitted, payloadHash: submitHash(preparation.providerRef), collect: collected });
251
+ return { preparation, state: State.Submitted };
252
+ }
227
253
  // advance resumes a corridor when a provider event arrives — a webhook, a
228
254
  // callback, a chain receipt, already normalized into an AdapterEvent. It reads
229
255
  // the corridor's state from the ledger, interprets the event against it, and
@@ -5,7 +5,7 @@ export { CanonicalWriter, hashPreimage } from "./canonical.js";
5
5
  export { type Intent, type Quote, type Authorization, type Settlement, BRIDGE_PASSTHROUGH, intentPreimage, intentHash, quotePreimage, quoteHash, isDirect, signingPreimage, authorizationHash, receiptHash, corridorReceipt, } from "./message.js";
6
6
  export { type Signer, type Verifier, authorize, verifyAuthorization, Ed25519Signer, Ed25519Verifier, BadSignatureError, } from "./signing.js";
7
7
  export { RefundKind, type AdapterEvent, type WebhookParser } from "./adapter.js";
8
- export { railInList, type PayInLeg, type PayOutLeg, type PayInCapabilities, type PayOutCapabilities, type CollectResult, type DisburseResult, } from "./leg.js";
8
+ export { railInList, isInteractivePayInLeg, type PayInLeg, type InteractivePayInLeg, type PayOutLeg, type PayInCapabilities, type PayOutCapabilities, type CollectResult, type PayInPreparation, type DisburseResult, } from "./leg.js";
9
9
  export { BRIDGE_USDC, PassThroughBridge, UsdcBridge, applyRate, applyInverseRate, parseRate, type Bridge, type BridgeQuote, type ConvertResult, type RateSource, type EscrowVault, } from "./bridge.js";
10
10
  export { type KycProvider, type KycStatus, type KycLimits, type RiskHook, type RiskDecision, permissiveKyc, allowRisk, withinLimits, } from "./compliance.js";
11
11
  export { PolicyKind, type Policy, route, isExpired, NoQuoteError } from "./router.js";
package/dist/src/index.js CHANGED
@@ -5,7 +5,7 @@ export { CanonicalWriter, hashPreimage } from "./canonical.js";
5
5
  export { BRIDGE_PASSTHROUGH, intentPreimage, intentHash, quotePreimage, quoteHash, isDirect, signingPreimage, authorizationHash, receiptHash, corridorReceipt, } from "./message.js";
6
6
  export { authorize, verifyAuthorization, Ed25519Signer, Ed25519Verifier, BadSignatureError, } from "./signing.js";
7
7
  export { RefundKind } from "./adapter.js";
8
- export { railInList, } from "./leg.js";
8
+ export { railInList, isInteractivePayInLeg, } from "./leg.js";
9
9
  export { BRIDGE_USDC, PassThroughBridge, UsdcBridge, applyRate, applyInverseRate, parseRate, } from "./bridge.js";
10
10
  export { permissiveKyc, allowRisk, withinLimits, } from "./compliance.js";
11
11
  export { PolicyKind, route, isExpired, NoQuoteError } from "./router.js";
package/dist/src/leg.d.ts CHANGED
@@ -17,6 +17,15 @@ export interface PayInLeg {
17
17
  collect(intentId: string, quote: Quote, auth: Authorization, deliverTo: string): Promise<CollectResult>;
18
18
  refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
19
19
  }
20
+ export interface PayInPreparation {
21
+ providerRef: string;
22
+ clientSecret: string;
23
+ method: string;
24
+ }
25
+ export interface InteractivePayInLeg extends PayInLeg {
26
+ prepare(intentId: string, quote: Quote, auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
27
+ }
28
+ export declare function isInteractivePayInLeg(leg: PayInLeg): leg is InteractivePayInLeg;
20
29
  export interface PayOutCapabilities {
21
30
  rails: string[];
22
31
  currencies: string[];
package/dist/src/leg.js CHANGED
@@ -1,3 +1,9 @@
1
+ // isInteractivePayInLeg reports whether a pay-in leg can prepare an interactive
2
+ // collection, so the client can offer a payer-completed corridor where the rail
3
+ // supports one and fall back to server-side collection otherwise.
4
+ export function isInteractivePayInLeg(leg) {
5
+ return typeof leg.prepare === "function";
6
+ }
1
7
  // railInList reports whether a set of rails contains the requested rail.
2
8
  export function railInList(rails, rail) {
3
9
  return rails.includes(rail);
@@ -7,7 +7,7 @@ import { isDirect } from "../src/message.js";
7
7
  import { RefundKind } from "../src/adapter.js";
8
8
  import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
9
9
  import { fromHex } from "../src/crypto.js";
10
- import { StripeLeg, ErrNoSignature, ErrSignatureMismatch, ErrTimestampOutOfTolerance, } from "../src/adapters/stripe.js";
10
+ import { StripeLeg, Capture, ErrNoSignature, ErrSignatureMismatch, ErrTimestampOutOfTolerance, } from "../src/adapters/stripe.js";
11
11
  function counter() {
12
12
  let n = 0;
13
13
  return () => `pi_test_${String(++n).padStart(3, "0")}`;
@@ -26,12 +26,17 @@ class FakeStripe {
26
26
  this.ids = ids;
27
27
  }
28
28
  async createPaymentIntent(params, _idempotencyKey) {
29
+ // Manual capture presumes a method is already attached (server capture); an
30
+ // on-confirmation intent still awaits the payer supplying one on their device.
31
+ const status = (params.capture ?? Capture.Manual) === Capture.OnConfirmation ? "requires_payment_method" : "requires_capture";
32
+ const id = this.ids();
29
33
  const pi = {
30
- id: this.ids(),
31
- status: "requires_capture",
34
+ id,
35
+ status,
32
36
  amount: params.amount,
33
37
  currency: params.currency,
34
38
  latestCharge: "",
39
+ clientSecret: `${id}_secret`,
35
40
  metadata: params.metadata,
36
41
  created: 1_700_000_000,
37
42
  };
@@ -173,4 +178,41 @@ describe("stripe direct card corridor", () => {
173
178
  await leg.refundIn(intent.id, RefundKind.Full, "changed mind");
174
179
  await expect(leg.refundIn(intent.id, RefundKind.CounterTransfer, "wrong kind")).rejects.toThrow();
175
180
  });
181
+ it("prepares a payer-confirmed intent and settles on the confirming webhook", async () => {
182
+ const { signer, verifier } = signerVerifier("alice");
183
+ const api = new FakeStripe(counter());
184
+ const leg = buildLeg(api);
185
+ const client = new Client({
186
+ payIn: [leg],
187
+ payOut: [new CardPayout(leg.id)],
188
+ verifier,
189
+ clock: () => now,
190
+ idGen: counter(),
191
+ });
192
+ const intent = client.createIntent({
193
+ senderRef: "alice",
194
+ recipientRef: "acct_merchant",
195
+ amount: Money.parse("USD", 2, "1500"),
196
+ expiresAt: now + 600_000,
197
+ allowedRails: ["card"],
198
+ });
199
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: leg.id, currency: "USD", exponent: 2 }]);
200
+ const quote = await client.select(quotes, signer.identity());
201
+ const auth = await client.authorize(intent, quote, signer);
202
+ const { preparation, state } = await client.interactiveInitiate(intent, quote, auth);
203
+ expect(state).toBe(State.Submitted);
204
+ expect(preparation.clientSecret).not.toBe("");
205
+ expect(preparation.providerRef).not.toBe("");
206
+ expect(preparation.method).toBe("card");
207
+ // Nothing is captured until the payer confirms; the intent still awaits a method.
208
+ const pending = await api.findPaymentIntent(intent.id);
209
+ expect(pending.status).toBe("requires_payment_method");
210
+ // The payer confirms and Stripe fires payment_intent.succeeded, which settles
211
+ // the corridor — once, even if the webhook is redelivered.
212
+ await client.advance(intent.id, leg.id, { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
213
+ expect(client.state(intent.id)).toBe(State.Settled);
214
+ const before = client.history(intent.id).length;
215
+ await client.advance(intent.id, leg.id, { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
216
+ expect(client.history(intent.id).length).toBe(before);
217
+ });
176
218
  });
package/package.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "name": "@myzonerocks/pact",
3
- "version": "0.1.0",
4
- "description": "Reference TypeScript SDK for the PACT payment abstraction protocol",
3
+ "version": "0.1.1",
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
7
  "type": "module",
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
8
  "main": "./dist/src/index.js",
11
9
  "types": "./dist/src/index.d.ts",
12
10
  "exports": {
@@ -39,8 +37,7 @@
39
37
  "scripts": {
40
38
  "build": "tsc -p tsconfig.json",
41
39
  "test": "vitest run",
42
- "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "prepublishOnly": "npm run build"
40
+ "typecheck": "tsc -p tsconfig.json --noEmit"
44
41
  },
45
42
  "devDependencies": {
46
43
  "@types/node": "^22.10.0",
@@ -10,7 +10,7 @@ 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 type { PayInLeg, PayInCapabilities, CollectResult } from "../leg.js";
13
+ import type { InteractivePayInLeg, PayInCapabilities, CollectResult, PayInPreparation } from "../leg.js";
14
14
 
15
15
  // The public base of the Stripe REST API. It is the same for every integration
16
16
  // and carries no secret; tests point the leg at a local server instead.
@@ -32,12 +32,16 @@ const defaultToleranceSeconds = 300;
32
32
  const idPrefix = "pact";
33
33
 
34
34
  // PaymentIntent is the subset of Stripe's PaymentIntent this leg reads.
35
+ // clientSecret is present only on a freshly created intent and is the single
36
+ // token the payer's device needs to confirm the payment; it is never logged or
37
+ // persisted server-side.
35
38
  export interface PaymentIntent {
36
39
  id: string;
37
40
  status: string;
38
41
  amount: number;
39
42
  currency: string;
40
43
  latestCharge: string;
44
+ clientSecret: string;
41
45
  metadata: Record<string, string>;
42
46
  created: number;
43
47
  }
@@ -49,14 +53,28 @@ export interface Refund {
49
53
  amount: number;
50
54
  }
51
55
 
56
+ // Capture selects when the charge is captured. Manual creates the intent already
57
+ // carrying a payment method for the server to capture immediately — the flow an
58
+ // agent or a saved-card charge uses. OnConfirmation defers capture to the moment
59
+ // the payer confirms on their own device, which is what an interactive card form
60
+ // needs: there is no payment method until the payer supplies one.
61
+ export enum Capture {
62
+ Manual = "manual",
63
+ OnConfirmation = "on_confirmation",
64
+ }
65
+
52
66
  // CreateIntentParams describes a PaymentIntent to create. destination is the
53
67
  // account the captured funds settle to: the recipient's connected account for a
54
68
  // direct corridor, the escrow account for a bridged one. An empty destination
55
- // leaves the funds on the platform account.
69
+ // leaves the funds on the platform account. applicationFee is the platform's cut,
70
+ // collected by Stripe onto the platform account so we earn on the movement
71
+ // without ever holding the funds; zero takes no fee.
56
72
  export interface CreateIntentParams {
57
73
  amount: number;
58
74
  currency: string;
59
75
  destination: string;
76
+ applicationFee?: number;
77
+ capture?: Capture;
60
78
  metadata: Record<string, string>;
61
79
  }
62
80
 
@@ -97,7 +115,7 @@ export interface StripeConfig {
97
115
  // StripeLeg collects card payments through Stripe. It exposes the wallets that
98
116
  // ride on the card rail as funding methods, and answers a refund with a full or
99
117
  // partial reversal.
100
- export class StripeLeg implements PayInLeg {
118
+ export class StripeLeg implements InteractivePayInLeg {
101
119
  readonly id: string;
102
120
  private readonly currencies: string[];
103
121
  private readonly api: StripeApi;
@@ -142,6 +160,7 @@ export class StripeLeg implements PayInLeg {
142
160
  amount,
143
161
  currency: quote.srcAmount.currency,
144
162
  destination: deliverTo,
163
+ capture: Capture.Manual,
145
164
  metadata: { [metadataIntentKey]: intentId },
146
165
  },
147
166
  idempotencyKey(intentId, "collect"),
@@ -150,6 +169,28 @@ export class StripeLeg implements PayInLeg {
150
169
  return { providerRef: captured.id, received: quote.srcAmount.sub(quote.fees) };
151
170
  }
152
171
 
172
+ // prepare creates a PaymentIntent the payer confirms on their own device, which
173
+ // is how an interactive card form collects: there is no payment method until the
174
+ // payer supplies one, so nothing is captured here. It returns the client secret
175
+ // the card form needs and lets Stripe capture the instant the payer confirms, at
176
+ // which point payment_intent.succeeded advances the corridor. The platform fee is
177
+ // collected by Stripe onto our own account, so we earn on the movement without
178
+ // ever holding the funds.
179
+ async prepare(intentId: string, quote: Quote, _auth: Authorization, deliverTo: string): Promise<PayInPreparation> {
180
+ const pi = await this.api.createPaymentIntent(
181
+ {
182
+ amount: minorToInteger(quote.srcAmount),
183
+ currency: quote.srcAmount.currency,
184
+ destination: deliverTo,
185
+ applicationFee: minorToInteger(quote.fees),
186
+ capture: Capture.OnConfirmation,
187
+ metadata: { [metadataIntentKey]: intentId },
188
+ },
189
+ idempotencyKey(intentId, "prepare"),
190
+ );
191
+ return { providerRef: pi.id, clientSecret: pi.clientSecret, method: "card" };
192
+ }
193
+
153
194
  // refundIn reverses a captured payment, in full or in part. Stripe supports
154
195
  // both, so the leg accepts the full and partial refund kinds and rejects a
155
196
  // counter-transfer it cannot express.
@@ -356,11 +397,19 @@ class HttpStripeApi implements StripeApi {
356
397
  const form = new URLSearchParams();
357
398
  form.set("amount", String(params.amount));
358
399
  form.set("currency", params.currency.toLowerCase());
359
- form.set("capture_method", "manual");
400
+ // Manual capture waits for a server capture once a payment method is present;
401
+ // on-confirmation capture lets Stripe take the funds the instant the payer
402
+ // confirms in their own card form, firing payment_intent.succeeded straight away.
403
+ if ((params.capture ?? Capture.Manual) === Capture.Manual) {
404
+ form.set("capture_method", "manual");
405
+ }
360
406
  form.set("automatic_payment_methods[enabled]", "true");
361
407
  if (params.destination) {
362
408
  form.set("transfer_data[destination]", params.destination);
363
409
  }
410
+ if (params.applicationFee && params.applicationFee > 0) {
411
+ form.set("application_fee_amount", String(params.applicationFee));
412
+ }
364
413
  for (const [k, v] of Object.entries(params.metadata)) {
365
414
  form.set(`metadata[${k}]`, v);
366
415
  }
@@ -449,6 +498,7 @@ interface StripePaymentIntentWire {
449
498
  amount?: number;
450
499
  currency?: string;
451
500
  latest_charge?: string;
501
+ client_secret?: string;
452
502
  metadata?: Record<string, string>;
453
503
  created?: number;
454
504
  }
@@ -466,6 +516,7 @@ function normalizeIntent(p: StripePaymentIntentWire): PaymentIntent {
466
516
  amount: p.amount ?? 0,
467
517
  currency: (p.currency ?? "").toUpperCase(),
468
518
  latestCharge: p.latest_charge ?? "",
519
+ clientSecret: p.client_secret ?? "",
469
520
  metadata: p.metadata ?? {},
470
521
  created: p.created ?? 0,
471
522
  };
package/src/client.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  } from "./message.js";
18
18
  import { authorize as signAuthorization, verifyAuthorization, type Signer, type Verifier } from "./signing.js";
19
19
  import { PassThroughBridge, type Bridge, type ConvertResult } from "./bridge.js";
20
- import { railInList, type PayInLeg, type PayOutLeg, type CollectResult } from "./leg.js";
20
+ import { railInList, isInteractivePayInLeg, type PayInLeg, type PayOutLeg, type CollectResult, type PayInPreparation } from "./leg.js";
21
21
  import type { AdapterEvent } from "./adapter.js";
22
22
  import { route, type Policy, PolicyKind, NoQuoteError, isExpired } from "./router.js";
23
23
  import { permissiveKyc, allowRisk, withinLimits, type KycProvider, type RiskHook } from "./compliance.js";
@@ -284,6 +284,33 @@ export class Client {
284
284
  return to;
285
285
  }
286
286
 
287
+ // interactiveInitiate kicks off a corridor whose pay-in the payer completes on
288
+ // their own device. It creates the provider intent awaiting the payer's
289
+ // confirmation, records the corridor as submitted, and returns the preparation
290
+ // the payer's device needs. The confirming provider event then advances the
291
+ // corridor to settled through the same pay-in path as an immediate collection.
292
+ // Only a direct corridor is interactive; a bridged pay-in still collects
293
+ // server-side into escrow, and the leg must offer interactive collection.
294
+ async interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{ preparation: PayInPreparation; state: State }> {
295
+ verifyAuthorization(auth, intent, quote, this.verifier);
296
+ if (!isDirect(quote)) {
297
+ throw new Error("pact: interactive pay-in is only available on a direct corridor");
298
+ }
299
+ const payInLeg = this.payIn.get(quote.payInAdapterId);
300
+ if (!payInLeg) {
301
+ throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
302
+ }
303
+ if (!isInteractivePayInLeg(payInLeg)) {
304
+ throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
305
+ }
306
+ const preparation = await payInLeg.prepare(intent.id, quote, auth, recipientDestination(intent));
307
+ // Record the same shape a direct collection would, so the confirming webhook
308
+ // advances the corridor to settled through the unchanged pay-in path.
309
+ const collected: CollectResult = { providerRef: preparation.providerRef, received: quote.srcAmount.sub(quote.fees) };
310
+ this.ledger.apply({ intentId: intent.id, to: State.Submitted, payloadHash: submitHash(preparation.providerRef), collect: collected });
311
+ return { preparation, state: State.Submitted };
312
+ }
313
+
287
314
  // advance resumes a corridor when a provider event arrives — a webhook, a
288
315
  // callback, a chain receipt, already normalized into an AdapterEvent. It reads
289
316
  // the corridor's state from the ledger, interprets the event against it, and
package/src/index.ts CHANGED
@@ -30,11 +30,14 @@ export {
30
30
  export { RefundKind, type AdapterEvent, type WebhookParser } from "./adapter.js";
31
31
  export {
32
32
  railInList,
33
+ isInteractivePayInLeg,
33
34
  type PayInLeg,
35
+ type InteractivePayInLeg,
34
36
  type PayOutLeg,
35
37
  type PayInCapabilities,
36
38
  type PayOutCapabilities,
37
39
  type CollectResult,
40
+ type PayInPreparation,
38
41
  type DisburseResult,
39
42
  } from "./leg.js";
40
43
  export {
package/src/leg.ts CHANGED
@@ -36,6 +36,34 @@ export interface PayInLeg {
36
36
  refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
37
37
  }
38
38
 
39
+ // PayInPreparation is what an interactive pay-in leg hands back so the payer can
40
+ // complete the collection on their own device. clientSecret is the single opaque
41
+ // token the payer's form needs and is never persisted server-side; method names
42
+ // the funding surface presented (a card, a wallet).
43
+ export interface PayInPreparation {
44
+ providerRef: string;
45
+ clientSecret: string;
46
+ method: string;
47
+ }
48
+
49
+ // InteractivePayInLeg is a pay-in leg whose collection the payer completes on
50
+ // their own device — a card form, a hosted redirect — rather than the server
51
+ // capturing an already-authorized method. prepare creates the provider intent
52
+ // awaiting the payer's confirmation and returns what their device needs; the
53
+ // confirming provider event then advances the corridor through the same pay-in
54
+ // path as an immediate collection. A leg offers this in addition to collect, so a
55
+ // host can drive either an interactive payer or a server-side charge.
56
+ export interface InteractivePayInLeg extends PayInLeg {
57
+ prepare(intentId: string, quote: Quote, auth: Authorization, deliverTo: string): Promise<PayInPreparation>;
58
+ }
59
+
60
+ // isInteractivePayInLeg reports whether a pay-in leg can prepare an interactive
61
+ // collection, so the client can offer a payer-completed corridor where the rail
62
+ // supports one and fall back to server-side collection otherwise.
63
+ export function isInteractivePayInLeg(leg: PayInLeg): leg is InteractivePayInLeg {
64
+ return typeof (leg as InteractivePayInLeg).prepare === "function";
65
+ }
66
+
39
67
  // PayOutCapabilities describes what a pay-out leg can deliver. reversible reports
40
68
  // whether a completed payout can be pulled back; an irreversible rail answers a
41
69
  // refund with a counter-transfer, never a reversal.