@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 +123 -0
- package/dist/src/adapters/stripe.d.ts +10 -2
- package/dist/src/adapters/stripe.js +39 -1
- package/dist/src/client.d.ts +5 -1
- package/dist/src/client.js +27 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/leg.d.ts +9 -0
- package/dist/src/leg.js +6 -0
- package/dist/test/stripe.test.js +45 -3
- package/package.json +4 -7
- package/src/adapters/stripe.ts +55 -4
- package/src/client.ts +28 -1
- package/src/index.ts +3 -0
- package/src/leg.ts +28 -0
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 {
|
|
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
|
|
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
|
-
|
|
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
|
};
|
package/dist/src/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/client.js
CHANGED
|
@@ -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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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);
|
package/dist/test/stripe.test.js
CHANGED
|
@@ -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
|
|
31
|
-
status
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|
package/src/adapters/stripe.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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.
|