@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/dist/src/message.js
CHANGED
|
@@ -20,9 +20,11 @@ export function intentHash(i) {
|
|
|
20
20
|
// The bridge that moves value unchanged when the payer and recipient already
|
|
21
21
|
// share a currency. Its identifier is a stable wire value carried in a quote.
|
|
22
22
|
export const BRIDGE_PASSTHROUGH = "passthrough";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// writeQuoteFields appends a quote's fields to a writer in the one canonical order
|
|
24
|
+
// shared by the signing preimage and the wire codec. The two callers differ only in
|
|
25
|
+
// what frames the fields: the preimage prepends a domain tag, the wire form does not.
|
|
26
|
+
export function writeQuoteFields(w, q) {
|
|
27
|
+
return w
|
|
26
28
|
.str(q.id)
|
|
27
29
|
.str(q.intentId)
|
|
28
30
|
.str(q.payInAdapterId)
|
|
@@ -36,8 +38,11 @@ export function quotePreimage(q) {
|
|
|
36
38
|
.str(q.fxRate)
|
|
37
39
|
.u64(q.expiresAt)
|
|
38
40
|
.str(q.providerQuoteRef)
|
|
39
|
-
.u64(q.latencyEstimateMs)
|
|
40
|
-
|
|
41
|
+
.u64(q.latencyEstimateMs);
|
|
42
|
+
}
|
|
43
|
+
export function quotePreimage(q) {
|
|
44
|
+
const w = new CanonicalWriter().str(domain("quote"));
|
|
45
|
+
return writeQuoteFields(w, q).preimage();
|
|
41
46
|
}
|
|
42
47
|
export function quoteHash(q) {
|
|
43
48
|
return hashPreimage(quotePreimage(q));
|
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/dist/src/wire.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Money } from "./money.js";
|
|
2
2
|
import { stateName } from "./state.js";
|
|
3
3
|
import { CanonicalWriter } from "./canonical.js";
|
|
4
|
+
import { writeQuoteFields } from "./message.js";
|
|
4
5
|
// The wire codec serializes messages for transport. It is distinct from the
|
|
5
6
|
// canonical signing preimage: signing binds a fixed subset of fields under a
|
|
6
7
|
// domain tag, while the wire form carries every field so a peer can reconstruct
|
|
@@ -27,22 +28,7 @@ export function encodeIntent(i) {
|
|
|
27
28
|
.preimage();
|
|
28
29
|
}
|
|
29
30
|
export function encodeQuote(q) {
|
|
30
|
-
return new CanonicalWriter()
|
|
31
|
-
.str(q.id)
|
|
32
|
-
.str(q.intentId)
|
|
33
|
-
.str(q.payInAdapterId)
|
|
34
|
-
.str(q.payInRail)
|
|
35
|
-
.str(q.payOutAdapterId)
|
|
36
|
-
.str(q.payOutRail)
|
|
37
|
-
.str(q.bridgeId)
|
|
38
|
-
.money(q.srcAmount)
|
|
39
|
-
.money(q.dstAmount)
|
|
40
|
-
.money(q.fees)
|
|
41
|
-
.str(q.fxRate)
|
|
42
|
-
.u64(q.expiresAt)
|
|
43
|
-
.str(q.providerQuoteRef)
|
|
44
|
-
.u64(q.latencyEstimateMs)
|
|
45
|
-
.preimage();
|
|
31
|
+
return writeQuoteFields(new CanonicalWriter(), q).preimage();
|
|
46
32
|
}
|
|
47
33
|
export function encodeAuthorization(a) {
|
|
48
34
|
return new CanonicalWriter()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Client } from "../src/client.js";
|
|
3
|
+
import { Money } from "../src/money.js";
|
|
4
|
+
import { State } from "../src/state.js";
|
|
5
|
+
import { FakeLeg } from "./fake.js";
|
|
6
|
+
import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
|
|
7
|
+
import { fromHex } from "../src/crypto.js";
|
|
8
|
+
// Initiate may only collect from an authorized intent. This guards against a
|
|
9
|
+
// replayed or out-of-order call charging the payer a second time, matching the
|
|
10
|
+
// same guard in the Go, Dart, Swift, and Kotlin SDKs.
|
|
11
|
+
describe("initiate state guard", () => {
|
|
12
|
+
it("refuses to initiate from any state but authorized", async () => {
|
|
13
|
+
const now = 1_700_000_000_000;
|
|
14
|
+
const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
|
|
15
|
+
const signer = Ed25519Signer.fromSeed("alice", seed);
|
|
16
|
+
const verifier = new Ed25519Verifier(new Map([["alice", signer.publicKey]]));
|
|
17
|
+
let n = 0;
|
|
18
|
+
const ids = () => `id-${String(++n).padStart(3, "0")}`;
|
|
19
|
+
const wallet = new FakeLeg("wallet", "wallet", "USD", ids);
|
|
20
|
+
const client = new Client({ payIn: [wallet], payOut: [wallet], verifier, clock: () => now, idGen: ids });
|
|
21
|
+
const intent = client.createIntent({
|
|
22
|
+
senderRef: "alice",
|
|
23
|
+
recipientRef: "bob",
|
|
24
|
+
amount: Money.parse("USD", 2, "1500"),
|
|
25
|
+
expiresAt: now + 600_000,
|
|
26
|
+
allowedRails: ["wallet"],
|
|
27
|
+
});
|
|
28
|
+
const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "wallet", currency: "USD", exponent: 2 }]);
|
|
29
|
+
const quote = await client.select(quotes, signer.identity());
|
|
30
|
+
// Before authorizing, the intent is only quoted, so initiate is refused.
|
|
31
|
+
await expect(client.initiate(intent, quote, { intentId: intent.id })).rejects.toThrow();
|
|
32
|
+
const auth = await client.authorize(intent, quote, signer);
|
|
33
|
+
await client.initiate(intent, quote, auth);
|
|
34
|
+
await client.advance(intent.id, "wallet", { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
|
|
35
|
+
// Once settled, a replayed initiate must not collect again.
|
|
36
|
+
await expect(client.initiate(intent, quote, auth)).rejects.toThrow();
|
|
37
|
+
});
|
|
38
|
+
});
|
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/adapters/stripe.ts
CHANGED
|
@@ -353,8 +353,15 @@ function parseEvent(
|
|
|
353
353
|
const event = JSON.parse(new TextDecoder().decode(payload)) as StripeEvent;
|
|
354
354
|
|
|
355
355
|
switch (event.type) {
|
|
356
|
-
case "payment_intent.succeeded":
|
|
357
|
-
|
|
356
|
+
case "payment_intent.succeeded": {
|
|
357
|
+
// The webhook is signed by Stripe, so its amounts are authentic. A capture for
|
|
358
|
+
// less than the authorized amount must not settle the corridor as fully paid.
|
|
359
|
+
const pi = decodePaymentIntent(event.data.object);
|
|
360
|
+
if (pi.amountReceived < pi.amount) {
|
|
361
|
+
return oneEvent(pi, State.Failed, "captured amount is less than the authorized amount");
|
|
362
|
+
}
|
|
363
|
+
return oneEvent(pi, State.Settled, "");
|
|
364
|
+
}
|
|
358
365
|
case "payment_intent.payment_failed":
|
|
359
366
|
return oneEvent(decodePaymentIntent(event.data.object), State.Failed, "payment failed");
|
|
360
367
|
case "charge.refunded":
|
|
@@ -368,18 +375,26 @@ interface WebhookIntent {
|
|
|
368
375
|
id: string;
|
|
369
376
|
metadata: Record<string, string>;
|
|
370
377
|
created: number;
|
|
378
|
+
amount: number;
|
|
379
|
+
amountReceived: number;
|
|
371
380
|
}
|
|
372
381
|
|
|
373
382
|
function decodePaymentIntent(raw: unknown): WebhookIntent {
|
|
374
|
-
const obj = raw as { id?: string; metadata?: Record<string, string>; created?: number };
|
|
375
|
-
return {
|
|
383
|
+
const obj = raw as { id?: string; metadata?: Record<string, string>; created?: number; amount?: number; amount_received?: number };
|
|
384
|
+
return {
|
|
385
|
+
id: obj.id ?? "",
|
|
386
|
+
metadata: obj.metadata ?? {},
|
|
387
|
+
created: obj.created ?? 0,
|
|
388
|
+
amount: obj.amount ?? 0,
|
|
389
|
+
amountReceived: obj.amount_received ?? 0,
|
|
390
|
+
};
|
|
376
391
|
}
|
|
377
392
|
|
|
378
393
|
// decodeRefundedIntent reads the pact intent id off a refunded charge. A charge
|
|
379
394
|
// carries the originating PaymentIntent id and copies its metadata.
|
|
380
395
|
function decodeRefundedIntent(raw: unknown): WebhookIntent {
|
|
381
396
|
const charge = raw as { payment_intent?: string; metadata?: Record<string, string>; created?: number };
|
|
382
|
-
return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0 };
|
|
397
|
+
return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0, amount: 0, amountReceived: 0 };
|
|
383
398
|
}
|
|
384
399
|
|
|
385
400
|
function oneEvent(pi: WebhookIntent, state: State, reason: string): AdapterEvent[] {
|
|
@@ -483,7 +498,7 @@ class HttpStripeApi implements StripeApi {
|
|
|
483
498
|
|
|
484
499
|
private async send<T>(path: string, init: RequestInit): Promise<T> {
|
|
485
500
|
const headers: Record<string, string> = {
|
|
486
|
-
...(
|
|
501
|
+
...(init.headers as Record<string, string> | undefined),
|
|
487
502
|
Authorization: `Bearer ${this.secretKey}`,
|
|
488
503
|
"Stripe-Version": apiVersion,
|
|
489
504
|
};
|
package/src/canonical.ts
CHANGED
|
@@ -61,6 +61,8 @@ export class CanonicalWriter {
|
|
|
61
61
|
// byte value so the encoding never depends on insertion order. The sort is on
|
|
62
62
|
// encoded bytes, not on UTF-16 code units, to match the other SDKs exactly.
|
|
63
63
|
stringMap(kv: Readonly<Record<string, string>>): this {
|
|
64
|
+
// Object.keys returns a fresh array, so sorting it in place mutates nothing shared.
|
|
65
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
64
66
|
const keys = Object.keys(kv).sort(compareUtf8);
|
|
65
67
|
this.u64(keys.length);
|
|
66
68
|
for (const k of keys) {
|