@myzonerocks/pact 0.1.4 → 0.1.6
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/client.d.ts +2 -0
- package/dist/src/client.js +40 -16
- package/dist/src/state.js +3 -1
- 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/client.ts +40 -17
- package/src/state.ts +3 -1
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
|
}
|
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
|
@@ -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,6 +236,14 @@ 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.
|
|
@@ -269,13 +278,7 @@ export class Client {
|
|
|
269
278
|
if (!isDirect(quote)) {
|
|
270
279
|
throw new Error("pact: interactive pay-in is only available on a direct corridor");
|
|
271
280
|
}
|
|
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
|
-
}
|
|
281
|
+
const payInLeg = this.resolvePayIn(intent, quote);
|
|
279
282
|
if (!isInteractivePayInLeg(payInLeg)) {
|
|
280
283
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
|
|
281
284
|
}
|
|
@@ -439,14 +442,29 @@ export class Client {
|
|
|
439
442
|
};
|
|
440
443
|
return this.finishAdvance(intentId, State.Settled, settlement);
|
|
441
444
|
}
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
445
|
+
// claimRefund reserves the refunding step before any money moves. It returns the
|
|
446
|
+
// recorded outcome to echo when another refund already claimed the step, or null when
|
|
447
|
+
// this call won it and should proceed to move funds. Both refund paths go through it,
|
|
448
|
+
// so a refund is issued at most once however the calls race.
|
|
449
|
+
claimRefund(intentId, reason) {
|
|
450
|
+
const { created } = this.ledger.apply({
|
|
446
451
|
intentId,
|
|
447
452
|
to: State.Refunding,
|
|
448
453
|
payloadHash: hashString(domain("refunding"), reason),
|
|
449
454
|
});
|
|
455
|
+
if (created)
|
|
456
|
+
return null;
|
|
457
|
+
return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
|
|
458
|
+
}
|
|
459
|
+
// unwind refunds the payer from escrow when a bridged corridor cannot complete
|
|
460
|
+
// its pay-out, moving to refunding then refunded.
|
|
461
|
+
async unwind(intentId, quote, authHash, reason) {
|
|
462
|
+
// Claim the refund before moving money. A duplicate pay-out-failed webhook enters
|
|
463
|
+
// unwind twice; only the call that wins the refunding step may issue the counter-
|
|
464
|
+
// transfer, so the refund cannot fire a second time.
|
|
465
|
+
const echo = this.claimRefund(intentId, reason);
|
|
466
|
+
if (echo)
|
|
467
|
+
return echo;
|
|
450
468
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
451
469
|
if (payInLeg) {
|
|
452
470
|
const kind = payInLeg.payInCapabilities().refunds;
|
|
@@ -513,6 +531,12 @@ export class Client {
|
|
|
513
531
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
|
|
514
532
|
}
|
|
515
533
|
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
534
|
+
// Claim the refunding step before moving money. Two overlapping refund calls both
|
|
535
|
+
// pass the settled check above; only the one that wins this step issues the return,
|
|
536
|
+
// so the payer is never refunded twice.
|
|
537
|
+
const echo = this.claimRefund(intentId, reason);
|
|
538
|
+
if (echo)
|
|
539
|
+
return echo;
|
|
516
540
|
await payInLeg.refundIn(intentId, kind, amount, reason);
|
|
517
541
|
const settlement = {
|
|
518
542
|
intentId,
|
package/dist/src/state.js
CHANGED
|
@@ -51,7 +51,9 @@ const transitions = {
|
|
|
51
51
|
[State.Held]: new Set([State.Disbursing, State.Refunding, State.Expired]),
|
|
52
52
|
[State.Disbursing]: new Set([State.Settled, State.Failed, State.Refunding]),
|
|
53
53
|
[State.Refunding]: new Set([State.Refunded, State.Failed]),
|
|
54
|
-
|
|
54
|
+
// A settled intent refunds through the same refunding step a bridged unwind uses,
|
|
55
|
+
// so the refund is claimed before any money moves and cannot fire twice.
|
|
56
|
+
[State.Settled]: new Set([State.Refunding, State.Refunded]),
|
|
55
57
|
[State.Failed]: new Set(),
|
|
56
58
|
[State.Expired]: new Set(),
|
|
57
59
|
[State.Refunded]: new Set(),
|
package/package.json
CHANGED
package/src/adapters/erc20.ts
CHANGED
|
@@ -74,6 +74,37 @@ export interface Erc20Config {
|
|
|
74
74
|
// The confirmation depth a transfer must reach before it settles. Omitted uses
|
|
75
75
|
// defaultMinConfirmations.
|
|
76
76
|
minConfirmations?: number;
|
|
77
|
+
// Store persists broadcast transfers; defaults to an in-memory store. A deployment
|
|
78
|
+
// that must survive a restart with transfers in flight supplies a durable one.
|
|
79
|
+
store?: SentStore;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// SentRecord is the broadcast transfer a later settlement reads back: the intent, the
|
|
83
|
+
// transaction hash, and the recipient and amount the on-chain receipt must match.
|
|
84
|
+
export interface SentRecord {
|
|
85
|
+
intentId: string;
|
|
86
|
+
txHash: string;
|
|
87
|
+
to: string;
|
|
88
|
+
amount: bigint;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// SentStore records a broadcast transfer so its settlement can be polled after a
|
|
92
|
+
// restart and a repeat for the same intent returns the first transfer. The default
|
|
93
|
+
// keeps records in memory; a durable one survives a restart.
|
|
94
|
+
export interface SentStore {
|
|
95
|
+
save(rec: SentRecord): Promise<void>;
|
|
96
|
+
byIntent(intentId: string): Promise<SentRecord | undefined>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// MemorySentStore is the default in-process store.
|
|
100
|
+
export class MemorySentStore implements SentStore {
|
|
101
|
+
private readonly byIntentMap = new Map<string, SentRecord>();
|
|
102
|
+
async save(rec: SentRecord): Promise<void> {
|
|
103
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
104
|
+
}
|
|
105
|
+
async byIntent(intentId: string): Promise<SentRecord | undefined> {
|
|
106
|
+
return this.byIntentMap.get(intentId);
|
|
107
|
+
}
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
// Erc20Leg settles a payment as an ERC-20 token transfer. Refunds are a
|
|
@@ -87,9 +118,9 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
87
118
|
private readonly ids: () => string;
|
|
88
119
|
private readonly decimals: number;
|
|
89
120
|
private readonly minConf: number;
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
private readonly
|
|
121
|
+
// Broadcast transfers, so a settlement can confirm the mined transaction really
|
|
122
|
+
// matches the payment, and a repeat returns the first transfer.
|
|
123
|
+
private readonly store: SentStore;
|
|
93
124
|
|
|
94
125
|
constructor(cfg: Erc20Config) {
|
|
95
126
|
if (!cfg.token || !cfg.currency) {
|
|
@@ -108,6 +139,7 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
108
139
|
this.ids = cfg.ids;
|
|
109
140
|
this.decimals = cfg.decimals;
|
|
110
141
|
this.minConf = cfg.minConfirmations ?? defaultMinConfirmations;
|
|
142
|
+
this.store = cfg.store ?? new MemorySentStore();
|
|
111
143
|
}
|
|
112
144
|
|
|
113
145
|
// checkScale refuses an amount whose exponent does not match the token's
|
|
@@ -138,12 +170,12 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
138
170
|
const net = quote.srcAmount.sub(quote.fees);
|
|
139
171
|
// A repeat for the same intent returns the transfer already broadcast rather
|
|
140
172
|
// than sending the payer's tokens twice.
|
|
141
|
-
const prev = this.
|
|
173
|
+
const prev = await this.store.byIntent(intentId);
|
|
142
174
|
if (prev) {
|
|
143
175
|
return { providerRef: prev.txHash, received: net };
|
|
144
176
|
}
|
|
145
177
|
const txHash = await this.transfer(deliverTo, net.value());
|
|
146
|
-
this.
|
|
178
|
+
await this.store.save({ intentId, txHash, to: normalizeAddress(deliverTo), amount: net.value() });
|
|
147
179
|
return { providerRef: txHash, received: net };
|
|
148
180
|
}
|
|
149
181
|
|
|
@@ -152,13 +184,13 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
152
184
|
this.checkScale(quote.dstAmount);
|
|
153
185
|
// A repeat for the same intent returns the transfer already broadcast rather
|
|
154
186
|
// than delivering the recipient's tokens twice.
|
|
155
|
-
const prev = this.
|
|
187
|
+
const prev = await this.store.byIntent(intentId);
|
|
156
188
|
if (prev) {
|
|
157
189
|
return { providerRef: prev.txHash };
|
|
158
190
|
}
|
|
159
191
|
const amount = quote.dstAmount.value();
|
|
160
192
|
const txHash = await this.transfer(recipientRef, amount);
|
|
161
|
-
this.
|
|
193
|
+
await this.store.save({ intentId, txHash, to: normalizeAddress(recipientRef), amount });
|
|
162
194
|
return { providerRef: txHash };
|
|
163
195
|
}
|
|
164
196
|
|
|
@@ -184,7 +216,7 @@ export class Erc20Leg implements PayInLeg, PayOutLeg {
|
|
|
184
216
|
// shallow success stays submitted so the host keeps polling; a revert or a
|
|
185
217
|
// mismatch fails, so a dropped, reorged, or spoofed transfer never settles.
|
|
186
218
|
async settlementEvent(intentId: string): Promise<AdapterEvent> {
|
|
187
|
-
const rec = this.
|
|
219
|
+
const rec = await this.store.byIntent(intentId);
|
|
188
220
|
if (!rec) {
|
|
189
221
|
throw new Error("erc20: no broadcast transaction for intent");
|
|
190
222
|
}
|
package/src/adapters/mpesa.ts
CHANGED
|
@@ -53,6 +53,10 @@ export interface B2CParams {
|
|
|
53
53
|
phone: string;
|
|
54
54
|
reference: string;
|
|
55
55
|
remarks: string;
|
|
56
|
+
// Same across retries of one logical transfer and distinct between different
|
|
57
|
+
// transfers on the same intent (a payout versus a refund), so Daraja collapses a
|
|
58
|
+
// retry but never merges two separate movements.
|
|
59
|
+
idempotencyKey: string;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
// B2CResult is what Daraja returns when a payout is accepted for delivery.
|
|
@@ -82,14 +86,40 @@ export interface DarajaApi {
|
|
|
82
86
|
query(checkoutRequestId: string): Promise<StkQueryResult>;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
//
|
|
86
|
-
// the
|
|
87
|
-
// refund,
|
|
88
|
-
interface PushRecord {
|
|
89
|
+
// PushRecord is what settling or refunding an M-Pesa collection needs after the STK
|
|
90
|
+
// push: the intent, the checkout id the unsigned callback arrives under, the payer to
|
|
91
|
+
// refund, and the amount authorized.
|
|
92
|
+
export interface PushRecord {
|
|
89
93
|
intentId: string;
|
|
94
|
+
checkoutId: string;
|
|
90
95
|
payerPhone: string;
|
|
91
96
|
amount: number;
|
|
92
|
-
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// PushStore holds push records between initiating a collection and the callback that
|
|
100
|
+
// resolves it. The default store keeps them in memory; a deployment that runs more than
|
|
101
|
+
// one instance, or must survive a restart with collections in flight, supplies a
|
|
102
|
+
// durable one so a callback never arrives to find its checkout forgotten.
|
|
103
|
+
export interface PushStore {
|
|
104
|
+
save(rec: PushRecord): Promise<void>;
|
|
105
|
+
byIntent(intentId: string): Promise<PushRecord | undefined>;
|
|
106
|
+
byCheckout(checkoutId: string): Promise<PushRecord | undefined>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// MemoryPushStore is the default in-process store.
|
|
110
|
+
export class MemoryPushStore implements PushStore {
|
|
111
|
+
private readonly byIntentMap = new Map<string, PushRecord>();
|
|
112
|
+
private readonly byCheckoutMap = new Map<string, PushRecord>();
|
|
113
|
+
async save(rec: PushRecord): Promise<void> {
|
|
114
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
115
|
+
if (rec.checkoutId) this.byCheckoutMap.set(rec.checkoutId, rec);
|
|
116
|
+
}
|
|
117
|
+
async byIntent(intentId: string): Promise<PushRecord | undefined> {
|
|
118
|
+
return this.byIntentMap.get(intentId);
|
|
119
|
+
}
|
|
120
|
+
async byCheckout(checkoutId: string): Promise<PushRecord | undefined> {
|
|
121
|
+
return this.byCheckoutMap.get(checkoutId);
|
|
122
|
+
}
|
|
93
123
|
}
|
|
94
124
|
|
|
95
125
|
export interface MpesaConfig {
|
|
@@ -97,6 +127,9 @@ export interface MpesaConfig {
|
|
|
97
127
|
api: DarajaApi;
|
|
98
128
|
callbackURL: string;
|
|
99
129
|
ids: () => string;
|
|
130
|
+
// Store persists push records; defaults to an in-memory store. A deployment that
|
|
131
|
+
// scales beyond one instance or must survive a restart supplies a durable one.
|
|
132
|
+
store?: PushStore;
|
|
100
133
|
}
|
|
101
134
|
|
|
102
135
|
// MpesaLeg moves mobile money over M-Pesa.
|
|
@@ -106,8 +139,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
106
139
|
private readonly callbackURL: string;
|
|
107
140
|
private readonly ids: () => string;
|
|
108
141
|
|
|
109
|
-
private readonly
|
|
110
|
-
private readonly byCheckout = new Map<string, PushRecord>();
|
|
142
|
+
private readonly store: PushStore;
|
|
111
143
|
|
|
112
144
|
constructor(cfg: MpesaConfig) {
|
|
113
145
|
if (!cfg.callbackURL) {
|
|
@@ -117,6 +149,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
117
149
|
this.api = cfg.api;
|
|
118
150
|
this.callbackURL = cfg.callbackURL;
|
|
119
151
|
this.ids = cfg.ids;
|
|
152
|
+
this.store = cfg.store ?? new MemoryPushStore();
|
|
120
153
|
}
|
|
121
154
|
|
|
122
155
|
payInCapabilities(): PayInCapabilities {
|
|
@@ -140,7 +173,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
140
173
|
if (!phone) {
|
|
141
174
|
throw new Error("mpesa: collect requires the payer's phone");
|
|
142
175
|
}
|
|
143
|
-
const rec: PushRecord = { intentId, payerPhone: phone, amount, state: State.Unspecified };
|
|
144
176
|
const result = await this.api.stkPush({
|
|
145
177
|
amount,
|
|
146
178
|
payerPhone: phone,
|
|
@@ -148,8 +180,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
148
180
|
description: "payment",
|
|
149
181
|
callbackURL: this.callbackURL,
|
|
150
182
|
});
|
|
151
|
-
this.
|
|
152
|
-
this.byCheckout.set(result.checkoutRequestId, rec);
|
|
183
|
+
await this.store.save({ intentId, checkoutId: result.checkoutRequestId, payerPhone: phone, amount });
|
|
153
184
|
return { providerRef: result.checkoutRequestId, received: net };
|
|
154
185
|
}
|
|
155
186
|
|
|
@@ -161,7 +192,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
161
192
|
if (!phone) {
|
|
162
193
|
throw new Error("mpesa: disburse requires the recipient's phone");
|
|
163
194
|
}
|
|
164
|
-
const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout" });
|
|
195
|
+
const result = await this.api.b2cPayment({ amount, phone, reference: intentId, remarks: "payout", idempotencyKey: `pact:payout:${intentId}` });
|
|
165
196
|
return { providerRef: result.conversationId };
|
|
166
197
|
}
|
|
167
198
|
|
|
@@ -173,7 +204,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
173
204
|
throw new Error("mpesa: a collection can only be refunded by counter-transfer");
|
|
174
205
|
}
|
|
175
206
|
const shillings = wholeShillings(amount);
|
|
176
|
-
const rec = this.byIntent
|
|
207
|
+
const rec = await this.store.byIntent(intentId);
|
|
177
208
|
if (!rec) {
|
|
178
209
|
throw new Error(`mpesa: no push for intent ${intentId}`);
|
|
179
210
|
}
|
|
@@ -186,6 +217,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
186
217
|
phone: rec.payerPhone,
|
|
187
218
|
reference: intentId,
|
|
188
219
|
remarks: reason,
|
|
220
|
+
idempotencyKey: `pact:refund:${intentId}`,
|
|
189
221
|
});
|
|
190
222
|
return {
|
|
191
223
|
intentId,
|
|
@@ -215,7 +247,7 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
215
247
|
async parseWebhook(raw: Uint8Array, _headers: Record<string, string[]>): Promise<AdapterEvent[]> {
|
|
216
248
|
const envelope = JSON.parse(new TextDecoder().decode(raw)) as StkCallbackEnvelope;
|
|
217
249
|
const cb = envelope.Body?.stkCallback;
|
|
218
|
-
const rec = cb ? this.byCheckout
|
|
250
|
+
const rec = cb ? await this.store.byCheckout(cb.CheckoutRequestID) : undefined;
|
|
219
251
|
if (!cb || !rec) {
|
|
220
252
|
throw new Error(ErrUnknownCheckout);
|
|
221
253
|
}
|
|
@@ -227,7 +259,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
227
259
|
return [];
|
|
228
260
|
}
|
|
229
261
|
if (confirmed.resultCode !== 0) {
|
|
230
|
-
rec.state = State.Failed;
|
|
231
262
|
return [
|
|
232
263
|
{
|
|
233
264
|
intentId: rec.intentId,
|
|
@@ -247,7 +278,6 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
247
278
|
}
|
|
248
279
|
|
|
249
280
|
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
250
|
-
rec.state = State.Settled;
|
|
251
281
|
return [
|
|
252
282
|
{
|
|
253
283
|
intentId: rec.intentId,
|
|
@@ -447,6 +477,7 @@ class HttpDarajaApi implements DarajaApi {
|
|
|
447
477
|
async b2cPayment(params: B2CParams): Promise<B2CResult> {
|
|
448
478
|
const token = await this.token();
|
|
449
479
|
const body = {
|
|
480
|
+
OriginatorConversationID: params.idempotencyKey,
|
|
450
481
|
InitiatorName: this.creds.shortCode,
|
|
451
482
|
CommandID: "BusinessPayment",
|
|
452
483
|
Amount: params.amount,
|
package/src/adapters/paypal.ts
CHANGED
|
@@ -113,6 +113,37 @@ export interface PaypalConfig {
|
|
|
113
113
|
currencies: string[];
|
|
114
114
|
api: PaypalApi;
|
|
115
115
|
ids: () => string;
|
|
116
|
+
// Store persists capture records; defaults to an in-memory store. A deployment that
|
|
117
|
+
// scales beyond one instance or must survive a restart supplies a durable one.
|
|
118
|
+
store?: CaptureStore;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// CaptureRecord is what settling or refunding a PayPal collection needs: the intent,
|
|
122
|
+
// the capture id a refund binds to (empty until a server-side capture returns one),
|
|
123
|
+
// and the amount the intent was quoted to collect.
|
|
124
|
+
export interface CaptureRecord {
|
|
125
|
+
intentId: string;
|
|
126
|
+
captureId: string;
|
|
127
|
+
expected: { value: string; currency: string };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// CaptureStore holds capture records between creating an order and the webhook that
|
|
131
|
+
// resolves it. The default keeps them in memory; a durable one lets a capture webhook
|
|
132
|
+
// resolve across a restart or on a second instance.
|
|
133
|
+
export interface CaptureStore {
|
|
134
|
+
save(rec: CaptureRecord): Promise<void>;
|
|
135
|
+
byIntent(intentId: string): Promise<CaptureRecord | undefined>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// MemoryCaptureStore is the default in-process store.
|
|
139
|
+
export class MemoryCaptureStore implements CaptureStore {
|
|
140
|
+
private readonly byIntentMap = new Map<string, CaptureRecord>();
|
|
141
|
+
async save(rec: CaptureRecord): Promise<void> {
|
|
142
|
+
this.byIntentMap.set(rec.intentId, rec);
|
|
143
|
+
}
|
|
144
|
+
async byIntent(intentId: string): Promise<CaptureRecord | undefined> {
|
|
145
|
+
return this.byIntentMap.get(intentId);
|
|
146
|
+
}
|
|
116
147
|
}
|
|
117
148
|
|
|
118
149
|
// PaypalLeg moves money over PayPal, serving both sides of a corridor.
|
|
@@ -121,16 +152,14 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
121
152
|
private readonly currencies: string[];
|
|
122
153
|
private readonly api: PaypalApi;
|
|
123
154
|
private readonly ids: () => string;
|
|
124
|
-
private readonly
|
|
125
|
-
// What each intent was quoted to collect, so a capture webhook can be
|
|
126
|
-
// cross-checked: the paid amount and currency must match before it settles.
|
|
127
|
-
private readonly expected = new Map<string, { value: string; currency: string }>();
|
|
155
|
+
private readonly store: CaptureStore;
|
|
128
156
|
|
|
129
157
|
constructor(cfg: PaypalConfig) {
|
|
130
158
|
this.id = cfg.id ?? "paypal";
|
|
131
159
|
this.currencies = cfg.currencies;
|
|
132
160
|
this.api = cfg.api;
|
|
133
161
|
this.ids = cfg.ids;
|
|
162
|
+
this.store = cfg.store ?? new MemoryCaptureStore();
|
|
134
163
|
}
|
|
135
164
|
|
|
136
165
|
payInCapabilities(): PayInCapabilities {
|
|
@@ -156,10 +185,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
156
185
|
payee: deliverTo,
|
|
157
186
|
referenceId: referencePrefix + intentId,
|
|
158
187
|
});
|
|
159
|
-
this.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
currency: quote.srcAmount.currency,
|
|
188
|
+
await this.store.save({
|
|
189
|
+
intentId,
|
|
190
|
+
captureId: capture.captureId,
|
|
191
|
+
expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
|
|
163
192
|
});
|
|
164
193
|
return { providerRef: capture.captureId, received: quote.srcAmount.sub(quote.fees) };
|
|
165
194
|
}
|
|
@@ -181,9 +210,10 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
181
210
|
params.platformFee = majorAmount(quote.fees.minor(), quote.fees.exponent);
|
|
182
211
|
}
|
|
183
212
|
const order = await this.api.createOrder(params);
|
|
184
|
-
this.
|
|
185
|
-
|
|
186
|
-
|
|
213
|
+
await this.store.save({
|
|
214
|
+
intentId,
|
|
215
|
+
captureId: "",
|
|
216
|
+
expected: { value: majorAmount(quote.srcAmount.minor(), quote.srcAmount.exponent), currency: quote.srcAmount.currency },
|
|
187
217
|
});
|
|
188
218
|
return { providerRef: order.id, clientSecret: order.id, method: "paypal" };
|
|
189
219
|
}
|
|
@@ -207,10 +237,11 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
207
237
|
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
208
238
|
throw new Error(`paypal: cannot perform refund kind ${kind}`);
|
|
209
239
|
}
|
|
210
|
-
const
|
|
211
|
-
if (!captureId) {
|
|
240
|
+
const rec = await this.store.byIntent(intentId);
|
|
241
|
+
if (!rec || !rec.captureId) {
|
|
212
242
|
throw new Error(`paypal: no capture for intent ${intentId}`);
|
|
213
243
|
}
|
|
244
|
+
const captureId = rec.captureId;
|
|
214
245
|
// A partial refund names the amount in major units; a full refund leaves it
|
|
215
246
|
// absent so PayPal returns the whole capture.
|
|
216
247
|
const params: RefundParams =
|
|
@@ -255,10 +286,11 @@ export class PaypalLeg implements InteractivePayInLeg, PayOutLeg {
|
|
|
255
286
|
// created the order, so a genuine, signed capture for a different order can
|
|
256
287
|
// carry a target intent's id. Settle only when the captured amount and
|
|
257
288
|
// currency match what the intent was quoted to collect.
|
|
258
|
-
const
|
|
259
|
-
if (!
|
|
289
|
+
const rec = await this.store.byIntent(intentId);
|
|
290
|
+
if (!rec) {
|
|
260
291
|
throw new Error(`paypal: capture for unknown intent ${JSON.stringify(intentId)}`);
|
|
261
292
|
}
|
|
293
|
+
const want = rec.expected;
|
|
262
294
|
if (resource.amount?.value !== want.value || resource.amount?.currency_code !== want.currency) {
|
|
263
295
|
throw new Error(
|
|
264
296
|
`paypal: captured ${resource.amount?.value} ${resource.amount?.currency_code} does not match the quoted ${want.value} ${want.currency}`,
|
package/src/client.ts
CHANGED
|
@@ -164,6 +164,9 @@ export class Client {
|
|
|
164
164
|
continue;
|
|
165
165
|
}
|
|
166
166
|
try {
|
|
167
|
+
// Corridors are priced in order so the router sees a stable ranking, matching
|
|
168
|
+
// the other SDKs; the loop is bounded by the configured funding options.
|
|
169
|
+
// eslint-disable-next-line no-await-in-loop
|
|
167
170
|
quotes.push(await this.composeQuote(intent, payInLeg, f));
|
|
168
171
|
} catch (err) {
|
|
169
172
|
lastErr = err;
|
|
@@ -289,11 +292,9 @@ export class Client {
|
|
|
289
292
|
// driven forward by advance as provider events arrive, so no promise or
|
|
290
293
|
// connection is held open per payment and durable state lives only in the
|
|
291
294
|
// ledger.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
296
|
-
}
|
|
295
|
+
// resolvePayIn rejects an expired intent and returns the corridor's pay-in leg — the
|
|
296
|
+
// expiry-and-leg check the immediate and interactive starts share.
|
|
297
|
+
private resolvePayIn(intent: Intent, quote: Quote): PayInLeg {
|
|
297
298
|
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
298
299
|
throw new Error("pact: intent has expired");
|
|
299
300
|
}
|
|
@@ -301,6 +302,15 @@ export class Client {
|
|
|
301
302
|
if (!payInLeg) {
|
|
302
303
|
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
303
304
|
}
|
|
305
|
+
return payInLeg;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
|
|
309
|
+
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
310
|
+
if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
|
|
311
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
312
|
+
}
|
|
313
|
+
const payInLeg = this.resolvePayIn(intent, quote);
|
|
304
314
|
|
|
305
315
|
// A retry of an already-submitted corridor must not collect a second time. The
|
|
306
316
|
// pay-in leg carries its own provider idempotency for the narrow window where a
|
|
@@ -338,13 +348,7 @@ export class Client {
|
|
|
338
348
|
if (!isDirect(quote)) {
|
|
339
349
|
throw new Error("pact: interactive pay-in is only available on a direct corridor");
|
|
340
350
|
}
|
|
341
|
-
|
|
342
|
-
throw new Error("pact: intent has expired");
|
|
343
|
-
}
|
|
344
|
-
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
345
|
-
if (!payInLeg) {
|
|
346
|
-
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
347
|
-
}
|
|
351
|
+
const payInLeg = this.resolvePayIn(intent, quote);
|
|
348
352
|
if (!isInteractivePayInLeg(payInLeg)) {
|
|
349
353
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
|
|
350
354
|
}
|
|
@@ -521,6 +525,20 @@ export class Client {
|
|
|
521
525
|
return this.finishAdvance(intentId, State.Settled, settlement);
|
|
522
526
|
}
|
|
523
527
|
|
|
528
|
+
// claimRefund reserves the refunding step before any money moves. It returns the
|
|
529
|
+
// recorded outcome to echo when another refund already claimed the step, or null when
|
|
530
|
+
// this call won it and should proceed to move funds. Both refund paths go through it,
|
|
531
|
+
// so a refund is issued at most once however the calls race.
|
|
532
|
+
private claimRefund(intentId: string, reason: string): { settlement: Settlement; state: State } | null {
|
|
533
|
+
const { created } = this.ledger.apply({
|
|
534
|
+
intentId,
|
|
535
|
+
to: State.Refunding,
|
|
536
|
+
payloadHash: hashString(domain("refunding"), reason),
|
|
537
|
+
});
|
|
538
|
+
if (created) return null;
|
|
539
|
+
return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
|
|
540
|
+
}
|
|
541
|
+
|
|
524
542
|
// unwind refunds the payer from escrow when a bridged corridor cannot complete
|
|
525
543
|
// its pay-out, moving to refunding then refunded.
|
|
526
544
|
private async unwind(
|
|
@@ -529,11 +547,11 @@ export class Client {
|
|
|
529
547
|
authHash: Uint8Array,
|
|
530
548
|
reason: string,
|
|
531
549
|
): Promise<{ settlement: Settlement; state: State }> {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
550
|
+
// Claim the refund before moving money. A duplicate pay-out-failed webhook enters
|
|
551
|
+
// unwind twice; only the call that wins the refunding step may issue the counter-
|
|
552
|
+
// transfer, so the refund cannot fire a second time.
|
|
553
|
+
const echo = this.claimRefund(intentId, reason);
|
|
554
|
+
if (echo) return echo;
|
|
537
555
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
538
556
|
if (payInLeg) {
|
|
539
557
|
const kind = payInLeg.payInCapabilities().refunds;
|
|
@@ -601,6 +619,11 @@ export class Client {
|
|
|
601
619
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
|
|
602
620
|
}
|
|
603
621
|
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
622
|
+
// Claim the refunding step before moving money. Two overlapping refund calls both
|
|
623
|
+
// pass the settled check above; only the one that wins this step issues the return,
|
|
624
|
+
// so the payer is never refunded twice.
|
|
625
|
+
const echo = this.claimRefund(intentId, reason);
|
|
626
|
+
if (echo) return echo;
|
|
604
627
|
await payInLeg.refundIn(intentId, kind, amount, reason);
|
|
605
628
|
const settlement: Settlement = {
|
|
606
629
|
intentId,
|
package/src/state.ts
CHANGED
|
@@ -52,7 +52,9 @@ const transitions: Record<State, ReadonlySet<State>> = {
|
|
|
52
52
|
[State.Held]: new Set([State.Disbursing, State.Refunding, State.Expired]),
|
|
53
53
|
[State.Disbursing]: new Set([State.Settled, State.Failed, State.Refunding]),
|
|
54
54
|
[State.Refunding]: new Set([State.Refunded, State.Failed]),
|
|
55
|
-
|
|
55
|
+
// A settled intent refunds through the same refunding step a bridged unwind uses,
|
|
56
|
+
// so the refund is claimed before any money moves and cannot fire twice.
|
|
57
|
+
[State.Settled]: new Set([State.Refunding, State.Refunded]),
|
|
56
58
|
[State.Failed]: new Set(),
|
|
57
59
|
[State.Expired]: new Set(),
|
|
58
60
|
[State.Refunded]: new Set(),
|