@myzonerocks/pact 0.1.4 → 0.1.7
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 +3 -3
- package/dist/src/adapters/erc20.d.ts +17 -1
- package/dist/src/adapters/erc20.js +19 -8
- package/dist/src/adapters/mpesa.d.ts +21 -2
- package/dist/src/adapters/mpesa.js +24 -10
- package/dist/src/adapters/paypal.d.ts +19 -2
- package/dist/src/adapters/paypal.js +26 -15
- package/dist/src/adapters/stripe.js +18 -5
- package/dist/src/canonical.js +2 -0
- package/dist/src/client.d.ts +2 -0
- package/dist/src/client.js +81 -20
- package/dist/src/message.d.ts +2 -0
- package/dist/src/message.js +10 -5
- package/dist/src/state.js +3 -1
- package/dist/src/wire.js +2 -16
- package/dist/test/initiate-guard.test.d.ts +1 -0
- package/dist/test/initiate-guard.test.js +38 -0
- package/package.json +1 -1
- package/src/adapters/erc20.ts +40 -8
- package/src/adapters/mpesa.ts +46 -15
- package/src/adapters/paypal.ts +47 -15
- package/src/adapters/stripe.ts +21 -6
- package/src/canonical.ts +2 -0
- package/src/client.ts +81 -21
- package/src/message.ts +11 -5
- package/src/state.ts +3 -1
- package/src/wire.ts +2 -16
package/README.md
CHANGED
|
@@ -105,9 +105,9 @@ webhooks, so run them on a server, never in a browser bundle.
|
|
|
105
105
|
|
|
106
106
|
## Conformance
|
|
107
107
|
|
|
108
|
-
The Go, TypeScript, and
|
|
109
|
-
fixed intent produces a fixed canonical preimage, hash, and
|
|
110
|
-
|
|
108
|
+
The Go, TypeScript, Dart, Swift, and Kotlin SDKs are held to one wire format by
|
|
109
|
+
shared vectors: a fixed intent produces a fixed canonical preimage, hash, and
|
|
110
|
+
signature that every one of them reproduces byte-for-byte.
|
|
111
111
|
|
|
112
112
|
```
|
|
113
113
|
bun run test
|
|
@@ -28,6 +28,22 @@ export interface Erc20Config {
|
|
|
28
28
|
ids: () => string;
|
|
29
29
|
decimals: number;
|
|
30
30
|
minConfirmations?: number;
|
|
31
|
+
store?: SentStore;
|
|
32
|
+
}
|
|
33
|
+
export interface SentRecord {
|
|
34
|
+
intentId: string;
|
|
35
|
+
txHash: string;
|
|
36
|
+
to: string;
|
|
37
|
+
amount: bigint;
|
|
38
|
+
}
|
|
39
|
+
export interface SentStore {
|
|
40
|
+
save(rec: SentRecord): Promise<void>;
|
|
41
|
+
byIntent(intentId: string): Promise<SentRecord | undefined>;
|
|
42
|
+
}
|
|
43
|
+
export declare class MemorySentStore implements SentStore {
|
|
44
|
+
private readonly byIntentMap;
|
|
45
|
+
save(rec: SentRecord): Promise<void>;
|
|
46
|
+
byIntent(intentId: string): Promise<SentRecord | undefined>;
|
|
31
47
|
}
|
|
32
48
|
export declare class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
33
49
|
readonly id: string;
|
|
@@ -38,7 +54,7 @@ export declare class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
38
54
|
private readonly ids;
|
|
39
55
|
private readonly decimals;
|
|
40
56
|
private readonly minConf;
|
|
41
|
-
private readonly
|
|
57
|
+
private readonly store;
|
|
42
58
|
constructor(cfg: Erc20Config);
|
|
43
59
|
private checkScale;
|
|
44
60
|
payInCapabilities(): PayInCapabilities;
|
|
@@ -14,6 +14,16 @@ const transferSelector = "a9059cbb";
|
|
|
14
14
|
// success can still be reorged out, so a settlement waits for enough blocks on
|
|
15
15
|
// top of it.
|
|
16
16
|
const defaultMinConfirmations = 12;
|
|
17
|
+
// MemorySentStore is the default in-process store.
|
|
18
|
+
export class MemorySentStore {
|
|
19
|
+
byIntentMap = new Map();
|
|
20
|
+
async save(rec) {
|
|
21
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
22
|
+
}
|
|
23
|
+
async byIntent(intentId) {
|
|
24
|
+
return this.byIntentMap.get(intentId);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
17
27
|
// Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
|
|
18
28
|
// counter-transfer only, because a token transfer is irreversible.
|
|
19
29
|
export class Erc20Leg {
|
|
@@ -25,9 +35,9 @@ export class Erc20Leg {
|
|
|
25
35
|
ids;
|
|
26
36
|
decimals;
|
|
27
37
|
minConf;
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
38
|
+
// Broadcast transfers, so a settlement can confirm the mined transaction really
|
|
39
|
+
// matches the payment, and a repeat returns the first transfer.
|
|
40
|
+
store;
|
|
31
41
|
constructor(cfg) {
|
|
32
42
|
if (!cfg.token || !cfg.currency) {
|
|
33
43
|
throw new Error("erc20: config requires a token address and currency");
|
|
@@ -45,6 +55,7 @@ export class Erc20Leg {
|
|
|
45
55
|
this.ids = cfg.ids;
|
|
46
56
|
this.decimals = cfg.decimals;
|
|
47
57
|
this.minConf = cfg.minConfirmations ?? defaultMinConfirmations;
|
|
58
|
+
this.store = cfg.store ?? new MemorySentStore();
|
|
48
59
|
}
|
|
49
60
|
// checkScale refuses an amount whose exponent does not match the token's
|
|
50
61
|
// decimals, so a quote priced at the wrong scale never moves the wrong number
|
|
@@ -71,12 +82,12 @@ export class Erc20Leg {
|
|
|
71
82
|
const net = quote.srcAmount.sub(quote.fees);
|
|
72
83
|
// A repeat for the same intent returns the transfer already broadcast rather
|
|
73
84
|
// than sending the payer's tokens twice.
|
|
74
|
-
const prev = this.
|
|
85
|
+
const prev = await this.store.byIntent(intentId);
|
|
75
86
|
if (prev) {
|
|
76
87
|
return { providerRef: prev.txHash, received: net };
|
|
77
88
|
}
|
|
78
89
|
const txHash = await this.transfer(deliverTo, net.value());
|
|
79
|
-
this.
|
|
90
|
+
await this.store.save({ intentId, txHash, to: normalizeAddress(deliverTo), amount: net.value() });
|
|
80
91
|
return { providerRef: txHash, received: net };
|
|
81
92
|
}
|
|
82
93
|
// disburse delivers the recipient's tokens.
|
|
@@ -84,13 +95,13 @@ export class Erc20Leg {
|
|
|
84
95
|
this.checkScale(quote.dstAmount);
|
|
85
96
|
// A repeat for the same intent returns the transfer already broadcast rather
|
|
86
97
|
// than delivering the recipient's tokens twice.
|
|
87
|
-
const prev = this.
|
|
98
|
+
const prev = await this.store.byIntent(intentId);
|
|
88
99
|
if (prev) {
|
|
89
100
|
return { providerRef: prev.txHash };
|
|
90
101
|
}
|
|
91
102
|
const amount = quote.dstAmount.value();
|
|
92
103
|
const txHash = await this.transfer(recipientRef, amount);
|
|
93
|
-
this.
|
|
104
|
+
await this.store.save({ intentId, txHash, to: normalizeAddress(recipientRef), amount });
|
|
94
105
|
return { providerRef: txHash };
|
|
95
106
|
}
|
|
96
107
|
// refundIn refuses: an ERC-20 collection is irreversible and the leg holds no
|
|
@@ -113,7 +124,7 @@ export class Erc20Leg {
|
|
|
113
124
|
// shallow success stays submitted so the host keeps polling; a revert or a
|
|
114
125
|
// mismatch fails, so a dropped, reorged, or spoofed transfer never settles.
|
|
115
126
|
async settlementEvent(intentId) {
|
|
116
|
-
const rec = this.
|
|
127
|
+
const rec = await this.store.byIntent(intentId);
|
|
117
128
|
if (!rec) {
|
|
118
129
|
throw new Error("erc20: no broadcast transaction for intent");
|
|
119
130
|
}
|
|
@@ -20,6 +20,7 @@ export interface B2CParams {
|
|
|
20
20
|
phone: string;
|
|
21
21
|
reference: string;
|
|
22
22
|
remarks: string;
|
|
23
|
+
idempotencyKey: string;
|
|
23
24
|
}
|
|
24
25
|
export interface B2CResult {
|
|
25
26
|
conversationId: string;
|
|
@@ -35,19 +36,37 @@ export interface DarajaApi {
|
|
|
35
36
|
b2cPayment(params: B2CParams): Promise<B2CResult>;
|
|
36
37
|
query(checkoutRequestId: string): Promise<StkQueryResult>;
|
|
37
38
|
}
|
|
39
|
+
export interface PushRecord {
|
|
40
|
+
intentId: string;
|
|
41
|
+
checkoutId: string;
|
|
42
|
+
payerPhone: string;
|
|
43
|
+
amount: number;
|
|
44
|
+
}
|
|
45
|
+
export interface PushStore {
|
|
46
|
+
save(rec: PushRecord): Promise<void>;
|
|
47
|
+
byIntent(intentId: string): Promise<PushRecord | undefined>;
|
|
48
|
+
byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
|
|
49
|
+
}
|
|
50
|
+
export declare class MemoryPushStore implements PushStore {
|
|
51
|
+
private readonly byIntentMap;
|
|
52
|
+
private readonly byCheckoutMap;
|
|
53
|
+
save(rec: PushRecord): Promise<void>;
|
|
54
|
+
byIntent(intentId: string): Promise<PushRecord | undefined>;
|
|
55
|
+
byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
|
|
56
|
+
}
|
|
38
57
|
export interface MpesaConfig {
|
|
39
58
|
id?: string;
|
|
40
59
|
api: DarajaApi;
|
|
41
60
|
callbackURL: string;
|
|
42
61
|
ids: () => string;
|
|
62
|
+
store?: PushStore;
|
|
43
63
|
}
|
|
44
64
|
export declare class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
45
65
|
readonly id: string;
|
|
46
66
|
private readonly api;
|
|
47
67
|
private readonly callbackURL;
|
|
48
68
|
private readonly ids;
|
|
49
|
-
private readonly
|
|
50
|
-
private readonly byCheckout;
|
|
69
|
+
private readonly store;
|
|
51
70
|
constructor(cfg: MpesaConfig);
|
|
52
71
|
payInCapabilities(): PayInCapabilities;
|
|
53
72
|
payOutCapabilities(): PayOutCapabilities;
|
|
@@ -9,14 +9,29 @@ const defaultBaseURL = "https://api.safaricom.co.ke";
|
|
|
9
9
|
// checkout id to one we started is the authentication: an unrecognized id is
|
|
10
10
|
// rejected rather than acted on.
|
|
11
11
|
export const ErrUnknownCheckout = "mpesa: callback for an unknown checkout request";
|
|
12
|
+
// MemoryPushStore is the default in-process store.
|
|
13
|
+
export class MemoryPushStore {
|
|
14
|
+
byIntentMap = new Map();
|
|
15
|
+
byCheckoutMap = new Map();
|
|
16
|
+
async save(rec) {
|
|
17
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
18
|
+
if (rec.checkoutId)
|
|
19
|
+
this.byCheckoutMap.set(rec.checkoutId, rec);
|
|
20
|
+
}
|
|
21
|
+
async byIntent(intentId) {
|
|
22
|
+
return this.byIntentMap.get(intentId);
|
|
23
|
+
}
|
|
24
|
+
async byCheckout(checkoutId) {
|
|
25
|
+
return this.byCheckoutMap.get(checkoutId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
12
28
|
// MpesaLeg moves mobile money over M-Pesa.
|
|
13
29
|
export class MpesaLeg {
|
|
14
30
|
id;
|
|
15
31
|
api;
|
|
16
32
|
callbackURL;
|
|
17
33
|
ids;
|
|
18
|
-
|
|
19
|
-
byCheckout = new Map();
|
|
34
|
+
store;
|
|
20
35
|
constructor(cfg) {
|
|
21
36
|
if (!cfg.callbackURL) {
|
|
22
37
|
throw new Error("mpesa: config requires a callback URL");
|
|
@@ -25,6 +40,7 @@ export class MpesaLeg {
|
|
|
25
40
|
this.api = cfg.api;
|
|
26
41
|
this.callbackURL = cfg.callbackURL;
|
|
27
42
|
this.ids = cfg.ids;
|
|
43
|
+
this.store = cfg.store ?? new MemoryPushStore();
|
|
28
44
|
}
|
|
29
45
|
payInCapabilities() {
|
|
30
46
|
return { rails: ["mpesa"], currencies: ["KES"], refunds: RefundKind.CounterTransfer };
|
|
@@ -45,7 +61,6 @@ export class MpesaLeg {
|
|
|
45
61
|
if (!phone) {
|
|
46
62
|
throw new Error("mpesa: collect requires the payer's phone");
|
|
47
63
|
}
|
|
48
|
-
const rec = { intentId, payerPhone: phone, amount, state: State.Unspecified };
|
|
49
64
|
const result = await this.api.stkPush({
|
|
50
65
|
amount,
|
|
51
66
|
payerPhone: phone,
|
|
@@ -53,8 +68,7 @@ export class MpesaLeg {
|
|
|
53
68
|
description: "payment",
|
|
54
69
|
callbackURL: this.callbackURL,
|
|
55
70
|
});
|
|
56
|
-
this.
|
|
57
|
-
this.byCheckout.set(result.checkoutRequestId, rec);
|
|
71
|
+
await this.store.save({ intentId, checkoutId: result.checkoutRequestId, payerPhone: phone, amount });
|
|
58
72
|
return { providerRef: result.checkoutRequestId, received: net };
|
|
59
73
|
}
|
|
60
74
|
// disburse delivers the recipient's shillings by a business-to-customer payout
|
|
@@ -65,7 +79,7 @@ export class MpesaLeg {
|
|
|
65
79
|
if (!phone) {
|
|
66
80
|
throw new Error("mpesa: disburse requires the recipient's phone");
|
|
67
81
|
}
|
|
68
|
-
const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout" });
|
|
82
|
+
const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout", idempotencyKey: `pact:payout:${intentId}` });
|
|
69
83
|
return { providerRef: result.conversationId };
|
|
70
84
|
}
|
|
71
85
|
// refundIn answers a collected payment with a business-to-customer payout back
|
|
@@ -76,7 +90,7 @@ export class MpesaLeg {
|
|
|
76
90
|
throw new Error("mpesa: a collection can only be refunded by counter-transfer");
|
|
77
91
|
}
|
|
78
92
|
const shillings = wholeShillings(amount);
|
|
79
|
-
const rec = this.byIntent
|
|
93
|
+
const rec = await this.store.byIntent(intentId);
|
|
80
94
|
if (!rec) {
|
|
81
95
|
throw new Error(`mpesa: no push for intent ${intentId}`);
|
|
82
96
|
}
|
|
@@ -89,6 +103,7 @@ export class MpesaLeg {
|
|
|
89
103
|
phone: rec.payerPhone,
|
|
90
104
|
reference: intentId,
|
|
91
105
|
remarks: reason,
|
|
106
|
+
idempotencyKey: `pact:refund:${intentId}`,
|
|
92
107
|
});
|
|
93
108
|
return {
|
|
94
109
|
intentId,
|
|
@@ -116,7 +131,7 @@ export class MpesaLeg {
|
|
|
116
131
|
async parseWebhook(raw, _headers) {
|
|
117
132
|
const envelope = JSON.parse(new TextDecoder().decode(raw));
|
|
118
133
|
const cb = envelope.Body?.stkCallback;
|
|
119
|
-
const rec = cb ? this.byCheckout
|
|
134
|
+
const rec = cb ? await this.store.byCheckout(cb.CheckoutRequestID) : undefined;
|
|
120
135
|
if (!cb || !rec) {
|
|
121
136
|
throw new Error(ErrUnknownCheckout);
|
|
122
137
|
}
|
|
@@ -127,7 +142,6 @@ export class MpesaLeg {
|
|
|
127
142
|
return [];
|
|
128
143
|
}
|
|
129
144
|
if (confirmed.resultCode !== 0) {
|
|
130
|
-
rec.state = State.Failed;
|
|
131
145
|
return [
|
|
132
146
|
{
|
|
133
147
|
intentId: rec.intentId,
|
|
@@ -146,7 +160,6 @@ export class MpesaLeg {
|
|
|
146
160
|
throw new Error(`mpesa: confirmed amount ${paid} does not match the authorized ${rec.amount}`);
|
|
147
161
|
}
|
|
148
162
|
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
149
|
-
rec.state = State.Settled;
|
|
150
163
|
return [
|
|
151
164
|
{
|
|
152
165
|
intentId: rec.intentId,
|
|
@@ -301,6 +314,7 @@ class HttpDarajaApi {
|
|
|
301
314
|
async b2cPayment(params) {
|
|
302
315
|
const token = await this.token();
|
|
303
316
|
const body = {
|
|
317
|
+
OriginatorConversationID: params.idempotencyKey,
|
|
304
318
|
InitiatorName: this.creds.shortCode,
|
|
305
319
|
CommandID: "BusinessPayment",
|
|
306
320
|
Amount: params.amount,
|
|
@@ -52,14 +52,31 @@ export interface PaypalConfig {
|
|
|
52
52
|
currencies: string[];
|
|
53
53
|
api: PaypalApi;
|
|
54
54
|
ids: () => string;
|
|
55
|
+
store?: CaptureStore;
|
|
56
|
+
}
|
|
57
|
+
export interface CaptureRecord {
|
|
58
|
+
intentId: string;
|
|
59
|
+
captureId: string;
|
|
60
|
+
expected: {
|
|
61
|
+
value: string;
|
|
62
|
+
currency: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export interface CaptureStore {
|
|
66
|
+
save(rec: CaptureRecord): Promise<void>;
|
|
67
|
+
byIntent(intentId: string): Promise<CaptureRecord | undefined>;
|
|
68
|
+
}
|
|
69
|
+
export declare class MemoryCaptureStore implements CaptureStore {
|
|
70
|
+
private readonly byIntentMap;
|
|
71
|
+
save(rec: CaptureRecord): Promise<void>;
|
|
72
|
+
byIntent(intentId: string): Promise<CaptureRecord | undefined>;
|
|
55
73
|
}
|
|
56
74
|
export declare class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
57
75
|
readonly id: string;
|
|
58
76
|
private readonly currencies;
|
|
59
77
|
private readonly api;
|
|
60
78
|
private readonly ids;
|
|
61
|
-
private readonly
|
|
62
|
-
private readonly expected;
|
|
79
|
+
private readonly store;
|
|
63
80
|
constructor(cfg: PaypalConfig);
|
|
64
81
|
payInCapabilities(): PayInCapabilities;
|
|
65
82
|
payOutCapabilities(): PayOutCapabilities;
|
|
@@ -17,21 +17,29 @@ const defaultBaseURL = "https://api-m.paypal.com";
|
|
|
17
17
|
const referencePrefix = "pact:";
|
|
18
18
|
// ErrSignatureMismatch reports a webhook PayPal could not authenticate.
|
|
19
19
|
export const ErrSignatureMismatch = "paypal: webhook signature does not verify";
|
|
20
|
+
// MemoryCaptureStore is the default in-process store.
|
|
21
|
+
export class MemoryCaptureStore {
|
|
22
|
+
byIntentMap = new Map();
|
|
23
|
+
async save(rec) {
|
|
24
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
25
|
+
}
|
|
26
|
+
async byIntent(intentId) {
|
|
27
|
+
return this.byIntentMap.get(intentId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
20
30
|
// PaypalLeg moves money over PayPal, serving both sides of a corridor.
|
|
21
31
|
export class PaypalLeg {
|
|
22
32
|
id;
|
|
23
33
|
currencies;
|
|
24
34
|
api;
|
|
25
35
|
ids;
|
|
26
|
-
|
|
27
|
-
// What each intent was quoted to collect, so a capture webhook can be
|
|
28
|
-
// cross-checked: the paid amount and currency must match before it settles.
|
|
29
|
-
expected = new Map();
|
|
36
|
+
store;
|
|
30
37
|
constructor(cfg) {
|
|
31
38
|
this.id = cfg.id ?? "paypal";
|
|
32
39
|
this.currencies = cfg.currencies;
|
|
33
40
|
this.api = cfg.api;
|
|
34
41
|
this.ids = cfg.ids;
|
|
42
|
+
this.store = cfg.store ?? new MemoryCaptureStore();
|
|
35
43
|
}
|
|
36
44
|
payInCapabilities() {
|
|
37
45
|
return {
|
|
@@ -54,10 +62,10 @@ export class PaypalLeg {
|
|
|
54
62
|
payee: deliverTo,
|
|
55
63
|
referenceId: referencePrefix + intentId,
|
|
56
64
|
});
|
|
57
|
-
this.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
currency: quote.srcAmount.currency,
|
|
65
|
+
await this.store.save({
|
|
66
|
+
intentId,
|
|
67
|
+
captureId: capture.captureId,
|
|
68
|
+
expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
|
|
61
69
|
});
|
|
62
70
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
63
71
|
}
|
|
@@ -78,9 +86,10 @@ export class PaypalLeg {
|
|
|
78
86
|
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
79
87
|
}
|
|
80
88
|
const order = await this.api.createOrder(params);
|
|
81
|
-
this.
|
|
82
|
-
|
|
83
|
-
|
|
89
|
+
await this.store.save({
|
|
90
|
+
intentId,
|
|
91
|
+
captureId: "",
|
|
92
|
+
expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
|
|
84
93
|
});
|
|
85
94
|
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
86
95
|
}
|
|
@@ -102,10 +111,11 @@ export class PaypalLeg {
|
|
|
102
111
|
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
103
112
|
throw new Error(`paypal: cannot perform refund kind ${kind}`);
|
|
104
113
|
}
|
|
105
|
-
const
|
|
106
|
-
if (!captureId) {
|
|
114
|
+
const rec = await this.store.byIntent(intentId);
|
|
115
|
+
if (!rec || !rec.captureId) {
|
|
107
116
|
throw new Error(`paypal: no capture for intent ${intentId}`);
|
|
108
117
|
}
|
|
118
|
+
const captureId = rec.captureId;
|
|
109
119
|
// A partial refund names the amount in major units; a full refund leaves it
|
|
110
120
|
// absent so PayPal returns the whole capture.
|
|
111
121
|
const params = kind === RefundKind.Partial
|
|
@@ -146,10 +156,11 @@ export class PaypalLeg {
|
|
|
146
156
|
// created the order, so a genuine, signed capture for a different order can
|
|
147
157
|
// carry a target intent's id. Settle only when the captured amount and
|
|
148
158
|
// currency match what the intent was quoted to collect.
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
159
|
+
const rec = await this.store.byIntent(intentId);
|
|
160
|
+
if (!rec) {
|
|
151
161
|
throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
|
|
152
162
|
}
|
|
163
|
+
const want = rec.expected;
|
|
153
164
|
if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
|
|
154
165
|
throw new Error(`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`);
|
|
155
166
|
}
|
|
@@ -232,8 +232,15 @@ function parseEvent(payload, header, secret, now, tolerance) {
|
|
|
232
232
|
verifySignature(payload, header, secret, now, tolerance);
|
|
233
233
|
const event = JSON.parse(new TextDecoder().decode(payload));
|
|
234
234
|
switch (event.type) {
|
|
235
|
-
case "payment_intent.succeeded":
|
|
236
|
-
|
|
235
|
+
case "payment_intent.succeeded": {
|
|
236
|
+
// The webhook is signed by Stripe, so its amounts are authentic. A capture for
|
|
237
|
+
// less than the authorized amount must not settle the corridor as fully paid.
|
|
238
|
+
const pi = decodePaymentIntent(event.data.object);
|
|
239
|
+
if (pi.amountReceived < pi.amount) {
|
|
240
|
+
return oneEvent(pi, State.Failed, "captured amount is less than the authorized amount");
|
|
241
|
+
}
|
|
242
|
+
return oneEvent(pi, State.Settled, "");
|
|
243
|
+
}
|
|
237
244
|
case "payment_intent.payment_failed":
|
|
238
245
|
return oneEvent(decodePaymentIntent(event.data.object), State.Failed, "payment failed");
|
|
239
246
|
case "charge.refunded":
|
|
@@ -244,13 +251,19 @@ function parseEvent(payload, header, secret, now, tolerance) {
|
|
|
244
251
|
}
|
|
245
252
|
function decodePaymentIntent(raw) {
|
|
246
253
|
const obj = raw;
|
|
247
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
id: obj.id ?? "",
|
|
256
|
+
metadata: obj.metadata ?? {},
|
|
257
|
+
created: obj.created ?? 0,
|
|
258
|
+
amount: obj.amount ?? 0,
|
|
259
|
+
amountReceived: obj.amount_received ?? 0,
|
|
260
|
+
};
|
|
248
261
|
}
|
|
249
262
|
// decodeRefundedIntent reads the pact intent id off a refunded charge. A charge
|
|
250
263
|
// carries the originating PaymentIntent id and copies its metadata.
|
|
251
264
|
function decodeRefundedIntent(raw) {
|
|
252
265
|
const charge = raw;
|
|
253
|
-
return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0 };
|
|
266
|
+
return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0, amount: 0, amountReceived: 0 };
|
|
254
267
|
}
|
|
255
268
|
function oneEvent(pi, state, reason) {
|
|
256
269
|
return [
|
|
@@ -341,7 +354,7 @@ class HttpStripeApi {
|
|
|
341
354
|
}
|
|
342
355
|
async send(path, init) {
|
|
343
356
|
const headers = {
|
|
344
|
-
...
|
|
357
|
+
...init.headers,
|
|
345
358
|
Authorization: `Bearer ${this.secretKey}`,
|
|
346
359
|
"Stripe-Version": apiVersion,
|
|
347
360
|
};
|
package/dist/src/canonical.js
CHANGED
|
@@ -51,6 +51,8 @@ export class CanonicalWriter {
|
|
|
51
51
|
// byte value so the encoding never depends on insertion order. The sort is on
|
|
52
52
|
// encoded bytes, not on UTF-16 code units, to match the other SDKs exactly.
|
|
53
53
|
stringMap(kv) {
|
|
54
|
+
// Object.keys returns a fresh array, so sorting it in place mutates nothing shared.
|
|
55
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
54
56
|
const keys = Object.keys(kv).sort(compareUtf8);
|
|
55
57
|
this.u64(keys.length);
|
|
56
58
|
for (const k of keys) {
|
package/dist/src/client.d.ts
CHANGED
|
@@ -65,6 +65,7 @@ export declare class Client {
|
|
|
65
65
|
private pickBridge;
|
|
66
66
|
select(quotes: Quote[], identity: string): Promise<Quote>;
|
|
67
67
|
authorize(intent: Intent, quote: Quote, signer: Signer): Promise<Authorization>;
|
|
68
|
+
private resolvePayIn;
|
|
68
69
|
initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State>;
|
|
69
70
|
interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{
|
|
70
71
|
preparation: PayInPreparation;
|
|
@@ -76,6 +77,7 @@ export declare class Client {
|
|
|
76
77
|
}>;
|
|
77
78
|
private holdAndDisburse;
|
|
78
79
|
private settleBridged;
|
|
80
|
+
private claimRefund;
|
|
79
81
|
private unwind;
|
|
80
82
|
refund(intentId: string, amount: Money, reason: string): Promise<{
|
|
81
83
|
settlement: Settlement;
|
package/dist/src/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { State } from "./state.js";
|
|
2
|
+
import { State, stateName } from "./state.js";
|
|
3
3
|
import { Money } from "./money.js";
|
|
4
4
|
import { DEFAULT_SKEW_MILLIS, domain } from "./protocol.js";
|
|
5
5
|
import { CanonicalWriter, hashPreimage } from "./canonical.js";
|
|
@@ -98,6 +98,9 @@ export class Client {
|
|
|
98
98
|
continue;
|
|
99
99
|
}
|
|
100
100
|
try {
|
|
101
|
+
// Corridors are priced in order so the router sees a stable ranking, matching
|
|
102
|
+
// the other SDKs; the loop is bounded by the configured funding options.
|
|
103
|
+
// eslint-disable-next-line no-await-in-loop
|
|
101
104
|
quotes.push(await this.composeQuote(intent, payInLeg, f));
|
|
102
105
|
}
|
|
103
106
|
catch (err) {
|
|
@@ -223,11 +226,9 @@ export class Client {
|
|
|
223
226
|
// driven forward by advance as provider events arrive, so no promise or
|
|
224
227
|
// connection is held open per payment and durable state lives only in the
|
|
225
228
|
// ledger.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
230
|
-
}
|
|
229
|
+
// resolvePayIn rejects an expired intent and returns the corridor's pay-in leg — the
|
|
230
|
+
// expiry-and-leg check the immediate and interactive starts share.
|
|
231
|
+
resolvePayIn(intent, quote) {
|
|
231
232
|
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
232
233
|
throw new Error("pact: intent has expired");
|
|
233
234
|
}
|
|
@@ -235,12 +236,26 @@ export class Client {
|
|
|
235
236
|
if (!payInLeg) {
|
|
236
237
|
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
237
238
|
}
|
|
239
|
+
return payInLeg;
|
|
240
|
+
}
|
|
241
|
+
async initiate(intent, quote, auth) {
|
|
242
|
+
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
243
|
+
if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
|
|
244
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
245
|
+
}
|
|
246
|
+
const payInLeg = this.resolvePayIn(intent, quote);
|
|
238
247
|
// A retry of an already-submitted corridor must not collect a second time. The
|
|
239
248
|
// pay-in leg carries its own provider idempotency for the narrow window where a
|
|
240
249
|
// collection completed but its record was lost.
|
|
241
|
-
const
|
|
242
|
-
if (
|
|
243
|
-
return
|
|
250
|
+
const current = this.ledger.state(intent.id);
|
|
251
|
+
if (current === State.Submitted || current === State.Collecting) {
|
|
252
|
+
return current;
|
|
253
|
+
}
|
|
254
|
+
// Collection may only start from an authorized intent. Without this a replayed
|
|
255
|
+
// or out-of-order call would charge the payer through collect before the ledger
|
|
256
|
+
// rejected the transition, so the guard is enforced before any money moves.
|
|
257
|
+
if (current !== State.Authorized) {
|
|
258
|
+
throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
|
|
244
259
|
}
|
|
245
260
|
// A direct corridor collects straight to the recipient; a bridged one
|
|
246
261
|
// collects into escrow first.
|
|
@@ -269,16 +284,16 @@ export class Client {
|
|
|
269
284
|
if (!isDirect(quote)) {
|
|
270
285
|
throw new Error("pact: interactive pay-in is only available on a direct corridor");
|
|
271
286
|
}
|
|
272
|
-
|
|
273
|
-
throw new Error("pact: intent has expired");
|
|
274
|
-
}
|
|
275
|
-
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
276
|
-
if (!payInLeg) {
|
|
277
|
-
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
278
|
-
}
|
|
287
|
+
const payInLeg = this.resolvePayIn(intent, quote);
|
|
279
288
|
if (!isInteractivePayInLeg(payInLeg)) {
|
|
280
289
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
|
|
281
290
|
}
|
|
291
|
+
// The provider intent may only be prepared from an authorized intent, so a
|
|
292
|
+
// replayed or out-of-order call cannot open a second collection.
|
|
293
|
+
const current = this.ledger.state(intent.id);
|
|
294
|
+
if (current !== State.Authorized) {
|
|
295
|
+
throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
|
|
296
|
+
}
|
|
282
297
|
const preparation = await payInLeg.prepare(intent.id, quote, auth, recipientDestination(intent));
|
|
283
298
|
// Record the same shape a direct collection would, so the confirming webhook
|
|
284
299
|
// advances the corridor to settled through the unchanged pay-in path.
|
|
@@ -335,6 +350,31 @@ export class Client {
|
|
|
335
350
|
return this.unwind(intentId, quote, authHash, event.reason);
|
|
336
351
|
return this.settleBridged(intentId, quote, authHash, events);
|
|
337
352
|
}
|
|
353
|
+
// A refund reported out of band — an operator refunds in the provider's
|
|
354
|
+
// dashboard, say — is recorded against the settled intent so the ledger matches
|
|
355
|
+
// where the money actually is. The provider has already returned the funds, so
|
|
356
|
+
// this only claims the refunding step (once, so a duplicate webhook is a no-op)
|
|
357
|
+
// and records the refunded outcome; it moves no money itself.
|
|
358
|
+
if (event.state === State.Refunded && current === State.Settled) {
|
|
359
|
+
const { created } = this.ledger.apply({
|
|
360
|
+
intentId,
|
|
361
|
+
to: State.Refunding,
|
|
362
|
+
payloadHash: hashString(domain("refunding"), event.reason),
|
|
363
|
+
});
|
|
364
|
+
if (!created)
|
|
365
|
+
return { settlement: lastSettlement(events), state: this.ledger.state(intentId) };
|
|
366
|
+
const settlement = {
|
|
367
|
+
intentId,
|
|
368
|
+
state: State.Refunded,
|
|
369
|
+
adapterId,
|
|
370
|
+
providerTxRef: event.providerTxRef,
|
|
371
|
+
onchainTxHash: "",
|
|
372
|
+
receiptHash: corridorReceipt(authHash, "", event.providerTxRef, quote.srcAmount, "", State.Refunded),
|
|
373
|
+
reason: event.reason,
|
|
374
|
+
settledAt: this.clock(),
|
|
375
|
+
};
|
|
376
|
+
return this.finishAdvance(intentId, State.Refunded, settlement);
|
|
377
|
+
}
|
|
338
378
|
// Any other event — a duplicate webhook, or one for a phase already past — is
|
|
339
379
|
// a no-op. This is what makes a re-delivered provider callback settle once.
|
|
340
380
|
return { settlement: lastSettlement(events), state: current };
|
|
@@ -439,14 +479,29 @@ export class Client {
|
|
|
439
479
|
};
|
|
440
480
|
return this.finishAdvance(intentId, State.Settled, settlement);
|
|
441
481
|
}
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
482
|
+
// claimRefund reserves the refunding step before any money moves. It returns the
|
|
483
|
+
// recorded outcome to echo when another refund already claimed the step, or null when
|
|
484
|
+
// this call won it and should proceed to move funds. Both refund paths go through it,
|
|
485
|
+
// so a refund is issued at most once however the calls race.
|
|
486
|
+
claimRefund(intentId, reason) {
|
|
487
|
+
const { created } = this.ledger.apply({
|
|
446
488
|
intentId,
|
|
447
489
|
to: State.Refunding,
|
|
448
490
|
payloadHash: hashString(domain("refunding"), reason),
|
|
449
491
|
});
|
|
492
|
+
if (created)
|
|
493
|
+
return null;
|
|
494
|
+
return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
|
|
495
|
+
}
|
|
496
|
+
// unwind refunds the payer from escrow when a bridged corridor cannot complete
|
|
497
|
+
// its pay-out, moving to refunding then refunded.
|
|
498
|
+
async unwind(intentId, quote, authHash, reason) {
|
|
499
|
+
// Claim the refund before moving money. A duplicate pay-out-failed webhook enters
|
|
500
|
+
// unwind twice; only the call that wins the refunding step may issue the counter-
|
|
501
|
+
// transfer, so the refund cannot fire a second time.
|
|
502
|
+
const echo = this.claimRefund(intentId, reason);
|
|
503
|
+
if (echo)
|
|
504
|
+
return echo;
|
|
450
505
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
451
506
|
if (payInLeg) {
|
|
452
507
|
const kind = payInLeg.payInCapabilities().refunds;
|
|
@@ -513,6 +568,12 @@ export class Client {
|
|
|
513
568
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
|
|
514
569
|
}
|
|
515
570
|
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
571
|
+
// Claim the refunding step before moving money. Two overlapping refund calls both
|
|
572
|
+
// pass the settled check above; only the one that wins this step issues the return,
|
|
573
|
+
// so the payer is never refunded twice.
|
|
574
|
+
const echo = this.claimRefund(intentId, reason);
|
|
575
|
+
if (echo)
|
|
576
|
+
return echo;
|
|
516
577
|
await payInLeg.refundIn(intentId, kind, amount, reason);
|
|
517
578
|
const settlement = {
|
|
518
579
|
intentId,
|
package/dist/src/message.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Money } from "./money.js";
|
|
2
2
|
import { State } from "./state.js";
|
|
3
|
+
import { CanonicalWriter } from "./canonical.js";
|
|
3
4
|
export interface Intent {
|
|
4
5
|
id: string;
|
|
5
6
|
senderRef: string;
|
|
@@ -29,6 +30,7 @@ export interface Quote {
|
|
|
29
30
|
providerQuoteRef: string;
|
|
30
31
|
latencyEstimateMs: number;
|
|
31
32
|
}
|
|
33
|
+
export declare function writeQuoteFields(w: CanonicalWriter, q: Quote): CanonicalWriter;
|
|
32
34
|
export declare function quotePreimage(q: Quote): Uint8Array;
|
|
33
35
|
export declare function quoteHash(q: Quote): Uint8Array;
|
|
34
36
|
export declare function isDirect(q: Quote): boolean;
|